From 477b7a9195fcc02827a143964c176e933ae43912 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Aug 2024 13:53:16 +0000 Subject: [PATCH 1/2] Sync and remove all non-FOSS parts --- .github/workflows/automerge.yml | 35 - .github/workflows/benchmark.yml | 172 - .github/workflows/build-and-deploy-prod.yml | 29 - .github/workflows/build-hogql-parser.yml | 140 - .../ci-backend-update-test-timing.yml | 49 - .github/workflows/ci-backend.yml | 406 - .github/workflows/ci-e2e.yml | 312 - .github/workflows/ci-frontend.yml | 163 - .github/workflows/ci-hobby.yml | 50 - .github/workflows/ci-hog.yml | 143 - .github/workflows/ci-plugin-server.yml | 264 - .github/workflows/codeql.yml | 105 - .github/workflows/codespaces.yml | 92 - .github/workflows/container-images-cd.yml | 203 - .github/workflows/container-images-ci.yml | 75 - .github/workflows/foss-sync.yml | 52 - .github/workflows/go.yml | 22 - .github/workflows/lint-new-pr.yml | 35 - .github/workflows/lint-pr.yml | 17 - .github/workflows/livestream-docker-image.yml | 88 - .github/workflows/pr-cleanup.yml | 70 - .github/workflows/pr-deploy.yml | 109 - .github/workflows/release-hogvm.yml | 52 - .github/workflows/replay-capture.yml | 35 - .github/workflows/report-pr-age.yml | 41 - .github/workflows/rust-docker-build.yml | 90 - .../workflows/rust-hook-migrator-docker.yml | 81 - .github/workflows/rust.yml | 248 - .github/workflows/stale.yaml | 28 - .github/workflows/storybook-chromatic.yml | 286 - .github/workflows/storybook-deploy.yml | 63 - .../workflows/vector-docker-build-deploy.yml | 105 - LICENSE | 8 +- ee/LICENSE | 36 - ee/__init__.py | 0 ee/api/__init__.py | 0 ee/api/authentication.py | 167 - ee/api/billing.py | 279 - ee/api/dashboard_collaborator.py | 116 - ee/api/ee_event_definition.py | 117 - ee/api/ee_property_definition.py | 92 - ee/api/explicit_team_member.py | 135 - ee/api/feature_flag_role_access.py | 84 - ee/api/hooks.py | 137 - ee/api/integration.py | 37 - ee/api/license.py | 87 - ee/api/organization_resource_access.py | 47 - ee/api/role.py | 145 - ee/api/sentry_stats.py | 112 - ee/api/subscription.py | 126 - ee/api/test/__init__.py | 0 .../__snapshots__/test_instance_settings.ambr | 13 - .../test_organization_resource_access.ambr | 271 - ee/api/test/base.py | 44 - ee/api/test/fixtures/__init__.py | 0 .../fixtures/available_product_features.py | 13 - ee/api/test/fixtures/saml_login_response | 1 - .../saml_login_response_alt_attribute_names | 1 - .../saml_login_response_no_first_name | 1 - ee/api/test/test_action.py | 101 - ee/api/test/test_authentication.py | 709 -- ee/api/test/test_billing.py | 941 -- ee/api/test/test_capture.py | 309 - ee/api/test/test_dashboard.py | 305 - ee/api/test/test_dashboard_collaborators.py | 267 - ee/api/test/test_event_definition.py | 381 - ee/api/test/test_feature_flag.py | 25 - ee/api/test/test_feature_flag_role_access.py | 170 - ee/api/test/test_hooks.py | 188 - ee/api/test/test_insight.py | 596 -- ee/api/test/test_instance_settings.py | 37 - ee/api/test/test_integration.py | 62 - ee/api/test/test_license.py | 180 - ee/api/test/test_organization.py | 291 - .../test/test_organization_resource_access.py | 193 - ee/api/test/test_property_definition.py | 510 - ee/api/test/test_role.py | 136 - ee/api/test/test_role_membership.py | 106 - ee/api/test/test_subscription.py | 114 - ee/api/test/test_tagged_item.py | 148 - ee/api/test/test_team.py | 526 - ee/api/test/test_team_memberships.py | 550 - ee/apps.py | 6 - ee/benchmarks/README.md | 65 - ee/benchmarks/__init__.py | 0 ee/benchmarks/asv.conf.json | 161 - ee/benchmarks/benchmarks.py | 804 -- ee/benchmarks/helpers.py | 79 - ee/benchmarks/measure.sh | 164 - ee/billing/billing_manager.py | 348 - ee/billing/billing_types.py | 159 - ee/billing/quota_limiting.py | 458 - ee/billing/test/test_billing_manager.py | 127 - ee/billing/test/test_quota_limiting.py | 761 -- ee/bin/docker-ch-dev-backend | 7 - ee/bin/docker-ch-dev-web | 5 - ee/bin/docker-ch-test | 10 - ee/certs/ch-dev.crt | 20 - ee/certs/ch.crt | 19 - ee/clickhouse/README.md | 21 - ee/clickhouse/__init__.py | 0 ee/clickhouse/bin/clickhouse-flamegraph | Bin 7680000 -> 0 bytes ee/clickhouse/bin/flamegraph.pl | 1191 --- .../materialized_columns/__init__.py | 0 ee/clickhouse/materialized_columns/analyze.py | 205 - ee/clickhouse/materialized_columns/columns.py | 227 - .../materialized_columns/test/__init__.py | 0 .../materialized_columns/test/test_analyze.py | 57 - .../materialized_columns/test/test_columns.py | 240 - .../materialized_columns/test/test_query.py | 24 - ee/clickhouse/materialized_columns/util.py | 0 ee/clickhouse/models/__init__.py | 0 ee/clickhouse/models/group.py | 0 ee/clickhouse/models/test/__init__.py | 0 .../test/__snapshots__/test_cohort.ambr | 300 - .../test/__snapshots__/test_property.ambr | 155 - ee/clickhouse/models/test/test_action.py | 317 - ee/clickhouse/models/test/test_cohort.py | 1372 --- .../models/test/test_dead_letter_queue.py | 114 - ee/clickhouse/models/test/test_filters.py | 1467 --- ee/clickhouse/models/test/test_property.py | 2012 ---- ee/clickhouse/models/test/utils/util.py | 14 - ee/clickhouse/queries/__init__.py | 0 ee/clickhouse/queries/column_optimizer.py | 115 - .../queries/enterprise_cohort_query.py | 422 - ee/clickhouse/queries/event_query.py | 73 - ee/clickhouse/queries/experiments/__init__.py | 10 - .../experiments/funnel_experiment_result.py | 404 - .../secondary_experiment_result.py | 84 - .../experiments/test_experiment_result.py | 811 -- .../queries/experiments/test_utils.py | 160 - .../experiments/trend_experiment_result.py | 543 - ee/clickhouse/queries/experiments/utils.py | 68 - ee/clickhouse/queries/funnels/__init__.py | 0 .../queries/funnels/funnel_correlation.py | 972 -- .../funnels/funnel_correlation_persons.py | 211 - .../queries/funnels/test/__init__.py | 0 .../test/__snapshots__/test_funnel.ambr | 3540 ------- .../test_funnel_correlation.ambr | 4900 --------- .../test_funnel_correlations_persons.ambr | 785 -- .../queries/funnels/test/breakdown_cases.py | 416 - .../queries/funnels/test/test_funnel.py | 205 - .../funnels/test/test_funnel_correlation.py | 2080 ---- .../test/test_funnel_correlations_persons.py | 650 -- ee/clickhouse/queries/groups_join_query.py | 92 - ee/clickhouse/queries/paths/__init__.py | 2 - ee/clickhouse/queries/paths/paths.py | 163 - ee/clickhouse/queries/paths/paths_actors.py | 6 - .../queries/paths/paths_event_query.py | 5 - ee/clickhouse/queries/related_actors_query.py | 126 - ee/clickhouse/queries/retention/__init__.py | 1 - ee/clickhouse/queries/retention/retention.py | 12 - .../queries/retention/retention_actors.py | 14 - .../retention/retention_event_query.py | 16 - ee/clickhouse/queries/stickiness/__init__.py | 1 - .../queries/stickiness/stickiness.py | 12 - .../queries/stickiness/stickiness_actors.py | 15 - .../stickiness/stickiness_event_query.py | 11 - ee/clickhouse/queries/test/__init__.py | 0 .../__snapshots__/test_breakdown_props.ambr | 252 - .../test/__snapshots__/test_cohort_query.ambr | 762 -- .../test/__snapshots__/test_event_query.ambr | 386 - .../__snapshots__/test_groups_join_query.ambr | 55 - .../test/__snapshots__/test_lifecycle.ambr | 672 -- .../test/__snapshots__/test_paths.ambr | 9318 ----------------- .../test_person_distinct_id_query.ambr | 13 - .../test/__snapshots__/test_person_query.ambr | 355 - .../test/__snapshots__/test_retention.ambr | 744 -- .../queries/test/test_breakdown_props.py | 550 - .../queries/test/test_cohort_query.py | 3273 ------ .../queries/test/test_column_optimizer.py | 260 - .../queries/test/test_event_query.py | 744 -- .../queries/test/test_experiments.py | 235 - .../queries/test/test_groups_join_query.py | 48 - ee/clickhouse/queries/test/test_lifecycle.py | 296 - ee/clickhouse/queries/test/test_paths.py | 4492 -------- .../test/test_person_distinct_id_query.py | 5 - .../queries/test/test_person_query.py | 405 - .../queries/test/test_property_optimizer.py | 552 - ee/clickhouse/queries/test/test_retention.py | 530 - ee/clickhouse/queries/test/test_util.py | 65 - ee/clickhouse/test/__init__.py | 0 ee/clickhouse/test/test_error.py | 50 - ee/clickhouse/test/test_system_status.py | 23 - ee/clickhouse/views/__init__.py | 0 ee/clickhouse/views/experiments.py | 492 - ee/clickhouse/views/groups.py | 214 - ee/clickhouse/views/insights.py | 57 - ee/clickhouse/views/person.py | 65 - ee/clickhouse/views/test/__init__.py | 0 ...ickhouse_experiment_secondary_results.ambr | 186 - .../test_clickhouse_experiments.ambr | 1114 -- .../__snapshots__/test_clickhouse_groups.ambr | 90 - .../test_clickhouse_retention.ambr | 493 - .../test_clickhouse_stickiness.ambr | 683 -- .../__snapshots__/test_clickhouse_trends.ambr | 996 -- ee/clickhouse/views/test/funnel/__init__.py | 0 .../__snapshots__/test_clickhouse_funnel.ambr | 487 - .../test_clickhouse_funnel_person.ambr | 117 - .../test_clickhouse_funnel_unordered.ambr | 163 - .../test/funnel/test_clickhouse_funnel.py | 289 - .../test_clickhouse_funnel_correlation.py | 699 -- .../funnel/test_clickhouse_funnel_person.py | 415 - .../test_clickhouse_funnel_trends_person.py | 279 - .../test_clickhouse_funnel_unordered.py | 101 - ee/clickhouse/views/test/funnel/util.py | 103 - ...clickhouse_experiment_secondary_results.py | 1310 --- .../views/test/test_clickhouse_experiments.py | 4281 -------- .../views/test/test_clickhouse_groups.py | 567 - .../views/test/test_clickhouse_path_person.py | 274 - .../views/test/test_clickhouse_paths.py | 299 - .../views/test/test_clickhouse_retention.py | 833 -- .../views/test/test_clickhouse_stickiness.py | 230 - .../views/test/test_clickhouse_trends.py | 1633 --- ee/conftest.py | 2 - ee/frontend/exports.ts | 12 - .../increment-with-child-duplication.json | 217 - .../__snapshots__/transform.test.ts.snap | 9005 ---------------- ee/frontend/mobile-replay/index.ts | 82 - ee/frontend/mobile-replay/mobile.types.ts | 406 - .../schema/mobile/rr-mobile-schema.json | 1349 --- .../schema/web/rr-web-schema.json | 981 -- ee/frontend/mobile-replay/transform.test.ts | 1235 --- .../mobile-replay/transformer/colors.ts | 51 - .../transformer/screen-chrome.ts | 178 - .../mobile-replay/transformer/transformers.ts | 1412 --- .../mobile-replay/transformer/types.ts | 17 - .../transformer/wireframeStyle.ts | 269 - ee/management/commands/materialize_columns.py | 102 - ee/migrations/0001_initial.py | 31 - ee/migrations/0002_hook.py | 59 - ee/migrations/0003_license_max_users.py | 17 - ...definition_enterprisepropertydefinition.py | 108 - .../0005_project_based_permissioning.py | 63 - .../0006_event_definition_verification.py | 36 - ee/migrations/0007_dashboard_permissions.py | 67 - .../0008_null_definition_descriptions.py | 22 - ee/migrations/0009_deprecated_old_tags.py | 22 - .../0010_migrate_definitions_tags.py | 21 - ee/migrations/0011_add_tags_back.py | 35 - ee/migrations/0012_migrate_tags_v2.py | 143 - .../0013_silence_deprecated_tags_warnings.py | 47 - ...4_roles_memberships_and_resource_access.py | 201 - ee/migrations/0015_add_verified_properties.py | 36 - ...0016_rolemembership_organization_member.py | 25 - ee/migrations/__init__.py | 0 ee/models/__init__.py | 20 - ee/models/dashboard_privilege.py | 32 - ee/models/event_definition.py | 35 - ee/models/explicit_team_membership.py | 49 - ee/models/feature_flag_role_access.py | 21 - ee/models/hook.py | 47 - ee/models/license.py | 119 - ee/models/organization_resource_access.py | 43 - ee/models/property_definition.py | 30 - ee/models/role.py | 58 - ee/models/test/__init__.py | 0 ee/models/test/test_event_definition_model.py | 19 - .../test/test_property_definition_model.py | 18 - ee/pytest.ini | 11 - ee/session_recordings/__init__.py | 0 ee/session_recordings/ai/__init__.py | 0 .../ai/embeddings_queries.py | 109 - ee/session_recordings/ai/embeddings_runner.py | 302 - ee/session_recordings/ai/error_clustering.py | 99 - .../ai/similar_recordings.py | 57 - ee/session_recordings/ai/utils.py | 243 - ee/session_recordings/persistence_tasks.py | 42 - ee/session_recordings/queries/__init__.py | 0 .../queries/test/__init__.py | 0 ...t_session_recording_list_from_filters.ambr | 1509 --- ...on_recording_list_from_session_replay.ambr | 1699 --- ...est_session_recording_list_from_filters.py | 365 - .../session_recording_extensions.py | 196 - .../session_recording_playlist.py | 268 - .../session_summary/__init__.py | 0 .../session_summary/summarize_session.py | 148 - .../session_summary/test/__init__.py | 0 .../test/test_summarize_session.py | 116 - ee/session_recordings/test/__init__.py | 0 .../test/test_session_recording_extensions.py | 172 - .../test/test_session_recording_playlist.py | 363 - ee/settings.py | 71 - ee/tasks/__init__.py | 29 - ee/tasks/auto_rollback_feature_flag.py | 85 - ee/tasks/materialized_columns.py | 60 - ee/tasks/replay.py | 99 - ee/tasks/send_license_usage.py | 103 - ee/tasks/slack.py | 103 - ee/tasks/subscriptions/__init__.py | 169 - ee/tasks/subscriptions/email_subscriptions.py | 67 - ee/tasks/subscriptions/slack_subscriptions.py | 117 - ee/tasks/subscriptions/subscription_utils.py | 69 - ee/tasks/test/__init__.py | 0 ee/tasks/test/subscriptions/__init__.py | 0 .../subscriptions_test_factory.py | 19 - .../subscriptions/test_email_subscriptions.py | 98 - .../subscriptions/test_slack_subscriptions.py | 199 - .../test/subscriptions/test_subscriptions.py | 194 - .../subscriptions/test_subscriptions_utils.py | 96 - .../test/test_auto_rollback_feature_flag.py | 205 - ee/tasks/test/test_calculate_cohort.py | 535 - ee/tasks/test/test_send_license_usage.py | 317 - ee/tasks/test/test_slack.py | 97 - .../fixtures/performance_event_fixtures.py | 47 - ee/urls.py | 96 - 306 files changed, 2 insertions(+), 106277 deletions(-) delete mode 100644 .github/workflows/automerge.yml delete mode 100644 .github/workflows/benchmark.yml delete mode 100644 .github/workflows/build-and-deploy-prod.yml delete mode 100644 .github/workflows/build-hogql-parser.yml delete mode 100644 .github/workflows/ci-backend-update-test-timing.yml delete mode 100644 .github/workflows/ci-backend.yml delete mode 100644 .github/workflows/ci-e2e.yml delete mode 100644 .github/workflows/ci-frontend.yml delete mode 100644 .github/workflows/ci-hobby.yml delete mode 100644 .github/workflows/ci-hog.yml delete mode 100644 .github/workflows/ci-plugin-server.yml delete mode 100644 .github/workflows/codeql.yml delete mode 100644 .github/workflows/codespaces.yml delete mode 100644 .github/workflows/container-images-cd.yml delete mode 100644 .github/workflows/container-images-ci.yml delete mode 100644 .github/workflows/foss-sync.yml delete mode 100644 .github/workflows/go.yml delete mode 100644 .github/workflows/lint-new-pr.yml delete mode 100644 .github/workflows/lint-pr.yml delete mode 100644 .github/workflows/livestream-docker-image.yml delete mode 100644 .github/workflows/pr-cleanup.yml delete mode 100644 .github/workflows/pr-deploy.yml delete mode 100644 .github/workflows/release-hogvm.yml delete mode 100644 .github/workflows/replay-capture.yml delete mode 100644 .github/workflows/report-pr-age.yml delete mode 100644 .github/workflows/rust-docker-build.yml delete mode 100644 .github/workflows/rust-hook-migrator-docker.yml delete mode 100644 .github/workflows/rust.yml delete mode 100644 .github/workflows/stale.yaml delete mode 100644 .github/workflows/storybook-chromatic.yml delete mode 100644 .github/workflows/storybook-deploy.yml delete mode 100644 .github/workflows/vector-docker-build-deploy.yml delete mode 100644 ee/LICENSE delete mode 100644 ee/__init__.py delete mode 100644 ee/api/__init__.py delete mode 100644 ee/api/authentication.py delete mode 100644 ee/api/billing.py delete mode 100644 ee/api/dashboard_collaborator.py delete mode 100644 ee/api/ee_event_definition.py delete mode 100644 ee/api/ee_property_definition.py delete mode 100644 ee/api/explicit_team_member.py delete mode 100644 ee/api/feature_flag_role_access.py delete mode 100644 ee/api/hooks.py delete mode 100644 ee/api/integration.py delete mode 100644 ee/api/license.py delete mode 100644 ee/api/organization_resource_access.py delete mode 100644 ee/api/role.py delete mode 100644 ee/api/sentry_stats.py delete mode 100644 ee/api/subscription.py delete mode 100644 ee/api/test/__init__.py delete mode 100644 ee/api/test/__snapshots__/test_instance_settings.ambr delete mode 100644 ee/api/test/__snapshots__/test_organization_resource_access.ambr delete mode 100644 ee/api/test/base.py delete mode 100644 ee/api/test/fixtures/__init__.py delete mode 100644 ee/api/test/fixtures/available_product_features.py delete mode 100644 ee/api/test/fixtures/saml_login_response delete mode 100644 ee/api/test/fixtures/saml_login_response_alt_attribute_names delete mode 100644 ee/api/test/fixtures/saml_login_response_no_first_name delete mode 100644 ee/api/test/test_action.py delete mode 100644 ee/api/test/test_authentication.py delete mode 100644 ee/api/test/test_billing.py delete mode 100644 ee/api/test/test_capture.py delete mode 100644 ee/api/test/test_dashboard.py delete mode 100644 ee/api/test/test_dashboard_collaborators.py delete mode 100644 ee/api/test/test_event_definition.py delete mode 100644 ee/api/test/test_feature_flag.py delete mode 100644 ee/api/test/test_feature_flag_role_access.py delete mode 100644 ee/api/test/test_hooks.py delete mode 100644 ee/api/test/test_insight.py delete mode 100644 ee/api/test/test_instance_settings.py delete mode 100644 ee/api/test/test_integration.py delete mode 100644 ee/api/test/test_license.py delete mode 100644 ee/api/test/test_organization.py delete mode 100644 ee/api/test/test_organization_resource_access.py delete mode 100644 ee/api/test/test_property_definition.py delete mode 100644 ee/api/test/test_role.py delete mode 100644 ee/api/test/test_role_membership.py delete mode 100644 ee/api/test/test_subscription.py delete mode 100644 ee/api/test/test_tagged_item.py delete mode 100644 ee/api/test/test_team.py delete mode 100644 ee/api/test/test_team_memberships.py delete mode 100644 ee/apps.py delete mode 100644 ee/benchmarks/README.md delete mode 100644 ee/benchmarks/__init__.py delete mode 100644 ee/benchmarks/asv.conf.json delete mode 100644 ee/benchmarks/benchmarks.py delete mode 100644 ee/benchmarks/helpers.py delete mode 100644 ee/benchmarks/measure.sh delete mode 100644 ee/billing/billing_manager.py delete mode 100644 ee/billing/billing_types.py delete mode 100644 ee/billing/quota_limiting.py delete mode 100644 ee/billing/test/test_billing_manager.py delete mode 100644 ee/billing/test/test_quota_limiting.py delete mode 100755 ee/bin/docker-ch-dev-backend delete mode 100755 ee/bin/docker-ch-dev-web delete mode 100755 ee/bin/docker-ch-test delete mode 100644 ee/certs/ch-dev.crt delete mode 100644 ee/certs/ch.crt delete mode 100644 ee/clickhouse/README.md delete mode 100644 ee/clickhouse/__init__.py delete mode 100755 ee/clickhouse/bin/clickhouse-flamegraph delete mode 100755 ee/clickhouse/bin/flamegraph.pl delete mode 100644 ee/clickhouse/materialized_columns/__init__.py delete mode 100644 ee/clickhouse/materialized_columns/analyze.py delete mode 100644 ee/clickhouse/materialized_columns/columns.py delete mode 100644 ee/clickhouse/materialized_columns/test/__init__.py delete mode 100644 ee/clickhouse/materialized_columns/test/test_analyze.py delete mode 100644 ee/clickhouse/materialized_columns/test/test_columns.py delete mode 100644 ee/clickhouse/materialized_columns/test/test_query.py delete mode 100644 ee/clickhouse/materialized_columns/util.py delete mode 100644 ee/clickhouse/models/__init__.py delete mode 100644 ee/clickhouse/models/group.py delete mode 100644 ee/clickhouse/models/test/__init__.py delete mode 100644 ee/clickhouse/models/test/__snapshots__/test_cohort.ambr delete mode 100644 ee/clickhouse/models/test/__snapshots__/test_property.ambr delete mode 100644 ee/clickhouse/models/test/test_action.py delete mode 100644 ee/clickhouse/models/test/test_cohort.py delete mode 100644 ee/clickhouse/models/test/test_dead_letter_queue.py delete mode 100644 ee/clickhouse/models/test/test_filters.py delete mode 100644 ee/clickhouse/models/test/test_property.py delete mode 100644 ee/clickhouse/models/test/utils/util.py delete mode 100644 ee/clickhouse/queries/__init__.py delete mode 100644 ee/clickhouse/queries/column_optimizer.py delete mode 100644 ee/clickhouse/queries/enterprise_cohort_query.py delete mode 100644 ee/clickhouse/queries/event_query.py delete mode 100644 ee/clickhouse/queries/experiments/__init__.py delete mode 100644 ee/clickhouse/queries/experiments/funnel_experiment_result.py delete mode 100644 ee/clickhouse/queries/experiments/secondary_experiment_result.py delete mode 100644 ee/clickhouse/queries/experiments/test_experiment_result.py delete mode 100644 ee/clickhouse/queries/experiments/test_utils.py delete mode 100644 ee/clickhouse/queries/experiments/trend_experiment_result.py delete mode 100644 ee/clickhouse/queries/experiments/utils.py delete mode 100644 ee/clickhouse/queries/funnels/__init__.py delete mode 100644 ee/clickhouse/queries/funnels/funnel_correlation.py delete mode 100644 ee/clickhouse/queries/funnels/funnel_correlation_persons.py delete mode 100644 ee/clickhouse/queries/funnels/test/__init__.py delete mode 100644 ee/clickhouse/queries/funnels/test/__snapshots__/test_funnel.ambr delete mode 100644 ee/clickhouse/queries/funnels/test/__snapshots__/test_funnel_correlation.ambr delete mode 100644 ee/clickhouse/queries/funnels/test/__snapshots__/test_funnel_correlations_persons.ambr delete mode 100644 ee/clickhouse/queries/funnels/test/breakdown_cases.py delete mode 100644 ee/clickhouse/queries/funnels/test/test_funnel.py delete mode 100644 ee/clickhouse/queries/funnels/test/test_funnel_correlation.py delete mode 100644 ee/clickhouse/queries/funnels/test/test_funnel_correlations_persons.py delete mode 100644 ee/clickhouse/queries/groups_join_query.py delete mode 100644 ee/clickhouse/queries/paths/__init__.py delete mode 100644 ee/clickhouse/queries/paths/paths.py delete mode 100644 ee/clickhouse/queries/paths/paths_actors.py delete mode 100644 ee/clickhouse/queries/paths/paths_event_query.py delete mode 100644 ee/clickhouse/queries/related_actors_query.py delete mode 100644 ee/clickhouse/queries/retention/__init__.py delete mode 100644 ee/clickhouse/queries/retention/retention.py delete mode 100644 ee/clickhouse/queries/retention/retention_actors.py delete mode 100644 ee/clickhouse/queries/retention/retention_event_query.py delete mode 100644 ee/clickhouse/queries/stickiness/__init__.py delete mode 100644 ee/clickhouse/queries/stickiness/stickiness.py delete mode 100644 ee/clickhouse/queries/stickiness/stickiness_actors.py delete mode 100644 ee/clickhouse/queries/stickiness/stickiness_event_query.py delete mode 100644 ee/clickhouse/queries/test/__init__.py delete mode 100644 ee/clickhouse/queries/test/__snapshots__/test_breakdown_props.ambr delete mode 100644 ee/clickhouse/queries/test/__snapshots__/test_cohort_query.ambr delete mode 100644 ee/clickhouse/queries/test/__snapshots__/test_event_query.ambr delete mode 100644 ee/clickhouse/queries/test/__snapshots__/test_groups_join_query.ambr delete mode 100644 ee/clickhouse/queries/test/__snapshots__/test_lifecycle.ambr delete mode 100644 ee/clickhouse/queries/test/__snapshots__/test_paths.ambr delete mode 100644 ee/clickhouse/queries/test/__snapshots__/test_person_distinct_id_query.ambr delete mode 100644 ee/clickhouse/queries/test/__snapshots__/test_person_query.ambr delete mode 100644 ee/clickhouse/queries/test/__snapshots__/test_retention.ambr delete mode 100644 ee/clickhouse/queries/test/test_breakdown_props.py delete mode 100644 ee/clickhouse/queries/test/test_cohort_query.py delete mode 100644 ee/clickhouse/queries/test/test_column_optimizer.py delete mode 100644 ee/clickhouse/queries/test/test_event_query.py delete mode 100644 ee/clickhouse/queries/test/test_experiments.py delete mode 100644 ee/clickhouse/queries/test/test_groups_join_query.py delete mode 100644 ee/clickhouse/queries/test/test_lifecycle.py delete mode 100644 ee/clickhouse/queries/test/test_paths.py delete mode 100644 ee/clickhouse/queries/test/test_person_distinct_id_query.py delete mode 100644 ee/clickhouse/queries/test/test_person_query.py delete mode 100644 ee/clickhouse/queries/test/test_property_optimizer.py delete mode 100644 ee/clickhouse/queries/test/test_retention.py delete mode 100644 ee/clickhouse/queries/test/test_util.py delete mode 100644 ee/clickhouse/test/__init__.py delete mode 100644 ee/clickhouse/test/test_error.py delete mode 100644 ee/clickhouse/test/test_system_status.py delete mode 100644 ee/clickhouse/views/__init__.py delete mode 100644 ee/clickhouse/views/experiments.py delete mode 100644 ee/clickhouse/views/groups.py delete mode 100644 ee/clickhouse/views/insights.py delete mode 100644 ee/clickhouse/views/person.py delete mode 100644 ee/clickhouse/views/test/__init__.py delete mode 100644 ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr delete mode 100644 ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiments.ambr delete mode 100644 ee/clickhouse/views/test/__snapshots__/test_clickhouse_groups.ambr delete mode 100644 ee/clickhouse/views/test/__snapshots__/test_clickhouse_retention.ambr delete mode 100644 ee/clickhouse/views/test/__snapshots__/test_clickhouse_stickiness.ambr delete mode 100644 ee/clickhouse/views/test/__snapshots__/test_clickhouse_trends.ambr delete mode 100644 ee/clickhouse/views/test/funnel/__init__.py delete mode 100644 ee/clickhouse/views/test/funnel/__snapshots__/test_clickhouse_funnel.ambr delete mode 100644 ee/clickhouse/views/test/funnel/__snapshots__/test_clickhouse_funnel_person.ambr delete mode 100644 ee/clickhouse/views/test/funnel/__snapshots__/test_clickhouse_funnel_unordered.ambr delete mode 100644 ee/clickhouse/views/test/funnel/test_clickhouse_funnel.py delete mode 100644 ee/clickhouse/views/test/funnel/test_clickhouse_funnel_correlation.py delete mode 100644 ee/clickhouse/views/test/funnel/test_clickhouse_funnel_person.py delete mode 100644 ee/clickhouse/views/test/funnel/test_clickhouse_funnel_trends_person.py delete mode 100644 ee/clickhouse/views/test/funnel/test_clickhouse_funnel_unordered.py delete mode 100644 ee/clickhouse/views/test/funnel/util.py delete mode 100644 ee/clickhouse/views/test/test_clickhouse_experiment_secondary_results.py delete mode 100644 ee/clickhouse/views/test/test_clickhouse_experiments.py delete mode 100644 ee/clickhouse/views/test/test_clickhouse_groups.py delete mode 100644 ee/clickhouse/views/test/test_clickhouse_path_person.py delete mode 100644 ee/clickhouse/views/test/test_clickhouse_paths.py delete mode 100644 ee/clickhouse/views/test/test_clickhouse_retention.py delete mode 100644 ee/clickhouse/views/test/test_clickhouse_stickiness.py delete mode 100644 ee/clickhouse/views/test/test_clickhouse_trends.py delete mode 100644 ee/conftest.py delete mode 100644 ee/frontend/exports.ts delete mode 100644 ee/frontend/mobile-replay/__mocks__/increment-with-child-duplication.json delete mode 100644 ee/frontend/mobile-replay/__snapshots__/transform.test.ts.snap delete mode 100644 ee/frontend/mobile-replay/index.ts delete mode 100644 ee/frontend/mobile-replay/mobile.types.ts delete mode 100644 ee/frontend/mobile-replay/schema/mobile/rr-mobile-schema.json delete mode 100644 ee/frontend/mobile-replay/schema/web/rr-web-schema.json delete mode 100644 ee/frontend/mobile-replay/transform.test.ts delete mode 100644 ee/frontend/mobile-replay/transformer/colors.ts delete mode 100644 ee/frontend/mobile-replay/transformer/screen-chrome.ts delete mode 100644 ee/frontend/mobile-replay/transformer/transformers.ts delete mode 100644 ee/frontend/mobile-replay/transformer/types.ts delete mode 100644 ee/frontend/mobile-replay/transformer/wireframeStyle.ts delete mode 100644 ee/management/commands/materialize_columns.py delete mode 100644 ee/migrations/0001_initial.py delete mode 100644 ee/migrations/0002_hook.py delete mode 100644 ee/migrations/0003_license_max_users.py delete mode 100644 ee/migrations/0004_enterpriseeventdefinition_enterprisepropertydefinition.py delete mode 100644 ee/migrations/0005_project_based_permissioning.py delete mode 100644 ee/migrations/0006_event_definition_verification.py delete mode 100644 ee/migrations/0007_dashboard_permissions.py delete mode 100644 ee/migrations/0008_null_definition_descriptions.py delete mode 100644 ee/migrations/0009_deprecated_old_tags.py delete mode 100644 ee/migrations/0010_migrate_definitions_tags.py delete mode 100644 ee/migrations/0011_add_tags_back.py delete mode 100644 ee/migrations/0012_migrate_tags_v2.py delete mode 100644 ee/migrations/0013_silence_deprecated_tags_warnings.py delete mode 100644 ee/migrations/0014_roles_memberships_and_resource_access.py delete mode 100644 ee/migrations/0015_add_verified_properties.py delete mode 100644 ee/migrations/0016_rolemembership_organization_member.py delete mode 100644 ee/migrations/__init__.py delete mode 100644 ee/models/__init__.py delete mode 100644 ee/models/dashboard_privilege.py delete mode 100644 ee/models/event_definition.py delete mode 100644 ee/models/explicit_team_membership.py delete mode 100644 ee/models/feature_flag_role_access.py delete mode 100644 ee/models/hook.py delete mode 100644 ee/models/license.py delete mode 100644 ee/models/organization_resource_access.py delete mode 100644 ee/models/property_definition.py delete mode 100644 ee/models/role.py delete mode 100644 ee/models/test/__init__.py delete mode 100644 ee/models/test/test_event_definition_model.py delete mode 100644 ee/models/test/test_property_definition_model.py delete mode 100644 ee/pytest.ini delete mode 100644 ee/session_recordings/__init__.py delete mode 100644 ee/session_recordings/ai/__init__.py delete mode 100644 ee/session_recordings/ai/embeddings_queries.py delete mode 100644 ee/session_recordings/ai/embeddings_runner.py delete mode 100644 ee/session_recordings/ai/error_clustering.py delete mode 100644 ee/session_recordings/ai/similar_recordings.py delete mode 100644 ee/session_recordings/ai/utils.py delete mode 100644 ee/session_recordings/persistence_tasks.py delete mode 100644 ee/session_recordings/queries/__init__.py delete mode 100644 ee/session_recordings/queries/test/__init__.py delete mode 100644 ee/session_recordings/queries/test/__snapshots__/test_session_recording_list_from_filters.ambr delete mode 100644 ee/session_recordings/queries/test/__snapshots__/test_session_recording_list_from_session_replay.ambr delete mode 100644 ee/session_recordings/queries/test/test_session_recording_list_from_filters.py delete mode 100644 ee/session_recordings/session_recording_extensions.py delete mode 100644 ee/session_recordings/session_recording_playlist.py delete mode 100644 ee/session_recordings/session_summary/__init__.py delete mode 100644 ee/session_recordings/session_summary/summarize_session.py delete mode 100644 ee/session_recordings/session_summary/test/__init__.py delete mode 100644 ee/session_recordings/session_summary/test/test_summarize_session.py delete mode 100644 ee/session_recordings/test/__init__.py delete mode 100644 ee/session_recordings/test/test_session_recording_extensions.py delete mode 100644 ee/session_recordings/test/test_session_recording_playlist.py delete mode 100644 ee/settings.py delete mode 100644 ee/tasks/__init__.py delete mode 100644 ee/tasks/auto_rollback_feature_flag.py delete mode 100644 ee/tasks/materialized_columns.py delete mode 100644 ee/tasks/replay.py delete mode 100644 ee/tasks/send_license_usage.py delete mode 100644 ee/tasks/slack.py delete mode 100644 ee/tasks/subscriptions/__init__.py delete mode 100644 ee/tasks/subscriptions/email_subscriptions.py delete mode 100644 ee/tasks/subscriptions/slack_subscriptions.py delete mode 100644 ee/tasks/subscriptions/subscription_utils.py delete mode 100644 ee/tasks/test/__init__.py delete mode 100644 ee/tasks/test/subscriptions/__init__.py delete mode 100644 ee/tasks/test/subscriptions/subscriptions_test_factory.py delete mode 100644 ee/tasks/test/subscriptions/test_email_subscriptions.py delete mode 100644 ee/tasks/test/subscriptions/test_slack_subscriptions.py delete mode 100644 ee/tasks/test/subscriptions/test_subscriptions.py delete mode 100644 ee/tasks/test/subscriptions/test_subscriptions_utils.py delete mode 100644 ee/tasks/test/test_auto_rollback_feature_flag.py delete mode 100644 ee/tasks/test/test_calculate_cohort.py delete mode 100644 ee/tasks/test/test_send_license_usage.py delete mode 100644 ee/tasks/test/test_slack.py delete mode 100644 ee/test/fixtures/performance_event_fixtures.py delete mode 100644 ee/urls.py diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml deleted file mode 100644 index 20a3dbf3146..00000000000 --- a/.github/workflows/automerge.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Automerge - -env: - MERGE_METHOD: 'squash' - MERGE_RETRY_SLEEP: 300000 - -on: - pull_request: - types: - - labeled - - unlabeled - - synchronize - - opened - - edited - - ready_for_review - - reopened - - unlocked - check_suite: - types: - - completed - status: {} - -jobs: - automerge: - name: Automerge if requested - runs-on: ubuntu-latest - env: - IS_POSTHOG_BOT_AVAILABLE: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN != '' }} - steps: - - name: Automerge - if: env.IS_POSTHOG_BOT_AVAILABLE == 'true' - uses: pascalgn/automerge-action@v0.16.3 - env: - GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - run: echo diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml deleted file mode 100644 index 9478b7d2f8c..00000000000 --- a/.github/workflows/benchmark.yml +++ /dev/null @@ -1,172 +0,0 @@ -name: Benchmark - -on: - pull_request: - branches: ['*'] - paths: - - .github/workflows/benchmark.yml - schedule: - - cron: '0 4 * * 1-5' # Mon-Fri 4AM UTC - workflow_dispatch: {} - -concurrency: 'benchmarks' # Ensure only one of this runs at a time - -jobs: - run-benchmarks: - name: Clickhouse queries - runs-on: ubuntu-20.04 - environment: clickhouse-benchmarks - - # Benchmarks are expensive to run so we only run them (periodically) against master branch and for PRs labeled `performance` - if: ${{ github.repository == 'PostHog/posthog' && (github.ref == 'refs/heads/master' || contains(github.event.pull_request.labels.*.name, 'performance')) }} - - env: - DATABASE_URL: 'postgres://posthog:posthog@localhost:5432/posthog' - REDIS_URL: 'redis://localhost' - DEBUG: '1' - CLICKHOUSE_DATABASE: posthog - CLICKHOUSE_HOST: ${{ secrets.BENCHMARKS_CLICKHOUSE_HOST }} - CLICKHOUSE_USER: ${{ secrets.BENCHMARKS_CLICKHOUSE_USER }} - CLICKHOUSE_PASSWORD: ${{ secrets.BENCHMARKS_CLICKHOUSE_PASSWORD }} - CLICKHOUSE_SECURE: 'false' - CLICKHOUSE_VERIFY: 'false' - SECRET_KEY: '6b01eee4f945ca25045b5aab440b953461faf08693a9abbf1166dc7c6b9772da' # unsafe - for testing only - BENCHMARK: '1' - - steps: - - uses: actions/checkout@v3 - with: - # Checkout repo with full history - fetch-depth: 0 - - - name: Check out PostHog/benchmarks-results repo - uses: actions/checkout@v3 - with: - path: ee/benchmarks/results - repository: PostHog/benchmark-results - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - - name: Stop/Start stack with Docker Compose - run: | - docker compose -f docker-compose.dev.yml down - docker compose -f docker-compose.dev.yml up -d - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.11.9 - cache: 'pip' - cache-dependency-path: '**/requirements*.txt' - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - # uv is a fast pip alternative: https://github.com/astral-sh/uv/ - - run: pip install uv - - - name: Install SAML (python3-saml) dependencies - shell: bash - run: | - sudo apt-get update - sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl - - - name: Install python dependencies - run: | - uv pip install --system -r requirements-dev.txt - uv pip install --system -r requirements.txt - - - name: Install asv - run: uv pip install --system asv==0.5.1 virtualenv - - - name: Set up PostHog - run: | - python manage.py migrate & wait - python manage.py setup_dev --no-data - - - name: Configure benchmarks - run: asv machine --config ee/benchmarks/asv.conf.json --yes --machine ci-benchmarks - - - name: Run benchmarks - run: asv run --config ee/benchmarks/asv.conf.json --show-stderr --strict - - - name: Compare results - run: | - asv compare $(cat ee/benchmarks/results/last-master-commit) HEAD --config ee/benchmarks/asv.conf.json --factor 1.2 | tee pr_vs_master.txt - asv compare $(cat ee/benchmarks/results/last-master-commit) HEAD --config ee/benchmarks/asv.conf.json --factor 1.2 --only-changed | tee pr_vs_master_changed.txt - - - name: Save last benchmarked commit - if: ${{ github.ref == 'refs/heads/master' }} - run: echo "${{ github.sha }}" | tee ee/benchmarks/results/last-master-commit - - - name: Generate HTML report of results - if: ${{ github.ref == 'refs/heads/master' }} - run: asv publish --config ee/benchmarks/asv.conf.json - - - name: Commit update for benchmark results - if: ${{ github.repository == 'PostHog/posthog' && github.ref == 'refs/heads/master' }} - uses: stefanzweifel/git-auto-commit-action@v5 - with: - repository: ee/benchmarks/results - branch: master - commit_message: 'Save benchmark results' - commit_user_name: PostHog Bot - commit_user_email: hey@posthog.com - commit_author: PostHog Bot - - - name: Upload results as artifacts - uses: actions/upload-artifact@v3 - with: - name: benchmarks - path: | - pr_vs_master.txt - pr_vs_master_changed.txt - - - name: Read benchmark output - if: ${{ github.event_name == 'pull_request' }} - id: pr_vs_master_changed - uses: juliangruber/read-file-action@v1 - with: - path: pr_vs_master_changed.txt - - - name: Read benchmark output (full) - if: ${{ github.event_name == 'pull_request' }} - id: pr_vs_master - uses: juliangruber/read-file-action@v1 - with: - path: pr_vs_master.txt - - - name: Find Comment - if: ${{ github.event_name == 'pull_request' }} - uses: peter-evans/find-comment@v2 - id: fc - with: - issue-number: ${{ github.event.number }} - comment-author: 'github-actions[bot]' - body-includes: ClickHouse query benchmark results from GitHub Actions - - - name: Create or update comment - if: ${{ github.event_name == 'pull_request' }} - uses: peter-evans/create-or-update-comment@v3 - with: - comment-id: ${{ steps.fc.outputs.comment-id }} - issue-number: ${{ github.event.number }} - body: | - ClickHouse query benchmark results from GitHub Actions - - Lower numbers are good, higher numbers are bad. A ratio less than 1 - means a speed up and greater than 1 means a slowdown. Green lines - beginning with `+` are slowdowns (the PR is slower then master or - master is slower than the previous release). Red lines beginning - with `-` are speedups. Blank means no changes. - - Significantly changed benchmark results (PR vs master) - ```diff - ${{ steps.pr_vs_master_changed.outputs.content }} - ``` - -
- Click to view full benchmark results - - ```diff - ${{ steps.pr_vs_master.outputs.content }} - ``` -
- edit-mode: replace diff --git a/.github/workflows/build-and-deploy-prod.yml b/.github/workflows/build-and-deploy-prod.yml deleted file mode 100644 index 62a559e7bf4..00000000000 --- a/.github/workflows/build-and-deploy-prod.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Notify Sentry and Slack - -# -# Comment the `on:` section below if you want to stop deploys -# -on: - push: - branches: - - master - paths-ignore: - - 'rust/**' - - 'livestream/**' - -jobs: - sentry: - name: Notify Sentry of a production release - runs-on: ubuntu-20.04 - if: github.repository == 'PostHog/posthog' - steps: - - name: Checkout master - uses: actions/checkout@v3 - - name: Notify Sentry - uses: getsentry/action-release@v1 - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: posthog - SENTRY_PROJECT: posthog - with: - environment: production diff --git a/.github/workflows/build-hogql-parser.yml b/.github/workflows/build-hogql-parser.yml deleted file mode 100644 index ff7270bdc50..00000000000 --- a/.github/workflows/build-hogql-parser.yml +++ /dev/null @@ -1,140 +0,0 @@ -name: Release hogql-parser - -on: - pull_request: - paths: - - hogql_parser/** - - .github/workflows/build-hogql-parser.yml - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - check-version: - name: Check version legitimacy - runs-on: ubuntu-22.04 - outputs: - parser-release-needed: ${{ steps.version.outputs.parser-release-needed }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Fetching all for comparison since last push (not just last commit) - - - name: Check if hogql_parser/ has changed - id: changed-files - uses: tj-actions/changed-files@v43 - with: - since_last_remote_commit: true - files_yaml: | - parser: - - hogql_parser/** - - - name: Check if version was bumped - shell: bash - id: version - run: | - parser_release_needed='false' - if [[ ${{ steps.changed-files.outputs.parser_any_changed }} == 'true' ]]; then - published=$(curl -fSsl https://pypi.org/pypi/hogql-parser/json | jq -r '.info.version') - local=$(python hogql_parser/setup.py --version) - if [[ "$published" != "$local" ]]; then - parser_release_needed='true' - else - message_body="It looks like the code of \`hogql-parser\` has changed since last push, but its version stayed the same at $local. 👀\nMake sure to resolve this in \`hogql_parser/setup.py\` before merging!${{ github.event.pull_request.head.repo.full_name != 'PostHog/posthog' && '\nThis needs to be performed on a branch created on the PostHog/posthog repo itself. A PostHog team member will assist with this.' || ''}}" - curl -s -u posthog-bot:${{ secrets.POSTHOG_BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} -X POST -d "{ \"body\": \"$message_body\" }" "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" - fi - fi - echo "parser-release-needed=$parser_release_needed" >> $GITHUB_OUTPUT - - build-wheels: - name: Build wheels on ${{ matrix.os }} - needs: check-version - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - if: needs.check-version.outputs.parser-release-needed == 'true' && - github.event.pull_request.head.repo.full_name == 'PostHog/posthog' - strategy: - matrix: - # As of October 2023, GitHub doesn't have ARM Actions runners… and ARM emulation is insanely slow - # (20x longer) on the Linux runners (while being reasonable on the macOS runners). Hence, we use - # BuildJet as a provider of ARM runners - this solution saves a lot of time and consequently some money. - os: [ubuntu-22.04, buildjet-2vcpu-ubuntu-2204-arm, macos-12] - - steps: - - uses: actions/checkout@v4 - - - if: ${{ !endsWith(matrix.os, '-arm') }} - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - # Compiling Python 3.11 from source on ARM. We tried using the "deadsnakes" ARM repo, but it was flakey. - - if: ${{ endsWith(matrix.os, '-arm') }} - name: Install Python 3.11 on ARM (compile from source) - run: | - sudo apt-get update - sudo apt-get install -y build-essential libssl-dev zlib1g-dev \ - libncurses5-dev libncursesw5-dev libreadline-dev libsqlite3-dev \ - libgdbm-dev libdb5.3-dev libbz2-dev libexpat1-dev liblzma-dev tk-dev - wget https://www.python.org/ftp/python/3.11.0/Python-3.11.0.tar.xz - tar -xf Python-3.11.0.tar.xz - cd Python-3.11.0 - ./configure --enable-optimizations - make -j 2 - sudo make altinstall - - - name: Build sdist - if: matrix.os == 'ubuntu-22.04' # Only build the sdist once - run: cd hogql_parser && python setup.py sdist - - - name: Install cibuildwheel - run: pip install cibuildwheel==2.16.* - - - name: Build wheels - run: cd hogql_parser && python -m cibuildwheel --output-dir dist - env: - MACOSX_DEPLOYMENT_TARGET: '12' # A modern target allows us to use C++20 - - - uses: actions/upload-artifact@v3 - with: - path: | - hogql_parser/dist/*.whl - hogql_parser/dist/*.tar.gz - if-no-files-found: error - - publish: - name: Publish on PyPI - needs: build-wheels - environment: pypi-hogql-parser - permissions: - id-token: write - runs-on: ubuntu-22.04 - steps: - - name: Fetch wheels - uses: actions/download-artifact@v3 - with: - name: artifact - path: dist/ - - - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - - - uses: actions/checkout@v4 - with: - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - ref: ${{ github.event.pull_request.head.ref }} - - - name: Update hogql-parser in requirements - shell: bash - run: | - local=$(python hogql_parser/setup.py --version) - sed -i "s/hogql-parser==.*/hogql-parser==${local}/g" requirements.in - sed -i "s/hogql-parser==.*/hogql-parser==${local}/g" requirements.txt - - - uses: EndBug/add-and-commit@v9 - with: - add: '["requirements.in", "requirements.txt"]' - message: 'Use new hogql-parser version' - default_author: github_actions - github_token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} diff --git a/.github/workflows/ci-backend-update-test-timing.yml b/.github/workflows/ci-backend-update-test-timing.yml deleted file mode 100644 index 01ad7d33ce3..00000000000 --- a/.github/workflows/ci-backend-update-test-timing.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Backend CI - Update test timing - -on: - workflow_dispatch: - -env: - SECRET_KEY: '6b01eee4f945ca25045b5aab440b953461faf08693a9abbf1166dc7c6b9772da' # unsafe - for testing only - DATABASE_URL: 'postgres://posthog:posthog@localhost:5432/posthog' - REDIS_URL: 'redis://localhost' - CLICKHOUSE_HOST: 'localhost' - CLICKHOUSE_SECURE: 'False' - CLICKHOUSE_VERIFY: 'False' - TEST: 1 - OBJECT_STORAGE_ENABLED: 'True' - OBJECT_STORAGE_ENDPOINT: 'http://localhost:19000' - OBJECT_STORAGE_ACCESS_KEY_ID: 'object_storage_root_user' - OBJECT_STORAGE_SECRET_ACCESS_KEY: 'object_storage_root_password' - -jobs: - django: - name: Run Django tests and save test durations - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - uses: ./.github/actions/run-backend-tests - with: - concurrency: 1 - group: 1 - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - python-version: '3.11.9' - clickhouse-server-image: 'clickhouse/clickhouse-server:23.12.5.81-alpine' - segment: 'FOSS' - person-on-events: false - - - name: Upload updated timing data as artifacts - uses: actions/upload-artifact@v4 - if: ${{ inputs.person-on-events != 'true' && inputs.clickhouse-server-image == 'clickhouse/clickhouse-server:23.12.5.81-alpine' }} - with: - name: timing_data-${{ inputs.segment }}-${{ inputs.group }} - path: .test_durations - retention-days: 2 - # - name: Save test durations - # uses: stefanzweifel/git-auto-commit-action@v5 - # with: - # commit_message: 'Save backend test durations' - # commit_user_name: PostHog Bot - # commit_user_email: hey@posthog.com - # commit_author: PostHog Bot diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml deleted file mode 100644 index 9f7afb3e2cc..00000000000 --- a/.github/workflows/ci-backend.yml +++ /dev/null @@ -1,406 +0,0 @@ -# This workflow runs all of our backend django tests. -# -# If these tests get too slow, look at increasing concurrency and re-timing the tests by manually dispatching -# .github/workflows/ci-backend-update-test-timing.yml action -name: Backend CI - -on: - push: - branches: - - master - workflow_dispatch: - inputs: - clickhouseServerVersion: - description: ClickHouse server version. Leave blank for default - type: string - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - # This is so that the workflow run isn't canceled when a snapshot update is pushed within it by posthog-bot - # We do however cancel from container-images-ci.yml if a commit is pushed by someone OTHER than posthog-bot - cancel-in-progress: false - -env: - SECRET_KEY: '6b01eee4f945ca25045b5aab440b953461faf08693a9abbf1166dc7c6b9772da' # unsafe - for testing only - DATABASE_URL: 'postgres://posthog:posthog@localhost:5432/posthog' - REDIS_URL: 'redis://localhost' - CLICKHOUSE_HOST: 'localhost' - CLICKHOUSE_SECURE: 'False' - CLICKHOUSE_VERIFY: 'False' - TEST: 1 - CLICKHOUSE_SERVER_IMAGE_VERSION: ${{ github.event.inputs.clickhouseServerVersion || '' }} - OBJECT_STORAGE_ENABLED: 'True' - OBJECT_STORAGE_ENDPOINT: 'http://localhost:19000' - OBJECT_STORAGE_ACCESS_KEY_ID: 'object_storage_root_user' - OBJECT_STORAGE_SECRET_ACCESS_KEY: 'object_storage_root_password' - # tests would intermittently fail in GH actions - # with exit code 134 _after passing_ all tests - # this appears to fix it - # absolute wild tbh https://stackoverflow.com/a/75503402 - DISPLAY: ':99.0' -jobs: - # Job to decide if we should run backend ci - # See https://github.com/dorny/paths-filter#conditional-execution for more details - changes: - runs-on: ubuntu-latest - timeout-minutes: 5 - name: Determine need to run backend checks - # Set job outputs to values from filter step - outputs: - backend: ${{ steps.filter.outputs.backend }} - steps: - # For pull requests it's not necessary to checkout the code, but we - # also want this to run on master so we need to checkout - - uses: actions/checkout@v3 - - - uses: dorny/paths-filter@v2 - id: filter - with: - filters: | - backend: - # Avoid running backend tests for irrelevant changes - # NOTE: we are at risk of missing a dependency here. We could make - # the dependencies more clear if we separated the backend/frontend - # code completely - # really we should ignore ee/frontend/** but dorny doesn't support that - # - '!ee/frontend/**' - # including the negated rule appears to work - # but makes it always match because the checked file always isn't `ee/frontend/**` 🙈 - - 'ee/**/*' - - 'hogvm/**/*' - - 'posthog/**/*' - - 'bin/*.py' - - requirements.txt - - requirements-dev.txt - - mypy.ini - - pytest.ini - - frontend/src/queries/schema.json # Used for generating schema.py - - plugin-transpiler/src # Used for transpiling plugins - # Make sure we run if someone is explicitly change the workflow - - .github/workflows/ci-backend.yml - - .github/actions/run-backend-tests/action.yml - # We use docker compose for tests, make sure we rerun on - # changes to docker-compose.dev.yml e.g. dependency - # version changes - - docker-compose.dev.yml - - frontend/public/email/* - # These scripts are used in the CI - - bin/check_temporal_up - - bin/check_kafka_clickhouse_up - - backend-code-quality: - needs: changes - timeout-minutes: 30 - - name: Python code quality checks - runs-on: ubuntu-latest - - steps: - # If this run wasn't initiated by the bot (meaning: snapshot update) and we've determined - # there are backend changes, cancel previous runs - - uses: n1hility/cancel-previous-runs@v3 - if: github.actor != 'posthog-bot' && needs.changes.outputs.backend == 'true' - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - uses: actions/checkout@v3 - with: - fetch-depth: 1 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.11.9 - cache: 'pip' - cache-dependency-path: '**/requirements*.txt' - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - # uv is a fast pip alternative: https://github.com/astral-sh/uv/ - - run: pip install uv - - - name: Install SAML (python3-saml) dependencies - run: | - sudo apt-get update - sudo apt-get install libxml2-dev libxmlsec1 libxmlsec1-dev libxmlsec1-openssl - - - name: Install Python dependencies - run: | - uv pip install --system -r requirements.txt -r requirements-dev.txt - - - name: Check for syntax errors, import sort, and code style violations - run: | - ruff check . - - - name: Check formatting - run: | - ruff format --check --diff . - - - name: Add Problem Matcher - run: echo "::add-matcher::.github/mypy-problem-matcher.json" - - - name: Check static typing - run: | - mypy -p posthog | mypy-baseline filter - - - name: Check if "schema.py" is up to date - run: | - npm run schema:build:python && git diff --exit-code - - check-migrations: - needs: changes - if: needs.changes.outputs.backend == 'true' - timeout-minutes: 10 - - name: Validate Django and CH migrations - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Stop/Start stack with Docker Compose - run: | - docker compose -f docker-compose.dev.yml down - docker compose -f docker-compose.dev.yml up -d - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.11.9 - cache: 'pip' - cache-dependency-path: '**/requirements*.txt' - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - # uv is a fast pip alternative: https://github.com/astral-sh/uv/ - - run: pip install uv - - - name: Install SAML (python3-saml) dependencies - run: | - sudo apt-get update - sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl - - # First running migrations from master, to simulate the real-world scenario - - name: Checkout master - uses: actions/checkout@v3 - with: - ref: master - - - name: Install python dependencies for master - run: | - uv pip install --system -r requirements.txt -r requirements-dev.txt - - - name: Run migrations up to master - run: | - python manage.py migrate - - # Now we can consider this PR's migrations - - name: Checkout this PR - uses: actions/checkout@v3 - - - name: Install python dependencies for this PR - run: | - uv pip install --system -r requirements.txt -r requirements-dev.txt - - - name: Run migrations for this PR - run: | - python manage.py migrate - - - name: Check migrations - run: | - python manage.py makemigrations --check --dry-run - git fetch origin master - # `git diff --name-only` returns a list of files that were changed - added OR deleted OR modified - # With `--name-status` we get the same, but including a column for status, respectively: A, D, M - # In this check we exclusively care about files that were - # added (A) in posthog/migrations/. We also want to ignore - # initial migrations (0001_*) as these are guaranteed to be - # run on initial setup where there is no data. - git diff --name-status origin/master..HEAD | grep "A\sposthog/migrations/" | awk '{print $2}' | grep -v migrations/0001_ | python manage.py test_migrations_are_safe - - - name: Check CH migrations - run: | - # Same as above, except now for CH looking at files that were added in posthog/clickhouse/migrations/ - git diff --name-status origin/master..HEAD | grep "A\sposthog/clickhouse/migrations/" | awk '{print $2}' | python manage.py test_ch_migrations_are_safe - - django: - needs: changes - timeout-minutes: 30 - - name: Django tests – ${{ matrix.segment }} (persons-on-events ${{ matrix.person-on-events && 'on' || 'off' }}), Py ${{ matrix.python-version }}, ${{ matrix.clickhouse-server-image }} (${{matrix.group}}/${{ matrix.concurrency }}) - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - python-version: ['3.11.9'] - clickhouse-server-image: ['clickhouse/clickhouse-server:23.12.5.81-alpine'] - segment: ['Core'] - person-on-events: [false, true] - # :NOTE: Keep concurrency and groups in sync - concurrency: [10] - group: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] - include: - - segment: 'Temporal' - person-on-events: false - clickhouse-server-image: 'clickhouse/clickhouse-server:23.12.5.81-alpine' - python-version: '3.11.9' - concurrency: 1 - group: 1 - - steps: - # The first step is the only one that should run if `needs.changes.outputs.backend == 'false'`. - # All the other ones should rely on `needs.changes.outputs.backend` directly or indirectly, so that they're - # effectively skipped if backend code is unchanged. See https://github.com/PostHog/posthog/pull/15174. - - uses: actions/checkout@v3 - with: - fetch-depth: 1 - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.ref }} - # Use PostHog Bot token when not on forks to enable proper snapshot updating - token: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.POSTHOG_BOT_GITHUB_TOKEN || github.token }} - - - uses: ./.github/actions/run-backend-tests - if: needs.changes.outputs.backend == 'true' - with: - segment: ${{ matrix.segment }} - person-on-events: ${{ matrix.person-on-events }} - python-version: ${{ matrix.python-version }} - clickhouse-server-image: ${{ matrix.clickhouse-server-image }} - concurrency: ${{ matrix.concurrency }} - group: ${{ matrix.group }} - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - - uses: EndBug/add-and-commit@v9 - # Skip on forks - # Also skip for persons-on-events runs, as we want to ignore snapshots diverging there - if: ${{ github.event.pull_request.head.repo.full_name == 'PostHog/posthog' && needs.changes.outputs.backend == 'true' && !matrix.person-on-events }} - with: - add: '["ee", "./**/*.ambr", "posthog/queries/", "posthog/migrations", "posthog/tasks", "posthog/hogql/"]' - message: 'Update query snapshots' - pull: --rebase --autostash # Make sure we're up-to-date with other segments' updates - default_author: github_actions - github_token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - - name: Check if any snapshot changes were left uncomitted - id: changed-files - if: ${{ github.event.pull_request.head.repo.full_name == 'PostHog/posthog' && needs.changes.outputs.backend == 'true' && !matrix.person-on-events }} - run: | - if [[ -z $(git status -s | grep -v ".test_durations" | tr -d "\n") ]] - then - echo 'files_found=false' >> $GITHUB_OUTPUT - else - echo 'diff=$(git status --porcelain)' >> $GITHUB_OUTPUT - echo 'files_found=true' >> $GITHUB_OUTPUT - fi - - - name: Fail CI if some snapshots have been updated but not committed - if: steps.changed-files.outputs.files_found == 'true' && steps.add-and-commit.outcome == 'success' - run: | - echo "${{ steps.changed-files.outputs.diff }}" - exit 1 - - - name: Archive email renders - uses: actions/upload-artifact@v3 - if: needs.changes.outputs.backend == 'true' && matrix.segment == 'Core' && matrix.person-on-events == false - with: - name: email_renders - path: posthog/tasks/test/__emails__ - retention-days: 5 - - async-migrations: - name: Async migrations tests - ${{ matrix.clickhouse-server-image }} - needs: changes - strategy: - fail-fast: false - matrix: - clickhouse-server-image: ['clickhouse/clickhouse-server:23.12.5.81-alpine'] - if: needs.changes.outputs.backend == 'true' - runs-on: ubuntu-latest - steps: - - name: 'Checkout repo' - uses: actions/checkout@v3 - with: - fetch-depth: 1 - - - name: Start stack with Docker Compose - run: | - export CLICKHOUSE_SERVER_IMAGE_VERSION=${{ matrix.clickhouse-server-image }} - docker compose -f docker-compose.dev.yml down - docker compose -f docker-compose.dev.yml up -d - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.11.9 - cache: 'pip' - cache-dependency-path: '**/requirements*.txt' - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - # uv is a fast pip alternative: https://github.com/astral-sh/uv/ - - run: pip install uv - - - name: Install SAML (python3-saml) dependencies - run: | - sudo apt-get update - sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl - - - name: Install python dependencies - shell: bash - run: | - uv pip install --system -r requirements.txt -r requirements-dev.txt - - - name: Add kafka host to /etc/hosts for kafka connectivity - run: sudo echo "127.0.0.1 kafka" | sudo tee -a /etc/hosts - - - name: Set up needed files - run: | - mkdir -p frontend/dist - touch frontend/dist/index.html - touch frontend/dist/layout.html - touch frontend/dist/exporter.html - - - name: Wait for Clickhouse & Kafka - run: bin/check_kafka_clickhouse_up - - - name: Run async migrations tests - run: | - pytest -m "async_migrations" - - calculate-running-time: - name: Calculate running time - needs: [django, async-migrations] - runs-on: ubuntu-latest - if: # Run on pull requests to PostHog/posthog + on PostHog/posthog outside of PRs - but never on forks - needs.changes.outputs.backend == 'true' && - ( - github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository - ) == 'PostHog/posthog' - steps: - - name: Calculate running time - run: | - echo "${{ secrets.GITHUB_TOKEN }}" | gh auth login --with-token - run_id=${GITHUB_RUN_ID} - repo=${GITHUB_REPOSITORY} - run_info=$(gh api repos/${repo}/actions/runs/${run_id}) - echo run_info: ${run_info} - # name is the name of the workflow file - # run_started_at is the start time of the workflow - # we want to get the number of seconds between the start time and now - name=$(echo ${run_info} | jq -r '.name') - run_url=$(echo ${run_info} | jq -r '.url') - run_started_at=$(echo ${run_info} | jq -r '.run_started_at') - run_attempt=$(echo ${run_info} | jq -r '.run_attempt') - start_seconds=$(date -d "${run_started_at}" +%s) - now_seconds=$(date +%s) - duration=$((now_seconds-start_seconds)) - echo running_time_duration_seconds=${duration} >> $GITHUB_ENV - echo running_time_run_url=${run_url} >> $GITHUB_ENV - echo running_time_run_attempt=${run_attempt} >> $GITHUB_ENV - echo running_time_run_id=${run_id} >> $GITHUB_ENV - echo running_time_run_started_at=${run_started_at} >> $GITHUB_ENV - - name: Capture running time to PostHog - uses: PostHog/posthog-github-action@v0.1 - with: - posthog-token: ${{secrets.POSTHOG_API_TOKEN}} - event: 'posthog-ci-running-time' - properties: '{"duration_seconds": ${{ env.running_time_duration_seconds }}, "run_url": "${{ env.running_time_run_url }}", "run_attempt": "${{ env.running_time_run_attempt }}", "run_id": "${{ env.running_time_run_id }}", "run_started_at": "${{ env.running_time_run_started_at }}"}' diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml deleted file mode 100644 index eaa95507163..00000000000 --- a/.github/workflows/ci-e2e.yml +++ /dev/null @@ -1,312 +0,0 @@ -# -# This workflow runs CI E2E tests with Cypress. -# -# It relies on the container image built by 'container-images-ci.yml'. -# -name: E2E CI - -on: - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - changes: - runs-on: ubuntu-latest - timeout-minutes: 5 - name: Determine need to run E2E checks - # Set job outputs to values from filter step - outputs: - shouldTriggerCypress: ${{ steps.changes.outputs.shouldTriggerCypress }} - steps: - # For pull requests it's not necessary to check out the code - - uses: dorny/paths-filter@v2 - id: changes - with: - filters: | - shouldTriggerCypress: - # Avoid running E2E tests for irrelevant changes - # NOTE: we are at risk of missing a dependency here. We could make - # the dependencies more clear if we separated the backend/frontend - # code completely - - 'ee/**' - - 'posthog/**' - - 'bin/*' - - frontend/**/* - - requirements.txt - - requirements-dev.txt - - package.json - - pnpm-lock.yaml - # Make sure we run if someone is explicitly change the workflow - - .github/workflows/ci-e2e.yml - - .github/actions/build-n-cache-image/action.yml - # We use docker compose for tests, make sure we rerun on - # changes to docker-compose.dev.yml e.g. dependency - # version changes - - docker-compose.dev.yml - - Dockerfile - - cypress/** - - # Job that lists and chunks spec file names and caches node modules - chunks: - needs: changes - name: Cypress preparation - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - chunks: ${{ steps.chunk.outputs.chunks }} - steps: - - name: Check out - uses: actions/checkout@v3 - - - name: Group spec files into chunks of three - id: chunk - run: echo "chunks=$(ls cypress/e2e/* | jq --slurp --raw-input -c 'split("\n")[:-1] | _nwise(2) | join("\n")' | jq --slurp -c .)" >> $GITHUB_OUTPUT - - container: - name: Build and cache container image - runs-on: ubuntu-latest - timeout-minutes: 60 - needs: [changes] - permissions: - contents: read - id-token: write # allow issuing OIDC tokens for this workflow run - outputs: - tag: ${{ steps.build.outputs.tag }} - build-id: ${{ steps.build.outputs.build-id }} - steps: - - name: Checkout - if: needs.changes.outputs.shouldTriggerCypress == 'true' - uses: actions/checkout@v3 - - name: Build the Docker image with Depot - if: needs.changes.outputs.shouldTriggerCypress == 'true' - # Build the container image in preparation for the E2E tests - uses: ./.github/actions/build-n-cache-image - id: build - with: - save: true - actions-id-token-request-url: ${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }} - - cypress: - name: Cypress E2E tests (${{ strategy.job-index }}) - runs-on: ubuntu-latest - timeout-minutes: 60 - needs: [chunks, changes, container] - permissions: - id-token: write # allow issuing OIDC tokens for this workflow run - - strategy: - # when one test fails, DO NOT cancel the other - # containers, as there may be other spec failures - # we want to know about. - fail-fast: false - matrix: - chunk: ${{ fromJson(needs.chunks.outputs.chunks) }} - - steps: - - name: Checkout - if: needs.changes.outputs.shouldTriggerCypress == 'true' - uses: actions/checkout@v3 - - - name: Install pnpm - if: needs.changes.outputs.shouldTriggerCypress == 'true' - uses: pnpm/action-setup@v4 - - - name: Set up Node.js - if: needs.changes.outputs.shouldTriggerCypress == 'true' - uses: actions/setup-node@v4 - with: - node-version: 18.12.1 - - - name: Get pnpm cache directory path - if: needs.changes.outputs.shouldTriggerCypress == 'true' - id: pnpm-cache-dir - run: echo "PNPM_STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Get cypress cache directory path - if: needs.changes.outputs.shouldTriggerCypress == 'true' - id: cypress-cache-dir - run: echo "CYPRESS_BIN_PATH=$(npx cypress cache path)" >> $GITHUB_OUTPUT - - - uses: actions/cache@v4 - if: needs.changes.outputs.shouldTriggerCypress == 'true' - id: pnpm-cache - with: - path: | - ${{ steps.pnpm-cache-dir.outputs.PNPM_STORE_PATH }} - ${{ steps.cypress-cache-dir.outputs.CYPRESS_BIN_PATH }} - key: ${{ runner.os }}-pnpm-cypress-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-cypress- - - - name: Install package.json dependencies with pnpm - if: needs.changes.outputs.shouldTriggerCypress == 'true' - run: pnpm install --frozen-lockfile - - - name: Stop/Start stack with Docker Compose - # these are required checks so, we can't skip entire sections - if: needs.changes.outputs.shouldTriggerCypress == 'true' - run: | - docker compose -f docker-compose.dev.yml down - docker compose -f docker-compose.dev.yml up -d - - - name: Wait for ClickHouse - # these are required checks so, we can't skip entire sections - if: needs.changes.outputs.shouldTriggerCypress == 'true' - run: ./bin/check_kafka_clickhouse_up - - - name: Install Depot CLI - if: needs.changes.outputs.shouldTriggerCypress == 'true' - uses: depot/setup-action@v1 - - - name: Get Docker image cached in Depot - if: needs.changes.outputs.shouldTriggerCypress == 'true' - uses: depot/pull-action@v1 - with: - build-id: ${{ needs.container.outputs.build-id }} - tags: ${{ needs.container.outputs.tag }} - - - name: Write .env # This step intentionally has no if, so that GH always considers the action as having run - run: | - cat <> .env - SECRET_KEY=6b01eee4f945ca25045b5aab440b953461faf08693a9abbf1166dc7c6b9772da - REDIS_URL=redis://localhost - DATABASE_URL=postgres://posthog:posthog@localhost:5432/posthog - KAFKA_HOSTS=kafka:9092 - DISABLE_SECURE_SSL_REDIRECT=1 - SECURE_COOKIES=0 - OPT_OUT_CAPTURE=0 - E2E_TESTING=1 - SKIP_SERVICE_VERSION_REQUIREMENTS=1 - EMAIL_HOST=email.test.posthog.net - SITE_URL=http://localhost:8000 - NO_RESTART_LOOP=1 - CLICKHOUSE_SECURE=0 - OBJECT_STORAGE_ENABLED=1 - OBJECT_STORAGE_ENDPOINT=http://localhost:19000 - OBJECT_STORAGE_ACCESS_KEY_ID=object_storage_root_user - OBJECT_STORAGE_SECRET_ACCESS_KEY=object_storage_root_password - GITHUB_ACTION_RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - CELERY_METRICS_PORT=8999 - CLOUD_DEPLOYMENT=E2E - EOT - - - name: Start PostHog - # these are required checks so, we can't skip entire sections - if: needs.changes.outputs.shouldTriggerCypress == 'true' - run: | - mkdir -p /tmp/logs - - echo "Starting PostHog using the container image ${{ needs.container.outputs.tag }}" - DOCKER_RUN="docker run --rm --network host --add-host kafka:127.0.0.1 --env-file .env ${{ needs.container.outputs.tag }}" - - $DOCKER_RUN ./bin/migrate - $DOCKER_RUN python manage.py setup_dev - - # only starts the plugin server so that the "wait for PostHog" step passes - $DOCKER_RUN ./bin/docker-worker &> /tmp/logs/worker.txt & - $DOCKER_RUN ./bin/docker-server &> /tmp/logs/server.txt & - - - name: Wait for PostHog - # these are required checks so, we can't skip entire sections - if: needs.changes.outputs.shouldTriggerCypress == 'true' - # this action might be abandoned - but v1 doesn't point to latest of v1 (which it should) - # so pointing to v1.1.0 to remove warnings about node version with v1 - # todo check https://github.com/iFaxity/wait-on-action/releases for new releases - uses: iFaxity/wait-on-action@v1.2.1 - timeout-minutes: 3 - with: - verbose: true - log: true - resource: http://localhost:8000 - - - name: Cypress run - # these are required checks so, we can't skip entire sections - if: needs.changes.outputs.shouldTriggerCypress == 'true' - uses: cypress-io/github-action@v6 - with: - config-file: cypress.e2e.config.ts - config: retries=2 - spec: ${{ matrix.chunk }} - install: false - env: - E2E_TESTING: 1 - OPT_OUT_CAPTURE: 0 - GITHUB_ACTION_RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - - - name: Archive test screenshots - uses: actions/upload-artifact@v3 - with: - name: screenshots - path: cypress/screenshots - if: ${{ failure() }} - - - name: Archive test downloads - uses: actions/upload-artifact@v3 - with: - name: downloads - path: cypress/downloads - if: ${{ failure() }} - - - name: Archive test videos - uses: actions/upload-artifact@v3 - with: - name: videos - path: cypress/videos - if: ${{ failure() }} - - - name: Archive accessibility violations - if: needs.changes.outputs.shouldTriggerCypress == 'true' - uses: actions/upload-artifact@v3 - with: - name: accessibility-violations - path: '**/a11y/' - if-no-files-found: 'ignore' - - - name: Show logs on failure - # use artefact here, as I think the output will be too large for display in an action - uses: actions/upload-artifact@v3 - with: - name: logs-${{ strategy.job-index }} - path: /tmp/logs - if: ${{ failure() }} - - calculate-running-time: - name: Calculate running time - runs-on: ubuntu-latest - needs: [cypress] - if: needs.changes.outputs.shouldTriggerCypress == 'true' && - github.event.pull_request.head.repo.full_name == 'PostHog/posthog' - steps: - - name: Calculate running time - run: | - echo "${{ secrets.GITHUB_TOKEN }}" | gh auth login --with-token - run_id=${GITHUB_RUN_ID} - repo=${GITHUB_REPOSITORY} - run_info=$(gh api repos/${repo}/actions/runs/${run_id}) - echo run_info: ${run_info} - # name is the name of the workflow file - # run_started_at is the start time of the workflow - # we want to get the number of seconds between the start time and now - name=$(echo ${run_info} | jq -r '.name') - run_url=$(echo ${run_info} | jq -r '.url') - run_started_at=$(echo ${run_info} | jq -r '.run_started_at') - run_attempt=$(echo ${run_info} | jq -r '.run_attempt') - start_seconds=$(date -d "${run_started_at}" +%s) - now_seconds=$(date +%s) - duration=$((now_seconds-start_seconds)) - echo running_time_duration_seconds=${duration} >> $GITHUB_ENV - echo running_time_run_url=${run_url} >> $GITHUB_ENV - echo running_time_run_attempt=${run_attempt} >> $GITHUB_ENV - echo running_time_run_id=${run_id} >> $GITHUB_ENV - echo running_time_run_started_at=${run_started_at} >> $GITHUB_ENV - - - name: Capture running time to PostHog - if: github.event.pull_request.head.repo.full_name == 'PostHog/posthog' - uses: PostHog/posthog-github-action@v0.1 - with: - posthog-token: ${{secrets.POSTHOG_API_TOKEN}} - event: 'posthog-ci-running-time' - properties: '{"duration_seconds": ${{ env.running_time_duration_seconds }}, "run_url": "${{ env.running_time_run_url }}", "run_attempt": "${{ env.running_time_run_attempt }}", "run_id": "${{ env.running_time_run_id }}", "run_started_at": "${{ env.running_time_run_started_at }}"}' diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml deleted file mode 100644 index d466e0d3cdb..00000000000 --- a/.github/workflows/ci-frontend.yml +++ /dev/null @@ -1,163 +0,0 @@ -name: Frontend CI - -on: - pull_request: - push: - branches: - - master - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - # Job to decide if we should run frontend ci - # See https://github.com/dorny/paths-filter#conditional-execution for more details - # we skip each step individually, so they are still reported as success - # because many of them are required for CI checks to be green - changes: - runs-on: ubuntu-latest - timeout-minutes: 5 - name: Determine need to run frontend checks - outputs: - frontend: ${{ steps.filter.outputs.frontend }} - steps: - # For pull requests it's not necessary to check out the code, but we - # also want this to run on master, so we need to check out - - uses: actions/checkout@v3 - - - uses: dorny/paths-filter@v2 - id: filter - with: - filters: | - frontend: - # Avoid running frontend tests for irrelevant changes - # NOTE: we are at risk of missing a dependency here. - - 'bin/**' - - 'frontend/**' - - 'ee/frontend/**' - # Make sure we run if someone is explicitly change the workflow - - .github/workflows/ci-frontend.yml - # various JS config files - - .eslintrc.js - - .prettier* - - babel.config.js - - package.json - - pnpm-lock.yaml - - jest.*.ts - - tsconfig.json - - tsconfig.*.json - - webpack.config.js - - stylelint* - - frontend-code-quality: - name: Code quality checks - needs: changes - # kea typegen and typescript:check need some more oomph - runs-on: ubuntu-latest - steps: - # we need at least one thing to run to make sure we include everything for required jobs - - uses: actions/checkout@v3 - - - name: Install pnpm - if: needs.changes.outputs.frontend == 'true' - uses: pnpm/action-setup@v4 - - - name: Set up Node.js - if: needs.changes.outputs.frontend == 'true' - uses: actions/setup-node@v4 - with: - node-version: 18.12.1 - - - name: Get pnpm cache directory path - if: needs.changes.outputs.frontend == 'true' - id: pnpm-cache-dir - run: echo "PNPM_STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - uses: actions/cache@v4 - if: needs.changes.outputs.frontend == 'true' - id: pnpm-cache - with: - path: ${{ steps.pnpm-cache-dir.outputs.PNPM_STORE_PATH }} - key: ${{ runner.os }}-pnpm-cypress-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: ${{ runner.os }}-pnpm-cypress- - - - name: Install package.json dependencies with pnpm - if: needs.changes.outputs.frontend == 'true' - run: pnpm install --frozen-lockfile - - - name: Check formatting with prettier - if: needs.changes.outputs.frontend == 'true' - run: pnpm prettier:check - - - name: Lint with Stylelint - if: needs.changes.outputs.frontend == 'true' - run: pnpm lint:css - - - name: Generate logic types and run typescript with strict - if: needs.changes.outputs.frontend == 'true' - run: pnpm typegen:write && pnpm typescript:check - - - name: Lint with ESLint - if: needs.changes.outputs.frontend == 'true' - run: pnpm lint:js - - - name: Check if "schema.json" is up to date - if: needs.changes.outputs.frontend == 'true' - run: pnpm schema:build:json && git diff --exit-code - - - name: Check if mobile replay "schema.json" is up to date - if: needs.changes.outputs.frontend == 'true' - run: pnpm mobile-replay:schema:build:json && git diff --exit-code - - - name: Check toolbar bundle size - if: needs.changes.outputs.frontend == 'true' - uses: preactjs/compressed-size-action@v2 - with: - build-script: 'build' - compression: 'none' - pattern: 'frontend/dist/toolbar.js' - # we only care if the toolbar will increase a lot - minimum-change-threshold: 1000 - - jest: - runs-on: ubuntu-latest - needs: changes - name: Jest test (${{ matrix.segment }} - ${{ matrix.chunk }}) - - strategy: - # If one test fails, still run the others - fail-fast: false - matrix: - segment: ['FOSS', 'EE'] - chunk: [1, 2, 3] - - steps: - # we need at least one thing to run to make sure we include everything for required jobs - - uses: actions/checkout@v3 - - - name: Remove ee - if: needs.changes.outputs.frontend == 'true' && matrix.segment == 'FOSS' - run: rm -rf ee - - - name: Install pnpm - if: needs.changes.outputs.frontend == 'true' - uses: pnpm/action-setup@v4 - - - name: Set up Node.js - if: needs.changes.outputs.frontend == 'true' - uses: actions/setup-node@v4 - with: - node-version: 18.12.1 - cache: pnpm - - - name: Install package.json dependencies with pnpm - if: needs.changes.outputs.frontend == 'true' - run: pnpm install --frozen-lockfile - - - name: Test with Jest - # set maxWorkers or Jest only uses 1 CPU in GitHub Actions - run: pnpm test:unit --maxWorkers=2 --shard=${{ matrix.chunk }}/3 - if: needs.changes.outputs.frontend == 'true' - env: - NODE_OPTIONS: --max-old-space-size=6144 diff --git a/.github/workflows/ci-hobby.yml b/.github/workflows/ci-hobby.yml deleted file mode 100644 index 0025e656c82..00000000000 --- a/.github/workflows/ci-hobby.yml +++ /dev/null @@ -1,50 +0,0 @@ -# This workflow runs e2e smoke test for hobby deployment -# To check on the status of the instance if this fails go to DO open the instance -# Instance name should look like `do-ci-hobby-deploy-xxxx` -# SSH onto the instance and `tail -f /var/log/cloud-init-output.log` -name: e2e - hobby smoke test -on: - push: - branches: - - 'release-*.*' - pull_request: - paths: - - docker-compose.base.yml - - docker-compose.hobby.yml - - bin/* - - docker/* - - .github/workflows/ci-hobby.yml - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - changes: - runs-on: ubuntu-latest - # this is a slow one - timeout-minutes: 30 - name: Setup DO Hobby Instance and test - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - python-version: '3.8' - cache: 'pip' # caching pip dependencies - cache-dependency-path: '**/requirements*.txt' - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - name: Get python deps - run: pip install python-digitalocean==1.17.0 requests==2.28.1 - - name: Setup DO Hobby Instance - run: python3 bin/hobby-ci.py create - env: - DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }} - - name: Run smoke tests on DO - run: python3 bin/hobby-ci.py test $GITHUB_HEAD_REF - env: - DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }} - - name: Post-cleanup step - if: always() - run: python3 bin/hobby-ci.py destroy - env: - DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }} diff --git a/.github/workflows/ci-hog.yml b/.github/workflows/ci-hog.yml deleted file mode 100644 index 6f4c326506d..00000000000 --- a/.github/workflows/ci-hog.yml +++ /dev/null @@ -1,143 +0,0 @@ -# This workflow runs all of our backend django tests. -# -# If these tests get too slow, look at increasing concurrency and re-timing the tests by manually dispatching -# .github/workflows/ci-backend-update-test-timing.yml action -name: Hog CI - -on: - push: - branches: - - master - paths-ignore: - - rust/** - - livestream/** - pull_request: - paths-ignore: - - rust/** - - livestream/** - -jobs: - # Job to decide if we should run backend ci - # See https://github.com/dorny/paths-filter#conditional-execution for more details - changes: - runs-on: ubuntu-latest - timeout-minutes: 5 - name: Determine need to run Hog checks - # Set job outputs to values from filter step - outputs: - hog: ${{ steps.filter.outputs.hog }} - steps: - # For pull requests it's not necessary to checkout the code, but we - # also want this to run on master so we need to checkout - - uses: actions/checkout@v3 - - - uses: dorny/paths-filter@v2 - id: filter - with: - filters: | - hog: - # Avoid running tests for irrelevant changes - - 'hogvm/**/*' - - 'posthog/hogql/**/*' - - 'bin/hog' - - 'bin/hoge' - - requirements.txt - - requirements-dev.txt - - .github/workflows/ci-hog.yml - - hog-tests: - needs: changes - timeout-minutes: 30 - - name: Hog tests - runs-on: ubuntu-latest - - steps: - # If this run wasn't initiated by the bot (meaning: snapshot update) and we've determined - # there are backend changes, cancel previous runs - - uses: n1hility/cancel-previous-runs@v3 - if: github.actor != 'posthog-bot' && needs.changes.outputs.hog == 'true' - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - uses: actions/checkout@v3 - if: needs.changes.outputs.hog == 'true' - with: - fetch-depth: 1 - - - name: Set up Python - if: needs.changes.outputs.hog == 'true' - uses: actions/setup-python@v5 - with: - python-version: 3.11.9 - cache: 'pip' - cache-dependency-path: '**/requirements*.txt' - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - # uv is a fast pip alternative: https://github.com/astral-sh/uv/ - - run: pip install uv - if: needs.changes.outputs.hog == 'true' - - - name: Install SAML (python3-saml) dependencies - if: needs.changes.outputs.hog == 'true' - run: | - sudo apt-get update - sudo apt-get install libxml2-dev libxmlsec1 libxmlsec1-dev libxmlsec1-openssl - - - name: Install Python dependencies - if: needs.changes.outputs.hog == 'true' - run: | - uv pip install --system -r requirements.txt -r requirements-dev.txt - - - name: Install pnpm - if: needs.changes.outputs.hog == 'true' - uses: pnpm/action-setup@v4 - - - name: Set up Node.js - if: needs.changes.outputs.hog == 'true' - uses: actions/setup-node@v4 - with: - node-version: 18.12.1 - - - name: Check if ANTLR definitions are up to date - if: needs.changes.outputs.hog == 'true' - run: | - cd .. - sudo apt-get install default-jre - mkdir antlr - cd antlr - curl -o antlr.jar https://www.antlr.org/download/antlr-$ANTLR_VERSION-complete.jar - export PWD=`pwd` - echo '#!/bin/bash' > antlr - echo "java -jar $PWD/antlr.jar \$*" >> antlr - chmod +x antlr - export CLASSPATH=".:$PWD/antlr.jar:$CLASSPATH" - export PATH="$PWD:$PATH" - - cd ../posthog - antlr | grep "Version" - npm run grammar:build && git diff --exit-code - env: - # Installing a version of ANTLR compatible with what's in Homebrew as of October 2023 (version 4.13), - # as apt-get is quite out of date. The same version must be set in hogql_parser/pyproject.toml - ANTLR_VERSION: '4.13.1' - - - name: Run HogVM Python tests - if: needs.changes.outputs.hog == 'true' - run: | - pytest hogvm - - - name: Run HogVM TypeScript tests - if: needs.changes.outputs.hog == 'true' - run: | - cd hogvm/typescript - pnpm install --frozen-lockfile - pnpm run test - - - name: Run Hog tests - if: needs.changes.outputs.hog == 'true' - run: | - cd hogvm/typescript - pnpm run build - cd ../ - ./test.sh && git diff --exit-code diff --git a/.github/workflows/ci-plugin-server.yml b/.github/workflows/ci-plugin-server.yml deleted file mode 100644 index a24eaf53d4e..00000000000 --- a/.github/workflows/ci-plugin-server.yml +++ /dev/null @@ -1,264 +0,0 @@ -name: Plugin Server CI - -on: - pull_request: - push: - branches: - - master - -env: - OBJECT_STORAGE_ENABLED: true - OBJECT_STORAGE_ENDPOINT: 'http://localhost:19000' - OBJECT_STORAGE_ACCESS_KEY_ID: 'object_storage_root_user' - OBJECT_STORAGE_SECRET_ACCESS_KEY: 'object_storage_root_password' - OBJECT_STORAGE_SESSION_RECORDING_FOLDER: 'session_recordings' - OBJECT_STORAGE_BUCKET: 'posthog' - # set the max buffer size small enough that the functional tests behave the same in CI as when running locally - SESSION_RECORDING_MAX_BUFFER_SIZE_KB: 1024 - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - # Job to decide if we should run plugin server ci - # See https://github.com/dorny/paths-filter#conditional-execution for more details - changes: - runs-on: ubuntu-latest - timeout-minutes: 5 - name: Determine need to run plugin server checks - outputs: - plugin-server: ${{ steps.filter.outputs.plugin-server }} - steps: - # For pull requests it's not necessary to checkout the code, but we - # also want this to run on master so we need to checkout - - uses: actions/checkout@v3 - - - uses: dorny/paths-filter@v2 - id: filter - with: - filters: | - plugin-server: - - .github/workflows/ci-plugin-server.yml - - 'plugin-server/**' - - 'posthog/clickhouse/migrations/**' - - 'ee/migrations/**' - - 'posthog/management/commands/setup_test_environment.py' - - 'posthog/migrations/**' - - 'posthog/plugins/**' - - 'docker*.yml' - - '*Dockerfile' - - code-quality: - name: Code quality - needs: changes - if: needs.changes.outputs.plugin-server == 'true' - runs-on: ubuntu-latest - defaults: - run: - working-directory: 'plugin-server' - - steps: - - uses: actions/checkout@v3 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 18.12.1 - cache: pnpm - - - name: Install package.json dependencies with pnpm - run: pnpm install --frozen-lockfile - - - name: Check formatting with prettier - run: pnpm prettier:check - - - name: Lint with ESLint - run: pnpm lint - - tests: - name: Plugin Server Tests (${{matrix.shard}}) - needs: changes - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - shard: [1/3, 2/3, 3/3] - - env: - REDIS_URL: 'redis://localhost' - CLICKHOUSE_HOST: 'localhost' - CLICKHOUSE_DATABASE: 'posthog_test' - KAFKA_HOSTS: 'kafka:9092' - - steps: - - name: Code check out - if: needs.changes.outputs.plugin-server == 'true' - uses: actions/checkout@v3 - - - name: Stop/Start stack with Docker Compose - if: needs.changes.outputs.plugin-server == 'true' - run: | - docker compose -f docker-compose.dev.yml down - docker compose -f docker-compose.dev.yml up -d - - - name: Add Kafka to /etc/hosts - if: needs.changes.outputs.plugin-server == 'true' - run: echo "127.0.0.1 kafka" | sudo tee -a /etc/hosts - - - name: Set up Python - if: needs.changes.outputs.plugin-server == 'true' - uses: actions/setup-python@v5 - with: - python-version: 3.11.9 - cache: 'pip' - cache-dependency-path: '**/requirements*.txt' - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - # uv is a fast pip alternative: https://github.com/astral-sh/uv/ - - run: pip install uv - - - name: Install SAML (python3-saml) dependencies - if: needs.changes.outputs.plugin-server == 'true' - run: | - sudo apt-get update - sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl - - - name: Install python dependencies - if: needs.changes.outputs.plugin-server == 'true' - run: | - uv pip install --system -r requirements-dev.txt - uv pip install --system -r requirements.txt - - - name: Install pnpm - if: needs.changes.outputs.plugin-server == 'true' - uses: pnpm/action-setup@v4 - - - name: Set up Node.js - if: needs.changes.outputs.plugin-server == 'true' - uses: actions/setup-node@v4 - with: - node-version: 18.12.1 - cache: pnpm - cache-dependency-path: plugin-server/pnpm-lock.yaml - - - name: Install package.json dependencies with pnpm - if: needs.changes.outputs.plugin-server == 'true' - run: cd plugin-server && pnpm i - - - name: Wait for Clickhouse, Redis & Kafka - if: needs.changes.outputs.plugin-server == 'true' - run: | - docker compose -f docker-compose.dev.yml up kafka redis clickhouse -d --wait - bin/check_kafka_clickhouse_up - - - name: Set up databases - if: needs.changes.outputs.plugin-server == 'true' - env: - TEST: 'true' - SECRET_KEY: 'abcdef' # unsafe - for testing only - DATABASE_URL: 'postgres://posthog:posthog@localhost:5432/posthog' - run: cd plugin-server && pnpm setup:test - - - name: Test with Jest - if: needs.changes.outputs.plugin-server == 'true' - env: - # Below DB name has `test_` prepended, as that's how Django (ran above) creates the test DB - DATABASE_URL: 'postgres://posthog:posthog@localhost:5432/test_posthog' - REDIS_URL: 'redis://localhost' - NODE_OPTIONS: '--max_old_space_size=4096' - run: cd plugin-server && pnpm test -- --runInBand --forceExit tests/ --shard=${{matrix.shard}} - - functional-tests: - name: Functional tests - needs: changes - if: needs.changes.outputs.plugin-server == 'true' - runs-on: ubuntu-latest - - env: - REDIS_URL: 'redis://localhost' - CLICKHOUSE_HOST: 'localhost' - CLICKHOUSE_DATABASE: 'posthog_test' - KAFKA_HOSTS: 'kafka:9092' - DATABASE_URL: 'postgres://posthog:posthog@localhost:5432/posthog' - RELOAD_PLUGIN_JITTER_MAX_MS: 0 - - steps: - - name: Code check out - uses: actions/checkout@v3 - - - name: Stop/Start stack with Docker Compose - run: | - docker compose -f docker-compose.dev.yml down - docker compose -f docker-compose.dev.yml up -d - - - name: Add Kafka to /etc/hosts - run: echo "127.0.0.1 kafka" | sudo tee -a /etc/hosts - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.11.9 - cache: 'pip' - cache-dependency-path: '**/requirements*.txt' - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - # uv is a fast pip alternative: https://github.com/astral-sh/uv/ - - run: pip install uv - - - name: Install SAML (python3-saml) dependencies - run: | - sudo apt-get update - sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl - - - name: Install python dependencies - run: | - uv pip install --system -r requirements-dev.txt - uv pip install --system -r requirements.txt - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 18.12.1 - cache: pnpm - cache-dependency-path: plugin-server/pnpm-lock.yaml - - - name: Install package.json dependencies with pnpm - run: | - cd plugin-server - pnpm install --frozen-lockfile - pnpm build - - - name: Wait for Clickhouse, Redis & Kafka - run: | - docker compose -f docker-compose.dev.yml up kafka redis clickhouse -d --wait - bin/check_kafka_clickhouse_up - - - name: Set up databases - env: - DEBUG: 'true' - SECRET_KEY: 'abcdef' # unsafe - for testing only - run: | - ./manage.py migrate - ./manage.py migrate_clickhouse - - - name: Run functional tests - run: | - cd plugin-server - ./bin/ci_functional_tests.sh - - - name: Upload coverage report - uses: actions/upload-artifact@v3 - if: always() - with: - name: functional-coverage - if-no-files-found: warn - retention-days: 1 - path: 'plugin-server/coverage' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index a914e2a9522..00000000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,105 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: 'CodeQL' - -on: - push: - branches: ['master'] - paths-ignore: - - 'rust/**' - pull_request: - branches: ['master'] - paths-ignore: - - 'rust/**' - schedule: - - cron: '27 1 * * 0' - -jobs: - analyze: - name: Analyze (${{ matrix.language }}) - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners (GitHub.com only) - # Consider using larger runners or machines with greater resources for possible analysis time improvements. - runs-on: 'ubuntu-latest' - timeout-minutes: 15 - permissions: - # required for all workflows - security-events: write - - # required to fetch internal or private CodeQL packs - packages: read - - # only required for workflows in private repositories - actions: read - contents: read - - strategy: - fail-fast: false - matrix: - include: - # TODO: Add cpp with manual build mode when we need it. - # needs updating of manual build instructions below - # - language: c-cpp - # build-mode: manual - - language: javascript-typescript - build-mode: none - - language: python - build-mode: none - - language: go - build-mode: autobuild - # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' - # Use `c-cpp` to analyze code written in C, C++ or both - # Use 'java-kotlin' to analyze code written in Java, Kotlin or both - # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both - # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, - # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. - # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how - # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - if: matrix.build-mode == 'manual' - shell: bash - run: | - echo 'If you are using a "manual" build mode for one or more of the' \ - 'languages you are analyzing, replace this with the commands to build' \ - 'your code, for example:' - echo ' make bootstrap' - echo ' make release' - exit 1 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: '/language:${{matrix.language}}' diff --git a/.github/workflows/codespaces.yml b/.github/workflows/codespaces.yml deleted file mode 100644 index b725c032dda..00000000000 --- a/.github/workflows/codespaces.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: GitHub Codespaces image build - -# Run only on master branch. We could also build on branch but this seems like -# an optimization that can be done as and when desired. The main use case we're -# handling is creating and working off a branch from master, so it doesn't seem -# like an immediate requirement to have branches as well. -# -# NOTE: the job is setup to also push branch images as well, and using branch -# and master as caching, so if we want to add the optimisation for branches we -# can just remove the master branch restriction. -on: - push: - branches: - - master - pull_request: - types: - - opened - - labeled - - synchronize - -jobs: - build: - name: Build Codespaces image - runs-on: ubuntu-latest - - # Build on master and PRs with the label 'codespaces-build' only - if: ${{ github.ref == 'refs/heads/master' || contains(github.event.pull_request.labels.*.name, 'codespaces-build') }} - - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 1 - - - name: Lowercase GITHUB_REPOSITORY - id: lowercase - run: | - echo "repository=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - - # As ghcr.io complains if the image has upper case letters, we use - # this action to ensure we get a lower case version. See - # https://github.com/docker/build-push-action/issues/237#issuecomment-848673650 - # for more details - - name: Docker image metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/${{ steps.lowercase.outputs.repository }}/codespaces - tags: | - type=ref,event=branch - type=raw,value=master - - # We also want to use cache-from when building, but we want to also - # include the master tag so we get the master branch image as well. - # This creates a scope similar to the github cache action scoping - - name: Docker cache-from/cache-to metadata - id: meta-for-cache - uses: docker/metadata-action@v5 - with: - images: ghcr.io/${{ steps.lowercase.outputs.repository }}/codespaces - tags: | - type=raw,value=master - - # Install QEMU so we can target x86_64 (github codespaces) - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v2 - with: - context: . - file: .devcontainer/Dockerfile - push: true - platforms: x86_64 - # Cache from this branch, or master - cache-from: ${{ steps.meta-for-cache.outputs.tags }} - # NOTE: we use inline as suggested here: - # https://github.com/docker/build-push-action/blob/master/docs/advanced/cache.md#inline-cache - # It notes that it doesn't support mode=max, but we're not - # removing any layers, soooo, maybe it's fine. - cache-to: type=inline - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/container-images-cd.yml b/.github/workflows/container-images-cd.yml deleted file mode 100644 index 18888e9e3b3..00000000000 --- a/.github/workflows/container-images-cd.yml +++ /dev/null @@ -1,203 +0,0 @@ -# -# Build and push PostHog and PostHog Cloud container images -# -# - posthog_build: build and push the PostHog container image to DockerHub -# -# - posthog_cloud_build: build the PostHog Cloud container image using -# as base image the container image from the previous step. The image is -# then pushed to AWS ECR. -# -name: Container Images CD - -on: - push: - branches: - - master - paths-ignore: - - 'rust/**' - - 'livestream/**' - workflow_dispatch: - -jobs: - posthog_build: - name: Build and push PostHog - if: github.repository == 'PostHog/posthog' - runs-on: ubuntu-latest - permissions: - id-token: write # allow issuing OIDC tokens for this workflow run - contents: read # allow at least reading the repo contents, add other permissions if necessary - packages: write # allow push to ghcr.io - - steps: - - name: Check out - uses: actions/checkout@v3 - with: - fetch-depth: 2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Depot CLI - uses: depot/setup-action@v1 - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: us-east-1 - - - name: Login to Amazon ECR - id: aws-ecr - uses: aws-actions/amazon-ecr-login@v2 - - - name: Login to DockerHub - uses: docker/login-action@v2 - with: - username: posthog - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push container image - id: build - uses: depot/build-push-action@v1 - with: - buildx-fallback: false # the fallback is so slow it's better to just fail - push: true - tags: posthog/posthog:${{ github.sha }},posthog/posthog:latest,${{ steps.aws-ecr.outputs.registry }}/posthog-cloud:master - platforms: linux/arm64,linux/amd64 - build-args: COMMIT_HASH=${{ github.sha }} - - - name: get deployer token - id: deployer - uses: getsentry/action-github-app-token@v3 - with: - app_id: ${{ secrets.DEPLOYER_APP_ID }} - private_key: ${{ secrets.DEPLOYER_APP_PRIVATE_KEY }} - - - name: get PR labels - id: labels - uses: ./.github/actions/get-pr-labels - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Trigger PostHog Cloud deployment from Charts - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ steps.deployer.outputs.token }} - repository: PostHog/charts - event-type: commit_state_update - client-payload: | - { - "values": { - "image": { - "sha": "${{ steps.build.outputs.digest }}" - } - }, - "release": "posthog", - "commit": ${{ toJson(github.event.head_commit) }}, - "repository": ${{ toJson(github.repository) }}, - "labels": ${{ steps.labels.outputs.labels }} - } - - - name: Check for changes in plugins directory - id: check_changes_plugins - run: | - echo "changed=$((git diff --name-only HEAD^ HEAD | grep -q '^plugin-server/' && echo true) || echo false)" >> $GITHUB_OUTPUT - - - name: Trigger Ingestion Cloud deployment - if: steps.check_changes_plugins.outputs.changed == 'true' - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ steps.deployer.outputs.token }} - repository: PostHog/charts - event-type: commit_state_update - client-payload: | - { - "values": { - "image": { - "sha": "${{ steps.build.outputs.digest }}" - } - }, - "release": "ingestion", - "commit": ${{ toJson(github.event.head_commit) }}, - "repository": ${{ toJson(github.repository) }}, - "labels": ${{ toJson(steps.labels.outputs.labels) }} - } - - - name: Check for changes that affect batch exports temporal worker - id: check_changes_batch_exports_temporal_worker - run: | - echo "changed=$((git diff --name-only HEAD^ HEAD | grep -qE '^posthog/temporal/common|^posthog/temporal/batch_exports|^posthog/batch_exports/|^posthog/management/commands/start_temporal_worker.py$' && echo true) || echo false)" >> $GITHUB_OUTPUT - - - name: Trigger Batch Exports Temporal Worker Cloud deployment - if: steps.check_changes_batch_exports_temporal_worker.outputs.changed == 'true' - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ steps.deployer.outputs.token }} - repository: PostHog/charts - event-type: commit_state_update - client-payload: | - { - "values": { - "image": { - "sha": "${{ steps.build.outputs.digest }}" - } - }, - "release": "temporal-worker", - "commit": ${{ toJson(github.event.head_commit) }}, - "repository": ${{ toJson(github.repository) }}, - "labels": ${{ steps.labels.outputs.labels }} - } - - - name: Check for changes that affect general purpose temporal worker - id: check_changes_general_purpose_temporal_worker - run: | - echo "changed=$((git diff --name-only HEAD^ HEAD | grep -qE '^posthog/temporal/common|^posthog/temporal/proxy_service|^posthog/management/commands/start_temporal_worker.py$' && echo true) || echo false)" >> $GITHUB_OUTPUT - - - name: Trigger General Purpose Temporal Worker Cloud deployment - if: steps.check_changes_general_purpose_temporal_worker.outputs.changed == 'true' - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ steps.deployer.outputs.token }} - repository: PostHog/charts - event-type: commit_state_update - client-payload: | - { - "values": { - "image": { - "sha": "${{ steps.build.outputs.digest }}" - } - }, - "release": "temporal-worker-general-purpose", - "commit": ${{ toJson(github.event.head_commit) }}, - "repository": ${{ toJson(github.repository) }}, - "labels": ${{ steps.labels.outputs.labels }} - } - - - name: Check for changes that affect data warehouse temporal worker - id: check_changes_data_warehouse_temporal_worker - run: | - echo "changed=$((git diff --name-only HEAD^ HEAD | grep -qE '^posthog/temporal/common|^posthog/temporal/data_imports|^posthog/warehouse/|^posthog/management/commands/start_temporal_worker.py$' && echo true) || echo false)" >> $GITHUB_OUTPUT - - - name: Trigger Data Warehouse Temporal Worker Cloud deployment - if: steps.check_changes_data_warehouse_temporal_worker.outputs.changed == 'true' - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ steps.deployer.outputs.token }} - repository: PostHog/charts - event-type: commit_state_update - client-payload: | - { - "values": { - "image": { - "sha": "${{ steps.build.outputs.digest }}" - } - }, - "release": "temporal-worker-data-warehouse", - "commit": ${{ toJson(github.event.head_commit) }}, - "repository": ${{ toJson(github.repository) }}, - "labels": ${{ steps.labels.outputs.labels }} - } diff --git a/.github/workflows/container-images-ci.yml b/.github/workflows/container-images-ci.yml deleted file mode 100644 index c690c11dd26..00000000000 --- a/.github/workflows/container-images-ci.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Container Images CI - -on: - pull_request: - paths-ignore: - - 'rust/**' - - 'livestream/**' - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - posthog_build: - name: Build Docker image - runs-on: ubuntu-latest - permissions: - id-token: write # allow issuing OIDC tokens for this workflow run - contents: read # allow at least reading the repo contents, add other permissions if necessary - - steps: - # If this run wasn't initiated by PostHog Bot (meaning: snapshot update), - # cancel previous runs of snapshot update-inducing workflows - - - uses: n1hility/cancel-previous-runs@v3 - if: github.actor != 'posthog-bot' - with: - token: ${{ secrets.GITHUB_TOKEN }} - workflow: .github/workflows/storybook-chromatic.yml - - - uses: n1hility/cancel-previous-runs@v3 - if: github.actor != 'posthog-bot' - with: - token: ${{ secrets.GITHUB_TOKEN }} - workflow: .github/workflows/ci-backend.yml - - - name: Check out - uses: actions/checkout@v3 - - - name: Build and cache Docker image in Depot - uses: ./.github/actions/build-n-cache-image - with: - actions-id-token-request-url: ${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }} - - deploy_preview: - name: Deploy preview environment - uses: ./.github/workflows/pr-deploy.yml - needs: [posthog_build] - secrets: inherit - if: ${{ contains(github.event.pull_request.labels.*.name, 'deploy') }} - - lint: - name: Lint changed Dockerfiles - runs-on: ubuntu-latest - steps: - - name: Check out - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Check if any Dockerfile has changed - id: changed-files - uses: tj-actions/changed-files@v43 - with: - files: | - **/Dockerfile - **/*.Dockerfile - **/Dockerfile.* - separator: ' ' - - - name: Lint changed Dockerfile(s) with Hadolint - uses: jbergstroem/hadolint-gh-action@v1 - if: steps.changed-files.outputs.any_changed == 'true' - with: - dockerfile: '${{ steps.changed-files.outputs.all_modified_files }}' diff --git a/.github/workflows/foss-sync.yml b/.github/workflows/foss-sync.yml deleted file mode 100644 index 82334bfe89f..00000000000 --- a/.github/workflows/foss-sync.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Sync PostHog FOSS - -on: - push: - branches: - - master - - main - -jobs: - repo-sync: - name: Sync posthog-foss with posthog - if: github.repository == 'PostHog/posthog' - runs-on: ubuntu-latest - steps: - - name: Sync repositories 1 to 1 - master branch - uses: PostHog/git-sync@v3 - with: - source_repo: 'https://posthog-bot:${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}@github.com/posthog/posthog.git' - source_branch: 'master' - destination_repo: 'https://posthog-bot:${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}@github.com/posthog/posthog-foss.git' - destination_branch: 'master' - - name: Sync repositories 1 to 1 – tags - uses: PostHog/git-sync@v3 - with: - source_repo: 'https://posthog-bot:${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}@github.com/posthog/posthog.git' - source_branch: 'refs/tags/*' - destination_repo: 'https://posthog-bot:${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}@github.com/posthog/posthog-foss.git' - destination_branch: 'refs/tags/*' - - name: Checkout posthog-foss - uses: actions/checkout@v3 - with: - repository: 'posthog/posthog-foss' - ref: master - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - name: Change LICENSE to pure MIT - run: | - sed -i -e '/PostHog Inc\./,/Permission is hereby granted/c\Copyright (c) 2020-2021 PostHog Inc\.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy' LICENSE - echo -e "MIT License\n\n$(cat LICENSE)" > LICENSE - - name: Remove unused GitHub workflows - run: | - cd .github/workflows - ls | grep -v foss-release-image-publish.yml | xargs rm - - - name: Commit "Sync and remove all non-FOSS parts" - uses: EndBug/add-and-commit@v9 - with: - message: 'Sync and remove all non-FOSS parts' - remove: '["-r ee/"]' - default_author: github_actions - github_token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - repository: 'posthog/posthog-foss' - ref: master diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml deleted file mode 100644 index 37c4e75ddb9..00000000000 --- a/.github/workflows/go.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Go Test (for livestream service) - -on: - pull_request: - paths: - - 'livestream/**' - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Go - uses: actions/setup-go@v2 - with: - go-version: 1.22 - - - name: Run tests - run: cd livestream && go test -v diff --git a/.github/workflows/lint-new-pr.yml b/.github/workflows/lint-new-pr.yml deleted file mode 100644 index 7db972a9bc0..00000000000 --- a/.github/workflows/lint-new-pr.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Lint new PR - -on: - pull_request: - types: [opened, ready_for_review] - -jobs: - check-description: - name: Check that PR has description - runs-on: ubuntu-20.04 - if: github.event.pull_request.draft == false - - steps: - - name: Check if PR is shame-worthy - id: is-shame-worthy - run: | - FILTERED_BODY=$( \ - sed -r -e \ - '/^(\.\.\.)|(\*)|(#+ )|(Br;)jd%7OkA>*Cv>tOzS2Ut!t#jwBSh1*O#i0cGnMq(G zg^ngdKzy$BkZ%z~WW#ha4Zu9008qZKMlKo?DXcE=qLEnH=s=BNfF8J0=jO~w=_6)p zuGlYJLMSH=W#wGcdE@4~bIugjx{9huIW$$oi%*NZS5*Cek@wH?$am$QS8%u=8{&(n z%UNPyl@k&VgwL7uH9dZxmq*U#i;GY=Iu+``dR{rsHf;HaTyah` zysbj9>BQ;fde|!{DMx&!zL8$9ACyI-`-@!nnq1c{3b{oH(BtSg*hPs@96pU1FmaVSZ6rH&SSWK7TKD-c+LcYlVwE zo}s!K2B264|0!vdCv2P*Y~2~OyLo!oI$TY@4LBDt?LPPdY2C*27CCez5tsp(cAQ22BH(I3r61c*zwI@C3+4qo zDx2snY7^a0wP7WzJtq%@&dUF(^`&k{+{0UoSA;5pxT6xT>Z_I6#rP^(w4dUQnpQ;)?QojgP+?v+JPxzHU*(CUP`0UDl;WToWi8cNS*oMW#HnApp zLe_)<9tRV;n!jT_vPfXN0|egi^M#W%{4P{jY{QUktB5 z%707PJB53rh&B0h+%z%KP?)M=cmB9WTrwbZ#(~kZ4%CFxYv9I{Fe1bVKv^S=}j}LLjl%S0$F4W(Bq$Y;N4IBAzmtzb1xj!~$>VDgO12n-tpzi}#vhA2!kL&kRmrS{_DIr!<*pa36I1KRNDL z_jJccvD#8O!2q8&MUm}=xjrI$|6~$Er9}cxY zP?Ye-VN}uARlIPRsbYCFRwmx5qKh{4=_kc|mFnwlebTZrry^V!j(wx1`A5vVytwP( z_w02W%bO!{t9bV?msRlL@TBn09l@3rm^hzauG9@a70w>^Xw zB3$U!q}oI>?u|;!i_f=Ls7-%AhCRdf5yC%dUvU)vyNCFFLYK?PG+7?CV>Zw)>c^bu z*#W68;D|;AzLg-=Q*KU9rZg_fio)eQRfs+Vs)}mJ_1z!_1Vmp|?a0Br7;^G3uEU!~ z#z*{0j0*3WJH{Iw+M8GVh(EPXrDE%)Y;Z{Hee>fJ)}vXzu!Dw@c+`!G2?~y^3y(QkSw=Y+S;Axet&A z5@TWN91QlnL|7gDV7_2LoO-Lqf4`7KvR{SA36aWyx)yEU2JL#0`kM36$K<~Tz6H!apE%sHiU89Nb2s%?bnRZUc1 z*^y>agOUObR)p&_t#!2}C^;((<>K5brsB&f!Z;>$6u{y1F~W{)J(?-Qq~pn-4*VQ2 z^-y|e|Hrc_-`q_-;6f8g&YaCTavcbbK3pkn;s^PfS|#a@V?dF{mxK#>p%ffZESo9E z<5W+j_J0Tc;UCe}9}CEz3fvEvalZUW$GR4{7f>nJ4aTwNi}m`s_a#-;lYUsV>{#5} zE?BZ?WfzUGU`gv-+Hss>o|yJ@dpCE^s(A}L|4=tz!B1AVtZwN)csb7Q#uEf$9M)(` z*XGf{zxKI7VAFk-o9&TH>Fv>UbOFGGz~hEt11KZy!!g!|(JO4dBvl#uSYWL%6@lmzA zt2&e-7V&HP`Qch{+sCy?m>;Vbf14g^KfpV?U;LW-$gk_MnEAAxs&O_&S>@|`ab`cU z8OksA!gTxP>EV~AM_!*UAFLP8^ixQ2XD$9euBi7et|ydMwYW!px1&~U=@-4lb#LOI z`SH=>a07eYaQG|{z2d? zz|6nsPx1K!><6gi|1Ow!xmCeD)GxbTHoer7U!m=HR(GRa^^5&9Jv*Oiv{n2((7$`j z(@G2X1ao6J^TB7v1bSQQv}?mZ&CPW)?*>%cKpvAk-mYdD{P1mcn#pIou|%Ta0cM$d z7k&ZI1oG2~VSYNTVm8KN;knVn!@1E&xXFpT!}I;zevX$;U=@`iLVLo!S5ZnR$z9%` zVZME8`XBhl5`XWX%1(}uujbOHec4ujSEq*jf&P&%`l|WzkN)D0{;BQ#q11BKSI)0> zHyl**S+)Clb>`pI_CWKVcoMEV%sid!#uH=Q(Qdf!M0bK4$BXbgb!t!UUoXYK>r#*P zcL%uz?kBbGj@tNKXZFD6(9s1sE~d%_P$<~Zeo*5+Qsc%VQ9O7fJa8opmasbvHw1az z4qrIilp(Zg(;qH5E#oI(` z>-wc?Bph^=v~ENs&14HHeMrV4UgEk0bi7j(^W&0%m8ptkxm$L-=;Ky(1^{StgL$@# z`BRsjC(dlgssu&@W}f|${Pn=SfJ&7g1@mnF2S1GGw)Wx+YdfRtxkX6^kJl!HcLf%8 ztM!9+nl|!oHGdL$j|R8V93aX{<0X@<*9TO27~DfsA>bxFX^|+%bwjZm28h0i9Rm`G zq!Poz^>D2EIrRyzuwPYlq%Yp;&ooGUJD@fkuW~;d5dK`bscr~G-C6Ga1LDsQU^wut z)~C)J)XjP94Tzg9>t5kJi&tT<0X><1Lb>iIdK|r;uph+^PbNa6K@4TFcyyBPkB(5# zkE5Eql3$%k7R(gPKMIvb&}(GTjolX{r_zIG0}YCt1k>U%!< zUjVlND)m0!>zCawm4kcA1C-Z=Zq9I(xVXy^4O{foX|0Ej@7&CIF8dmEOyhali22s>8k&;+gMt7%%@;4`N$C zP<&j)X4?~=vNwNJCEgjJ5bAgJbnHEK3Z?#`R(#M0z99ZkI}Lc=@6%lzqgnU|$=RYW zGjOO^D}SRrdcwO``>1-S)=_82+U7tY2yCEHn(O5Rm@8QhQp=#DNvpCJJ{7guIoB2o z#$glvYv%C__BtQ^}4)x2gR9kaivbS`_EQ-eNXw=wZzTfP=drkQK|G#hG*_qvaZ+GUI*_mga8fZ5+PFDu7e2I8L6Aksp!vr2W zdFZ6L_EIHI@zBTf{#t~>t?cwpkspwt$`~t5LzOjeSuCjk3hLFq{#SGD|F!<# z|ET^e0wnc=!a4Oi|f!@%A@ z8m=R88tjGkXECKZ?WeM2(4X}STktfE@^QdPK!~T?DL)AO4p8Z*=G6z=>D?@9Jax%} zrSs`cxcb`_SARq!Du_d&wt8g{Rl_(;YtQ<)N?zIB+}=I^5<-7Ow(8=qKf=>iy-eW= z5w{ww&la0HgEPR)TSB_18X05cRGsi)YUr9G|3?U^iVuyYwfd|~`xgc6Qf-?@c{y-0 z5Vl|WH#~>)t$<2RHwSb?nEMx79g;`1-5+Bqj$(96%-zh_IBL27Emjgipv&=WI)_iL znB;2j^(s-UWEt5|Qn8NDl8#pH9y_oDrZ`+y2pSlqrcC{{#V!4pq+9~@0K)n!yZI|P z-wCJ`(nI;59-JOp-)>zD>W}()-il{*d~3cVaI9IgQP1?<{Zjjyj*H{cr%)!bEr3me zct+Ws=b$>)kv!k9jY0=?DjE5w>ed6gc&dB*;s3p!sxuGI8Sadf9pED-N`@ncgNDIn zk)Dj?9A?OYvT#yF1#kXN!n^rI*qnSOf3)lo>BvtzvV;DGU~T5B$0#emBT4dbf8LRD zXCUm)Fc0^$zS+M#Tsx~u86^0T3E7juCDz2@%DOe23$>i!eOq`|$jAQ`<$HiffUrMe zC!ogw6aXrPa+V+YdC2>7tc#n0Az_zE!7U>Z$h6M#DR-Tn&H3gyJR^DRI8kKCZ%M z(($6~d#rp|EH1`fDcLoem5h6(QPnwYH>GhwrG8=P>s# zwmM9F{(S!|?1$E^KAzTN+pfsUVVFdeD3b_9S}K*92qI@MU7>wdU2<8q*j8;nTJ1Jf z!(qO!TDkGqUKPQwCm&NF>71?XCAuT|i^@v%i7Db!sjfs&ZuZOyRnJWO7M~c@TUBa3 z!kK7=kEb3h5dRc{iu)HeP?ajw`ks)IleEC>MNA+{V*kGuaSZ7y|O*> z(xbIaeH3pfVu;r(1GV~n)!lq!v1~`Y;kUJ^Dvcph9N&_7YG&+0M~+d}KzQQwogni> zwL3G%`R+=B?Sa;mKhm%N*rhG)olCh9SOsLh8{j934en+tr70iv_vTIaQ-Hj@D^RjZSRtClQG*E(f7+nr}cf7NpYtsJtHZ zpJXUsck2p+dMB5))O#G|BY~rVu%8rt)yVm2fJ))`3v>Tst3z$O@2_<~`RzD9qe;=c zHwfviQ&)}fztA)@yl5DqNa4X)h*fH5^<`>Tl6PCYTGnY_S*g+!>B3A_$^=hwFq#9E zVHL`Xf_CkrzTv!bK(%MNq1iy#ADt=p1VVbT@^@|fqq<$37_};8CK2WGgo$LuaV*ZW zfD$0y$b|Bd&>ri@{9z@%PD!szqLV3y6f$G{lC}ucoYZ&k8bm+`qXi3!TKxxUG4JzYQoEvZf-t- z>bs(=#T!pHKx>eiGWDx%Y*{yFQeFuB90>0pS~)NXP$~2eryU{=!`sib-50jPgCZgtu28Ghb1G<3 z&72Gk5o`<_eE#jd$`7Yv*hL^(`)K?7`?SSP9(2P_Pkd9=o^Igcx&3`6TmFBD7%cXL z$a=nzspN`&YBP9Ce^m0Fw1>2mRnC+-5n`A{A~KDeKA5As3l6rG{7r%R_iFZ!#@VIj zx`O=w=3{vq5}NO@QCD;69DO483wdVRis;oaxsHJy#*Zfch$!(HuQEU3rn+Y3`|Z3! zDL*#vAM?yI&xEIz%uj^hcvET6Kl?LySGfNdnkJ*o}3VoN^NS#0(yU#nsz$ztR5bK=2~&1N;M)I0mH z)#}TG`c7TdGVdNsc?qx#2}*!Fi+j9m&QknO=B*gOBzu-`yz~Z< zeQL@TxtyEGGqHai1CP+L5-p0%zkxTcoC$Q7rzzz6sjOpAzXQBG95dSLz;A>@9@Z~K zIS&Z=ckSbKm3^_re_pswqkry4DVI3qSak+EdIQx0Fe{t)#>EMW|I4&b+L3wxI-Xta zU%@9QtH zVmGXItE%oZ8&6k@r>f!8JyY#PqeW)Y$d3G~pLwfVC%wqU zmEKjAE*AX}BbtnMAuy1%zwDFz8nsgUY62pnnz6N3Jkb>wbFULC5oVywy0A7O`$LB3 z$RZ<$JyeO2>k#Q3(k{(T!?oPM_sQtHf7B;s=OD|G-BV`KMYZvF1{TG;96uRai}XSjq-8nxHRx1o*gxc82)4pYm^kKLH^=My$p58khs9v~ACMejeI; z`6sOo`6snqXBRCDRM@MpkkA(Oinf@?GFrxN!AXy&lme7YtTbo5wxu0^J&wIHz zrHdt_pR;U=KTNgv4S;gO+_^5<#krx*f~sImJqwcnkaBhr@~ZcgUNt}bWB}{*J1r-M zxT%)4wsdP9{-0zlU8p9iV4%J1tG>Yx9i!v*7sI1I27-}*yIMu4P@Bb%O4^0dLWDyo z!6$S~2<^0vszpxTk(oBvR|a@I0DeL|{y^C~75W4S@pu{K>w&ugl`5M8JhrDVw`BYi ze{@cJJO-NSQ_qypl+%=H%5X3@8O$Yfy7+sqzDyXiwXK87rB?__ZqH!h+8Qed|Bw3$ z6`c`(1IkRiQm829_^Yr+#H$KF$$!+D<@WAE{gFbsyU=MqVt@h4y|?ScZe(Em$)km+ zdrGS~@p6s1yQZs^ok;g;#^_!jqcQjS=Ap^(za=V-A^;I7;>vXs)h zTB7lbbcWjP^j5xQ5kZ&wwbQivs-RuT(_7jV`VsEIwG6!=LqEddemKm-3gYKBs3FU6 zV8!+3VJ+T-*@tJ|v$oZHHc`F^*xKqnms7qT2%jJ3;d_pse(EZ0rr_};3lsPQ8lY_% zG9X~vM6t1;xMC1Gg+E(?hAXNu=8~ckN zKYuRQLcINCdnSUgHt)F9e@A`h`L`V8`S<>Bo{#UO%X#9u%=5lJ$n%PR`M<31^l@XD z0!NQLS%u{ozPfSQl6f`#wQZ{6O_}#J@XRoUcwhd%dC$y|)2C=xwcxu?fbV@gqg&>| zA5s1}z~-fs9= z#}{3ur4638b4Xtq>zm#rYOU_Z(0Cm#7xOGQq*l4ibxt!Jp~qvXLd&d($Pv(L$?sdOD-@ z-e$j!Jh$&Jg$G%8L@ zBdcPbR_t#jz_0iQJdJ>YZf0U?naAfc2(A;G--{7-0Dh+!EPlv0W}xlzTnIP{;JljR>z zk;1T~fI*r@*R(ve89CcCpILq}AEznM}b2hmYTlt&8-gWGU_ytG)TeQ1jNI zN_Sg)9yu5X>z@&E_t5x*L(K<Zd87G790K8dJdFF$b{>A zIqHVS1lHJ4qTtg&n@q%^Kf^Q2eO*tPSslS&GwUlY@SbLNK-)H_SD9HF?+BhdWp&}$ z_0%5Vxu;^5n!U|l8NV6ph6Lm6YsODF{yS|z9|%|pgx~8W8?j{qUI0{DUlEM6ik?C4 zUu<<)df$cqSvYhQ69nDdC8xF6y<`~s%jT?AH`#O&(d)mKAK>f)^CHP`I<^=;LB&SV z)k+d~F$NT(k+vptI_9QwY{p7ta*s*VCOeZX|0=r)++3OIhsES!lNz-9M^w@EdBSjF zSB!9Wj<9bWVeA}%@XoNU+)_-fqj{0|Y-3ges?OK`$KI&i%z=v`@o1C%>M;GaVUcVp zWAQX1l8)@$lxOf*l-L?q3%Q-`Du1O%Fl}^XQ8YIm&qiAjlU0MKMRjz^a9)iE%WbZ| z+mqHcuDGt=EqC|S+eHY`6P|}c8^m`ub3#ckapp+auD6cFzV*tHsT)Sx7mP69JKW0= z_hI7F>HdBN6-KF9W_L2#JeFM3&)L?`tP=k@oWHqwL<*u#+%zJ2=ZMIahnpJ@ue1)6 zqoPJvRNP$g>;A*;>2KaFA`vqIHv2?er@-u7sCJBjmB3jj+-lmf@;PFri+Zupf^iYG zXLv=jZzN_-F}qmv&6w}4XjSy+*kF0IQi(fOiO^~4)e=qe1Cnn`;19!f8>943tYa#b zWn+CA!5DX1wv)D$mVaKC;$%^TEz-lH!?2Qu%vfm}5sbgt=d{doODL}f)&SxB`@ue4 z+rWAI{ZTl+GSUlWnpt)jLQGo;F^|)37*%4#qbbGu$zh%9g5WleKLk4r$EKBy%#44Y z9nQ1XCcXur7zoGzILcFinSe^|>yc?rbGEG=A9W4Ea;Ti)`{2NTFd7>k1I55Wy|!l* z+YlpVv1$7CaPRiv#<|0d^M^Cqa!do=Gcb&>!Mjw*fMB(8gJk}~}QkKTd^R)UgLA!QP->_ZxQ+@(?2?*Pz{Jeg~`S*ZIAw3c1EiDR4hrQ5N zF1}>>JmrKmupm`~X@ozR+!l#=!rxLSwe6!z9vLMb8)d&g%KdN@oP0~XHA-!hpBzbi z93=)%BL>e^Dkh;A9B==K!To8rDbt=6=LPjtwK)*}~yo2l#KLl<#w*{sF?p*?g>}aCp?Gi!x9OEvvj9;vjaPxA` zS6MO#>ckdXgK|Irin2OS=6or&dq>nMEdc8YBBzfptFg#!wa&*KV z6)+SYVocYc!(>-nIl|sP0yhwgyc~jE8Ymb%+B5N3Pd!P<$MQ(RyBBRxami@)%v;A4 zloxas&-*Xzgu|2;-uQ0k+Jka6$X{+@TBcM~HPqr_sUa33FFAE=R^)+Cw+ zn7c(h*@LYeR-t+#Ce9qER^4%mShK(IaNP2Tspe};$6~XpNqXNHdygrV`kgVfs1`>UbIbv}_IMGqLV953g4I~0`K!}Y#u=@K`9Fh+Y^FabZwOAx zw9Chr-#1P?HqL&3ocoV)@zv%kr7^>wBn4k%XQ;b&xbZ00jIg{y)>P(Qi)!2eq z;^J{?-0m75Wzup9T%p zm%0p0tUEHm^Dgigu9v@~{4DSS5Ykm2QvM8RkKYiF8SPUla8H8ApDBAIQUqtD9YoEzOd2DW(4(^eQ`^FpGf(az=c5A-yeO)YQp($K&79e zuXeSgM>6#c{7qL6VE9(~6uQMnw1__P@VFYTkV-9kyu%2W(v4dwLMqSC;P8KTf}>i@kcO zf7~Qrq`o!EgEr24Wp>VOYVys^L9tWCddS8Px3+a+73O&p;-5@FHOvx^P7s~5Kb$c7 zx~y8|QGv;*N=;bM9*s&HS@vUI+iP{V;9jd(RE`_6)ag z9Fp2O#Jpxm_O(NzCVM9Q((-Q#FyuRt#pjd-@`D}Ard&0?s@b>2<6bo~d-W@Egja%ylw^okC>S1pA~Se<}172@{HEfoLm~1 z9>`|5KSU-7^e3ro?XgMaf0`)vPqa5oayLyXbl?xhmBa>K$XOze#p7=*V-p)v?`$E? znS{l;_0m_d!Vj@&u z4AvRA3@4j9>>5nlfGaAZ)YKh_LUCutm5Vr(`X<8qf`=V*1H4{&Ov`s)eHpw_U?>pI zKZ;%-&w2ar9mZa*&(vd2u}J%P5-NVOi;=L=E9uY9mMlnR^Mdz0$TLDeihC%(4g3WN z`|p(HQX6!+p^XGo+WSWEJs!%h%#-c>?S(9{K$_WbzEZm?0378`@oq)QF)h9~8TBfM z1p|cxcmQU^L>$ilq#>?C%|QQ^DQ|_5h4$H%Q_XM3BQrPhb=$FQq{5dfm$%Rkri@)? zCZK)ISlV)w4%!RCSRoy?VLwNSC?#P}O}(F0X%40x>*i4^@C)>}jYFFfo#M9MNnNMp2foZ})mVztHPkqP&a;f}e+AsA$?1(paO{7=8$xppJ?S41E?jixyFo4Mc~Cvgb~s zW*As%yRmUTLK+qa_^rOGWn7J-JPlX`gySmp0a1B5e;ZKg_RPFo7*nLLZS8YPHDHv7os12qkP_#WsxVCpkMO!9TiGT34eCB`}j>a#0Et z#G178oGsG$QbY;dE51<(z2OLPjvmce&s!Vew;rLEi92O`Y(6CQPQDZFhj1(c$FwJb zRjG+Z45VDE6ego#<29cBgdqNkDY9Y$z7}jkRtl7B6Dc1J+Kwdw{#IPwg1>c?F9NOt zLi{yRR_WR>h5gnEi51hYXw*JA0@wG7*cZm@ zlal)<$$w5V4ot!k9v_~;N{dT0o0%i7CV0*NGx%+dekC|GoDj65c6-ZrHjeU9z!D&A zhoT?u=KNtmrJX;~j(zRsU-g|WS~+Jf6G`=jO1v}>SX>41uoBX)H?_x{9IZR;mTBd< zt5Fjn+YmzA=y9A7OY6GHsb?l5Qk1E@=mV3@4<@_sPd3k=Y*xw+*+?sFRu(A`PO%)7 z%EYmJDtCxXD8Y6w@T;#iofxz`c}+{ZJ5sIz1_9yvKAZAO6Rd3ZU z6cgt7R<2u=)n6P)0mW)%J%9%K^l~H~J*D9kvQxF7)`1wlpi;#JR5MVD;``-`o@Shn z7ZjSXMB7s14v2p{vp4sd@yp`Djm8X zm3qSACok;YTQLTvoPt)fzh)U_4a^AyQWZU>SxmEtMKYbw+8QFjpgK#~o)5?wHdk z>2NWVnlkm+#d|`$6kJanN}v)5`}b#*8-Rs?N2cvnql@&p& zIV;Ye)9{ZOZUyub) z9KCeTqSnq1TMx5f46eNuewXzv+#p|kRTBdy&s{}Czo z%C0H#YdvziE^gC}y?XL4-QI<)7VAYL87;8N@CENW0}!X@8Pm~jROWt!E`j%x8x-wp6F&oun93txN!D4I<85g(X@=j9)yAx{Q=^D1?iN`_@{rv_29phy_<*&0fgf~ z#cS@tc^^QfpBn$kF9P|0*q^PsTe$=~d4(S;13{V}yfa1c&L_nx?H;&D%x%9n%x4Yf zAtSNdkXBUT6Nh2ifx=t_}=z<(z`Nx(%SN1e-+kiQDaauT;yC3IL9d`xVtv!3PH4a$Ls9o;6td3 zr@M!18wndYTjmw1=K^?SYYI>SgzcM!Tak&J&jwTq>5wq@FSa_I z-%hTI-8&K9k3V_&lC^3tyjijjF2tH>&>-6POJKdwt`eO*wt*sArJ80`TlnfEeAr-h zrXM!dZW}oFBTRDKb4|+}0-j^8Ps}2fM+37*O7gMJ&okIB>U^5Wb`)kU^uMb^s zU+(J@s_z@u39&=mrC+DU)4h(iRhCB`bk_-Kxq6f`y=VI!AusTcj$J72LT^~IqnR@#f>0V?OlJQ9aHWzx zW080cLb-s6{Ti}QO`9brGPM)_VX&Qcj+2)Qd$!U~{aS}tpM|)Yurh+$kk$uyT1WfK zGA-Rg`4ZrAAT!O&J1ML5%pvwOYST4;$tjvVUK@10wwHjW5l9PGF6KbRHsAv^Oidi0 zd4<-HdGFUeH=K9(nyewW8CnDg-}@HVRBFFJ55J=Y3+K#RFlC`u8`17mMxE<2&t1VY z%KX37v<;Lm2DSrXzpMS@Ue4bFRQjp)D7#(1c5Hl~0p}2Bo%(yB>s7k1V{Fs+?^hBC*Jy*_S zEzVYJ@m$72K2*Xo>bGt~&`*Pr1%-5Zx7*R#1bPD@zG^6|)IRQz9IDk0)z%XaE!Qa3 z#8Cf79GWuE-NE05>-R&Hp9Ee3LVPJc@83E998f95S7=Y^U*xdjOPH5(*MagCnJ70*+!Z}~e(ngl-MrvmK#uE3kCzrpmN{#Ny zihVT;I9Ul~729!sNNU&^;HBY?miqsk@Jd5f2~@uH0ZXxsTZmZ zd6dogGh)@x-P1e#LjJN$JEq*|&g_ohhPD~K+%71SB2I|PsA5;FF*;-M6C(Bpq+bqRNF4ft!OlnO<+VkPa#@A$(?KVg@&+_IxYw< z5$-yn2#-tyPgd<~Fv@io6E&_LL+cQ~5`KcmDLI*+c4XSQkNS7ZTr0a9`X0C*2;2GB z`-qbV^teB0XT@W|`q{0atsLM$#*dD(vMryQb}E0ijji^5i|66W1Gn^zD_5Vmphda7 zTMs43KB;@}`3p``4s55NJil=avV$h$WA|~Cy^V)DJZ>obe2D$$_?^PIQN(wOyX-TZ zPWDc*McgRNonoENsj*YsVV*H?ov1ql>uUXsGIc0#K7cO$40Mj-XVj}wsXBE1(cQ=y z);h5z$u_!gb}8S&5M+0lrpnG3rsSBhW6YzRzD~9?-1M>@Htowyz82G&)5_obG{UrE z$LfrX>ay6p)YPvq$81waRNSm|mTToQQq4GI(Idfs@u|^S#-s7B5JnKW z=tQEEB=;NU-3H3JaeX#_G+k{TRj1-(0iGH00-?HG2urIkyvA7$CNs`VJl9W;zori| z@_Hjk#C2#lBB`K)(yR1nL#Ey`oVSGipv7PLTw#ahZP02sa%;@zEc0P2PlaTze@Kkv z(lGNz-R@wcyR1h{hvOu(TZF%Jt*mU`muG$=3(qlmp78+|^@_*rKW#FvJ;EINkcd-= zn0M$di&q?{6A8z`BLr{2DK!K}nNw~$D%J)u)GeI-gKNh?xtnJ}f_w|B15`wP<(kWE zW3;mj!@`rC+l4bM_ONZ-X6v`xKAw4kdzr0owWXV-lnHd}dRxE21`oG}^miT-J+q3QZ{OZav3 zGIcXEb#o6|w@7o{OzMWG?w_g~wLVnc+)Ulv8})8r6D)UeyL9aweF2Lg`!+%RhU}8; zM08f=JvjQb;m({(^|K*doMOBK=Qvq>`u)rc^+;pHEx1$fA&W0DBBPv0OeD=RTo5{Z zC>UF3IfD_jsKqA`FkabBP0rhF10ByRg**utQC?(l{5a9icuwfk%@dsC@ULt=Yst;J ze8rY$3t5zNthL2=D)pf>#?#ThOE{5cpFRedg<;b=cJKCQ2BXZ5Y!QS-S;w>krFw^% z8kmJqR+gSB1IjbtlBcuc+4T(3QlKkP4!A%%9Z6YP=b8jlj^r9SdTw{}&g41}0g^dx zZl5;iaeforPv{yaT}kb8WEx zPJN(dU$Kz#Nx;cKxX=Bx5`Tf5x2FTceFc7t=ASxi$?C<*$FO4TDAlh5*i7fc&)*?R zWYo%)o|!^6BNbO?kdY7t*txFDy#D~tF3YTo*$+ae10#WOU3`i1JHQWsN|$Ehe?NH$ zyO-?hwtUf53zlU}6Z$kSja*yV*ocoPd}#a`!zs~3U~oSxR8~~yiyP!Hw|?P_;+fGI z(U}{?aO`&K&lXUxN^334DQo-Y?9% z1*4_fM90iXS+13LrPm0y_s$xx6%No(wsFam5M@P$6j*oTlETyI} zk9i}HEfV2YfMQXv{5UgXF0_cHXhlt2uG2_2o1IiIpca`$Fg~_C)Iy)#OZkt$$3WOW z;~qA&8Nez)rO*%m{6qNR-`kFFt@;AeWzMPvE&Y@6u}S~z5PPN1Jz0mePwWt`e=Jm- zl=Hg;-H<;kAAdnB*yA-lu#H&CB*%jp_3N1UO4eL0?WyvoAC&D7s#z6!%; zfzzajHC%QfG;&f57FqC?6K)3SU097=*&RLw(qWJd%SQ}@I*i*(y`(#vr6?Y&^|8jj z^~M0-`@v(l&;N?@H^6^@aNinvB*62bb`n+HGiifPH{UP0yX@=b;?gKWTM1>__`qRh?;kZrywPmZhsTjV1jfXg0_QOS!Me#vz7hCEcX%*@hp+ zh+13x&Drdx;JQl3gKW(+FBc_OD3(;Pt9k_T17!@`$kSJ|PvrU?mK-C%T_)Av5{#p6 zkGAydNXkb5Gl6hke|NsFeara=nf~1La?r2gyxy%xOTT3{3iRBH-z&cH6-Hx%UfW=6 z>rEU2^O-2sX=sqnins`>@nf~Y1@Q<^np3mf^ZMDjc7!DtOQdzYG{xn(w$}W;Eg=Zy9 z+p=TjdK_y#!$7&yGi}#n;wdif%4G8x0%)~&*6QyF>a9Kg<9e5I?f<>r)0Qk+yjpwq z|F53fjyr?;HBpz4Klbbs@Wz13-?q#ruTy>>_$Q!Jd%D8E*y`}b>bCYX^G{xes(Bl^ zBqsCoR$ZZl*dHlA~q>05-#RwsA6YD zjl*>UE2raDjTy}qIj&P@NApEq+^ch=g`yxD9TJHaiy}8(8;f=jCDCMEB3dR&-BfK> zw4*3@vj?W56{3^Nh9FugdPegH=S8bURkUzWLA0;vlPIn$iuM=%5*-dJiPne#iLzk` zG?*9yVa9VzD#}aYJ5f1BO&3*06xJ^l#u!jp6W3C2@<>h6&l9!TDtFVh`F@@^u-Ly| zbaeb(Q}8os7V={qufxwf_0Q+Qa;O@W6UMA67HczdvH| zSU(>#Xrjs!6Y8e=`IN(s^z-RQX*=!<=8dm^*FtBW@FYA;;C3LSL#|sZweL8WPX+VF zu8jRo{Hz>yShEd-x4U}_>~9$TK!v$ zeS{o4m5`$oK0%*l$Dj;9VxkoG;pM2Z+HeTvCBnWG$`IR0_z?sUzD(V7iMMw-WOz)Q zTi(e`8`*YR{!B<===~TE4W%g3UTwMA_FT56Yvf!BH|$psGR_*~G~#X#i<~Zoh#^_q zE%mGImi;GF46zqWG!(Vj#?N%xQ|1vDM-9 zc6?jq%7YoAxqr|hZGB34+;SWuE?qjNaqOJs=*F*X{;7X&$?9>7mg5O@z9x+w;y#40 z&D)1cZMSG}dwG-aUfO4aNN$9snTlxpMGpFzv4kXkqv983!viqCfXai_{Wl}gH`d4K zpRO{j1gp3B5MxwCb#N;xOxr1n6w7#gr;)tgi1mzAniYA2&9S*-vd2b_Mhj7>XPRzo z8&eiqZ0~_0_zg3eaIO{1JSYz}$X>)Cis=U2tU_XK_rd2)2_znJULEaFeSpPZ25HBG z0sfue2Y6LiD=7B_s(_FV7*2UK5aK(`!!bQiY4}WEvIq}R8T={jsQM#@39vI$JB!Vu zik=1GqDL5T%qel@VP+7+S&<`}_E1odUA!Y42Y;jd74Tmm90w)O7+QCr8c->$N0|E; zTOFph+duGC1un%#Or0?mb+#p~d%9Z>B~CqM&QiGCe3&P%T0m?b?BS17_VBaM@My{v z0`K;9+IuJ>Dc{?rad@Nfg7SsCL3hqWIk!APdjjJ69$#cJRsK>n@d_n6tJC&p+PN)L ze^u2dDDMGY0m61FJDQI;{}fOuY+soB7h4_Xw{Iu@o0hCtpf#?lV*Ju8y@Tv{PJ&^a z_ATnVVfV3Hbb{Gbul<0fzSW3v3Y+>gDr%l`uro+@R1S7JF}u|BzJ`Z`ewzJ<7P|34 z%1;8?vn};Ml5zvE1W@Ux^rN4ApzZuRzmdT-H3%Zo{3WiLvwZdNRi`eW*ZxK;QiNu% zIOsIY4JKOl95dN+Jgr>I?iO8S>>OcjVs`48beTB`w2FVl?i-rt)`Z-k=2o_l+);C@ zgU`n*`tw4w+%mhxc39SRmeQel3&&0zHl45UYd5DARdnzIMk6XA~p0}orkmU17h8i&66!#AohaoC)<*so;w~1 z`s*Y5A>;#mOZh*5c&>#HU{Y48z5F`-zQNw;1;i^vvXcOD3GH$#p^-IZp1+vC59MxG zP<|MA1_;M<*&ajd0vrygG%~T-&yAcQ_fI%%Sr^E$_q3b8c|X>Z%T~0KNTIvEnxQ;~ zTa54N%9|}y&VKQjcvp!1!u5~k{_zUwXWcnc`R8Hw`d#@Np_Ndf?_+gDK%|)Mt`pBq z<9Ob?%QAzWyvjmrq+>2jM?9Cx0^RW9UT@qR+4=IGNY-5u!BK59W$sgvs4^i(USKM= zPF$dksa46rNLB_Cgs_+hZ#O8bjwv=AKgC0PURY+(qvoGsIit%}W%m#9pgk7!TNC&y z%Up~+&zcDI1VX%CL-|JFPC%vGpE%FYS7-cs`X?O9J`eDESUWwnIrC3AdDZIatx(w9 z;|nsEeLunY=4y4L9nN=%TLQM)%|SNfIv>xcx~6wK>Hv1!SG`OK(<@CPRv%*4i`P&f z_wYlntU|GigQh`622@4=<|W{{^5`8qgVH*Fk>|w^Z$Wf9EA)g%K3~R44XA zRUt06^9k*h#ti+BShZ-{sj$ohic>mfmDdS#f!5R%;J56B7W`gHc`tCmi!Jy)?vI9c z0&pXsQvc4ucs;Uf+i^QJGj4aC*mi%(;6!TLhMs6USPB#Bl3y;lORBl> zbrs*WSJJsmc$bR=#N$Tc5sT1FFh*skWD@V}owE9#E%fzb0cY%3WNzX$f!9=rqLUzC zXyO@hrYI-);+eubOC&dl_(rknOktcQvXW|Q4W`#)HVA8@=ys-X&k`eYdgWdNi^^Fq zdZp~Fq+cphfoA2vi2Ea{T?~3>twy`y02iQ&DUZR zNO)qe3APE>88eM&saf7x<{U;e@Or==5;0Hm6nj3JMF<=E&X{AL5WUWH&NEFrIYEv` zZ2FyHUr78*b@eMaamgI(5+`~&zBrHoBeSCPqLZo0OAgVCWU^3BFds1-^nM>PyxodB z8tV|ZPjJ?lk$lHK-Be7-q}$W6*W-S}FB{Kcqm)@H-?O{R~@x#a*!N7QH4qI^_$honi2^sXBCU4{0& zhjeV4XL#k;c}ii`l_dn87yq;iWasa*;x~Eb&0ZHvyy#jlx$!$Z^G+|yv9B`y`|MVtvWpS-YuYUW&A|rTsO8{vGzZ6OIkIO4p7xbud!Ru! z${6y&)CPs-Od3 z493GQ`Z?SmJxzHp@G21Q)4rtq9T4_^n1|gof60Q;D;t*u3qIsh_2tU=FhXA`L!Agw z#SG$Y#+8nW3DFbf^G8EQZ3cQq)VjJ2$v}?0xLH2Xl&RO^S6k@IGbmpKTmyu3%F`2c z?Rm~$15|3S&+lJsb=cHSk8I_fC94+9oim^D3bDr!tKX%R$y}hvusGFkOlBoY{N4Al z+Er0@XVFLp3=U*&e)_ve*7s<@M7`**t>nW{7hXB;7pvk3xlMF|;Y19V6Xap~;hNUS z9<>B_LdS+x*}pfy`_WENZ*{%fYwV4Ien5!#!IbNP5Z_@gjj~egQ+JCBZBs0s3mz2~ za}^SyQ=rTK7(9Oye;fAewUloHZUw^Uclx8Qy`T5v=ZF2eT-|<|=+5)o)tmlVxMn`PC5bBC zF^YdN^bEO9^i`!_A%j**b{yjBP?9OB@q#!_MXESJ+hS~u;#LmqsRV9HF``uT2u2Wz zqEug(XoSVD$Bp;UfX|a1y@-=Ad%DED5&){jwEqK|@(bfWAx@Ap+1DX27CgA=)u7+D z(XMcOJWBa#;3Xhz|A&(g3+RYayFZZL5frjRFi!x*~j2&>& zuo{kPj0Wf9wyCS9CV|~aWh^SbNmMtPU3YulcPhX~T&&f!6J)Wb<;!Z>6~ZCIw`_PV zXh;27u`XaK_Rb}8jGKv?fE55L=GiZMCe|8$id6QR8wYBfmPlT-{rWnhbv z3+8OB7$t#M_k0zIPur1s|9+kwrf(>lZ(#!lgm@fxtkmi`9}lP$%GrxE^4aijr~V?? zAMR*3j#||(v%}6?aXJi?2LlEQJLr#-<&d{(%NAi5U8uW`dll*l2@E(1eD>oF-W?*T zT>nfnIuUGZ4LmdPq; z{{rkiyPBR66KO7&y^(2urd_HDFH+tI{0Ru#e@QK|B02vKP$_Kp+WUj=#J|WPc~;P` z_qOZTlb5%`fMV4)%%)j=X%rTQcV@+9F6z}Mi?zzlCyEgl2_m&CFQt;fSK)KRdWgf+ zdo%T2{HK=h`E<%>1Dk=czE@M;0o(+r6v}D0J=(UuD>C!hGwte27qA}qtO!-vBac^W zYCjkZ<=P(R;Eq!NW5j$Vk{7J66ELVWn1%X5ZjB;$t{2)n@T`CmApBmB zqPzeI@fqgf_ewohVSoYw5_kna9_lF%P!a1YSaM$wm+GLYoRF?bt4kdIg+K(4!W@3M zEJGGlxH4H_Np;us*M)UO?Ga*V!QZ$#TqrdDe%Ls_m!_ANS5raNu|0FKQ znQTDFtou`dkNvc>EYq0ieqwn6#XyJ;Wp7l&`6xi8Ek9wupnVzODeUiNd^Ar6ZE-Pg zj^Zg#T|7ljUBH%SufW5_ElBCovU6cKoc$C?akePkB*YnIi)q;+W2l~w3PwJ1Fix5J zO5sbDWr#ECx9**wT@O;Zq zZTahqRyMBj`?|v`Lx|`M>|S zFi*EnwovPGVO&~&RS`A)s#JXlos=Aj`fN}0bCLC`QL|slH&D}2Z~2oj&lNhNS!AHe z&mgCqw~gohOO6xsYWJ|q4rqaR7<&+5I&7X|#dxPF8= zlH)vcChF-&nUUVxMe)TLJ7*h(dIz8o1+&6WL`fPIr9c$5lbtslXNj>;SBZ~NE8pJa zpkPWU4{ZnDa$%?S6N|8!+mGJ5S?%vo@nYb_`TWqg6$2uDk`cWMmGo%Dj0!8BWg<^5 z#_zf#s&c9dv-FxAnVYiWMcq0;CyJVqnvU6eO^MkdS^^`*N_JY=p_^Un_IK+2a)9yIdRXvKPMsGhjJpCI7ZX>q=_q% zuWTMws{b%($Jh^A)|Gk0RQ(y}#{tkcD#;Tls}$Bd%)?sw^<3Jxs4rD1()L4%qW7oc zZBFBq)bL^Op1nLLT)+N9IsO-Xga9GFeopxuU@M?fNbfbha`1VfcyBxVCM8B$(b%{Y zQFvpE9Bt9OrAwBdxMKCn-he;(|0=L*zu2Grw~ARa`pvAJVa&unHd!*moLNTx2cdl~ z%NcMchNxn1q~G%qA~IPqtX||}hVh7@p9#AaevM(K9>mflf(G#gQs2V2sN8tEP9kk4 z)2IW=NI?M>$Y=nfNDvO`dwq@BUmCxZGj(6WI!pEwDrh0!kBK`1QHI7ORR~i3M*;ql zAGYAH3+2AR6d=UkC6uoN?gCT_^+68hH}2kcbfw>q3V#ccU8R8_SM9Mgbo-_u!?$fmF7Q z-Qd6xOcx}UkB#{qt-|Js6|ag=8OOMnnRXCJL=+d2O=pwgwU1>@>a_Ka)W z>(8t4HE#~)>_k;vq9U~Csc^TVAp%9cRg4(LUT16y2qok&8r> zMmLUfM|)#rt)84rqZ6Y?X%8FaPH)@(E68Z zdg44IPc=O+qKpJr8B0YS4X4l?m62SA?+NWgu%pIywEYVke7Z)iTpoJVi!c8>&ehFw=!=+@isuzZ2O2|Bx+ijq<0ZYAXSnGzO;B>HiF^DF26#&DGR^T7 z%i<|R7QJei`wa7SL*I|BnsQ_&V=#14K1yLzh$IG?tN1m4o_@(p-v-kV?B%01w`wD zGZM@|Z(~WQX%7gat0LHY=|@||2zdHBDYsJo-j4!-W(?h==c65_d|jKa8Py|8km{uq zY53h9UJ$d<7Blj!!oCH0_zLcf2V@We-Ebi2*M0O?xDI|y8HuMh3<&%6FO)w668~)5 zul>Ib#PqL7Ll4@jIjI9>e>Y_X~5aThK=qY}dWJbpoaL zD=<4K30lyfn6*JXJ}rzMtSGB2{NWW^W6dJbNp^KQ+8cZ=l_!E8Euu%`_F?$78S620 zqi#gS_D+e;5xhW0^6}A_%<6@AOOpVbUGoYG-GcE^F9Nr{;ftVOchO(r{`U8j_W@r5 zVZV<3#L$ie<^n2>&BR&X`Od-n^+3Dzd5*8|u%dCva>5zH_h1{lH<*k+C5zLmM;u{J zGp6fD9(II1ExpWGUUGynExF8G{@kn>QuQam% zW83@qz6{oD{bJX+6d%XlU@vOFY8$Vi7K!JP|2XF5uIXgU2cWv^n(6aLFxp}#`_f90eyiGUoTO92lxt5 zX=GKfPaOG7koy-oG-UKcDjVC|6^mfmlr+1-pd6?pFG%(X7e zdcC+Fxks9Px<-{K$lNTQbuuht~lPAOCxXgb_2 zt+8^kJVTC@L*&{7zriti8K!#duUxC#REF6z(5D)~^fk;#8bKW=KfeNE5@%1%L(cH{hqwgL(Fgw}U)*An}!Bj2?Y?Rb-=ZH@KC91(?j5ktkkBGpNj$VgGL zqA0t}8Q^sCIv`~f=i+lxkFu~NZY8{18m|}1e?Y>T6)lp<8Mz&quDTc4g(}W-VAKA0 zQ&DMaSb%$jL>4g5UM0?%eE@)+6w%}uJvDB(K?+1kAv8iV;?}hw~Fc0?u zEAb0DUoka0X}4h9uCtOcy=q#S44%J(XNBo*%D)9Z0z!Ph{T1gx#lJJZ`&BURMm`X% zQ{mr+`A6;Mq2&wK1n4G^t#yz2q9yiPH|DEgIvgIUA`|9R#n5MTH8A4rmfcB_uP9=65dMv{XhT1=ehTs`;vQ}^PF?fcxIKEz~7p3uffU`bKC}d zgngKbycNgqL|^kov(P=#otB8$ec*UDkgxY5_7WcrtLQhD`H{u(FUpjMqEzO2F!>KK?e|v@Vd058`LtYRVJjW_h?A8eK0w6{67ECBzLfIb6PE+NVS(O+>9KWwrCajN`Q0N{vJ}`E^ne3Nn*8SF;4#vYS`k7(rr>Dtx z0iOYSJj7o#jY0cas{ob0>HE7idp=rCtmd`hkSJ=tMxsDc6e^M^WfBFgcqqD3G#uwF zzDo@ZjA0){w3m9@cDm3F99f=Q>%<%J7h3$wi1}8;jpt%|8_g||9zt_eOHy78rc4ZX zdZ|)14ZSAhd_l}fv&ovL$_=PGqlKc_?{ z%fxqbb8F;ZbC_l@fUk+-i68f4Qb&{oW-1 z8zA@J0sWq?5B9-kQbNz;4JV-z+8fsn{)lfmlH7mOnTB+sp2cXvme~ zD#^j&cCi5J%)d0aea|yK%bmi!5%r3!Bx;bG%=Y*R@w8bH>lE=hDb8_ts*~mfYqB*Z zT7>C2#1`k}TNmUPrzaj#O!OGr%O?)3c*%-?o^YSfO&9mAM)h&LonI>RJY4kI#J|~m&0_R=7|vq8B0=oIgZSr z4}wi_moF~(N?7Lzd6>P_9UnQ_Jy*zq@&dtbc%(U8UM! zF56Gp?q~O9?Yow@vESHC_+uin_gH)e7TaIG?cS#+~p*0ri-J=Ax`b!SUn*E(R9G8Ai$i<9qV+=tO`xr%@AmUPa}j3-f!`L zOL(1;qK3mZ``zd@{@-~!1p0HHZO8hgqOMuW?kvyFO;y`X@hA?UN=lX6nV=hf#`_H8 zN5WcTp6Kk9vO#TL?zd!LJo`&bk)S|l-+-=fK`(6b{PY?5zXAJ!P_Au#MIQSUV{s^N z&)%=3Lwf3YqBk9zP)uo`*XA0N))^<4%V|mhMX97D#Vnqw-A9M@ z|A)N&AJ!E>*WdDY(@0aE7Vc-W>!15S>L0G^KT6fV5$C<-a&~~gK!rdi6SkjsugeB-<%KT<^wx5r@K20g zq27h=e_uAd?6l3To7;kqhoxff(m7)8EFd{2ZA>EQ?f1k$ne6Kn;@pbqsu+W@GWZj+ z1oty=x4f!Ul-c=Pn99wt^)%gC8PMlcaM9!SGV(is8-NgBc|ZB5fZ?G%i|!wd-^Qiv z>(ocSdA)DcwSXhVVQqZ6_oMNM{2dV!5u5AHfk;jwBBs{e;5cw8CJl-=eu#Qx7%lw* zd|boso|pQO9|{ZuG(L(SK9BOQw*vZUd56X)M5e8qZqTbktW=tHOV5rOlkn{RqO63y z&a24=UN6yEqD!{9gI`w?{82RUcmjR8}+tW?_#@ogn zAs_6Ht^mKU#fyhZCeSyjqjY zLfqDt6Dy85G5jjsvQ#mA9NTjfG3CE`xx_o;9GqgM)$Y+xnd$)l7I4$}pH6-&a5JTC6_@z+7ZoGp(AW zZPrIo$&B_Vhd&WGt7+IYQH&yD@TC2i?cprz3li6{uf0S_Hp@vh%Vq`y_-7a`nXpYw z@Yh8e{Bp5GxgJQ6?zCbn#c8dvK4irV{0IDW(hj(j;qreTmsHW&0r!>Cf`6 zSPx2F!bbU@|6()`3i_)9@5>oQ0Md8J-wNO+znf0DQZ&ke!GKB!qto_~PV4@gPU`E> zbm~Cjq~@F#1=h-wT88_oI>U~th-S(wlD#OEfKmg8^*5ffn(zc(csr7zkXj(tX+6fv zzgxPw4c^^OX-opT?4*~pO6r^{Tst-@xq|29xL7lB0!zhGkreTDa_|S0kJE(|?j2Iq zj7Bs81b-W*mihp%{nVQdpG70KQ38|$dfpg8ehi?`)7fzj_MCR$yrKAv8%|R4d#h92 z8hBI7y%|R4q$gPEl;-$Ig6?o2PU5?>(&{UV#^)(@>i+{F&J`AX&md)n2KeouZoN+Y zfc%rdj{%LJidV3gveq}4`(n`V`aLawJrF-Nh?h57*9qCA#OOilXbAh)4f;<#9bXN9kn#pVkI!Rx zc08~gP)UzVefJ;sx)f)PPi1cagRpgdz@q41R$qu^_~}V2Ili!&k+(a&oK4)DD`WWJ#L_*3ZV3dp8r5$0u!AKCpiBhRquZ z0oiZ6wI#OA65FjGn*MFjHm2lpSEP)q#X?!YIjXOl@0b!57c1`%uK7FHAyDUYqQcA+ zl$(9mz-qFrOI*7kUDHoi;qTDRw@f$X41zyw)hjQkwB41N=-G4JxeMo+^ZOLI)?o~JS7zE6x79IrnnO{;#eF(4^O7ZfooZQZ4Q3mK!KeBcTa}-i zkEN|OYDgUm;tc5)*!gxMWete2-48ZbZ}>;pl~z9(gZ(&ftLWFQFm1ZV1nueoXPvGl ze=BeY5VlMHWwJEhqpbHauZQPFJ)f^{TXE95)=hyPU(qgcmvyfYyF~wcnWo1fTl){6 zH9m8c$bdLN1#&9dk2V@Kc_;`)%yk*!d&86I?-j7zCGn<{YNQ+L6qdi2XiiZGQp>mi z--dV(zSGIi1r`7rU-$Q>aROzH@2x-G>c8MXd{@AUz<_eiO2&F#!aCZuSkF7 z#!=Bn9VdsvQ^1VK*d5k;2k#6&RpTe*UjyC*biJb!wlN9VJvQj~#V-f_Cw%7Ez%y!|3@yJS#lXQ{a|O-%kq9DPf&VnRR=lQ<|}@@EdAR^{~G&>!_&Ci2+iPD%8HwW zc|FdKJh!}mQI+jYMARf6w5HvMqro^cL?46*oUKH`YHz%N=Fjn8-7!6A|Eb`i+rORsRls)v z-TqGUDjm!pi`st$bDx?^V1bM^#*H=p9?dY^a0F`j#yR}`?yx?*{&tU>67oZU5rC$T z;v;OL{B1xbZAYZ{4gSMkmxWpMQTV9g!O&>Bg0@ zmm*lad%b7v@M2CLLN3cH#b7(*xCDuJlKpXWT|OpvA&w;?(MU-Iospbm5y#I#^3PJa zEelx!2bKO;u zWinPU;4Oo&|_U9%$>qQ zs@0}c#OAUcI~E<7y~0V@!Wl}C6gFcv&GJ*{yr6$Bg6^5Hf9@v#5bz|R`{$9QG~T8B zA)r#(o}ho8ekr(ze`>U!9_;hW+7HxIQe$sJ?>TFE_Y)3#%Q)y&oDet09pVhJ#~Xh} z&%pk*G+$Si?mx6^rTrl03`Ka8aU6F1hgpkoY)q!cG6kM^`I~$crzLW`?(;y){GeSe z`91T_wd8LF?gn(b3g8<)OL-5V(tBZhvfH&iY}e5TX_qqES95aP%9Y)l=I$o;{*l&h zqO~6C)Z=D3gX~)D&S~vGr1=LLQ103<*wxa0-Y%xCbl*I*N_{nH)z%8yI>;`jo%|A} z1wlKf71-VU+GEL|0-O!#cHT+;hrmw&mF{@?>-%qR*v{v&)?d|IYPDrwg7*8b#4Ezr z%kytT{cFi9YD@Q!1jAE}&IJ4^KfZzEOp=q4MmFF|xs(li^dG8y`|awShIB~*?Fd%d z`+dH|O8L5w$h7hlV@l>{a^>TLFL8;z$hxc5Z@}ibS9K0@eDfL0rr8&u2 z8)c*10AsUjQGmyO>d^a$RFQ2|09Am-PN0t9=OED00baYPQ`7$~^1lN< z1N1n%!mtddnDre{>Eovk><9m0ugkuyan`=v-+O1xy8+#vrqiVHQ_3#@Dt%LXivND#zP?v`)Tgsu)cD9R--gq^adSZaNaJcB`2yh; zg~<0EEpyb=eUS(_cZ?Sw`32P9aZJz;qf2`FVHWwtz;Zz2z4cvboJRQ^K&9+);y>(l zX+AjKh!?@T&d0me$NNa|J_>yyMRXJw2Zwk!%}|{Aiv=-~xX%u;KGLtH`PcyOec+P` z>lLN85eF&&jkoe2Ig9clK&2;s5saVh2eFS@orSmh{?upz=jH|IiY!B|aokw+W)f_1 zlp6eapuh1DxEDFr_mi=mNdnJ1;s;6V{$$>LN&Bwkp|0)U`7l^qeZFG5H9(G1AL>}F zad7W#<{@SokI+J~M*WmIE@n)EKwQxFD6Z@{??Qh;#;P5+U9m#>3{^I9El&pI^o$*1=?QjA(JZu7 zW)9$*oH^F`gGn?nPSr6d;^q&x8zrHg;_U)LSrr?#7Dt(Gz;bNZ! zX0S|=uE|mA16az~D8kq!Y7bz_Nk)|!zR_}g(B6HtBOPLEmfJ=?P!8z!&Lh7BSPiK3 zP2cV6tbP89wq7mn6u%ERzjtv{%^r6`tmPAFTxV5c<(G0qI+nmT33go>iflEKIC`*I zaJDb!(}o1>7B}VPM`Au>AZYG3f1Q3%$hR?BnNl|1$OX8h$BTV)09SiFN!`oTIW0lckH@H$R_{AsVxi6Z&R6gIJ5Ne56-!;g`%>zS9r}&uvsFqFVw^Y5k2rRR`U_ca zuZexjUKO2BAei;u1{}oc(YMWK-(7`Dy=cYW#mP0t(9y;;I#ru9NTOuJ z2><7LiKsxuFF!stR)gAg87FlcXMt8?s!!*xWkEmgsOag(r^&wxyaDKOx~R@FN;9_6 zA5bZKe()dmx}5P}n-DDgg~R6 z2$JNath4asP7qi;TT5{VvBaXcMH#+#MAJWv*2qzoeOJ`}LDY`nO%DH+MBs>(C!5Ch~a9;D_x!fj)7Aw>=gOd+9pK;Wx42l*;%*?|EILSkjd9D+Dd-mus*!0tiY0= zIha^As`xLwsV+I!p2Jd{XMM|DlK?OnT4%0J0CqK4Hi{;&Y%FobaD`=&l|SAd9>w=0 z0q)T!CkCJ=SmD(#iGmUf44Ul=MRFR!PA0_M*wkncVS$TA%y?9=5e9yy)-eMe<$1Zvnlp z7}4K04hQPq-Rl44*pGs{9yiJ;b%Q^0loD*qHj|xDG=x&l&O5|j)3_hbE|MErlvc`_ z`0eA$2Ls9aIJuQ}Dp60YD_J4!A`6a-$0`mntv)jnGnqDLd9&p#dkXH~C!!KuT3Xpn zw@3XoZkwg- zI60W=RP*LOY|e?%q_<6@6EM!C zbE={#Gnd9$@I@H8eaZ{^6IeEbPEC&I=8X5F&-U_6L~1!4+on00rHu) zpgpU>RrlAKdNV@840c(5o=}6<3=5up}vSy`;nhK=#4Mu+wNZ+kI2A^aOXf6i4TJx%QEXru_bP z4$+||@I6U)p4*hl5bsksOIQ$NvCbNUgx~H zF`(0SXrTN3F7o#Q4*{A^&yar=I9R{4J3HJraco`TuXJ>O-rQ6D-v2I&J+TbBEA%Cc zTG)R|jdQcwVlJ{W*lLE|X0&Vy@W~A5;S)5Hp9;(bG(Nk?KLKoiAsB~Rzej&(;oaXK zcdGqy61Z7qejxqN>?YfIF^1>>DII!jZq8!sC^V!oP{_+k9c>?H$MU}wJshiVYyXv4eWX2GmPI|z1YWYW1UE#GIT)$?iNRnP zoys(M7Dk>ct>erSBrCXKoE)^bp|+>JlgKXwS^>RYDL(n_l%E7t8lD%9*W}l)SHp)L zxW9xRb2bEDCLd;}xG&JeTW~YXh8^N7;h!05XC#F;d#Uk8yb|A52rpI0Ck|pJ0?g5} z!ePURznOoE#7|?a3DNiB11#~Uc(TU(Niuz#j5A`3%e<1vFnBEgM_B#MCT9?0feKj= z8Q?}@JmpYP!8uc%A!>2*?Ad8C&u@;g`;>qVqX+hUuM5a82UY``4u=jx4-QxYsHErF zgPogh&Dx*%P66N@wQlYis4)H>i@c&3f0JH8>3;{VT8)J1It}p40+wLcGy~P>iSoyRd*Mq>mdF(bOly^<+6J1QE7?5(OgU$xH-TWKFPTo`n+>;(X#YIgyx$kL)63VZliJ7cNc= z<6ahQ*@Y(uf$~X{`2*orDNi1cMffT`HjWd6Ug3I1&@X$Sw?6;;j{Kj1+`&El@@?`L z0@na4?fGr+T{ax#JlmOd{!ugRf_1H}?MJdYD(@VfI5fK%X3`E(IZMor%!tmsJ{Y7k zW+Z2p%(7<>Wyq#y)*8D~m9HelJITmb$>_PM!K>4&5cQzaXk&=xEp%ougC_0#_?JnJ zygO5M9NQfk8HaRPc^-HrmE4z1t@eJJv@cC!=Ps;_RUH{=4V4Yf3HD;~qa*`8{Z^{) zrtVL9yT9lRk+%tXjGTunkSaBsR^m-Kg3i7Y#$qZmBhK~${{lnVVx&{G%(G_(bleYp zGCqY2BVNZh50nFXUb~<}8i!NP-goPHZPS{Sn@?)@r&x&8aGer)9Wqp{K7SL&W4J`j zbMn{`5H;DUWZLRm!g*KP#mZzRMuavneH3RbvX7d2RN8%3fY)~F%=l064o3a~;1NLM zb?AK4aEJVFyjHZGvY{KZ$MH7UaFH0QzNbH!Mpqi&Y@jBUwKb!)5xKNFY*xAi=XDNM zs3vfrld@IN=v;Dr*#4*OD!agMKy%bi7a`79Lj9DZ)Vt3&(e4|oWW0Xb-4V3oXmHf& zl}2f-p}Zc@?Rbx8zXP;D2S2@d)pJ zyx03L=Gkt_`n~!-zs9IxdPEgXT@0f+d|z#S&wMwQ{0yKK(DU7m|x$bKF8hQ|bXZaN6CrS6ZPo*iS70y$UnmSoCdQSijCmCK8pr2B~_n29`zEV)4Z(^1xE`)&4R>N^Z*b(K%6 zdDcR;ah(i}P!=b86?Wut-fPBfy)im3q)$VS9A^gkZyJA-XMM4q1b(x+C~by&@G7!z%~z zO1WLgae4yT65zFuI;VwXKdjLOAg+H z_sq8CYg<=z_Ytv^)DRDP$&a7(H9rlkS4!Qqc#pWz^jG>Tq!R0G5qn^N&}0Iu{?T?m zv&)p@#6Ky|U;8nuQcWh-FrK?PmEuJ+_Y;{w$^A2?SL^pO-rp`MU{i}rk;+&tKlA_a zpl+p8N1Bm9NdU9gD15J%Of#gZJ6HDw20f6Gt!IF zvf=4S$}Wsnbhqk$pG9+ruqNZ3-U$Xu^F;x^#lw2`xx>hh0~P{$oE&|LX`D{^96%-Q zr}bd--PKugYrj2*FF*05H5=8Kzzu|2Cg4r;^lazjK^kR6)Y2jWlD&A zj9qiR96O$f4fKh_rQ?!td_XU~kIoxu8)ZNxpugAgzZDvdZxR9^*!>bHH_~* z^!~+oCnlBx9n3yn`S3|#)*poI#nrfq9Gn#2=FDlK6Cb3^r2$^MsFQ7~pZ-WbG0HZo z0o~sxkv|962B`EzD6cC1e5+s1zHHug;JO>)?awp|m7nW@_3bD1*2C_Zh?vBcp*Z8B z6W=U1tAK~q{x#uWbNp*k34XSS8`U=)9sQcRB}e!1uSXa=;;hBaAnPzU@k;Pt0c0CV&}UucY|gJ1i@~9^;?xS?DU5gGNhe~a2nU3Hyp49jg4iOW z!aKRGF~hhHwy|NfBKxXVQ68C}Li~4um!1z^C;u+67trJOTi6Bsj&insW7fFs-T%1A z@QZMYnkSD}y}8Wz0{2W~1vAk&tBh_{pKFhQ~%}UuLHghX!`wRGQLqLcL6G8 zp58B^wXwWW&^i=Y*f%$-@fBY@eSWH>3Bl@n};D26f-wVl=x=CL9atw*L zh_j5FQsr{4^pwWXYS0KyJ4RPn?{mCUuV?R*{~hpeK+}ExHfanPgWfNolAgB?whm0o zqI)mA+LVGo2bMZ0wr&x@LY;F&Vn%%Ci0Pw`z`8C~c^kgnth-!sC#rt2yD)_ifAZpg z^{S9!ire{32`_WEyO8k|YqHu=2a9zMJf}F(N9((H)5h*A0=ixl;;nH1NuyU5!1f<5~X>8kO<ljI1$cJ$Ojyx$7(QIG$m@`uq5K;!+bd(n5MTo0)9 zO?Y?x54ltfagVP)UzB zefJ;sy8QKP>DLy1<#PC5#<8YKY-DMb3sW^OR}~q9)rs4y3yK)z}f> z)3iRYyHix!L%nYT@Akq++2{P0vZjN+)AlhZsEK$H(J#solK2zx80_AIiMSV^-~U^UZX5B?BMEhx@s+n6htNqh+z z##0PmdcXPFpnZFIclJ8)G0*h4{r|cSZ27u%U}_uq`75mPUQXq^gx<8eJh9hfvhTt% zued!CzboNS_P^pcf3kl@ZO=%V>jFIg{#QLxQSCe&yBuISpy{-IuxYHPtm&igOpnL; z3*yPfpXrBbK|dVV-4C7N`yb(Nb$XHfo4|X3e*eeh{|e~$>iYrxpiB^_PGr=zFJVfa z+@^ZrXTtawz2I3#Sd&%!+UWxJG>%ndDUN(+9#WuOhT%FF3=Y2b0d`DR4iZhIrLmA#h2$P6#>C4rWgg2h? z*h;4x?Oa#pUl}#GMIG1MW5r*x+%F=|KO*`2BhKF{!0P>z0x^b3^x0_^CSGa4ac@3&C>5Wj-3xIfWdD?&Y;D+Zw%;o2lP2Q zB*rJ?W0R0C0h*4tPQ&*GkeD9Ov1NHcr{aFW`9{-mZs@nED~tcS3Qn8iPbpG%Gp07T zwYRO@9LpGOZRq;vnUtaRo%5`{Dd`93)z#Dd+ zP{BYEQ)5Nu#%~kOPLYd)OD7Y5N}5mNKp!P)gnAXYc$V((r8UW>6nFbP7cqYpaq`n> zAB_J1-*0JVNd@sf?kiT_VjM4DF0IRCG?|vziS4lMdG1m6m%{C9e=1UAi{3ZWzcSMo z#Y@kR=dMUQM5C1PEhd2}uCNJbgskaig}bgdPux@se7lekFDD&+`Aoji|xkJj|k^H z>D(pUsV)&&abIx^u3BLbr0)~v{lYoaZmh0B8RaICxKY@#%!K66rTYQ$Pd5gno%f|n zn1JQ*^hS+h%RJqg%XBHzb*XB0cP=N)YGs-jsYzGMHd*EzCZ80jrNqy-TwFm!(so*o zCu%Knc$6L{scPImS?+VDUF|w{#=c9~F9=ztVzeY3^DbfjT$s-b+F(^21`d{6`4izj zAWCDcxIAgbA6^_1xX75kjgnis*vH%b8R}9|ks{jgg~AT5=Lol~Oj;A{p%}#1A)A!; z9m2g&B%g`6Sh`EX1WD_A2nx~qv+t5_3mnHb_kGiaw0otyTi8*Y5lC!r(8;}9$fqJ6 zoqs=GYK1)*%xm;3-xk>an94_+@{Frm@TM@|67KDIZzfu6^;kGF!#|9~_D0}HWZVig zS`eh;h>8yR8G**-dlBz9uzB3Ogh#xQzBZoOK{fiCu$P-N(+u>+hnux_d12J?YNP+_FMeo8?y>B* zoye^+)e%!Zw}`?m<5#Fnl5=C+siQk(vc|2*TfR6 zrsXbnSK42R$op^|(q+Z?Nq9iWQD*EeGyNUgzD~HiZTI(T=bEq?8*-wH`I%i59c+&n z&T5QG2%?vgKbz@4n)96a0^ataU**G+VH8yMxRrk2EcmTye`H3lz-ND4R^ia6|TLkDLY z+6%3bc{ErzcC#Lg4k!QD$p0Gn2+;e0vLoQ90mH*M71@0Eo^{keMEDk+pGZy;JH_*+ z&vcI=Pma&bpJhAs(^8Wwy`26D7iA2L^c`IEd1#^db}g{>aXTV)1Yt>5m z9-SmdiuxdA%;N#x7lBhli1)4J9|Rr+LcFcW4BN;9w7-k=OToUOdz}mS3wqt^UfeM0 zpV)?TbLAMiUkCBmzlw?uZ`4TF;@@)@HeXmN}opz%m`{6FKDfoD_<-$w_D5#7-hn&IHc~(ypueNtD@~)E za_!SW`}BF7bJH{oNOp=<#)n?H9Kl4zhHn;jBHTz_;rmzfw;BJ5VVp(&V&HN>zhB9d zZ=#(2e%;UAR=P8jsDP{e1YR-M5CUe(c7NF6_74dBgmXBX&C}|9XS`5vG((SU?&K-@#5dM9vjB^}Lqz0MkXsx}}#NS9xOjfhQ ziCf7gw-II0>G+J(U*3LSa`~x{zf$%R8Ho&q-%e#Jz6@HXkx|qOo#N0>DT-*T`%$C zr@q|&qLwgEHL@r&L^b;rb3kus6SJ=oi=D}G z1sZ3;cifErf(`4r2hbcsE2o*EGSekYj*Kbx)}eBg{59c60=!Om%IsbTJE(s<@Et(U3rY@s z6XlDt>^#+g+CseyC{J|{r@7b<{x{0Pd#e> zDWJUgr$M{*{HEKxX?5EvEBz=K$QmXiYM3`CW^_?sR1y(c#GNUM>emP&!};}gz8JKx zgLh^WBBb5qe+B#-(Bn_(bIo6X?g*gLMWLON-lzBvdtGYV50vNmcrn`6D7PFMS)8|S zUb+VN&icU`xJ~VC8_~6#f0D2D?r(M;^o6}M&*qaHpe}we=;YaIHXp=+)>-RV?&`Cb z4q4_bO)RsPTFb^RZCtj@_^WF5?eXx)`aL$M=J(^yQ&D%Hbllk3t-LT9mXS8wR>{4&m`q&H$cN z+w<(&PZQCP5`(atMi);c|0D7*#-0l`;9N5&CxKO!{=U8>;EXRNKUy>qg6}kV-X=dH zs2Af*nIn7XHJbc1U?ZUE^%wGA0euz)^t!bym>=_V zf_pci!pyD4*XS20rc2vqLcS?uggxS6%UAtb`HKJD4uWrzyEeTphkNok^f->aSwO=- zi?&F6@)&rZiVOX>WFK!W1mW_=gyi6`QP)iPaxqXY72JVsCllGT5d5j2W2;`(F`nqhCm zZT5;tqu(@}-sjk5|0Q9+)nkx(t-s=)C#)0YFnhFHBkIx9U2JD?AvG(KufG04&^?!j zIoy-JO2^GYS$KH$(Z|q3izU;vDp8%=l%u?<`qPDxc|GWlPUxHoY56Gmp93!gdY)GD zf3%&u^~$tfj_!x{gPjYewVt}-B;Qx=F7XrdUZrhTa*6$&3M+u+{RjS-y$OCe`N58v*|9;HKr%Tgl%7 z+z06K^vn#?_!Z^f0xD(81#~;~eS4Pu^_oqvrg4uqRey(!sJYXbk1K&9U=T1IpF}%F zGy97ZnYEHjYnJsjzDQMC6`$JXJ{xEK3=s?#b0WC9NLj^J$=jawj+cDft9b&$EL2$= zt>=YOX;ogBPDFnL|Mg~|Aldw8(9UT`_k1_Y$ZrHr0dzlo(AP4qrL58g=fu)M%W{@_%dQ}#R(>VcmgwU5L0epwz7!uM zz1QQlZWRjDqd8^gxw$#^-uR#o;yHWcURQikq_)@`fD*Ny`))$t4|iX(3SSb%CWga@ zTgCPU#iHLNhl}S3T4v@j2U3-vy50%sv7dJ8bt3mzbiaX0K<_*Lga5>#l(WaD?k~0f zp{)x}TfeMr-I~*tdRty|GbX|n<0Fi{(~U>+%dIqmT`#9XiS(yggVoJwc{iy4BHo<| zP%>^JzYF*wpvTY0|CYvUl=XhBF5H)de`0LU-j8)3`{zznvuuYrOD*ZO>f)l!wNt#x z{wSZ(Ls$|IeI&(DvpKPOeK@WYEP#?G&7*Oe({Ee;4+CSG0I$`r^?Pwu&dr59S87k%hF?3OC9~Q<+tS_IJxj#n;m?P_1 z!eZ*I-Y8F&KBfcl%Yh`}R{_3zz(dPJ|3LmT;LkwV{j$&E0MFG~eq(jNcH_gIs%`!H zHU#$f2%{D_HP^{&!b)tMtS9JH%&W9LZ>)EMeFXP0=)K|f&Fk+C!}S*b+1MT8(?NZD zKD?IvJ-{wN(@*(Xd6Dwl!2b*VIu1s^_O+|pmu^;CeE6gg%{jmzVEp)$QHwK{D`EPu zU4rFpTZ6q^b8t%DAd0RR1C%3{L0IRvpo-L}3Xfu!(5-G@biE(Yb=s1ic3eUJ{{TM* zbUVD`ZKDtv2&m+S@%Zxpa^U>o|H!2?YahF@byM5AlUtRGqd6;kTpST3c3tc8%{_s!S6@=?ITvB^ z+!JB@T~z5|UkPg#4zX?#)&q($ujmPGAO}`FDX& z08Q6JPe2X^90#amxxv0`%U54d*WwcoJRgK~UC1}4=&E$h9}INOll~sy%I>WI8-nNf zg>UYIZel^Q?_0(rrcWy7=>pFy9fFq-SmcS+U^`cw5)kY<6N0T$1VgX&Qo>%G4y{fV zrCv@Y*2%-!F)82bC3&R-`Er9Z;e(E18J9vylM&NuvrEeOC+q3A^{rxKrYCy40SPC~Q?Z-}(AY~^D z+0BYdZ}z6rNm<@I1fCm8-)3oP`fUS?hm9Ccm%2QO|KVurqUvwsg&(+(SO+MvLnl$QTbe}-@J6yO#CV6In;lqF8}q=_ncGP z&rDlI#&sM7y=d$oPWplf()9|N;bSSn0swb+(Fg)!p@LuzZu@M=Xc$NU1tV-j4!gzb=^1{@^GWJj+ibv#vM2c_8d`;vvlb+ir+a_CmdG1 zLF71wINUG@Lg=e(TXv4o65`oT{Tk16$X^Ov1!z2#fBt(ZKLn_>?nhhwyXH6e1ulC+ zJV$RlP#-|ko9zaEV@1Od+$+{DMFLT2JRPaV3c|($U5rVmK-Yc5N7u_iLQ{{^q%q1{ zUz!o)+LgK<;|~FSYnS)neHi&!z>$E)TgAUPlkx?CO5e23Z~i*GS)=9#Yt-B!Y}2n& zDx3D4xKVqqATYxT#(UWA>XKnsm&Bh3u{A<$o)EE9f_|z(Ge^4ud%Pt)2V%46!t0j zEM{zXRbas_;sreMM4j04g2=RZ)vhbflQJ)wMw%PVt#vD_2{7(?BV@|EU7%w>GpC=! zKBCwgz)8?fl8-x8g9gb7Xae=K8nDJ3Ou?`|NVdGtNs5|>0ciU{_em3(>46SDA;2$4-aDMfLUa{ zC~)yL4|tWCIof0$nVe^pa8OPQm+)c5q8tV4LRp$$=oBKpZ*s&Xc1qfNt@MXh;sToz zeYeH-eiL;+w8U?$_$Se^()`jQel*myTPrJV>af0P#9eePV*fHeh;tPKyiWuF?KY8Vl!L_e0yo^BipoQ3 zCdm=i?Pb>pWkB-;{$%J%45plf3CjtEFHEN*xurRS@=Nm`SI?p`G$RDTyGgsn6#qVVO%I=aIr+1I^8kIW z{t5Z#fPqy0oecqs0#Jeh!@UdiOE;F9Er>2k*z8 zCCU@!YBNNT$c$|V# zNQ^bd*ac>xnKIL8jpf6z9&1jtlhJ^uGrU*M{JR@7EY(+3bTOeZP1Mr084ONT~Pp0B# z4lKI3%DB4>&!>kr9V&kbwH(tSGW52 zt3!XrenCAyH`s@EW%1EeKTN@3Xc5fvHH!OyLc@{Ul-hk5JE}n`3%@SL+$+poN+G{` zyNGWS_I7cyf22(^ZX$)^-_raTivpwtUL>Ad0z>-|tNLLp{+MMyY7wq2>JfFi0w?XX z?m}UGn;;qF)a*AIHZTq>%##C5TVg@GN3ZW0U-QUs05$=dUquLR<8#W|56GlF0{-_#m#&|q0r?3SO3A4!5~zHj-rlz*-I5qhi)xQOvRbEqtG$JBbIBbm!o z2u71x%`wNq0Yu{zpF0eVc%jUVM@rmy)WN+u(jqjya`Rog#7@H8R>4Sxmv0ZOLL}m0 z5RVuD)trH1FmFTqF??%GR7o$M9-m%F1hInjC?<#pOzZbTqJ&rFW|XihlM2Vfh90^s zC4VAtGN9>l5&5eDy}vKc;-_x^8oDe!>C}^?*gL-~?&+-d@# zPbRbiEb2h>HjuC)K^wu6GZdm{(gA*5)UD@*zmWes@Gk%sl}gfW3&!d0aG$B~x}Vl; zSTt$DoViO^wHk66NGu~{6#h{N@99L+vfSW-pE|?(=7#T650&2ca>^@#kRDR$eYa88 z^w9Q6y1w=6m$ug{C##J{!^y@Ghq8$Kn)nNl9V`B6CMRguBfaW*iTvxpn}Dw8sxzhW z4rSdQeebU4qzyeaah4j0RdT~rW5raCN=OIz7=qLh)>qpe&`CX2`Z3LvCjz=Yr5`hk z^1<4*Y0avo%Qy2yhmC7mg|2y%WYeG&WhUT=SFJ*%+@O6sc$el+?^`8}2Pr=c=zjYr z&s54jx9IjQU)82q)JpQUsxho{c*6LEZ`*%GCalLev8P>C33@1F#nDU@7*43k>O7%c7^qK@NS)M zA%6#OH=yfR_8LE=tn1bH?D`dhYlUyJpli>YJ7$h+Oj%^ysy1Z7C~Br_)hIHI=DdK; znTxLEGSY>J|qQ$9iO3KD63^|`bYJm&JfS%!ur)SjpyF~A9yx=4W1iYm!BNsd9^#> z*@(jHK_?uA`2oG=Zt9`edh*+W9e}3S2jo8koXx@idoG+;hKqnM3jf~eiLCSMiq>T( ztx}5j{;1MaLulOMi;*_@*DK+{AyMLy+#^20k%VU~nqFiUUlK7E4U>hBMa_rt1;w`| zQl=L#w(@0lnb#-3&Z{R>x7A2Y%h87nikLu|P~sWt|D z%KK1MK+nb+PVnhzXz|xY8l49FS?P_7B6XO~xGQ@B zrttygPXLwle6cG_&um{9|Mlpse(I)=vQg+J&u;O%Ko_RsM}#O?^a7jyz9r?6z8=A_ zYK;y!tgg~Gw}!hp=wd5{?gW;b_>p*Dc<*xfk0m27ipWoe_p~to4S%S#0^Z7**7ckh zRLFxD1f(oc=2o1xYJI#jB|&>zPU@Mb&LDpga3i4G`^*m0c!Tn<0hKyK{m2!6JaB*D z|H#GNcp(4!zuViSL>$*BZ@lFQh!zbgxBCvO$j^v!)_-~ zr}*^&Q7iV#0ir{)A^fnKP3g5YI4ixsCi237Lh0odD$Q>tJZ&KgSOxm8WQQP|AI8=Z z5};P*s%s=-vOWZz+%|MdKE zK>PJXj=|DX5{sqwHVCj70#qMB0Af}U5L{GLK@JqKg_BWF(%#~7bRK4p8rC#v_7-k(RQi%MF0kAmI<{+2tO#cd(2;03y>-p}#@L;;nYhS=*SHWa=0q0=jX{Qn8U*9?WJjMEF z8TX9Nu>O6#Tc_9=@ce;(fbQR|$4Fx`<=KErx_@7PW~=_Q1Mc@^jn7T3Sd7kX-ME}D zM$@UN`4%xgVYCmgX2kVBwu%u~6E~hShe;SU^z4(6qOUZyp*GITILL<_8K`!&NMytbKGAedv<&L~%t?mGN=$AX$z7(+Xyu$fvm#pJ6(gt90H@ zu|}9BzPdt%s4|+X0z5Nk_uzR5`DS1upy@P+{IP%@clzEl{tlqiFeSGfO1#VVBaHtP zm&5Nwf6bk$(mF1)%YGkNocdUBA8`h{wX#jq91^ zdY!`=G#NE$#u)ob&@WAUIo-&VDP*d{Ij$?jV{S(e9_{4M06GBOZ}W*&aS!E(0hM&W z>F-6iLEk_8+J4K%)8F&1Wh-)_V9(orwKOg$MU_iN`-*=S#MqN0_7|k3_s@c;a6c?i zGHNAAEeAa1e0sAy8jBJZ4Z}D@HXxSE5gj2T!Ke=U83Bbx$*3hi6lel8eMgc%9ME*t z_y0{_zq`v+caJje?=wnwb8*nk>MY(I)_)Q2*7Lz#1a$pRk^ebxu>D&w-m~d8 zJeU@$D&ogjMQ`JgRNu+8$0#yV35Bs2>>{<&2Ju%4c`=i*om;g@J1a_hm9@ncP|8 z++PxTtt9{T5)`LwB;4&B*9gHJLt_vtbhXJcjFy^keh=|e&%Q(c7T{?>x8tG*q*1a3 zJybv?%_mssd_AAwXmPp!|9a^BU+qvIN&DmKTy!j3h4$+N3o-gK>Hu_T$sxs@BVhL{ z_w@cU{XJnk*mqdE*mvjO7pAeerEj`Mes@85sCjKDx)hFhekdoNn2h@;p6J_iIM&GX zM~F+x)p_TRzD4-SB8VkYj8e07NU4f9H^mw&ix7!N>>?*+k8_J;d8FPQL0DIQ#IF}f zah8mj4zfyA?UekIcuHW&59u7x@>8ZZpyTe4KI-9{!2BqZj5w_OR;Mxj3&}B(Pd^xq$w`@6~s|4Etc_on<&C|O=%m^8;k`~b z+r?Dl8MhkObb1%fxqMJiU;2Wc@2{5pP+%CK>w6l1i-%Fp)_>9Ca?^77eafSR-W=vN zj~?$r!3D;Z3F2qVl-dT3=D-^)aUEg37x7O0-K%}xEtGHTRqr?L^OmimX57iH(#NLn zqQkk>>fY<^*6q8nXMP(YMBk zYtCl9=}`WezAJ{$V3;3K>0e_LlaoeFj7}<@&X#$mM~Ec0%zq>%R{Fald9E2b$8@$J zq7i>np|R4JTAqvTNb)h*bdkrc=u=jSC2q9TX8I4Zdu)B=+nO~a#Y!-{3TvXQH~qbr zzr~gq+F)2tI2)Oc#&8Zkf-ruixm2&XKcud~!8jOwQI9^!3FO;=ZGfgzJ$iGmQhpau z>6_O1mf;8TwQ2{oBUAB5CA!aH*P%b}4;z-vqV|i`Og0F}Yg2R^=3t`i;Qii7V-$!6 z7ZXe!6HH$xh7HsQ(P3A)g*#mD8aH=`8%-B7Xw9ew#DT2yWds~JC zbZWV{XFQ!k{w!cSpy{OeE_YMDA5clJm-_BM>~;Af`@E$bx^?gJ)iOGn#eAz!e3{5$ z(Q!G4(Ra;JhI;v(rntw9ylO^YH&Jr?sej74ODW|JWY5E`Jai483bClS zr9Plv2k+GB2J*K7cLBOyv!N`Q#o}U)x2>r<8K5 zTta`s$94;3O$@aw7HnzZbPU@&MtuvR<;H7|e9c1xyoxXB!D|HhNx(Ef<8|tvq|riI ze=*Vui0tJ4C6_j(tEFmnN%GZp}d{paIM6B*EuXf$zH( z<^ta+Kcr4WfY%=C)M+32FM!yk|Chc04v(@*|A6sxo-#d?Nh6KGB(%^%LJ0v;hayE0 zB1LdrC4q#ZX`6($>N>I(tV9Y1I?- zdiolCZ$=P()T8OCd1zen4frGm0gNrM=Hh*j<8srEG8ty~n)5v5jw28E8>GCgkVEg^ zwjurrzOj7Y?!!kHPeE@k0>nIc?THg^X}Xi+^+w?Yf#z?Zfwc_;P$NZB%kN83S95IUGKT^ zY5mq?7y0mCZl|7(yBj$fkokUYHS=FnpHqnQeAvZ@XI|`1xRbGQkE@VD7d*Y=vOC<= zX%SjSO1r`{%Vy(;Dkn zE7j<#vY752OsrY4tZwc~Y+E<3ol}X|FN3AmJLsg6pKa!8_AgfBsF|I++Vz`c+*x`P zYkc+@8L585NO6bUDY>ce?&sZL+Gph6Xt*vglb1skIM#)_k%mKUUFKc1Tb?KrLor~wUVR*UQ z#_&?E+Dm%jF;eap2fe?}HQ1d<-w)8`^)>RIKzgV8o-QZ-l-S~*f%%t;gw~6&L(!3& z!LTfz!nP(2{c+QX$Px&A5`>5it_{z9GG=I~P_fz=`cg7#}t*SA-o;3I~ zW5DNdNHiB3%lmJLrV)6V*)){y`nhx^E|9FQJhOUb ze3!O-9miq!2l(x%mwc3Q8tMmHvwR`^hT8lnRM80ah6rONA2HC-f&8dEhV)Ya%|}mu zRGQW{%}qFZ@&G)lY;`M-DiKAe@ON=K$09x(Pyx{8PIi>8MEVv01^wQS?Y%^^*Wo!~ zZ^!p4j7$p)hPpH|+xT_nyPUW2AYE>Kl#i~(PJF{IqZZ@2hHQ4d3sZsj?397NC+$gA zHeRQbaW8oZP8gDBb1YrLR~UcWrosP{R*>{~to0!`ns8<{k2E;i0y@6S5Mw3 zeTFv1jNG%Df&7{+?d z>VKolxY`wR@dvEHJKX*Qx3+Nm23NM3c{8lbu)8=cb+5uQAL6tz7&aj)Lrn1z1r0s#za8;ifENLpkNO&u4Zarn${x$izsmNe`KYX_!mM$2 zLv`JhYB<@LN+wEV8>6L993I_*)f`+XdqghkREkBNp!htRmUbTH4_fRFf05A}4su|b z-{-ZwS*Rk@=m2dJ4(Iw@Az#u^xHX+__e;ViAWl>UQ_&oIxnPu%7MNX0n1u$+^b@_a z+^|`cHZ9qkY}tN$LUwY_aMZL#)>LzX>rTVyOOuVZW2FAqLna;WM*NR}M*&K1EAnfD zJ&Ux~`dFILY^V)vwsz@cOK5Yq3PO0w25 z0^V7(Y3omhNJP?a0d5`cM0`8oF@UDOVV%LAN4hKeODnOF-;s$ZUry^U{&T6caSwiT zY8j3K4Tgb2*m|JbH)U1<7xt4txJ~hqbzPi~62vD0rUEn{PqvusWTf@{YQH+ys`ViF z5RId~$B*#~M9YCIlqc zFTGZzg(K%z_NQ)dvB3hu^PSL75qBRHsbOqrd9#C_u;to z0*{YgOxu05o-D@)O$OvY7w4XlLm{U4Y6VRl_9Ff!;BA2BYx#pF`y0}lAN|yP)xu7Y zE}^u@7%Wwmma{9cbyOe~GG1zdvB^>o#Wy(gfcPxHNdQgnRK%A8y4t5{r$;3a%1P#M zTI6P>6WEh5J(-4OaWoAogaGeLg;0b;XQI!HDEixgTZd;6e-W?;py|Jf_+J59PW{yK z!hR17_r#ZJB1I(f4EBvhmnR){UPrKM*vE&q*i`ZEwe2{0d^>C?K%BBV84{nYfA zR5mTc`f&5gcr7TuicZOvVliZYpu{*`9+NG>^=T^ViYco81E&t2f12$7kbc%dm(Er1 zL%OSe0p{2m<}I&A{j1Sa{L)pdB{;Ab%!trYNg581v-;z#iO_eOq8q*`PM7utPrKG) z6OgCn?P*^SvNkp+5Z)df%fiP=xuU?O_x-L%{HUw3_U*t==cevOTI*3iwLVwWodNp* z%WAO1-(HQ2r;SKr>kFw-r^@3MrC43)CDa#FbPofk4jDJY{s$l*pye8dco{&`(@#wo z!x|XbY{c-Ij#+EcnH6u*K(F*W&aNsRV8FzzIP)xG~jRPJ>TF*DauU#V4)h<7* z=&M3l3Pb4a(wASz@#-zb`~JgG6L518TX-IuXL~Z!@XDQF4L1hjIvc7)o8rS0i_87l zL6emrJsP0prgP{Kq_zI_vkN{Jp_h@)&)QoX4x*3Fx6&=_(~~EXVwm&BBXYdd!+Hn( z8xY?NxE-MB7uK5WKBT*9-?f!B%e3%md+9>lW!;UF>hk;9n z;H@~rcZ$Wb@znHo1PwMEX)ULIYI!lwX@D0SO)|6L*H|2N*9Tb3*wIE`_$`5ZUbw$S zkDdgD&%&-Nyf0$7Q*nZncMWjsfP)w8GNd;+=np~Obx3z5@3N&>aaYt!Phq>q57ck0 z;X=|@MTc#U)5%4A5TFR4=QqKC$z~!w4?sc7sh^rh{T!XJo`H3!!9`WDcG+0p{!+;- zzE~^(jzH@xDIGXw5j!|(5DuMt?wyzcpRn%RCZ@uUogc$(pIg|nxe5(~dixSM0j4|b z2+^5RPpyhC%6k{_j{%5T+Xd4I7i(3>zuPfXNTB4yeD(LeK6uTI4-09 zj;H0MqOtL>OzR7iHzUJakh7VGU^ycf2LxnKcmsYS^< zLLpfp_xf{!p(MTbU2&q+$2KJ=iS%d0Ujn=e(Dn9p#NPwx^3zXUZ=0&gp3Jg(EoWN8 ziS-_~i!Wyz-OKd=eFnYz=ZJTIo1$BITf81=y2m2#C(#|rb`voQ6|+?P2QDq^?-73x z@UR0v*%NyW>8{!X+^00bzidr)Ws_c7!ye)mUT-waLd)z`?_*P7KWx2c2+p^Au?mm{ zldEuUf?KCJu?@2{NpdR)MsuKj^b@lsU*X&1a+e}b_Bp5GspTg7oF^mQ6<_pr#A{Nk z8y7h)ke4^SFN4gkz!(f^ZDR)J@VF2oPKfsr3{;d$`Z3_vdU*=*X8_Lv)G-NTKjIX0 zIqRn`?{>Sq%Kn{Zq!bphO4$R+9t+1;PU%9&H_esKe9J=r6ya%cH)9f!gZsycQbLTCZcS`wj z?e3wXnVBU-?ZkZ zyc2#V*Vm#OsGe9?HFtGoL!7aSxma)mV~y-8>_2&36HUQa6?yj?u9u|dOFf0|jPsR` zcp+dYK$jzp$44Qp^`zUqGRRf5MBR78?5^T?427pJW7qrh;J6bV{!|#pRom_?s^~?5 zOY7lE#5VzM0BCyDpZ^}|MEk!^dUS6M^vX}fsAu|eb{ptTA+aW?F^|yM$x^;Uz@_U! z?%im`ccCou7o&+vfMVytI}|A*}G116Lyc+8=>mR3etWOKR$ys@e1j>>ZQ{?S`RDVhaBn;0hCi2p1#1 z5wOXD|KBHC>^7wLse5sW^=!q`$|cpujhsRC>||8$5LQwaU%=V-*w~niw@HZBf5*dY z=Kx6~vOtwTaO(1T0B5-#0hss1<)ZUlF9VJ^-=*YQS=Us#w0hC3>eaZyng^o@G^j?1 zDg5QYm59ILN8s)3L+=TmFfz^hf=aD*)tLb0J4*C>IoLHvfBz;ME zuz1I?E)#z4$jcX9c>xdc}7o2UYpFY>TeXR=4rQ2;oA?pTGINh8tmu+r0B9;d4E2kx-qX*c4p0QLbi zeOeD{qdYYZ(enb`ZxVeRj9f+zDa%hq$GK`1u7>0pgXppu9mq%9F_I}FLz|*od|$jC zP`#Uk^l<=9m+IYYr1g8N6S=D}N^P!YtIFA%AgI?FaqfqRMSdye+vK2g58@93wgWVs zJy;ib2I;WsAN2d$As>2BI0vC&MOyhhlCYkA2!c5n@TQYsg%GyAqPHr#p=}-ez5YlK z0%*ED^?T4O(XAu@E2}23&p}Z2d`gk0N%__Qr|u`(QVez}(pLcVd+6MLIGT=-XvZ!= zzB8-WvQEi*K|DN8X&=b8zSLLy*GJHWV8Iu;FLgC-k3I2zE1pZF@QAO-72c@W@!yf2Z zpEHoo2I%tcsXmjvwW```Sc+X$&7!=VeQ4yRqE4r&meZ!_tnY-*UC7h@r>3(Vc@z@c zkH~a9`i7?$P9&IFX_;1*AE)}K(`ol}t!h0!j8#a!8bDLG`?oik?0lrxJNTmWMpq%- z)%aTLb4g7jSeS<6OL@Y$1=i4u(S_=zbhxsX^xA+ctn~0L;;uj1%m>hVAb-_aNPn{b z67j9X_k@F9d-0_SL)ZPV%MB~j?0RsJ;evq;yC3!7$5}%Scl^B(ReY=g4K4TY5Wg9) z8KC&EXkT+1(t2Ka#C~r_J2@l7eeELWZA|Ls7h~j*f`LN=wmEm21FT3NH$3yD^g~%J z!VWy_ZRv?&c(K$E+aB-l^AR5g7y;1z*ind6NE}DgJg`VjUa{D_jP6i3me<$UOvVyS z-AW8((n15UyOoCBt%kX5UB6tMXYk?<7E~nnw@T^N&Z3HsEuf?I^C03+0bT^?c9>pl zuzw@{UjPNIC;b$ej*kY_4h5gpxELU2*Xzh^LOyO&-^PzQcN|#;4@7aj_k4H02PbQS zUT*r0KiEkDa<-dpnVW66N4V7vY=*7B{A9cVU|!u#+vUd?<8Tr*fS*<@k$Rl|u;hza zp!*B8NdF3;>seR#7ux%;hI(wHXoXT@Zgu0T>PE6j#A1HVQ*_%vLyv=h zK-|9rXZQeGZ`6OyM*1`Wg*9);dhU5uK1HVEW7xjSM5=4P=&S{GVTY*;D@7N!l}}xQ z<;ulp;WM~+6}uTjs2sx&W4{BuAM?JhT$dO3gUDqZj4@#K1?w-BSQia`2Pa3_dD(Uz zt-KAxvfL%ym~9qN4W+x@Uh^#64+^@KA+Bnv=RJ@^H*)(Co23A<0b1U*MFty-^h5xK z#QB-XbbOqj_#Qc;943odoL;A?t8S)m$f+Gx7~!jfH_XQB;#PhdZtXofem%TY%C!aj>T=wL_zQqn09vlYh*S7ZJrmob zLnIO1)T3N;s?mEect8Z}__)xt4;lV{;>-~&m!i{vfjY12bEDV44dW}^4okval&=i* z3-~I7^CdnJmn3ke1qY!8W35V#hDYOaY(RW7;12*TN7g{>$0Pk2fI?4lM3w!QuIuB1 zO1W~j{8%G056wFWD8~ZYRv1%bTkuk~UoeuNgA>?3j~gL)p)^8|8@-+~j8>BBdBfFe z*!LM}dC4ysxvv=ZtCah;5qQ~#&n5-{c&)H@Hc2#Yt zAb@k>_^J%cWqGc7EUt%}5x)d@wBzU;4Q}Q-5YRbdMlrV*6CgRzKKV0@hfJ=qp+LW~v94Q6QaVq~MOAMcX!3UjOWDzwOHLq6sG(rGdO) zD!hfJ7=elINiJ8&2*a~a2u(c674+e-+SMkt@_Q_wD`zVY8R_?8|Cc|2Sn7R<+3qkr zUE*jsoKx6#hBp9PoVFba!7{3y=?|8;N4hX@>x0=t@J(~_Qzm@5CB>3sLavpBY$VL#L z0h0g}_6?Brp?^R56q)#FQS~9J>;!dPALiGI{cv>E4ewDW^)D`ZsS;5SHgHkqHyG>^ zJKqT1ZK2Tj!E`7Ytc1lNKp2)*LvSX{UlGN45bi$H8&P(@G8#fg+G<=Cha*Y2&>4h$oa;(jCOMB>rxMz~bZ z)?$FQrruLWj;_9VX7Z>jB zOF0ayil0Um{sssB3lP5yumPauxfbyo0UED<>i3WHyaU4}g`p!jD=lW97Ly+bC^g6n z$bx{Ht%6TQ7yf+(-}KQN@u7fG0A2q!AbvIAb^rxE4(@84uqDARS9Sax0@y0QoYt@& zBKzi5tn>u5`?pMX*GMeBqa#5B5Be>bxBqRCePNN`$cUhGz~O{G!3A^7?$5`{e# zx5*(kuDD~|Jtl}=5Jizqr4Ui{mIId#n%=sfKyTtwjI#x~gPM*&8&z};Iq0N3gLe?1 z0HDixEaDRY69E*oT>2?89UpsCIVZ~9&c}?p$;(&NR6+WRWs6W3M_tIdchFfW?26JL z3d{CnUpbIp>KnAV7-dTJ^K=|IHWtv?G7f|G(kclEHp_C^2EO$8ZcT54y@vFDfabq- zs4Srmei5UL5x4#X{Qz`3&q6#8pvy-;JIrr!r+qG!2Kw86xD+-A5Tt(h63%WrISBjD$vo`p z?@A_@X+d(vRACHJ!dI-6^?EsI=ur5&#m+|hT!7}|DC9)|n!bK^;G?QuuuG{Lki2oDtxdT7>ulNdSy^pHbixb;*b=@i~$zxAl?WP}1`{=Ke?cEvx zYqU?(7`!I0mc4Gh8W&uD5pz&rQo(ItU>Zj&g zyxt89#&bCM9)OjgAdW4I4h}Z}d&OVCYgG9YepLZ}i}(h>?*N+K^N7CzNF2wgwx-s5 zFvg$f@F`qA4KLU)Xs2lW0fU|I%D1uy!X3MdE|U6^`~zG*><|TEx{Q0Xak?6#Emtaz zyQaBdI3A0lK1@n!CMtJ;wu>W?S8=PATP^e*h3ey7^8bDdH6>%g34oB5sVK9g@Vhu#Mc#+SlK#6@lvbK^k#T)5|$m1-Pq z^?`Gx6OChF*xu`gEl<;e*W_l*v&Wht+?(}SFmg>^9L)?I^&snySio`F@YhI-4>ljm zv#eCiYCJs2HN{K2q==IWY*uWh^L)0R%3mH@Nb^(`e2So2( zz()XG4w~MV$fJ-*S7bUqLMO{|O5kT6mh;K$)A)jvUa7sjeMKAA!GpUZeyR=Lr&&-lmB`FRhAR z@$((!AwADS-cQwYf2HT{_|Z!LgtYw+v)3n5+oH6>=Sn^A0uQ?Xcn9%+0Qd`WzTPu2 zcSQOm0ENVUL}WTX4r@EJYM%o4sOHG1bo$kBDa{%GNpr^W`6+PqmF&jMb|%GOrRQRU z9d_s6WRp9u>)r0B+}>y0X}*!@Y%I)`ZROZ&w>#C3LAz0Az_G7w^FVSo&U|7~7~VtB z=8ACE|2RI*7=(pHB=a#Ia_-yPD;kPjr=DPo$%?pt=wXowR@=s7`99%2;Hb)bA$X z)aCgI;;n$40Nu{WuHLIi{{=uHk-o@ue3T`&vqb$n6f;67=6JRVEZQ-i30oZ0yN#A? z7Qh={3X3T|!!O6%-BE}i1DFKR`?FO>YGIuN zc&N~-=xqZ&-Om4l_(8yT0IioX{b7rJ5B3%S6siKUAK$0my9!iW2E6}tdk?v>_+hvMw!uGh2fuRL@41YFFlR*l zV;aX*Q`46zmfpCXB&>2ptdZy}C%9oWZ~`Rn4=3<8OsoK@2(b&K+-;CY%gtYf+<;<$ zmivsu2D=#Pn*kKG++QB(T<)v~x~(r#Zrr%V9HIPWTHiSfkPYz8*78@1YH|%f7M5Hl z$vV@Ay-u3&c<=Ohx54_lFW~AE5Du1Th1m;ZAh=-b;?KLO7QF9)4-+`7@uqw29B-c`@6Fa8e?S&_NI%`X?Y*JYzj?)M2Yq$@5;OYeu zNqt!4BB{5-(1+Gr#$N0Z0|o$ezejeEOOV#%e*NsCJ_u|5&`EJ0%njF$@S!R^!da^~ zAG@B546!I$YOJN#+fYc zPi-~036JxR@5T0b^9=J&ulFvm7|-RQ;iBUULs@!Q7g79W?UQtg1lfV?hx8zT)=yV< zATjU4sj7w*4b|*%*jX42TaV%;5LI{^9C)W=jqSHcU*y0`>u#q4w4C~>`>0whtqS{c zOK_MM?xfO#hFEBCRrvP+mwu1FhxpfkZyosOY%o~HYtXH-=eAxwHU4Gw?T(mZ{3>~0 zH{}-ce(EEWO;h%87R8D=Y$M{hLjgX0v`gVmb%~y92#i{cg?mXKi(M?sxdOB_KXhKK z0qG`y)}uc6($#qfeYB>3Ijm*3pB6)*Tq5ze0++5wbWU{-()%3vyE?B$)dD`J>2e}s zjd&MMV4KW55Bh7moy8&w|Fcuf_V-Z~XEVaD!%hG|^H1kBh9RB!e%{2V;mAD(9(3?| zVEo{7OyPa$IGnqn%7w5AzrPi5J3!a}2N8b~@FxI;MEoMt@lm1bzme9$t2S^C1Ui>J z3W4nRt?BPsxalp=9Rzs=E&87ZV-d(HFs5@@=|KozD)m|XM!dX^NBktfsQ}FvoyT5| zbUlEAuAllTG94d>)%vN>cU}GJ_Dc+_aWO=+O&r^pIor$j^Y8_{?}dCY3!{w&d%3-E z_XQUF7?Wii?S<+4U=~w+#uR-b^?!)}8SsK5PuJ(BGgmZ}i(4`Co2tpL2ri=tBMDgL ziNZw?$H!Da>@q38XMeoihY-&MiQ{Kze=y?+JY(24+{>W-D0)S}8Ut{e2G%GH@dgZE zF8ST!z;{354*_;K@L`X8*`j4jmN2Z%96$r!ngr7spdC@aKj`@Gmxvz*{MV6JS>3d} zvT6C^`YIfP`V-;6$JQWry%k?J_@Y3cFxrKk3;YlE<-joI!O|ePNu+N{jaUZ9t|jHc zxCH)-GP;Fmg&p zrJBK>%$Y%Lg8fe7ZHePxH!8ea;(1-;EgcQKEqs_Tagxkh71|1g_fQ-M`!C|&12#(n z==n?v;?AJkc{|@FBg>eP+hUY$Fgz_rDtNDD&*aq75}IK|u%0Fzle8&%6%Ks0h_3~l z1JLx&bNrv^9zk#9m{Ml+X|c*SSe_P(=rypNISu4K)Jd;mqtx3T2fnuvCwhMe{AhY( zN>DI;T0CVNJf0R$Dtne_q5o3&i{Fmx=NQBZ|M7VKX#6F`BT+(qT0&(TLY|gTD%&Lp zR9vmf-GTpRh5xSq1^>w6(I}ukE#a~aVNXjqmF*Vz+Z6so4*cIE?t8~($pFoNI^xc7 zM0t!Z9)|)cZi$p_h=2F9p0b5yyAwCF>3%2VTu6MTZR0!xxgJ*Cf0)=r@ED^KKU$<6=5U zFHW3F(SP_AIuWF>-sr-v0bWkXAyHWLdZ{1Jd-3w7^B`GB_jcrI`6`>>SOEvoU<8tV zj3Xa0_511gt{$JyNBkFn(*YE)TiC9jW%Z;Tt9ULjEMS}Ax`T!W*yRAf^0iI6p+5{3 z=*Krm{S~%_vARJ-)qF@j-b<@Uf__en~Sf-?ZSS7Z?pIJp1vR zF#Q1G6T}Yz{vF4|*?;ADUgJ~SCbHJx@Xi4`!SjII!Y*PmR%iaVKWJD|#^)_kt~DRV z`MC)37Qod2`XbgMjO!7<9T5Ib@}=KHnm=NUhEQqc3ub%75b8Y69`)cu954SR1|m3G zGR_#ORyJaa?jgddnE3&5qU->yP+?JTm>!Gnhlm;4!Iu2T9yYN!e_gtzrabqRnad8ZcV=e zaiU*?XCnQEZuBdLU4Z*kH}a^V8t9-YM9~&#sP>Yi6a9mv6Zv~wZ*veQy;b6w zsJCt1&{0D*&_R`tqR!J$ZI7f=u|?9^;-K@GqO-dbIxXGlZB$9|SoE17geo6JosXi@ zzbXht?v!*wAIIhChd9YI63;|=SU2*FEFD=g8hs?_pvp&4=cB0f`vjfvU6Rfk2c1h5 zo$EWH6YYl1n31DLmSHRnI;iqd)cGjpBd<%v-7V>~Ip}<;=(zrIq&yYf=?zn=F=H{9 z1|dg zv~@$TY*blUX|XIgQRPLQr%L}$JH6;uNiXK0_eVwV&z;a~?M9y^rDMmA9a*9ZkgC3@ z`&9YcG{K7dB)!l-s$M~xXx3qhn%*(79}phj_! z=(Q<&dmQvWQuMyPNt*H zYgbg`fc61cpZnZHb0wPMVMriCtHO8K@moF5O*_<1gU8f*e>>ili*Y$vcoI}z@7a1U zkhUp&5&Tx4W1#!-Cm}sw;iJ72jpvNo24Nl>?B%iRagSKek!aPNRLIyLrM#Q)TjlbY z-HrHtfQJ-5%pdul?>o+4FdtvCtX@uE9;JyHK3*sEmWlHUPy&D^U8D<&!D$Cb<3I4c zA*W@OmJ>wD621%N@f`ZUSN~5uk;_^YpJo4w*N2&i&jl<1=%D3R%q@neEZRGnEcQ;O zvqL^P@h3g#KboJaT@=jcwo83%bI^Gn@x6fkiVlwF8i%^7-%=?=7wa)gSJq)?#47%d z<2dsHexAXu@CSRx^@A05G4&^l^HvU4B0_34qJUOaNE33ig7jo!{X|S*G0inG(WX4{ z4Ih0|PH>UNR)$z$Ae-$7k%y%`4WGx?0mxp%IY^%e&_U~s_H)2#GP@+n&XQX>;wu#j z&ko0Lo<;mQz>AJNji+*PePc632QE03C9~d=QdHq#U&LvThRvrmq%$3P8jswIUS5r2 zJiz<1o!DL?0Y&}Ar*38}rtmIw;8=lp4WPl1r|~XnSlPT{$(h)t3j=YOy_rPS0QD3n zUuBJz$XgZOZNL$hBCt@V!CH~t*$H0kC)0iwa2Bv{1Wr}WEG6#_Ssv`m_&#Vh?qP(G zP6z0q`NVFy!q*?xbcsi*1!9d&V2mhyxv=0i`@uM zrn&+}6rN2EJX;XI8*rZk&mwdzi(u=xeyOOL%*Y0+Y<5ou)k|6#QwS>*zJre6evSAy zfbUfvH3?nL%j)Td7lY;Y1K5s!5}Pl!MW~_*Pw`hmH&JAD z_`}yar|RkZtXVuTA0G(+R%6_lR%q47R%?KK7W@U->LNM%7_^ZNSp5reEH~;a6HEJ3_h^+^7nA|6q z^*g6yTWS#1_Jq{?rvJppRSzKk2;d2T4oApUP7XTGtfmWk#YN&RKZ?DX6Vxwj!3SQ& z;U^{j!w&rVJ?#JHNc_UJ!Hl{poc8@DNANB=h$uV}{6>%eG@g0g;+fCZ<;D?JC_L*s z!E;Mjc;=G5nu%mr=@N}9s_^XT1kb@9@yuseDMT@aC+piIr?qHY-GJ3eT2K@I2lvo=NO3%?vU9lq~l{o#3&*>rSpo^WoCEy}-f> zPZ@rbSnlQB;u$3h>`{d(qVPmJ!E;^Dc;>Tb;yjUf6$;;;PVgP<0Us6IZiOzY@MQh> zNcn~%PlqGRf7E35ah#`^!c)-+o>e{Ina@6NN7SnDY;pYN5yYPWJmbjI{8X)6y7br8 zjo4$n-(c@>e59)3Io_-P{~42S*r#RvZ23N}<69BG9dMT;&nc(0SaqHoa^hn@c9>~I zD#iT&p8xOD168EhPFaukGgo{6^l!xf1Na`GgRV!Ihrql z-!jA1ey(>q;xhp!0d#QaSC|Ym9!p~Hb9@{xNMMaYE0IVLgYh(_lsYkpg3VsI$-xt+ zIPecK^Q2WA49z`SJ@CtgmXADq7h>ZRa|j5G{s5;z#1r|jb%eN96=5O;$v>$y@ju|! zs*~!}#T-{56$Wt}SDaRqE+(Che?zNQ7Igem|Dvn^b1{(A|CLWUmyLfW;)wr8#w0aN zRY^}+Ss-1y%1Aqek0xJqwA4k~po5+C4LYJq>Gq?Rq*Fq&~RAhX?dZO0O^Zz z<9VWArqWPYY9(FZ zD2I0)IMZeZ4_P61n;kRXwK>jA!I=xc59jf5E(dMe9n#9tY;}C1E1GEituISCi(Nv_C<*`DkijM*eVojrEEYdUwg}g~SFT|nahs*0J5|4L zz;~rZR>o*O`y8atbNpWS8)6mWAeOQlNci??IAePxA3N|HeV%AZ5`4uW{c;z0<}D{* zSnMFrWxs(>a8b^vK4RfHtnhio!}LQ=9UtZFu0D+NSu8GiyC88;oFAa>cb#?p#P_k{=ZJ} zt)|tN5}?arJKNb5-J9ekrtmRO93QPSB_W*x&_T()_^pZ094?I64py0cLU@;2$LTl z3dDM;^Ho_s>*a5w^!sZOzaDV2179fNyjt_JGgpY)m_V7y4gsb7(~^f3-u=L#?;Q<5 zKl3rtpT_aFuM1(HVFg{!X#~DrEQ&?_Z84SU$8aA-#g$8!Qo9UV&>UC{p z6L#Q=pu{4!12=5r#Y1CC*eaFyD-`~$Bp2(igr@zQ;YgPNbkKTeY+eKcl}nme)Yk!b znC-DOW@<+5Ej_C6Hb@*%!gDd=Er5-k;H4eS$;;~Jw&O-M7$Jn>asU!ZTf^#cz5>=QT0h;c}T z!qeiwbL;08iy?hmXL!~&od|R9z*5d|%UtMD=oR{vV-Xr_)BZK~(eTv$c&nmw*g+={ zaFc=5%gwX#;YOV4t_$;RAm!-z_q|0@t%StB-?fO}4A|TmK8&MkutH4Nix`~@P*Uka_jj^iOlODx8$$6hF$2Vnk#z+hrn$LUxNeR@>5K<4(SWK!nc}C zJ|E-**v&vH-%K=saiC@4H)T2Ra^ThTq63}a74s}Ki8a+r*mhv0TBC|u$P-ccvNGc1 zdb+)`O7(J45byxSr6D`|YEdj%F#c=rQ`9{1|;^hXZ7ECFvVIm3j1Lfz45l&uP1S+6+0 za>P#sECNt>K=(H;dCOo+kWRE~K>iepimHF5t>qRi5<6vNPGk!1!p?#%J0C7OSajK8 zm6gMVUr^wTz9q|Z8*pklb|JnO@CHEB-ThyKy@hnobmg%#Tp*bUy2V6y1Ly*9Yb0U> z$SNE{_<*Eam>H*=JwdF zu}`Jwv^eOHJ*GR6z6YS?6Mk@!?oqy`hUz8cY{a*aSqsVXQx;hhV6BSIVF#U`rN`PP z6d?G2Tk5GiE3T(A5U&L^0JNT(5U0?io@y%_&zuC`#>^POd?T1Og7FdfT};v00X(`M z{471;VjQ|=#@>8SQ(T^J_9 zMvV-g!+ZmnH9#Q4M?}$YanRp__=A9l0lNH0t~J2vksNh_D4J}A6VCtk#F13U~MEu!7nuOhx5@D6}N zqTe}@>G)_?c0ROr3x`lgHK(%f%)#(S34d41aQ(e_2Ij@5GvCP5Q%*}iJ^eJ}^Z@|- zwB9hY|B;dMS0nusBmEzS@wqYJU-W7J3&S-wfVV(78@ZLw*hnLVr^D)_VGID+@q1R` zHmR?Lxp951MEo~^ive1$O^DwF*bJc1lU(bMkc(b&bKuUlzRn?8C<60&Lz!=AQD}Ht zQTp&qW(|#oLO&#@T^LHgAcR|CefX)c3T9z#c_>`Vra=Z|qTMLI4}l-uZhls~Z(3PZ zzYO*EJ*kiBz5joNcR6{`#Q0C)jXCf>i1;&rX92oB>_+@`K$rc=simj2TlA@z3PWAL z1O#EM%eN2B0U*quK1_G&u)_>D%|l@&1pVauQm$}bT&~XOkHY`Er$0{97leX*D^3TY zJU)>4qYnH(slU+;VeyLQRq*Uw-$RB z>z2dOoAiM+1J@7MvbeHu)>#erFYGqBeIKzs*au%dpLVe|4R1N@ZHs;Iv@K-5cao@On z<%pjOSOm~?pB)H$Hc0oVPcikGOO~aZ#n@erL&g&AYd{--amW-SS@qKuA4&S#fLrVL zXZ6#|mdss^m2J^kL$$36e<&QsUy^LF34jPd%Qpq_5%^A*dOdaIXnf+?ohs``OyS?}!2dbo{{^u8csYIA48NyH_gGG~OW<;$k-WC0 zm5)a47&6<_(3z! zK7!C0@i9e1WmWjp(Z#2&gc`~)vH+`PV6~Jg(`Bek09K636x1($##$A>tRT*BSLHni z-|4!%OGb^R9dL6b z!iJLL_d-8?E!8r-v@|)0r(oA>3@s3dq8*eM>*(U9;c511Fn5p06~A7 z8Zf|T**NOhD*h?ymjkz^-`RMf=kbbYO*D=bA@ZrjyX9x&9i!zEc*CDbylh~6Uq{&Y zL%JV8>x=CB-3&;yzo(m3;Sd@a%&S~nQ%z>92NzXVRSjQS%N9>#Pa4xuE{e`_;M96K z7x7C08=Q0yzXi}^Jr~C7Xj)T;dVV?CzW3c^Fbkl@S6_?q6ZIry3<#vq0o?Nq4`4?#~)0EUsT! zSJfm5LXQ;+e_3H%uK9>B1}p_=xz0d*B_MI$qjgs}#DRElkrF7aVi6j7a2mjgBe^gQq;#J2$M15ilRhsbn% z+<3%1u$szRPHp3Qit_q%d6c`(^+j@ACy|Et+S=DPq5A3*+amylBugxJ5OeEWe@x4WP9UR_S^tf9BBB;MkoalA7TpAT3F z(DIy$cm*J_oV6H*LmFx-SI7q3UYtpx(XyT`VAcYvXIK@SB>68ti?fKLV&=ZA(~>)&B@x z&?2IA+)n#7DjafhWz9;w!)DgwSlGvCFXLfw96k+j;!$i*qcdt#bf*uG>uCw%O@K83 zss{+x?-73xus*@RvivuOt^D~WdF~SXhBlUL( zJR(HtgU;=x7P(k2fC{`=?F%^dG`YSOzL2XY)>X}2UD+@nla7YwM)nB~^hL0;Awxvr zFPFHYl&kSqJMf1)!9TI8Y6h*5vN<-ZPYq@x%7dmO@3pohl+NkYDTR0mQch-T@@)DZk{$>8aJY zBCe+zTu0M-(ps7f_vM(YzL>MhV|Zh|dT7B94zo z)p=Uzbcs)SDow|QsYuuppl44Yzmf0>JHX-~ ziMRnlhts)>XrgFwIK~IcbPa&MAvc-n2F%v86#$_gp(#J@t54!q52ivt)P<55u#5vhDwx9{AEhQ)3T0%$o z4>TtrK~GNI>2K5b+O;iXbfJN?K{VATF-@TrCt@j-jgHGl{rqI4r^!6}H*ayuH&yg! z>!6{fx?^lpc%q%)xzvFt=EO5owD%f;gC?5@t)2XSx8wJ%9ez*kW{oT-_50#6@&2i{ z)WseG`~{%1l-19~Hem z8!)=;^q*oZGJ9#~|EzAp<;3cy&i}cxt^wvfj$2x}q`LDj6b@;med)nPC#ijD! z=-0U4yP`Ypy7~Rmn#yHGXLgBR2vQMQ9`5?Y8%B+tU&-FAZ339DLkoz#OLUEFaJE^&y|OPujiiwMz|t zh1+wfn{1@}*1O$5xO{1zNH1$EA3nhNm3M&A7{nP^S8^IYjR8iJg`DIRcz`K1nkhcE zuQ}i=+m7;_b3MNap69m1`P=L%uUMY7Gi|JJk?F^&FtGfJf%mnS=NnnP%s3hGEbQ{h zXF9rdN@pP5OXROk`2P%equ<;FGKc$6EPxba7BT*r;` z3=AAIK{$dwrx-U;Qb=`h-{XSAW?$0WP_N{%sh*UKbaxumXB#=tYtS4Rn!-Iv3t7x7 z+hepW{$9Ixp~=2Q`Uik+Pif<1J36^i_5@S97_aMgb(!*K)M=BUvoJ-?mCI)026)$B zpl@~mjjGD#%FYpBb&IVxX0VMmoIQH|*Vv(pY;L&v8sQ*Aeet1uuuMV0r{srxdc1Sq zV))@f`u6}W|5J$Hhy3jT3N=q%BA)y2kWZ1o$2QfUR4KniU5f|#!6M9QJZcXhKP5)0 z#m9z{`5m_BFX+YbHc7!U5e@eG!+A*><_GC0IY}Mk0mXOO_&DEd5x*F41wixtB;vaP zdjS;me78Z(cSI&WS`^=B@4HN7bfU@J#n0xu*-%`ylED zgS`ruEHFts!wWY=fms%AXkb`S4UxT%pxX8_eA8@LeM%bN$8`H`@;y$2q0iM|XpZp< z;}n_{9ft}IC*-^UcLTk430#(sX03i%j?<5d*W-G`n*eJ7S|3{8+mT0Mc@On?n_B1T zR3GiVAJ#nTYjIqNp-n~}?o*#m|Un-d)wvpHm;nok^ z=MJ){`u!G+6ZG%toL=$KE;bsVLMOk6wMcg=i>co?;JfJ0^bKe+lA^fYkt9-q#_1J761tf-djG{zv>1AJczSd1D#|^IFwa zCssEvudnK2m{K@IS8usfmKc!~l35dl<#yKw&z)c}3k%RKZt&wiPk=Ue7) zE$<%gHglin{%2_?h$_OR>J>KxF8vn4x;C=MVSbj4QL@Rsyuk3&Hz`DevOW|Z8!wM2 z;+Fz80JNTdkN8%=9|05+*ONu21o!=TB)M0nuaKL8j493#lMe@z9P)_dc$+j zdSe;;l3a)8;ULFDhIOBjY~^{eSV(<*IkbT}ID8ph|P+~G*PKbVU6 z$$(P+or4QZakl?AP!G@Mi#>yEKTYqG9_M*79(B@_!U6wffg!Jqtjgr~ZBakLm|#ngKarcdIyWgokY;8J^(o zCm}-`xJQqC3RqShGqKB9y43p)@S(>Cn%{SS2EU*DD1PyNnJSFM-bS3h$lWi+IThTo zsN!$o)Fb&j8+kufpKQU88ApOYG?|BpxwpCd9Vc@cQg4SGe2t$58w7yk0h+Jnh&KU# z4WQ6dIUoE{^%Z>0UfJAv&*yB*_4pDR^H|h}i0JaJ!^jp+LhXNY_ov)-46as{ zINCnOp4`5PbGGcwYOMm@nYpYaGa2u6+}1J&ksL8CKjaEi214p|7s~?_0JQvb5I-HT z6hNV;eysOvmx=Fn-|v&~s*|c8k%ab&UDr5QF(N7fqJUH(u)o4;bo4Ue&orXs=y3UPh|zL`T(bhqOWqBLx{1~ zUb5T|LY}bTRNj-fW?;V*pygKoVyVcZaJY;4S-G0mq<2>zsbj{<+;_wn}L6u zuqkL`>PV&qWyq9#Re)YtWnGN;M!;J;(r7r?h|&e?-{iXc+=uwBCbbdM$W+4 zp-PM$4srIlFW(5dGF^+zu$@oUh19xNjTa^uMaJ2b$yk=;j~yT9Zz$rWfTIAKKRT~; z0@D8*e>kXuEf;Yp2o}dOBlE%GX@Wx>xZWc;3>YWzd{b2MS;k1?c*zxOQ+&06rtbgf z{@Wu+KLJqTi1}6b`jR`A#19HB#NPL5)Qn|IJrtf0f6uid{x#q`fUZAhp9mcQo&`{- zdQPqPJ}%pb;)_MfuN2Ygy*7wvr+lr57hk9Ur;Su*BmHnQD=#(IPL?@j32Yiw0@iV4A}>((wGwbZ<0$9{&wy z^5bUE)eBQ9A1+PU*a$PwqeFK^28JL9`h4;^P<{sPUwF7-!wh6;P zxG)db}_`OdoJIHPG+BzUwhmo znYCR$!QcSX{h*!mknMj({(q+{!;*}K1D=1nJny@FH`v)X+9@~KBW|?8sK^7O-`eI? zmU*S+-E9Z$tkbgGSj?}tJ?`;Ae^L$3LCsIiPua?Yo}}FLjI@;WB%>h1kJQ+#%*@7| z)aMEvPkLw#OZ_;_a~waayFzy- zT@GBGs978Dw$N#nPr&?|j!_3^C$k#^`Bw68Eq9A$Y_NRzG?HJlg8MAnH!v;P#?d3{ zWiRGfK17kMv<8{TDu!-hjsv4?Tqbo-m%Ky^qoa|bF z>GLt3h3>Fk7X3=iohq$9e6V%2d5o)%5wZvJRQwYAnf)dI3(Mp0jr$OvbP~qT09`&O z%)xvUa07rs`Y_pFR<){rQ{-aYGDh{6f9qzStb=^>VRW{!W-U(03Q0RvNc#kfE)z|` z9+uI!#XQg4VDc98DJ~wFM|{o~ITst^Q6N4yy2Rrg@i|(29{D?&bG}47)bXV=Mf~<7 z=`GuUw;h%>GuXbs2qVYCFR**9$KimXz5u)(`rNpn;|jY{{%o7OZO?Ny2D(0v^}ZQ2 zY77|2^A#J-MwTlpGpjl4dE0)*a@}hOs_>07-Bo^%^@4RN_txHPhql6KN7mm>>wx(? zZu))+#IB$#{V9*=S|9KP+GA5(M`4KpmeNcw+|_3o$wXIpwa`sAeV(?HKV=2m%q)MF zHyZ}Bxc#8jdu`+zyi>%^*47N!o!wy=Bd{@XBgaPr{{^U&8EI~O@^O5SNWF$RpyB(J z8?*VP99tYm8P(otMhSM%%CNOXwfJ~!l_#6<&Sttj9ybr#*PA}u>k0<2jN<0W$%lEW z$MhuSrCVuxd9VD8KvsU{Zl05$9q65(`-TnU;vp=%g!=8|1zyw6o9pWB>ucuwsruvm zr>y^FbL0I}CE{lQ>H)g_u10(vK(CkS^`tJUtJn_}r%u}s1*XM#^DVFiav@G_uxDXM z`b=toG}~P(RaK4cJINE+iYdMhfo7QaL^x$0&fo&p0d%|Eh4?-|8-PMj{pO+W_S@tx zZkKy$^4|^hLgpR@G3r}lJi8X7#Xf)&07C$ofQvYWj~5!JpifWgg@33}UqJAYG4Qk%;xOz`vHnondCr!Yb&xOV^>`c+F^%?9c7l!w= zIqfxipXb!Y9Ya;kWd+*kq)q0dze-9`VPK->UX2^|RA@uFg9Y(c)GKw&N$U zk&|SV9t!G0Wo?S?CI{We5Puf18=&d#Mf@Frrl+4h(#1ikwBkWxLBkx^0)g$v?(I05mK0jB~qKZ_7=1ZY0=vqyeL5kK?5PaiA@oNwR? z@;thROzr#_o`M}^INStJ7*_lmHL|_Ew<*3_K~qm=K0y3az!w0`*Vl*#7ybmkM$?3T zCHU$q_}b3dF9D)1orIlkTkLkH;EbE#?N(0Xf9NYVTIy>dXllOBLi~KddVuDObbBe% zT2K1fqrS$F;j<~=D+p~ZV9Rk{dM38L*ceH74{&O_Um*S+fSuBju8Fj!r=LC2h4U#3 zi#C$(Tvj)iwj2DcLeZTLoSLqd_sj&kKS|!PtQjJ*Rz+u6lgpEU?*Z+sT_YTjaxca|$X3M>IFDkcsvuw+~$ymls?iJj@bO~E7U~FT{V7h1q zOf%h-VBr|!5Q-llv=Cc_8X(}%LQ8NGS_q*gJR}f!KeMwd$pXiDp7*`3-|vrieSLRl z_m+3hnQ~_4%sFS~8>h}!{bPI2u>4ZaihI(mJ(@HwAITT1Dw*YT$Wt9esD$EM;vtZXRV^ zCxb!lsXwA)9PmSaHplb(-PwQR-}8e7(|KW3%Yt!QE*Y){p{zm#cuus2=AN+2|Fa)XO1&%lX=b{M4Bo^?5(h6-XGGEpZy3sJ|>-_q0WXHH; zF6pDeu^?#g%Sf*SQ|{OGA*h$07s;Cb2REIp0g8UsBxM6!Zdc^^^nBcH7X+sTUN?&u~nsp|HUHAG{RzimT?q% znPO{5w6|Rxs)`p!9a)_&u0Rz3l`zpk0kn+J{49mm3jbli&xJhuU-B~*w|Otv$42x2 z1)GD_1|b4*8mA3=HzSO)_6b_eMP!DB!j`&&1t@%6_)P#!ZH*ED-&ve$3w384Vg zrexVwABdH5RR#;Ed{6|^D-#sVyV$9il~%${%a~fRLusQ-k`o`v7|~1!$);=%M`9pR zV&(M8hKu{U@o+bP0Qk6?)-&>ZT*&hUhI%|+$AIDw)I{$Xqw`^4Lq|EyBfT7)2!edL zh4h`^L7l^R9gERdy3G)AHNd zW%9qJaR+*|uq>n?TyMX0JOpIiEg+2!QsyLMX<0xRJyRy4-ZSrMI1-H|BQ}d4>ew?@ z)7J_u72XrWc!2by;K?rZ$z%5;2UOh}j~^#>%Nn>*DiJ`YJ}HflAT-6iB8F)<%jo!j zL@^~-SGC`ox|Yatj679z>~5eUk_@Nps2#1Oncpa_K1Tg`P3IJ72;^`;=MtWE&d2|p z&h{#CKY>BZQ9S&QiEEvp>DmJgLH>O~I=vA8CeWF#!@2*r zZ}RhD(^wx$Sn2%2yhKD=@kB0!DTSgo&Bsz`4EnPT zq^|=vfzEt9z5~<$OCKOGQUL9#zuLW zFnTAfIHC|;Hl)J?v-qTw47nl5Y7rKa&oJ72x+q z?_sCYEMMLQbO&MBWVS_oDwNM|4|O_8ohFl7vVo)WTxu|`DjA){@YE|-eM9qnTx>7`&b2>4g5nM)ia4AikdebJ&W{MYne zL>xMt(|YwMB%%>y%kq^c8r-Hx#zbB<82l zXw>c%??!g)uq~=|K%bXAM(aU3U)Paeb^4L9f%F65SrFvwpO-qu-+{U8KdV=*zTJ0_ zfhz94=iSF}p+($ftNJuqorbB?;p#L^okpqC7gC+4oE7of2lDE}#x>2~>l|`kwAU4UlQLw!uFD795mBP7Rm>Y!2pa);R$+5Aj zu&ovvMmS`i&AN;83p2daj0~6c>c2##b*gi=Fju$#hhFxv>{7c-_IA44a|;UXbjpfS z!o$B5Zog>MjmA!n7Uy^O#}O|@wVLB|%eq&Z-woB}OvZo!ft+a?+@hSFL z+qNfTgVrK!wDHNbw8w>K`?>a}qjPPg@<9HPl)G@CQ~9QjibHiiwU7rvzi>6_d%y!A z$S3u;f8jouR|VG~pBkH2ElqDL1(&u%zZ#_aP3U@kiqSAKd$H=3%b&sqa>hd+!Wf2Zr2SvDTlfnhwiwdyHZwr zbVs3kprV_;?kA?qQTeBiw!<~uspa%>`jIh(^kHBH2;|Z?q;pST3=Gt9(=Ru8*MNTS zrqe0)551n(nXf5q3hDWEdID<5;CA=p;Ez@}u2>Sp*vPK>$Yg?3%!KDXu~JE>->Ygi z2bZIsauDuH6>L?a%)WbC(Ufw5G|ld;kkKW&SQ?lm8Ffw)fSpM9RMUtRWxux=>RU4w z3bCY>YlU)L$~zYaNq&i1Y}=L0mp!bQgF4lgce?idZlzUv3J#xh?DsX<|!`Y;Ff!BdL9)DDqL$HqI|JU}*k-k1H z$YRwlaC;AKaf@$mT&L9>vZ^(ChuoYLt;vV<<#ByE&3kr(zPU|!cL#aroXttE@37?x zI6hy9dSdG=^lr+;8FHz2e~|H^J6P7blU3$N9M))3G1NHbPurim1HT z;`A3Gb3Cc8nJkk_%zCTLTT9w&$T`+g+%Mr6w(l^_5nN9PaB{nA$w5Ar#&U#&D zi~KSpY}OJAtz1koo(}I_Xa_i9zt9e(&?Y!F6wJ`D{b+c_b72bRsc?B{$&=ywC&FcS zg;k;P+pXahJJfGK4VQgOp>@-Rc1w84hH(8w;qsPn-w8H_hA35qwyC4g%J(X?>pB-2 zzC5~U>qR-LG0(8VD>VLUy(+W^RH41^t1^5p%l$%Igc-CaJ(Ig?AWD|#*LBc%Oj;5BEFc}B78AZjn@il!qIXvY?BB4-sv4i#d6Kp zZs-lhoqs0%H}DS-^h=@D?CAhn+V++wfuJC;hflYjYbkA16Y_cbN;Ov@BIbKg8^w&U zn>8b_7`fk9nvX$A9Q5zT37Vc3z7=$y50iczyaWW2>6{_K@4>6m-xKFfb1HNCZMk~AM;n7yqYaMu4L3PDR@_l|Nh;)E9f^qA^jal zHFxA^73tAn8c;{>YdSxtJgcwX!%nA$F8O&ZQ)=B!1e8r}UfD=3*deyayM@>xX5CG` zTq%25C1!o7&fO_ONjG8~j(c8CNp5{fUGYv)k}M^IlmhM=)r!kNm}(sLs`7kQjSs-* zvQGX|D9^oKvP9Hdb>>ciAsYAIQE{Tq&o=nz%I_;Y3+m7Rl-~=x^1G~AxtME~kA;ci zl9Kw|x}2RNH>rA=w$*;QuI*@7hm*bqYy$xuEhn+h5HL>G`QEjCb;@!t+1%|i?;7N? zCofd28nSvLy^?3N^}me#%Rx2Y4lk^P7F<%$?}Yu-Xe0Fa(d<#=VVyT z*Y&EE`Vz?N|04Z6@JA5Tt6A$9JAm7PIxcx(gLl1fx4wEeolb9c(Gz&}YN}VQc8fc4 zuM@k)jl#QJp)U{W%LDqig)exm`!`|Z14-pc6v=FjogE?TWi`C^APxwt4orKcNJOG; zPM(?1RMQEuCRA!i-j{4gqRx~|+lwht7<}94hp~=Sbt9%S6YeON|_tUUS&B~TVW^TMyz6%L9AOXXPXs^pn4z>K`xfo zgvAKU9+_c%qmh^swTYS}%V6zo8kk`mPHUZn+tkCTF^!6IHGfY-x9d^PmSm)V5B>;( z@>4&3&V5(Dg7RZE@dQQpe8`O%o8)3+qe!4>3So69WnKu0bBy|UtN*>a(>m-S=8!%b zECj*#t|5I3*w^36i%F_ov`3AOswixsa;a-%f165}JyM4y;FAv@PhN0`1MrKQV! zN-5++gs&=5R_#Qh$R;z)(7Z-NpR`!9X za=$OSS1((#w0Z8*Ws6luHE@qo+$uH8@BxZwVy^#QRK{|PEcAtDd8ooH&&qH?PF{rh zxoO5SQ7i38Y%*-4^hWgy;^@v@L(W?d?~QctPX215H{3JYViS%9uJN7<7iIT1Ua zPIZULKTGSVcE(!QYrfi`IT$Bg{HbNU&i(Izf5_!s><)r*U*)YROkFdhY0a8>YUhDX za++}-#-uhm>)1{#c*O0-ty#bOA(S zL-DKPZUuJ*!I1JkzkVOAdKC;8yL)@}RBZ6;H#7wGd*oTn>A)-y$T=0?VjcJA0(Eqy z*RL<_m)E+;xlVk{QIT=mCm3s%u3Wo(k@sx=s`jT{|9ryAlRbCdQdpk_loufF1_NKL zSy`+S>GEeM%J#a5pbx2Oa2B(;%AB#BjR0Avq z6RJq{ak0*iJ@6Ui$JeA|Ev$ippxo3S58%FQJ_PNfD+BO`lspE~%Rm?;VBkh!e5!nD zB5~@Qibh7WWlM1)14<&4M40$rBP=h&TYb7V@y&p)he_`QkAa|^d@hXt=9xOW#^VTn z53ZMVnSXbsyGezVY+9lgogNiX%xMdD-ImM~`E1`C687c7zDcmg5OZ>^@MXfjjec5<&bI)S$U z#a&=wB^1xZ)F1F`nC*qi?N>zpJ7lc*gS6g36s4|L5i^yO+>ZX`0@62tU7&NhUVI+=wE=ak^Y!NY z($}`UeyR6&KU8v>evG%&x@jj-(owbttzF){Y}Im{QR(1)y6_W0pe6=OCwI5RdBfovrJxX`PO!u6|~ZAX$2?BEh&g$UIdbEAb!%}ih?^5gLoS)hFJslut~fkx|r} z?-(~9NqR9j9&|4EYq-A=sH3YK^&WOQwSG@db(E?X=5^3ORj4nk;d6qrd|wz>+6ktN zW^O{3Ib|4JsDF0Cx=kNV(nPmN!0*e%G#jRHH~PAquInl5t5r~RPD6;#S>{Y?U&jp&#t96UqNDmu3U znRknHw@C9AX>K8Ug&LmREUh2Q=vHZO<}TIB!h008oHr)9g8;N@M&8@HoC&wh(}U^Z zXqHIce0sbnMORbzl6=>cU@V z`7&wQx~4@z^OV^(@twt-w2i$~y-V6VPxsC(JW|Ct6{1-a7uSc3_sEQNEPJo5n#UUi z5|r%{@(p?##(dJmjxQ$j6Lutp4UU~iVA9}rhxux{`B;V%i`)qo|Bzm41Q;DQIoFJahd$(=%;p&TKnBHI!A zC`T<(BL1awn|ioOm(wcpKNzQ9O8OSC4FuznH%Pw+y0)W0PBtz&Zta?8WkNZD4M>{3 zqEIuDU8=z-w+2L4HbHxKO36adKHJjgqlg0+}uafg>Hg78!(~Ds+>*l2t4a z#@%#hhk4&{@Cs?o_XfiiH)#52UDTl`XeNCUjJqUC;K_53Kt) z^=$4^<^Bz)eor8?yT#R}r@J}Z@-7c(-OW$+WuDUA+#o+zJgWYDk1%dvVK^N--bz~$ zEE0)yS#5T+6J|7$H5pt+WRyLyRlRiOU}}uAv>C*77YQjd581I_66G+TJMZk2V8)hq zLl(-OXiQlETe&XI)T)Mdw@Z@w1RMz2Sv3-kGPSG3i7t!XUM?9;pXyA;UYOYkV+q2| zplwsu!=>!+jS+^70gI3=s!^L}x7qef?%sXJeYX7z+r7lLFYa_6jccTO7WS&?eZn1p zm4=8PVdI3DDP|z7<~A=ysHDh|n=A}7jlW_?$4kSTPA1g`s14Ds29{-uH zkNaBBzpqRGG)avj^?=0R_9mx!s@EJJHtDF zC{^2tw9a7FKwfUkEw*@0hWfe@wLEqx%RL{ffGsC548z9D zKF3mJRPj??-kT`LV4T05^h01L2+Di><&H5EGyruhO6qd1{k5*Y-os9(r@PeKW0x)Q z%9=PA$sbv7C*_{x8|K@|BxQEHYdlWGk+Hb_^+l&m#+RUG4B=2&8LeGt^%!Q>oi7T> zoSx>u>H*O@)YZMrGL^lUi1ajTcwT0^mEGe#?8-X1(28Phi1%VVnjoeGu0_#YJ143( z_a;-#?T&ey6HZ6#%yS(3R)=5ouQ{k^)na&yt3z|qiC!t>dx8xc?!}(PFn){1OnHU~ zV)bz}+^zF%H~F#7=lfmKk&U!>5aiooq?^DxppNQtop1XZ=e*S=-xhoA-RoPGFK!DH zC*dVf{#*Q0&q%couPWwn|{ z1|2fy7(0qCaqcb>JBmuYbG>(dN_dyy-nq&<*W6vCvgiR~Yd9&p&}lJ^SIxmzwnwfU zl^RYJtj+C@m37=4pq5wqW*CX35ET{Po&ss7KxSh}Cs#y!xM#%9Bp^pE${<$`G6&ls z({)n$G8J-}$fjJIC=+qa?|2d~JR^FhjTNjN4PhaiWsT0t3mqDt*FPQ!MMM1ci7CvA z86wa$?S83VIWo0FFqsM^ZlgfGdbtZ7oFuD067o+%-XYC$CyzCgBkgmfQ=x|ASdZE) z_Z12_;%IqLm%}RZH>jT%k-heXawq*&`;OT<$wO+c5N-tzXx)B@d?cn zJ31m28?St${HJ-j6dUEH@Z~Ik+$tMgX0td}z>vgt55mq>g%^($A)xKUc{U?el(wtv95+ohR?)m~ zz=B)V@&}|mN6KGV%9ivX^BdvBl>mKH&Egm_iC>Z1M*S|o{gZF2yqq(P-;w?pd;x;| zJ^pI!R=~wT9g7D2`}{5Ve*RAK^7JyDryp3COC>aKb&Yo%FHd8xS(r$p7|NsfU@Qm1 z%umX!MA(ju0Cw1oByE|f$r-B#R=HN53{8l->!o=<`JZ#1bk0YwJ4&)#Doi%Ph)8@& z=)Ojf8<7+pnV1%x5i5?{y`#lW8d0e7>RTld$dzZM^{NnG3wfwK$eiv(L;U`{5($ho z#RF`a@R-i0*Wf=GPrOU|OYk)a#%%+xK|X-zp3wC!7@v2lXXB86yT#3VUG#D;s&2ht z8QVgAs9W9KdZ&>1oXUq@?Y%Iq;!Oz2TC0MsQjIb`i%dR2eG_N7toKU}m7Ek!CDbqy{-?2J>N` zpt@uSc9j^27xuRE-2v{@=md;Yt-(xUS(8`O3dumA%BkzxyL5zQ zSGjk)?gj)Jx>@W#9hvJ*Wl-o~pF@BlS(t0**>Y08%PNz7z3Xmr5pMjoo$F>tTF{(z zuXEjNQ4L`_TVUe3H73DZd%B@gb4*637>pA+A{Msm&6#$MSz{i9VS_H%2mGv6niU=mdEHE0*rerj!^ahFnGG<)aLUDStb`w^!b@b?=yw(qP;S-Huv_zYA#_)G zPYmM*(szUVK)~N`NdFGJ3)Hbc{^tE4f7;L?VCYnjp-RorW@*&I5EZjze0ECT$&&Et zilNZ7r0God89K+G_CGJ=8p3s>9n*9KV#V#WQAI@@PzR@!t3Bvtb6*oD%H(hD<86qIRoUN9;^a) ze?|1J?M3&)ijZsDePO8=^eVx5oiB8r7S;*Mc;#r-w=`g3@rsbOSPc1`wY{YI8hAqo zU*kz14vqldtMqr`&zbUmhy6%xp>~mE4?|VUW1vpx^da^U=fDW-6OBJ-1V;rtMA?nQ4ztLHIgv65P*&|BXS|(oV~D%* zSQ*McA{sD^VO>xbohF|nt0FQ<@AWELmu5LpEOExlVIenaKP%*G0x`BK)Z{!WtQS$M zhLP2XM#9mVQS=YIB8#JH(XHw)3g1NVu%L$S#WJi~NP&YNaZ(%X~kYPb}+9*(8q&T`JuVg(|qam8k`mU?v=^qwP&y zZmpC@;IHry>1V(TAn3;@{fOAs;Bj9ckn8Aj3kpGPu(M*3XA8PWn}0H=`IGMZu4n!d zF|L+3hmE>QrzTRxE*@^2WoIjcEmw~0EsN5rlq*N3^Gd?u95Fg+bLXbrl5jL5hOlnH zT~giAlSC$D`75Tn5xamvAOjyfesOkEk@CdU(lP2uXb{>+)d@sQ6ufE}sD=e+t3fEk zz|W+-$y7TROf}CIPH(oM;tv}3=zMq@9_zfEF^q3W$8UCwA`sNy5u_)BnLr)0_G7QS zu}iz`oKMPK*vqTEoSZ!;SkG@=St#DFO$~p-MP=b6JL&8YcCj@~_?~XcEg)I|N2r#2rz`oEl|vNT^4z3q|~`PAe>}c@rZ(%ns>X+}-pA83%{sZ+eJyTW6O>yI&rV51MVl*9hiRl+1@E?;eOlWOuk<&N7 zPsCCxZ;A$Ct#XE$7z^51U{_aHk}3v+Q8dSG`V%XvLZJ4-IO7uGoDfam1Izz&P~Tgd zg)2f^=wYxbk-ctVSi!2(4FW~3QrL&D72&X1P$=C)P$SGH1pN$lD;RI%rq-)FNMd$` z(O$?5SE^SR#Ph-z3ulXnZx(T;3(n0VexnG7tc*-sWimu))UqV{MqCJ)1&~T#Ander zgNXAv>0B$~SFvd#V);rD$4;Z1FGLTtvvRunUKE_4a9RQ$L@e?FG;mwM-uz4Hq1T<4w7*MBsu(mDhvh8J3~F^n(5@pyt@v*_`<%Ofm1>*kh{r&Hp6N@fhHMt!elf5I9)5W7DbA@q);PpZDr3@z*_raErWV#yp&|?^Yp zJq5c;H86NodC-MWg$VR3#nvfuvGT_{QvO_sqpextXN6gAVTh2ha`<($WK)pstR>;% zD9}JTRuA(9*XeJc%9c$|qz9Wm85RH1<-3P+3;atzC;hc5$6Gt(kt(_(?)N2+j$PB# zxO(BzL0^?N zZH_S*1m9mx`b_Zjwhi9jEc!-YgYnbUHM5Abql`UUAFJy0fje8yLIN`psr*Daco4d!yYw*<0H|g(ybz2AD zxuh#Vunrbn1HPBB&+cTraF#AxIAhrw#ec)e80V_cG!6#3SK~ftJc!?D6qm=Lc)&Qp z7OQx?c+zJc_7{WDNl_uxQ7<)JtN3PcoI&~`a2W{7;~LW2K|oJ%ZKrF+GDVE;7fXmt ze5Eq5=y8NI$1c@bREMsiH;AyIAvrY^+wh^RyO{d>pYrYkpMc1Ad`>|jP~TT^!%9i_ z0$u&mRgm%i{hLng9}p6_T`6Lg&_Au9e~SFKFf8B|)f;|BTq+18JTAzG3QNpp`Y%)oRi?KF7T2wk*!jm*jzn*Kpb1o56I5EmG7QLw zu*5PryU5krXpO7JQlyKlT5OGmD)$1SkTL{R!@wS5F_wWm;;67SpDoZI@Od@q&0s4C z_FRo@HAIc&*jU47Mrm@`AkZU)sFAfhgQjM9D?f0(c-_i;HMq@2Jh|9GymA%d*`oMuHO8l|5nBw{(Cp^ zj)DG5-XZ-p=y^v+KHft5PVhKT$CN!Ayz8uY^wqoJ)K;qFXFnK>J9KS7sBx9QjlIt( z!NFI&SIJ>w7=)sg~JK#RuXOLDT5>i6DHypOv%%Cz?P^EGhQ3HV_|g*Ub;Dj#OdN5)Vfh2C+Fo0#mH8bQx!+1xpP^TUgh(1$TDz>pMt41wH~n zJ6LoVavkgf>X@sc~smrh84S(F&srx0tr3y`V#x>ailpP*`NQ+rwZ zzpiP<0jKjXE1FK8s7jvUW}Ez*wM&XN`4^i&9Bp#&F4Rn0b3=C`ITT5=3C1v z#5jdGid1*Hn58oF3fY-?nXI+tL2~9*UNm z(P>CVm?s~4R-YCz@2pr0nxV9u)GOAzYEs_pMB4tXF1{Br^^7;Ud(e%P$vhL4lvk=x z@|_AjG91RPeV5t9eq=X!iKlTIyyTQVQ78y&#&38c zq=a-~6Do2#ooQmGOVO+~UY4bpWyRQER>cWpSg9oTF%$u>pFl6qla!|81HMu3QO0S9 zV`RZZ5Y)$KNq+{)|I3$MQ*=FSd1>$Z*g8Vj$A;$b*T)$MkD04|LeX1Zto4>3hdjMy z<=ZS2ZjoaV34{mIvS9}@TMR`wB&8JBIm0r(aJXC`9deD0<^U_hVk{MlNeM;;WgDgY zzPf);E|7;0>R#(Ob4tYgd}%EhIy*imIa~HW)oEl^+ld{0uIzW7oD?Y!$Ig|p^JKoN zU;SeJyT^}^>7ryIs<+>v$wL^uChfB&GU7dHM~Uk!`-N;)5&6Y^5(g50eer6C8s$>O zh&zBWB(8Krc~CC;6OKe%Xs8N?cR2H!(Xu+UAaaZ>jUHvqiVTTIQ`6;1@k!zsXLM{- zntcg*=U8OTZ9)CIN7b*xB1Pe$2$m%4olnYGzvzitniq_+I_mtMIZ7zf}XDV$c(e@X}FA?;6wC zgI>jzStp#RW0uzoePx=cOIY}Ya0HEVRf8TST<5a<_kduSFjfoMd2+h^Ncb9|mI6)x zDrj?krY<0TouU=^^jr6neg*{o9bMz1f9j7Df(}acYbPvQv&I*4L?`%}@WRK4RiTwi zp8ZoWR#C=>h0YPmcu_{8sK{(|0>Luul{y>5J zK+v9UBK;6}3aDezcRJr+eq!(TWR&Ul^tZjoIbQ$Fwj<4_1dOmj#p8N|ifNZ)1ieKb zJglT{i-`a(H(o{on04LFwj~R0wtLY_o-N>TDVFN72#Z+Pu)CJ(3kg+|Qe3JT_3f%2 z|5nLMB{)-*)lkjugTiW*lO+-{e3G)NE)iP@L@CO34zv|({tNEwm_H38Jq}C;LA^VI z^kNW{Tl+jk_g{X0gj{12qfXrw^6gOMTi#5?;l;{psFWg7vqBeTUee7^dX-Dc)tcB@ zgaG0R^!Zv{o z6T@Mwe=s!~X!k@g%21j2bc7wx!A#H$B?QGF(b5<#mG?k*IKU2=Y>w?GwXh0^ z%kw)uWgNmg#xqj$%FC$G-)rMt!94$U(jS4(K=8es2k7%a*Y5@LM3>iEZk*4-Y@S0EGMyrHLL7T7_TH}OJFE~ZpLXH$qCe|B741+@tDC=T z4jELb>0Jda0lh7xF9w%`fZk1{e+;_LzkPaVu2|UAtN#QxRm<@l@bbv7ZSr7aPH9=5 zl~?I!R-MkQ`u5DC{rKN6cu;>omuXI4(j&oC;LA1VIMOG9^ME=wey-)(?pyEd?7eyg>I&*6L`{T04IDLpLC~)(bUAwdGGDeY< zbI9%yucWGp++ZNT(O)iN)Za}`pd(JIUT8W!HcOLnQDd}L>HOUd-GP4N52QZ=pMfBM z;}4_z_cN185bVHllaB31qJ<1t zrATEXYIAAT9X4a{=y-Y;Pm@_k+}Z^;DiwXSbo0xfZwK_9P5Lr$6$t42Iq7G?Zr?v8 zuq+K0P zsamKf~*EouF3pgJH{om`PKL-B*>KOU5 zt{?k~Gczx+$JYIW&gUgf&0YoZm|I2!fpzqTN|8Ybge_vT3hLkJov+e2S??yjneZmt z<-076OQll8ZvdNGwcZwy-9Hl6pG5_>dlu5el0>@*{TfI9Dk6kM<2K4iRxy;?+EUdE z1B1tf^{D#$m}6zSXDXvQcmAZ}Ebu?{j`DDLrLevfa*i^5JV$x95S-E^4yk1?)5UAF z_VCO1=bg%z^d;a55aio!r0)U00P5(vKal^r_DhN1zV<5rRm$!$r+xzUvo}6g+gUU= zE@aDtSVZ~x*jJ0l6(ZN_qbw!VgrQmYrKM;jtE`@CfN&t~Y^wK-`ktEZfsbgq*HaNE zl0FhF1A$!FO8OD-GEm1mU+eNd&(Q7Od)Vppv~M5L@QD7quJrqY;Zq`6da8v_&@-T6 z81f17VY{4emRkQX&6L&O*lJF4?5kwyCX~ftE8lVm>>GBoNME(}NIq)fY4PisUB-)- zM^5Z_jZ9rAvDY%QDpG)|=og#BfH{zMSwJ;7UgyLlYYdyU$$9c*bGBU*DI&mIiCS!* zP7eJNe>$@t1E&5SH+y>N{Hghc=36}+PI@Ak2DG-wkj?(@!8jcx(A= zQ&S$TaNQiNr7_=dhPcEoO-7v%p;MDqyAuCwL1ih&UZz@C7z}eA)ozC3)S!03f&acb zpBr{{@P7^ITfyxh;Quw!AA!FCb?k3{>`w!`A}uHPVsX{VRr_Q=VE(_9ADr4&-5C&t z@eR`Wqo`zZU`o*#TEsq968j%&m3dsuDn?vnB0X3^%Z6onHrENn-?;(}Tp?43?ld4O z);|>ch`!#~1mH+#{!$pN{e1r0I`}`G^d;a55b*yL={LclRSc&P^}q9*IrR6}d@p#kL(Xp? zeGAwI0=_>a?fjCw0qSV>{V;0Fepp`Cf1ht`2$rqrTI)Rx-z+6h)UOkltARkPctCnR zW36}IMrWxi{e714j5n7LXKG`KSUSo|dMu3i7>`^Dk4;SIDX{=y^dtolTH-|#4_j%K zCaRf6G6x_U3lSWAsdJT@GarDcnIihgCaxt!Wu#`zw$2xFkezX>%1ei|O@;OA7*s{LQeHQ?u7(!0QOKpjE-|K>Lv+Ii@lSi0|euyU1O`&+~VUQDU1 z8vf;<6*pQfVuU~!)_eJhwf3ncZX~xl6247jwhNifMhgnFwm(Y!i_nqI=357{YMt*K zq<)xzj$p2pG3P@lv|}L6tWryP2=PEpv|E@hmBVM`^FO;-!CBD4fF)T_Xm<50rva-V9g|F zU=7k&&13J9bBr@fW93#d>9aplvp=EJyv(T8`EViM2CnTgocd|xMjxiIcd0sllTbsbLZ{rSiig$A>Od5o6-1CG+3M3V@X3z+m`HjSI1&W) zc{S-X!397aUFq?Qvi-7P@AlbVUb?ul^|K&wz?cLC)?!jRlckqD<6&Y*hsj}n({mocQLl}G>lq=`b?^Rp8v&$p*lamg1(?$C7)uiU{DPLKBkbK z2O5Do_Q%Kaeee+!d$wV{>XHsPM&o#ZKn zOigf>)&q2`Uwoy(V#XYmR6njy*m-4|bv_XqPW^q9J@7J<(uxLIN|F_^VM@9pUH zy{AaO3f=@kK8t6ueFg>KKgs7u_LxVor!hPbyybxuQNZBet_}k z$$_U)n@`7Xz7g>I0qL&%e!;WvKv(_h-u$-Hp_rb!gP@$xB)tK&cF{jBTiO56)ytaM0?ymkPHFda_|Q`c9D0wM z;5^45Z>XGPofZoELZuU>5!K_&K8g8DZxst^&nQjDYkWhe4C8&$-vaab4m$dg9uBrv z>iI|@r=VjFQ|z(LgbMS_-gb!f%J+H)QDeP}b2pvSJ@OgT_z;4IMMkr9*%KGp97g&W zj7F;MT$!>QyAJ_OWxCdNf+@tEDs&G;M!$BVSZmE>hDCE}8>6Y*4lVV5UOr9w_ux+; zX!qZdPQF0d0(Gq0k6rdge;f@z%EIShgT#zwCoF4L+|mE}&ne#>0=I3&U|;W?b2o*0 zkGx&7y-%eKJuK=j5>xvT;?}J)Q=!uPMb6JvsjJrChdI%MYMBkL`pRXp(ri|3unq@~ zh1ePEx-nMsw-mZ(`TU(k`YNyq1oh%8($;SJkHNY;3O>>O2XrdSwQ*|~FIKVZvU|k) z@@*mZh#jVPneCm!Z+nGuO~|;=sdOgdnwl;c>1}^*hsF`g($1%*#Hza41(h+pcX0HS z#c8ZZhghETsu^LvOmOFjod~g+RDvL^Zn531H%m+X9Ado0!=d@1IW=K)DIVir`5U`T zk}pYlJaK=9%E``Uo*z%^1kLXPc&YRGJ(2X8;9L;!dp+q}!6M(zEm#MF-C+qK~UhHUxeMnCRGeD3( z$B{k>Jnh>V>{V_*kUwLUf?}hX;APEKI-gc}H({MmpR;g5K1EpQEg=3Lx#ew!K*$vL zsCYOK9`WxU8nuQd?Elmd6Z+^3G^M(*mFlQhWa(0^?|+tsu6C%9l-Xi_-4R^y~jC9pi`p-M-3xKNE;AKNDL0_pj>o z{#z4tGT=U-@n;Ib}CYn_B|5V3<*mzygpEv^QeA~re^`)s`1OdiS$|EJP^>MetMAmuJfjVo@Hya z%gJ#J`ISORg|)j%>Gr#WeB)uY&Fpy+5OI1tdYp7fPqU-T?nGjZL*l<9-iNN6=n_ ztM{@qiros1qP*p~4nE9PEYOk&!G>}Y&=K|(q4 zoX7;8%UC&Fs0w$OrgzGl-=}v0&;GaczVkhLXLwtpUcx#=C!)Fy@9z<1wEA@H=tRe3 zq~8YbfS^1xZ#hO82=Xbo2Kg{|*$JzbH%(f(T+QbPKBZb}AyB(CD8#+1*D$29McGlh zB{8;@pmg(PR9RR!`Rr?)u!_=!rG=P@QMPH*2^W)NYT&)oAE&jk35AYsbA-;nrO*@{ zYe`=KE(HPIJ4he72VK)`8@xZ8;IESfbo+yX9pZOa=-JwLOw=N5zWOZ!I`{ASpAWnA zm`}@F@_%3Ug}KxEDbbYX{jLn3$@2GOpAEa2Qc~2-W4(M7i&vw(YySV?YZd<9iXH`` zf&E`W)Bou_`TxKF|6M&O@$1+B4Rvc@v;=J+CdV+th=}uY)u_Bu4w1Lyiy_tO zU+39F+%_{bB=VSuUKLo#-=*bB8#1H97c8%mejEG=1ad|F^d9$J?W~SHg~+<78nj}i zSO0P9o~~}-Y$PDvkv!5^ain1!;&ocQqrv~)z&~{K`;$o@0*(N|_m+}w2K)P7yPmIM zCEaQnP;R9=@o-_&E^Hx>#NoMJQ^0mq-fU};HN{tF7!`MG{vYI9K|iYIzt3^M8~BIp zI{)p;H+!k`)f^n{^whWBe{brK9q;|;GnP@!eGT|e-#fl>74J>5=BT`;$UpzR8#}#s z7wPB0OCab+PZ^7?5cmHA>gYOt@*Z|NJ?QKCy7Dz|_1YB+)sjiqALcZveaPwirc%(| zKowf8ooI}VZCD7Y4Z$&{#x9yw>!c{b&I*%=NX#}%)!2YhV3jI?16Yx<4z%ymM`;WLmNf2r>!~7JO5dF zmw8Hcvs3Q22@i?;>tWHOBU1Duunp};R2Iv0RniI{Z=aNj49s;I_$RYD#G#B3FtC(O z*C*j2#$+lKGm`|_$rZ9Zf&prd2;~=M>~MmuGYL9p6p7mx2QmMpLLOnE;1j7@trDo z%8N`Y!cv@3B-7`K@1Lzmx6rjGg9qD;+s92yMVF?p`;vqSFRZZ9ZK~Kj^3jEu?P( zw}AF~K>B^~IZ(%}7xZ@n{kr$C(`nB>vSs{Z3C z>+$+CVd0qP$&%izHM+={3gpWoLYuJIgM4{dm&?*WcZ@^MA$=*h5(NC;LwYB83aDe_ ze%7xl{#`p5$6$pVs6QMbRO<<$9m3eiavV+i=U7C?f5|Ch7hn?4CI^qo`2DIrDYmfV z^Vm8Ishm-As*kJ`2PyAuk1E==&rjWl9eSZTq>lwlK)}xxq<;i%1M27+f6ISe`=$2Z z@#DwkTJ?coem)y_zKWGfvi?gNcfeGNKAF&X1+wTpc{CA|3viSo&{{Mji&L&hzAM8o z2JG$eOYKo1SIc_E-w0fV*|FC%cT&VOJfh31;4dBWcRcAsz!4ze?;_IIfXzT1UDt!W zhn-IDH$N=5l-&4UKihP|s%EuK%B%)ta&IuxyT;*}b;lP~K|W<>o>8&=t|HnqHTOzp z-o;iQt1n74tk|qLTD*C6z z6H{n|w67l2vzrA>PSQ+OwCQqM2mQe~_A1ghgKZ$-^AXZdgBO50_E*o2+($i|zhX5l z%;%Kt^u)li17eiZf+0O?dl(<1!HP4K$Baj2&bNF1ligpbT1paLpd=yRUT6(dj}{O? zs0VcH@##-}(n0?ljkeK;`vJf|zGr9Gb-bYI@@mjdIhLsNQB3}DtRy!J)jR4R)%n!I zJA-lH4Ww@ecY|PjvWxVyV5A@aW2Eno*Y$f!&Zw^`HTS3N;rNO2rkU`GH#9+se2-Hp z>zy71OtYOa!9e3&Ri2dLaBHeONtUU{M$0cXU3H&!&~+^772qrol;6FicY$94b#z@v z@*Z|Nt=dQVsnXLU4$aY2DPZ~4fwf)^mcG3 z2>97e`YkZ9%XmrW?|61)&;|ws5?15C3Ep{>cMhqsV4K`%8mn*Qtj^ zA*Yrg@+EZX4*SP&)cfz>-s$}hlKunu1O)AEz~A^DI0&et>paza*y+@=SHIxV;0wuC zaUYcr*UF4{zFyx{dp8Gq=RD;<(kAzaL9xM?ib0vdC#W?t!b&<$Ia@Wc;Ul9BWdd{n zlN}-nO7Xev2w(IaXiKVWj>Uo$qb%8?0Y_NP5iQ9b*Cr^5sd=FM{`gI=1d-UUA-Da>&b< zv20lvG}p#83zsdM+tf^mu=YTWM83O1hqN32o!Sazn0LJ>koK{9$4*PGwafeljg%EO}dI+Rb3?oJrHF$Fg(BK@1l&5E@Y? zEbb&+*J7nJ?FCq|*|sYZ-Tle+S4u?X5RayoD}N*3WtmFJsO4DE$d{$4vzpa}yX8f{ z-T!xo9c3St1_yzF&xc7r37UIpJ{NV@S6!i|t@L&%*)8ueU%(fcMSJhE#ye+Up!02$ zZ-!gAHsaI&9hsBm3IL~Z*iWc zybznpbv}pxN1$o_j4;!x3S~??`KSo>E{lp-Ic2J%gZ#n^Fia#&A)k=4Nj4B`bE%vy z(`v4-j)qq?|Mg#W$me57uLK)F(EbYkK^cP)KphMIvcbDv(j`ur_mI=3z1kmPP=XcO z0c)EV*Y#I2N~xsPAm$tU6E%n#{E63IE|G&0gYT4xBzsU6sB3KSE{xmU%2+R15O))@ zccNEu0kex7GeqQ3d=~ABNC?+(nNw|-rr4hXo2Rt>sA)c6MZXQ1PLVBhkFaBoT}VVq z@+?;lbaI)H@E?@*>IW> z!J&NHE^d$LeXnmHIa;n1V~H_!5;a`53Nh8{tBj;j>mf@Xq7z_OkQ^)A)$^ zyeejZlOh;M)to|$P*aJB6=uqb0g3IFBgO+8uzUU=(%u6wifa2GpL?h7l-=1)HrXV* z*-Z}wlF*SNp?5@zf{jE7MS%neh&?m~M8Jkv$rDgieDW-)=vQNbhZq$FyFPeU>^v(f z%Kvlc&ZGg4@9+D+aAxkzPG-(Mx1D?LDUAxe(g{z%>x|;P6#G*KAWFeGUz9vdJ z7SvYZFa}Bx8axb}2Ex38rXwYI59cCZr>WI{$ng^XXu>NS`BFd^fY*M9B0nB*0f42* zPTqb`w%*;^qWv2S*fS@@Z>GmDHR8Cz!LbL!(Y)FOj<9H6 zX=P#mMMPjr7D^H^5HKq;|G^jxAxdB!89s+qNLiuEoD>$ZP=!{^YCaRD+A9y0pUapL1#X+sdo zKIZgE{MAe!82L_s?f?((GUSH?&U<;KQ@Z2(wbPv!LTS66-VQev{wxfNE=E_fN`veW zVy#klsdLc%?s~kl%%xWm^6a}?0RQ{%I^&5@=YFsD6JDOfc!$?Mg=2~k17rid_l`rp z9MD>B)YQQ}J;7S?veP!xS=+2;hYEHm;jL5>wnEOIj#hwmN0OWk-D}43`w-hxPtFl@ zyNL0)s9cmf1M!4HF-`R4pk=aJ`kGDkAj-2;sz)p%mjpuHXB_`+z-_vF^I7EI27CbU z@LwpX!csu)4$g=CZZqp6O^gBJ|2G4a>E)VECLkDb%5$udHZe2 zrw*Jmzhb7VELBGq(+zChbPZoI{mD=8M#Jc~Ll$c7EZPq0P)VVX5rC9B5JD`tkT1ld zn!w$JnW}_J0{QUNW@B#|6Mm8+_0@xckUT(^LY-tQN;{Y0h5BpmRLSIr2ZBw+7Dd>*TVtPRpm(sZVA4(AY)kjr;$}N-i;b`N@sz4P7t|gJB3!*F7e}ur(GP6;t{r{H zE_@SyJ)*MlT%dD(AR{_Yo-qC1;u3#M~Q+@FA587WW=Wi>SB5e?O+TG+!><)J!m`5j6{qHvZ1m zqX+=}3vYKr;QZcpTg~)|qHrFr%b7!bo=!5K7Pe*bOUFJK%>N5CPw0-lbO~ysb?$rX zn!T6lqdbmlFz5g4_l{*Y9xp&4Wd!F}>fQGWa82O#lQ8mmfVKc{JVi(g;S50Q{U6@M zoA74%W2PIuh;6fv0hQpWDX zQ(yserIh%Mm!}$dCR}V*AioZ9JHVqi`)Mn#Pdxv&#J8Wb4JtXBJo(Q4%nS5#VL7zT zu_RX5mPU%|Y{$%JyD>b~xp+k2P1obcIw5~LU=YBgEAzK70oRiNES+k9V*g3#+H4;H z2E1nj%NQKHTYr4F>~~JLEx@DD!9)$Q4DvlaT%*+%@G~UY6`GZ`-fY*Ib;#cixEJ8j^;zWi0Nw(ybSk=TX(7jK zs{fYzKIINw+1`&IrRWOUo(3os-b7(c#aB@$S;mZehQ!(;FafQZO)0L`l*CS2(R?AWAU@Q0ypLS&AOAt|tX+NiIs08ixHIHts^pNWn^y>-mzn#qETn zrRxcb5@B-VFjd!COAnTHCv1N$H&lI_Ea2iJ# zHg36aB*xzcy1CykHJZL(hWv0BJi3j>^(4TMR(4MO`=cr^;S(LR=4Xx8U&3aIRnA8# z_-f{I8Wdt?#OF8*|8ildQ=Tn&rd;hN5wj0mhb{BA>dWcbMkgJ`UT#b57}Z{NFALIA9V33HJj%5;mD5zOaORz zFdQ$ywfBAR+=NHdI@Uu*DlG;nG;nY|g@0KonEhdw=Z@G3*H7VYywl?=y#1F?aL?<9 z9&NtGJ(j%hdS~xIAlrG3}>W(3$Y=H#R;k0#izT~gwI&y%K?=D zj}Cz|6yZ`_d*gtohjmgqbaK|oL$0Q^q=zGnc>vy#txha#2NcQjArpo|DszKVCAEj4 ze}eLMQIBtU=@sPP0elGX@c5Gb?mt<5c&3&+*__`V9LuxxW-6RBiT2nckGW6wGr!nu zsO099p6O&q)P}-PterWoOp3QzRgqP?!?W4taKn{^b+klJ0uxLkn zj2s+CjCBP%81aCD&}=wU1wY}e7G&7@{Eq3~iI*q{QNy3HB%R`K#rQz553II+a>dWn zXq_WHJO|#|@Hw7TUSCH*FR#9Qwkjk8A^@-5rVhIohlx% zeJ+=GG{+C4Ejm>lInP`TC{273;0FQ3$;fc8R=;fW8jVChu*(YY<_r?4&S z`EYjBL<*~PJV+52^o~1d&QsLhLDi>diXe5ondaO??VG836E1~m62>I%`cx$;LYdC# zv>SMM?fD?`Ujp9oHQ7}&9KXT6)_A&f^6+6;aS^L!7s2E?Ea;<{f{f>0OsI3e*Bx%- zz4kjC`3Zn?0jyq_9rp#u&j7UMZ(DuOX`)GGgD@FGFQ+UZ=nDjC0k51h(Q|@=&`oM1 zrU(gxmvb-P>6P%DR$hxO^eIl?Y%4n*T7rg5UB z`+^E{rr#5Ml~axNF8<4bmxup-$Ug#j0^s%YJ;=WWcx+|row>bXqw~3_!Wh`h zU349RM??f!VD_c3*CioejPDIl>Pq6TVUAvb9_VUux|XGYmvyP~Bl7E0RiR&`iLR5= zRiO&72f)(OXZbkX>r;Mq|5T`+yVl9BKWmM1o?AI}X659X;whDt)rc%SAB2LaRq$>* zw~B|(bAk@Hx;q@ZSdF))&a9YLd~x$T-k*+wr&#ZGEx+ROcixZu52~?M4{>JWpA@!5 zaE@x`b(sGE#k%Zm(!_@)vQ4^~IH$WL=alLk3!LL+&T&8g{SWF~t>dRnqI1Qc-XJ%- z;J2C||DpcZpYP#s{FHy@CXU8$cz;0HP0O$!hDhP)izeAY(dafK+#AOD2<{AoFYFwg z5mWC%;WZ+8BSl|i!84nPp@r$cEiqGc$N>3V}-dEOt{1~Uxzp09h$f?K<^BA?+D>)730Z*(7$;ikj4H2ZfaHp-Xb>! zRQ!Q@S0H$AKts%D{!x2#fUXX}nRhVolz%5!2-DW#L2e}d3q(^Q-#A$B5BSsAd-N1@ zXTYaSRjyQq(qqm8Rz$|X?G7@kChiQViuNQ6HtG~9WoJPCJKheV^tXQfFrt2NBCwa4 z-@>Q$9OZHaEAb0x#nOPjD4;A3h>HT{a?FpH8qTYK^0RWPfB2zO^9#(aINE_F0r?xh z&B_VAX3*geKR)~mVqekg0~gVV^ck5?x(B*QM{V&BTOS?zP?tYIh*;FdME5>ve&0`x z;Y@km@)uh+9AUEPN~3}_x>Ai}(9*>F(dlcRRfxY^d;v*q3P*YxSYY1gBH z2w}9eJKCYWU_?UX;elv>^T|{=-IHdB!^ne@b`~PxJV~IJI6@re+fJluVi}o7?-Hfy z;&Mu6(;G#50nJSsm+}csT1k{KP&vyo_K`>(cCJ%b5&bsNz-OEe0|O{{_JH5A9l(iINeO**XU->b-edCG*gR-2>Tr= zmfL5*dzb7V9vKoCBH>>m>Wg)9<=;7cd{0E1EAXBAs z`abdD;d9V3O5ukb8%fw=#b%N;>Mf_-BkN1#$e#u$#*h?98Dloz=C5$dnht*qaW z_hn))6yWg}1<3aSbpDO=7oWOufV~gByftx(x;% ze9}$jMVub>D5uBoKQuuU)PyP+0FNF?r}2gTS+G%HskObg`?|(cwA=Mg-lNCtidmJ# zH5JpC(p`&t?1$rSPN}G=Xnd>!j%phqhI$DdNB0>YYWAxbI-x+8fRw7<#lIG~di?&P z3uWO3T(4_}|8v0qVcg#iU}yhf#ad}<5j=s&u z!xnf|Up#GeMa}d^iaEq)2VlE~3X3%aN2Q^mzR|uj%uz60pirQw1lGP|DrOU^50(+S z!0M4Hs)o#(2~ur)j{lRu)$7l@k$(^HFM!vdJ7=RG0km&hO|2)3 zMW;J|j$W-ZIx50Fz$~aBJ_ObZ%ytztgunkVd!9~NnG(7*%;e?JhRdK1=@NIw4zJ2>p8-m zzAV@s7QUlJKh%ba&eDhp*mk&^wzDy0qgpB;CpyQ$0`=2@+&()bS4Va&f9N)s+CVHRSOA#jG`T~H5?^@iu6L2qp zrIYoC(m89L-;cQaTwZ%?#MhrGDXrO-KCnj7M%G~#M&ue<`DCExr zQ~*4B%tw9^Up zWh{5!TbO_Rdk5g2n@;`SK{G06PT?=V5ihQF-#ZmNr-$$AF!Uzz&ZYn5y$v)3b=nZ( z7Vz@cH^YnVKfH_U51ajc>;83IJ80}hhH$qDWkvVBWd%+9eUndDgcZ164e;8*ZMe4? zunoYH*Dt(%AD1qTr^KI}UpemkFwLr(TT?lOGbc^#aA8}ohmUi_5v`wVK%`IM9w-)L z-g}tZ+i4mM+V51vI~3nGs)`v8QsZ7|N7Z)Wbg~PZ=v>=J5k}#948SYTM%?>5;Bf#; zr)t+jPF9{#^Jnp*G|qm7Z5;^I9njUGO(}YJ%2;*aFrt*L!ZI%L3%%?E~x^ldC z1IKc9jnuad_Lcw_06e@;Ym5E~xE8?DbT`iSfc-7yPC%XLcQ_;ei7t{8|3 zgV^}gRB>!8l@|m?9!tr5~fIegkUA zZ5*X&Ov5-MMj@?3c%ay4Sw%MsqkxjDTb5ebhz+agRLisM<5q!_D7N? z!%2X29U_p?*mX2>6-}0UNj44oESLt;r6NQwDPlP17SUP`E?Ci0ZWZOfi&}eYnV9rD zmH(g@;zKJ$H9c#&n3|e;o0wqMC2Z;Ql8EKbRV1GtqHyxrKp{ywA8Q0BGVd$y_;wjq zL5nCIpzb63Mglt>dM=&d>tW{8A4t+g+L_{5Hu{Z&lnL?W)1hyaC1+{*Sh`O)nNdZ$ z@iOs4{17O$QvyYyFu3+uaHHtYw3d(%VjGd1pcRryG$5yFKFdggXO0VD%2*hJF9dkQ z)qQQ9c2wJ&x4$7p(ED)i2*On48Nam>XRrP3L;e8ZKLD1dr15t5slR3WJM6Z<+E#pc ztM=FLQqG;l4~k#Y10p#nE+x*X+&M1d7sdf72w!83tqRQ5wPfnMLrmQyc6yGczd((D z^0u-=j*%3-kJ>gY!Uy=+R54k;OT_pf-=SD{Bc^N*bumG_;gw9&m@QX6m${l?HI|X* zNQWVHkuOoISdgHxpmYIZMRbDMjG<+z#|T!UvTTXF2!tniz)5vwoL&ptHSrUJ$`BI_ z*XsZty{^Q)6@Z%oEP3N*YyR4H`HAY2jWeTN9=icPS%e3m_a1`UrK}*bUg|-reFiF^ zt-mY7pEQhCpq;}>b)gx^s3oEnVl5daTA=7(w-U=^S_ekU8(x1DT$ck1!D(P4VU zJm-f7=6Gs1u$EQCCsPo0g87djex;KY^82t;P}a{K|A3n}KmHr}?*KnG!&fd=g&-gT zVChuj_Dd(j7tyn34`31%uq4HdC7Fu=Qw>m-5)LvnUmc~)E z;4o8rm?8yG|A43;0`*jRfwvZU$Q^1%NpsmPhWcF|CpCcMUxs~cZyi#J{562<03QCY zBmV*5697xC_xqfi%?|I@v|N8T_WOY|D`z#%A~jWPVsYxgS(tQUrRmHLCG60F9WHYU zw@nmkVd5ZvP1JADec@Q+o`m_gLbZ&o_zll7x-PFBAT-^eHe6|G~;40rA^ti2lOQ&f1sKlUaX zScffu@o1KAVeWMG;4YQ6J8Uu=zL+zV!5r^M2gFKnFae8!{2;(^fQR?F$WH@Q16bPo z;eW?lIPWBUor8D3OChGLTnK4X9et4?YIYr+zk#xT^%)g*_|M-+1NQ>`Ir4ngh1fV; z?`m1zMWm+*yLmqFM@)g*AsqjAftQE>&&bOiRqUKM;h%wg0iYDX(v$-n|1IzHvvafA zA^8;eyVKEOaV7m;B!|TX--FQLJw;)U|NKX303L?4#|ZCQ4FBbBTfzc=6=ik8@P8J5 z@rH5y*8{Iw_xn#F|1#h;fJgrW$bSv^3Bc0+ul_s!h5rlux%?7S>Q{XFTyVfC!%Emo z`Oklm26mwgH#aK-m=kBEbuE#8bJ`Fq!`)oMa&WlY{yR0T4{DHK47e8HmE#fQp9TCA zz*1|yX6I(J!-#iU%8?q!2j(ChL-HKf_3z`W#2r-liuL`ssQNb5=OU7+WK{*X$IZv% z>6?hy-Z$U8h|Vw;=$AM4io4t*JxruUPLbG{r%PF45PdvdgVn5vjcVwP>f#j+R*8|k zoI^S{jf3YSe-WSx;Fa@QWAMJiV!_KjRML6_|n0(s+?f5!>BJqd5Lrm!^5hn?GZ?=i&eVI)C;$S$ktL z)0F{W`!iL40k-Z;dC5}Mr zJYBls-v5;@Q~nD+Xu=OLWNs1FbuEwqK6RrxKHHk%vm5!h0UtEO=l-FHV}NU+8*gW) z;&bXx*3LkmGn_E*wWJ$sYPa$}b(jb{7>=7o^X+u^H_Np1Msvv zI=s$2$v5^S#64nzgJfdWmn|ZKI)(>TywWY)SYF=g-J8aTtC3#^*Z}a#`vmeY0$u^I zbSio6!dC6bWlNijY)7(cb40eTzNUc*sS4l1zlo ziusE>Ud*MpI_9pGkl*5OoRk>H%hUa|CV4|O@@#&+24}B4Zy^5>;A;R&or8S-Z+K;; zbL}0g}~Ar38OK?1l`2t(ppa>g3w;^sN7Uw_FOgur;x9*bQ^ zog{1G7joz;S^FMaQ&{hOtR=7UDK{dBum5{Fb$5>W4%{DL`&y(vrtuj3l|S~CmiDDA zACl!DmT!zQ2$t>`i9ru&sFxWhYhFa}3E$s*{+6znE|C&5e0Ch+gp9?!XkYn?C}d>9tl&v_(*+}N(-Ts z6qB<9a?l6_(tJF^GR7S^JYk!{b)r;CuX5!3b?3O{1Mc4XBE2_k^8h6PuU%b<{0hK& z082|tc)L2>iqEZc`{C~6`r$lIc}KX)XH1Vag>Ct7&fPY}m9s9aoHC`dN%ySP?^RQ$ z!ayHAwT}En9hKwi4G!tnO2P}Fv9yf>rvU%Tus_PMsqZ)4X?fr1$=@3CVZ)@-U50u$ z0@1_DH#th}MP}$4Qw#N`dQ3mYpZ!74!>?K1VEv=pI&Yv-u9&CX1N#8BiM9;lI+Et} zRvEojReR|n-$eA+@eY0J&*OAG0(yDthm6yqFA8W2@Y>A?%!?d^Gn`OThoRM zKwB+x>S?|B9E8pdux1x(lg>vNA8MZ&D1{P$EG1}INnpa1lS~6g}*1wOHO1?ivW9g@S&0F-e`%kzSl& zf2m9o)5Sqzg$aM3Nl%$f;2XNv)Gl=1lx9C<>5o`2%sr+Wi#5HoR*)g7nQ(^^jby>= z&%>I!4W6Dlz>QE3dYU$XcA#hVWnV6Z0$`smTGvR0cA-T^5U?|)_%U%#zGIH`{w_sW9G<77 z(3nmh_4}Un(OnXuKgrp)4 zHmBnecf0&mDpk^dQ#Lk2X~aiLr5y1p^z>D3fe+5)bUBRjCfvfYcy-5cE%s}oi&yRt z_-K}noMhj5tt)q3)rt>e{T<3%4G3ztEiC^YF@KFfM;-gIv-tO`@hwl@cMtMA0ehN# zU+b?5*?`jkEG7TG(mB_-dPmL$4x`=gcR#LPJhj}Kxo`vRRoC3gc~vtPRF2_V#jdcT zp1#NYIM>q^7>#f`*EzOvj%PjxTO){HfL*u*zEZmC=h0G0QToufdIngfGo-e!8-cxs zK~?_{DGcN65;-X~p40YcJ@kzp84)c?4`)~gAG;9Rw1~M@4%3TCN64ozn}NDh=+)3W ztD;k|F)!Xu;8<~ma2t_FpHl?It%Lmau@dxGZdk^9r3HQB6k!R~EbwJZN&(>3^xL)4Mzr6SUCiw%4 z8#JqOwzFGZHX8;D<$Vw^Ifd;~XDH{3nS5sILrdsFOi|@?dAZ8*Ztwd=!w?4)*Grmx ze@|2qhU5Mzzu$M>JZwlas~J>#1KkfFpw72V=+I?}&h9uXX4yRc-FKUP_gD74fhae? zo8Oo}?o?c#^1D#^hf9l^8T`X=x;+9#?}wr{P&nP*4;*yGr0s!&Gcym9&SMia7+j9W z1boBm4>u>l{}rwm0^D@!{c>(*;8+VaP+Rysg}*J~zW*TJ<;kb(ha)!SAY22yaqx#DALB9@Bo=l=qnamrZSt2?g3$%+PD5u`l54Zte?N{#Im#;Q#%&WehOslG{d{h;7+q-mnpjUwEPIRjC|i(slQkViD||aSrjGc z%|a;?Y$=siSfN!`E zTl07Gsw*eYsF=x?-LoradYx|ud^R%i+*(rNtVOqCEy{YrpIC86vh+8Jx5Pf^F0tD} zdp{doNJI|fL8OCMa{Sig+g`hU6!~3%R{&nS{TX>O6f$i9OReK!IyajgdcDirv)4$W z6b$o{nU{K)Gh@5d(Y;~j+7D0y@FNxmCIH0$i@@dCZ@PMoL6;fqNCS_W{zps<*m_%E zSl1B}27wisEjn8xzr>2>6ND?IJkk|5?m}mXP$?uLK{)wQv7PE*b?_=)&T`;i>f(7B z^0xx+1bFqZ4f$sPn|^lZ!DIaFm1BCvg36&&Fzn7iRCIS!Xf5gBQ2%-^3HTd7C73{< zfo>LsgAuH=Xn*BgOf&pV4^wI2VgAfLA+s@8 z2v434|0m@tXWT%n^(Z$d8{)kp8 zi(wTGCzG@CtU~QiT|BCbx({xVL-6y*Tp_D{@T^g53{Y+)9R{OTm5#m=?y)t!W2nR= z5TCZ|+l{t0QjLJ0QIXZhGMTieAG#9P5~#q^F9_E0P>8>k(oFUgVvlIzEGYkrkjEgJ z`zYxB8wi=eh*GwO)A~AN;hDI9DI42VjKOK)=unvI>8d z3+%!VFy3XJO>@Cj+tk$knvQ=az(TY4>Akg{QizICv?bV1SW*vqjvPN*pPg$cN=KRt zE;$(xz)Hu0KF~xsbPXub-k}hfTx~3dJPf_i?n5gv%aq)jnRRbw4((6J;cB4XA-RxF zMD7B;S8J#pcE3F+bKlV^cMKd zwq}`2nX9NVa-KGo7K)W}A7!L6f!31Xxa3zz>MJCDpz#V}9@)RpVTQCAdxGM0kPr31 zQvDsg-9$z=&A)}np9VM`;Pp@KO!x!<^arq1IE1&G<%3&}@6L}neAudgGUYgQPDjn{ z4|4-kbKC9dx7os9aaeHEq>Xk8wkCpgb{ahW>6!-L;x=L@F{Uor45>X}%Q#yKi?d)H z{Q)t0ik4=HvKfNi7?`Ex&}Q4u)z|K}8EXl{!xRzT{&Un6f4T*W6Nn)HPE<|mB8DVJ z2Ixe{vLU4?aADO~JRP3+p`K@pKNW zypp7z$?Hh4Ey)cfL$ybk4o{p9ViLi)91|Z_5FSM&xuONxST}Th9nKFCY6}b~g4}TC z33M*o#Ov=})QiVgNMo?Z0E7Wv{joSAeQ`Y$z|z7~#NDcEwa%}cHKS&5|B)5*E^ZuL z8<&F9Ci9352%@)_z`FQ!SQmr&r@{}}P(wA85LkMug{@>siom>2N5}W8K3M;tie)X$ z@;fV~uZh%?EtNWB3{8To8AKJaNRfjq4z-(kIqQMHC*OVx`ELO~0K9UJ8;gA!Kn;MU z=PPt81J-* zK*G`k=H;5QgwO$cS0-eEx?9^<(yWFw17gt_qNCuafoh7H&g2;J0lr+1d08rByj)G- zT;z2^deiBgMsWFjHC+J41*TNfp`u26%fAsO1pk@&MgNYKweIhn9@_Y(b$DCky8s3P zJbJJ=9+PlA4ZxCThvA)_o6QcpThU|2yb+Z(%zoSpSUl9b0g*vrA6i!7hzzE|k0&Kb z@b8(HYzy0Rv*@7TvSoNuzMCW+$)-PMr~Q=e_-wh6iSbdAX^wq%8u4TZzA)k;p><}6mk2NA=Aiw{THxtixD=O-9k~(>KAlUYwC%Z64Pe&@ODhpO z)g+J{@;sQWc6p);VZh!CK@>rS+1PapV>y0_P`8bj{~*f6eqVF_7<+sV<}mEbH8WQQRbUt4{mgv_ zjGsNPVrpgIDN~x<2k;+;GM54^ELmF$DxKwy_pVkyIRUh?560g#55&pA_)W5Ndf4Zj z_7Ugw%ZLMUM)egc$)q2Xf_UK#A@E$^_`p+C0q!Lvhwj6E5WT_dwuN+mE)8az^jE1% zUe^U(askn<{W^(ws$w@pXmy=-@ea z=j8b`TbrYGc|2}yj~n=V7>~mloPh~LeU2C}FosUCzm|fBCI1pxT`KEe#N;C}3AXSQ zM*ZR6q@=&3lpEya8|8LX{xv2q&QuIvX$W>`Ni-Nrqmih-R92Q@%TZS+l^E&au|rJL z+R1Q%s7Za;sAT#qnT_>n+EnieJVj+2UJ=6b=;TV(l85MEJF|gNO0FHVU>&Ea*bwXj zUtTdWrVP2E(7vWn4)W`GNH)5vGMpVJFBC74N%{yos)^cUIcc!iC!(cl>DqX$mn=nO z`7|wM;%!OB?-BceY}0;sBx%TgglxCFoa_hkIS5Xez>@w#bM*DF%r}NJs*xOn`x|44+qTglU zD7W$59woO$4KuhlO4dXTf6~$@SrUyO%`pGSkd9`={>V^|W{mCPOB2(rof+b;3^@?V zUQX;ANl&qt_CD!pIl0O(r(6O|Re3oe*%2Xun@icXN znr>@5)3n`bx|HqEM?OPbDJl`I7c2)g(XY+*ohNRlS}!B2{U*x)62*z(*QvOd%G1Tm z5yhoOQ`D<_{DZ1*Qaf&6CSESf=g^`b^T-=e_}f)*buBCPGBRDBdJ9zc;t+6#ev5`{ zV@dvq6@=|IJ7-Z4Ip@Y`*d+gq2KPkC%Tc3abbXZE8kO_>^-=O%)F_VL7$rAE<+$&$ zD0wt$Bw{y5$xZBfYt(!&Ds7GSwQ?Is3Ylxb!M5TskZ!3C%Hp=%9k)MvL4bic8lgNWazAeTj znbz$weJe@lziy3=eU?OCA@Xlg_t)Q}7Et+s$fM&lqV;$=ma^AD$p2AP`&U$KD)xbB z#>cGKx5k3E$H;9lqhst)6iCW>zO^y3CWc~v93}r|#atRAOJYFr(DTE|d4K|V&1ZC*oe|v^(|5(9ax^2l zF{Uq#MVG|%U!u64A5FSEV;T8AH}C!21@GnN?$2GYFE{#H?g~N#;r8^ zf`%bp!o=7>a1{Yx^c|d~!@@p^ej*as?c5xIVaZg2iS(>k>cYM4hpF1=kt7NN9?L=m8H9DJ``mvO}Z~5)3ov843R| zoL7)AG%!CQ>9go0aLwWvWf8%8lwLz&*+UbZBAsCp4mMx~JuT9s9oW3MZ?ah}FBP#r zy;JPJQW`XZ{!HXs;Lk(eO0?nXQmStuM!&vKlF=zNc&0V_Rcb~0K1;?S1jTr3+{aWJ zdiF)<44pW%UuEB?$a#3?eCxb_QEBv~v-{mh5*2jTnXA=cICKrcVHts)&w9evTPsxf ztNEBnadEMvSn`7~PgZY5z-AvLr;70>oF`cGZ6k1-BH7|r8SY!eWIJh03bt!g2dCqh z@gdC&#;Ov*_`M`MCu;>s_d#D)4Q?j6T26UxC?_?)DDN9u7%d=$5D~W_Z4DYMBJB*C z)SeU@G`Ykql}cizYRSxEvHc4aho7a6X&v5{J7;yeiFP%+_`9p!LOrz88pK}AIY951 z#IkUo8%e*Mz9|EO{mB4>MhB8XaKSv73_*S<8HVo+CnF3R8%ahXe}*|)I%6{#lX~Vl zGA?Iq+F42C&9e;HZ9@g*9Orz#c>(^C`YOx|rHZNx)r#sdVn_^HXSNxwo>5(f-TXYO z&mClX%Cyri(q>G&C}T#-%;3djCO$EX%*H1Wo)s^cL*^nskJKPPpDaKCn@h}v(j{{j zs+U|c9TJp;9xCib(d4J&>a%fOONGY@ z_&a$qF7Jv%&Zxk23Xxx^rYcFdr1Dcly?F&}S&5SqK1mQUXEm+)Ry4GDF zuxN)kF%PqvtoxFeb0P3&{5ea@k-rzP4d9jYoyn?@H3hyC0W5VM#Otx}>z3=!+9>DG z+r8JaoPFoct+>xlW|Zt{9j z=l6?NW0-SRqBpaZC=*Ox02Z+Q_1V_yY_<~h(>?_L%h7ERwt=dB#eUG41P|wjIVARY zz_yN5l9>CCa&V8IWQm_C~WK@2Brgy(6!m}r&-_|>G)jnccR@zb1+O$ z(X0%KkjtBXW=e@=s-c8amEu&|4c7sL+JNK8kdjNy_6Z$Q1!9BGmaTYsAbY+q2eG_%j6S=#n27P7!hl~en`^?dOV@slj=&aAwG z6bl%EgN;DS=B%-|l0X-=AS)@_Kbx(z*aWjkA)V+G6mtqH(`reo*=yF1rO4cpJE|ZrN z?KUc2qbZ*d-zI0{@DOGk@`D6P+z;TGAIYMA!I33{z8sL^oZh+Rf{4Q*`Zl&+y^&2h+VkTs6KuN@>VA{4=*80(m z;Kq7&G2}nk$yR|HbSz{abVN*O4i8cxYrU39XW3?8?Gd;A<69m+_aXlr;3a_9{y&?F zHTyKk?g1>BZu?(Y(NZp7@3#NaA*-F|JbamabwtIb%#zrQL1pTxFa=R$rc;3b;WukTYEEG*Df$)Nk&Au%s)q= zh;*XUJOt;Vi-`P09Gb&Y@R4{MG=GF^#=6~9k_mLpisiq>wR_@W(|RbL41sKr2JekS z-YUbOQA%^3PIsP8C0bwY3N1-%W0lE$e7dHDv2PmIMRY44&7NqC(vlU7oLXXIp0z2@ zP%cywdK`9+{RaB`6e3Y$P3^hVoLve@eQ6$QE`H$}h_-(w~bPspi&1cq2(y z=WzQ*)*Cje@X_=vg|YHqBAoP8p+`JV(C|~x@S*d=GmZu;#>KxAI4L_yq@@(o0C=Dx zSUFco=nCEF>1ag0w}@|uHcFkRy-LkzC=1_xI)uKd5cjSliXRa^&R1oKzIgkkB9cGbpGySaU`B>wVw-TKunn7db6k} z|KTp8p2DggB8QykoN4v2F5C^L$F`Q1&|}$faRYUVxJI+3BymJk&Pd88IU4FHkDBtd z6v_6-X>Kyr+Vdwyx0Tz3OGyU}O){SjH1=a z*S3|Hx0SAKD=u#v6627R`e>l1-b)^w3C&GsHiK^_G%VC3>;No`a|YXzVRtNMBLIDQ z2m*RJXM=i{Q(qEkpm-S!B&SHj#kYv`At7iEx{|}@`vnm0h0(xJZ%KqmqeUG)>EO&6 zTaMQgj2dD1zMn|X_R0FWkQu0C3S!j4@EkUlFg|zodWjF4ws@1E4|C{WkNWWD(KnI* z2=F<;t2Y*(#J&hIKmaUx@fY`hyVAM#4u@SoMbi0ve)R^8>Wax1b6;yTwfa2pc9&J@ z!XDZNvbPrri=QBAn~KdliXo>%JR2Rl+-W&UG?GBr8n~4-utYXW1NNEVk+2Kn{`kwJ1o2+RQM7ou%TXSW-J-*WUJ5S#1)el#$awM=csd@q`D6`5y5pE&E`&deaz(UAMff#3W_x$f- z_aG0mF7ALIbMZKYZ?wka*#8NSu@}#%77n-mcA1}-cl1p7oZ=J^s*$e+ECYDuy&3sU z0Iyy>zxZBxr!s%8v*ypM=~wC5zKoq&#r$~JlRRfE`56{4tP-E0!kZ8s!|$OuPg&fk zc{CZU$Oe}M=}jSaB-b({pV|P&?=ZgY;TM_(T~t5;z{9U6^1}iD3;dc?iyAR3aWMQ5 zV;`#{F#f+V3^h}+jcPw8(Z$3t_|S6^(5z$3G}Q72L-IO=z+)wTD-Uu!*8_)AmyQo2 zza8*2z{4~CnIgP`>u+9M={)7(>(P#0KLJn)VClnGIbBayo^~fL4?I3IZ%)Uoq(B8n%u48E5M+)!ZXoEiG zVYi)BH?-+y- z-pe9$?xCr|HF7G$rOth?J*UZ^bsywM1I7b9`qd!66mSE8CCRmyI$1k-x79qv+jakn znI~-5-jIeSa+thHz9)!pd>u}#XC9-%orraX<# z75FBwOlbAa9`Q&zjaJj)X!rQB4TRVVBrofU6e5?h$5~3G@_N$d9$)SR?)_iOEgWmf zM>Lh2>jrFRML9--i>dM-R+Jx5lplFfjxyZpA{?(e;4p!sD(po5ZNQJs@Vcr7V;>-O zKCh2<)A{=7yo&4Hzv8FEuKrT#CH#KNb_U(aruvwN1ybdh$qO9i6z!(e4H~J_4%-{R z74D($TOeaKmd0Nw$gCEzzI_!6SmYB+*d=$glND@OC1ag^I|;|Md8flIHXJ3>OeIZG zqrtP~2|8BB8dDk7bHQ0)J&*BeGda!dXDe;s0ktcJ+V=V);%f&PBYZJ|GJR+fm)EZ4F%W9!B@dvX6_VWpP$xqtxsFyv#}t~q4arhw7wU_?qGH^ zu$AT0MjNskFFjqc&J3KV_&X~dba=l^#NuYc(qJj1XksZ%m15WvH1Ww8INP>cwcvwV zi9I7OCT!-s)GX|sNlOiRtT9e*4{eP|*-u*Xk8m!eC{Z*nD5q#X?AGPrCoSMVAcn4i z_fO-2Jn{ZKs+5ZD-wO$N!f zO?RvC(>eyG7ligihxORu8;5Uu>F`c{idvb>RKAvuCY$Rx59Q`5nA zZu>{O_W0t*klzh37Buxs^%DFJ=mucvo9?{dUGXYE^VSc08JEAW5nIX$Sj!*7tzwuk zK>ARBT_Ss>_r%vFYp;}s77x?bJW>T&%})f)9%^Pmf<3m5h~tGNF7w(AjYJK53mws# zSz!4Ln9#}yv59Fm3UNrjuubcOKxC%gtxkmxI?39oWThq|ubOwO@;+rX zQEwumwp7(`P@}&q##3rOZ-{Roj3(V?poL{IweWW-Tlqr<+!M|FeT2k-X zfZ?=m+s4@w3R)0aHdF@WB z-T`Ca3{4yt+APZ0QHI1vqHw9K$dYeZczAe(TvU*nfzc+la|}=Xgj{;B%$~u{nMF|0 z6HRd$QKN<~FCi-a$C#Qdn-w9748ljYNndjpY2{KC9VSjwa%hGxS+AjsMW#UI;0S$F zSsty0)iCydCg~7sVIwQk3fh?gu3O89P}ZH(wd~TS{y7%;X@J`R9$m99gWqhxBmhg> z`f<9BIEkL5ly`jpbWQ(hd@p@bdYzKJ^iTRa)%Vg2bk7?o2C_oTGd{=~4?qDJK^07F zeM^whLPH2Y1~AG&;6W}#e=QSM8ojW3A4z*^ISN@Aa^{Y;vZW+FC;4_rx*|d`P5&Rs z?hE75hkqysZvY-%2P~c*CGB!6M z2=Lk07PDbo{WpOsSOFbGgMl`o)Zk8%+%*Y*469}oTS~}2v=63at*bU#{Z3*U2KM-^ zT-f?_3W+{wZz}A^05h8eO(*DiF;D zWC5l@A8ilzI%M)C5tf_a^gq+puMj9%*{28G$PNk&uoxdBMSR&`>+oCUZXF$o z^|{+zJ*`UVpN+1BOo>er$>J0o2TF}rdFW54^YX4mIlOwFdte-UU3AiKkI_j0ypxPnE zxDoX;%?SADoZm?7kXP6jneHKLyQs$Am_Ry6BeCGjW{fwBlSts4pOriflafI$Lxf!X zz*21=Uhd^6k0-~w8~KL-j{v-KfAX#(G~jwafTdH_^SBeP+nBN7Oy*}S6()+LtOzjN zspk)1=EMqqiy_@^u&{EuV!JyGd6O}S$?n#^5-p_-#K!CxmB$Cs!)CDfEx%7bDe6O2ppNB z^c;t^7Mbhgw^_*^HPj~!C90!rP~q2n^Q9|vQMyFDOs4@O1HrRtMhb;o{1qK_=!JI> z-S7@C>GF$)xyuM5*dA3gpEu+mjKLrrqT#XtBfWSvL+=977yJ;zPg8XEdE~GW1dn(K z9!}15%Ae@R>9+9drv0B)$lnI|2f(8n)6f1L*R+LAP_|Q(@_61;3|}PqR=H#{ToqXcE3bXmn-md{)Zy} zrO1COV!cm$o+gJ?Y;G#%FA7|H1f$oQa;-Up5n?})#&`tj2)j1~w`ZF5U|^v2?TdpP zdHC`eha5tAe@>6(D5uAtY(#zs;5mRtkIsvrj|(^#z*5bxE1mNIqa~l~+{B^bg#Esy z9|~x5@8H_p7awEF+#f-iJAjek0oCzk8B)l@dfFp;G!zT^LV;jE>cbAtPCI3fnQ|D; zOmwP#P22qhI|5Nsj!|DNrcR#|f=9hpq(u4wGqV~rKA}GAbkY8a%Kt{30Xd{w2<0A- zle05sK8Ut9=Se-fQXGB<8~E9%2?wh&m~`+7VG%*7nlaK1OkV(d&_l^g6_0hsN)SPA zz<j<9~LoA+T^=!}?_5}+_6nZjjDoG6H^gRT+mAS;Bi!rwWG65cayCXjY za5jLYD_nnukCmR7zSB=QzPR+|6CWHH|BX799r@02ExZ{s@~)JGReo3jzopyn>T|Fg zA{BfDJ;deYO&u7sl^a6O=YTtO=EQ0VR&CgapNO4a{M-Xf&EF`*lq>)cQrS>mzAeDJ zyK_enb|L>Z-~)gs4?TqZPk{Zd{_cQeUM_FGZq{F#`}Ke`!2fV(fcXbtn**6G6*l=x zU^m3jM(7wv^6v<0AvGQ%T@RIf8Fz)cVH~gWB~9~p4f0G*x&&vB?sp-7KVTbxrBjXT zSDcvcGpj0?XPL5Y=;l$t8GQo6cIFfBCQDime;^7?6Jxi*+{hXh0O43e+v2vdE2KWG z?Xj`FANu7)2MwWiILAM+w27_*ke>)x0Pyht9eHJ$Dg*#5oobvihqmNzIo-MUORp{} znC?bjr##CERO2s3fi3`S!jWaTu$T!D#q6@l<;T|9<4`CO+&izVj>Kk7nN&uZ5lnz~f`QeHCoJol&KMA{PnY z;Uj|x$ZmnNABQOxxc7k%CSDPv`F9rL9iE(i4f0z7{{VRP_ZIU127C`-=~Vq{Q>*%0 zz+xsgqXA6v@8%jSaYt{Z)H&XVTa5R866{DzjHw1L>KLwt}N zH3l+Tv2g=~v%}>=hA{5_r)mG~D7WRdXnr;ymVv2P| zX9E@hSZduaotw=L4Q@I9 zKh~}UKC0^K-+SMCvu9?quY?J*42Xz=h%3V?ph#4#RILm`G<>o|7L9GK;~pbu+z}VX zrHVUMtEn3!iXtMWRx56GTx!vxqIE&5_50r~Zzh=}6EctAnRn+g`M-0{J@?*o&)ra} zW}y@ngZiP0J5iJ-OvrdUh|=_Uz{d|Q^dtNMZ`CY?z5|wt7;AYcKW5NTHMK^Ss2!>j ze}mz(We*Unk7o&gc5p$9ChJ^9LXM zi9h|>$1ccoou7w*>c8>pyj6bd$6&#~n0p77r8+)087sLGzIVR$OAw!B|2F-fnQX-5 zIa8{XwLhtL*uosU3n}_TI1q##hAAooa0nt+3b1h zvrLD_D!{sUC)`#h$YmW0XS2ZNcVcTXP##F8eICSky#+Wa!R5!f(E+|@w#@I(JHnhI zD%-?tX_t#q>{5>lHmbsQ%Y4^uvps$6}RZ*2;iF9b1cj-6R3Zt_a1 z_wE8-NIm=YEC*q$;JbjdJ#WUd*Fi}Tzk0Igk2{>xAnPT*$?q08iB5VNT&jy1`G%X> zUxU8au?)zCxUS>bAi@exemo`~JQW;hlsO!mfTX>s#)CiJ}%-5KH5~jd+OLs{l^(|8`Q?+ zrAhjc@jj(V8WNw!zAuW?BMkB#4@GllEJ4Yg1sK-*+#g^k#R7l8WFOz<`R)ty9e;F) zlRyiY_o94k(t{K6Tu(d9jo1#-dpE+{`TnQTsl*DMG56(rZqKhV@~#Yo5ncJ1%go!H zC?dtz^C+Q;u#82mjj@=conzR^>>gplL)riFgFf6i{SX`cU;&Tm40xPwKh|~~guvhk z7RKT#4~hIn>TfsnqT65pl}^$hG#sSuU?tMGfm%WQx^}SaFMK>GbARamuJnifsxji< zzXhjsU@WSIy&_H#+``U)apXaHubJeNf_<6mp+MoI0pEsv=kxjgw;ay59mb#X&3wlf z?PsXomCsiXdtDZ`>f767IAqjaVijYhD6@R$1oBEo4YC}56X&a0c~7F8E=vZixAObl z#TPd4*-8IpSQYXhg|{4$?e|7%4#h#<5B<*$xNoVFNvnF^Q1qTq$Sxs?Ktb$Fn_^ zv$ktFMHND}rb_dY)3wD}`bdHA(E`6nk9?%SbFY?$D>QzpWeo=h0#XYH8$h9$Q)_92g!hhWoLT^Ul0~D)6+|XU4q+gZu*! zHFd(MvE71!Jw5(h(C_{N<;a|j<9!BTEMOjt(?dud&c7Ui-rWH1)Ev%xVc6pU`+oLQ z-F}jsC*@5*j*yUqkT;R$^L9SMUFXa9Nc*qB{6`SKdYZTMO-4TxHhApZ#ixsq@qD)(8$JVR2^>u% zNvKZB(FFcY3e-UfOT#IKf3&El@-$B8c~AFfkmJ*8c$c{uGIh6NwF zb_q7O8Z?tnK;xZK3`so91$m*;xe4QHn7hgLF$eRgFRyslsF`x3uv~Sv^c2LOkfh=} za;+U7I3DR!L1%z8-!CEk2IxZ&zk2G&RJ|?dZ)m<{d#XpkN+a)cW4oS-K;{YXS{^k> z8`$?d!q2*Vn_ZssO`LXJAP()uA=PLEzA)TA&(np@*1|F@h_Ie6^gL5&{eb=3FyR0m zb>1h2_h*BjmhEdc@xRu?Sb<;lkumunMwQjx=lN0fc_v`&@^WV0Ct4i-dJm#-1$3xU zh%Wsw7a{@J?cHluVR4`3tieR!GupGHUgmS{b+*0MBYigL_aNQBU5WH+P)ENbT_5(z zXxL+8KXh)ZK$FqI-69ToghD(HtSZA9Y3(AcRyjGtJcZ|DLh+NtyCRCo|02!jJC)M7{12il*+#LKDT>Gs`JVsfm;Ib?v2(uT%_=V-4VXMqBOBBia7vEG z;$Zv_FX3gF+K+jqXGs3*Z?Mbvtw=urdKjene`J?~Y{&N`h+iG^Up!3th-Dndn~Aaa z(=Z)YkMrnUTB4p$;c>!I`}3F%AM2Y2lH)(OU=ku1@s=X<_9D-4tZz3f_AMUf!@b`= z4i>n($hyBsj0J5^84L1Y>zrc8D0F7{fPO7!FTPO9J9~9X{~zfKK}{en@4ZMr26_g> zub$+McW8%Xerb7yTwE$1Cai9k>N8A&=2AUgWIk2oDBx1{D-M>p3L(+6Mb-;aBA$(} zsM7Jr6zLr1SR;rj811rgKI4m|d}D94Q)ZbrH{-(7R^K*elwThha&TTLWV)C;KE|7sdzeX zeQ>Cb2+wi8XMpcn;`JoDMDpDVenRpQ*@pB#L0^M3--qAiB-261g80>w{(}c)>i^U( zK@37nXAwc@>FcB8;DVF@BD@PIzmP6N{m$u(_?DM9!3YX@k6a9ONUUzXAOK(t0Vq8S~jeqe1*y zx2;*+yPChcIb*rQJfE{r+!8nzV*^FM?_xFQ9*1qkOOd6Y7Uu6Pr0<|9b-!CU|Gh#l zIwUJTDMYsN!j|M?l4r2Jct=cdhOV01fuTsuyxxK#GAo2V1Tc{xwtTvfH3@xazw$pwo3~;O z4M^*4DAEUkrhxdha=X-5SN5_YA~?o6 zaHQV>{S&0+DqZU&dx1uR#PzzAtK)jLpqjVyRL1snnUrfiT_fX-zm`EnEJPJ!GGCWs zXIO{tks{2qeYB|2M7-mb68G~Z>|b7#ax8yht`|xUf3B$DQdZSkBIbPX{N1f4#>_yp z@d#sIhd4PT0zdL{9=~9SUlbdM!%(V>AtZXXly@DMMajDz=^dcYL0aC*IL^HQO#pQ# zuYw@EGs?>+#cQ8IOtX+HZS0jS$mI2ym@Y!9)1@+SXxr*CyO55hUpJB5FrZloUpwSu%<|3LZ&kneUWSM<$h0nk->jdUrO zUW&4l*LA0gs%{s~h2rA9&$BXFM6W}5>G(sb`q*8 z{1>{r9(I_Wlf{tt5aTF16~kW0G?|kNj59C?3fapU4*gEb8-pAj<-GyV_Eg^bjPl~# zm1D({l_VGMTQ1&rMf5%{-u+sX^-&uGcWAM8xK>~LaFf_t$-^l-nXrPL zX^dK6BA7>N7qxf$HoSRA;xyiH|MtUAa)7 z^KAK|nBGwmSXaWQfWa-{)!LcsO4^S`yOCFHs-Sb58z(9@Rmf$4yA(V1xSLAo(i!aS+VMu_?h>U zGB~Z&Q(^@?pYk7mCVuGgU0Ck>vCMIc-^F`1_}x!sm`g!DKa@=1qYXdMLtiWNNdNUm z3oXNmvNf2&ev1hLM>r0^>X~EQ;~W=K$C1`}-n{GeXnscl77@C5FT1~U40i9mmSX<4 zBj}?2^Y-%5m%L>%v@1QpDPW#P+G>D`m0KLov`egQsf4g=n&-rOvFkm)u-bH zhcCFDp4zJm7$Z9$hT*j&O1?Go}{tC+yVB|KJC z4Jx27tA+!FPshXh*i1~y7roE!rz}VM63~qxEnngN*lQLv8N{#HYt7=W&*2kaQa(mL zlCfS`xL_V1oM^ZX`;Rny#67xu=@LFcYdc?@Xre0E%@aGYy1TN*XPsZ+Y_7nB1x$Q1 zCs?KX4Ysi5kL51Ll0q!rV(v?W#)E~J59j*W>+}2GtMI;CfvIlqRe0JeoQ{0Nw*BL= z8jAnzc&rvNhhw9bAoFfE=!=H=tk>A+_2Kv_r?0B8s(;n03hR=J_F0FobHD#!1V^2? zH}fxhp%ZY2n#OVmHLqs0f<_%UUqCdNPI@0TF&zOrPL+AQ?s5#}K0tj~REfEA7y)jd zub8}C>bnhk)Z^m+MEYy4*9UBUpN#YhPz=Pc7Ue&8rSIh#<3eH$>nPsN+{j=}+vprV zoPxO>QrBp4uCIccf39#|QmNED!YV&F3^nJqEBQ;UCZ1(uqB? zq2zrqTacL}tbHt3RllmsDy<7Drvwlf=T^C0DiT{zL5H_fJi*F?yQ#U8vV}Z&&c}9e zigUW^Fa<wLRgXE%qD2B}UQKfT7rGtfBw@`0?p802+cT(;(?GH)5qUs%yD<$8p z;HP7pYg-E6y^V9VWvn-5Ej>qsy6)mEep#OXbvgZ@9OdJ~^7$W?i=fs{ei$9DSc~MV z_My&vorY&xpPa9Y@Lk8>dWyH~?usuFa(RODbzw#RB^C5r&ewP4^S_sT#VWwpc|cyhaux~+ zuXg)`E|e|IVTfjyA?7gwO9D4jvbkRkPD}Cl&6o0hTl3*yxXe|B1y>YS2CR}Y3>LX= zDlEOVuySZ5Wjlz8x|^iN4ISzJOn;FmeW4!&gb_XDTcg-m(9SwEi>jWpI48XGsb59!WUiqNPmIT zdP)Bpb|li=J(z`gsDsS?utC;X;qnPQa)acf1@F-H`z=U63i=C3^YIDNUxRK|@jhLz zYd%h2DmIJRMYkKmb#9T1RQr92BaSeg*&_FYlaX-Y+z8lOu8|m74&W>ubUw{;*KgT4 zcBFw*(y>99v!Bx^YvRi-v|y-wk6^DezFK;bh5tz0DEW;(V)MHM=@{r@khZsbkp2tk zB@n;#x|Y|~xrb7C`!m*|ejP8)ClFg1ea_Z08fO<_DHN%fr!G(Lc%E$G~$6nVP=++IF9{UQj0oz=O*$RdH{}05{%;y-U|CDep zk~c}Y#y)D-bCZ$&73d_8mMe+$PLTJ{Qm$|Q+bo1w?n&?OXvwyBXlEy4z5=95uy5tr zj4zlQum{K%_L+;U2|=pwt9?IKV>dYF{7Y3Hb3etm?!}QF*s}Ms{zX^xFX!EGKjuq) zTZJyn?y6xdZ;*R`ho^9edrCpM!-e&x!Ijh-@L{@CU}Q;Ip%vJ##ytgxqz3x4gNM3L z^zUo^QdO|CYCmdyTIJeVh1J`iR^<;Y-dZ)e^hn-1akV|ixB5iRpNUqr6m$|o_sg;H zZw>|xo~O=b=EcH`IS^G{KeGhq{#0Uhc@gHL6}bOq;$w`*Iie8$`}>hvibwbw9A`FN7fp_*cz~Aa^j{;tmG?zem;B=opMUz%W*eolB#)OFl!7+4aI$ zq$h%=f;68eB7GXD?ODmE_8U_9M3?r21cARSAYzBmM&%NA8gP>|bTKYtiE!qz&(W7o_d-K0M>sGwQC-H`V9OYkM52 z#|6|l(H3^PT2T0ZIJE#1PJ;A)oLN$JkJJ750IZhlxMJ|Z0X6qJ3-57S>zwQ_&dT;c zZ{$wNXXE2GpBE#2E$B9o=98bhxe?!A2J!2u=hF43`PBCiOK*S0d}>VgS&E#m0*w=y ze<%jncyQomN^*L$n60* zFSJJFEM_E4WrKOcge!EHd~Yi-b!_uhq`v_D1k(DO^%o~u0J;Rkul3txy>ro_nd_fJ z56;xDnkxdhkT zf$1BBxvsxwhw>>ZuTh3$a}21|`jWYPq7PMyDY0Hp5b;*%C33IS=MLy8!WD(U;HO;7oL#P4@IT@YAJd70L>H0w%Bo>0$AGXW3dL5I^g^6gMo$N!KkK8zKzwFdf`7pli))}X z>tb{y(O)lQ^s)-y%8Ej>#Plq!zPQrYQdx9mrSIy>pr`nzO5d&MKb7G8s>0&`1E$Ao zIZBK|^q4J=6I+*B*!`#E%n}3t{2oScsqn=sJm|f9n0$r}UH??!dsI9Nz%j+sTI;;B z!|{Amg>Q3(pOs8Bm$L6Gd_Pw7_xW)kUS+B0qYB?A6$NNc90nFw_s1K}68BJS%W4&S zUaj!GRuRPiVN&);L|VoF4I7Qr40KR=&fm>9ML~tUnxAGf88dH>XZxD_81GSvbIm4U zY88w+#O|bg=R2V~{NxzeauG~d~5Q|4<vyXF- zhw!}>#IK(6;i<0p=d;`jBg@C}z|61Ebn|Q&Oq#;|L^gQ7uXO)d>G`43__4Bzd%62V z#Uh+p-pz3XaRwk-qSDMNrQT=V^e z=6|O&Kas2nOB>>v@3*STyuQkFU6pY?PK7`}QfvMI3p1tczlr@>>U}f#iU`gKc?ao# zf<6Q3`u#_w9SJ8{_;R!O)lqNByRP54eWyfmUPkJOhcQ3dqdBJW| z{@G|8PBxXRHTKwafm@4r=>FTO{C%J^L7I>AkzNG~tM_N(V}csGj?=#>Hom1K0X9xz z`=zsiAemUJcp&DKP81$Q>~YD*cD$n_AD{J-4>_c~t>cS@oUvBT# zcJngQZ-SB_evN&)S=_asEWV_CG<1lw%sXqjtae)HP4r5>Bl$JBagPLJKRd!Te=eu3 z<<_PlzN`J_Wq$WcEYfwY@;jdya>^s+yxwu&U(W6+$HKIA<<=i@cimZT-Nx_na_gq@ zscU$6i)ZK~D$o+6<_n^iUc;bU(}xZ2-B>l|v`)dOCWbzsZ+>1Or>S6rB>y7yP`}aE zLnG3cfvx~)J-mzb4$xO1e)Xh>`#bAFjnuR&VjUDAx{MhA3)lS9Lw9(rSBLm^R+%4F zxj#V+#`Ui%=jI`&yvHYszw2Qi@urZuwtK8M@WlDL$NDS(pEi%@6@K69v0n5{eSz;$ zz!kDtMQ5OpcSM-7vqJn(2v4q<1%Hn#`lQrL>^WO6*CKr<=pK;P%iody5cC;{ zUp?t%M`yi=>RcNMw+g$nDfAb$(G@|>>?nfG)B z?Bb~k_SO*W8!+WD9#Q0yf1?Ujqj_bDB{*RT4Ez5%t$ExMOp8w8`5${q<^S`xoaZ9F z3UoC{%lYsqCrRM@OQ3Az9C&tme+T72$k}cLf0TkQlY&0M$R$H-Ua6pM71l>eP=^%s z-67}G7a?gLbnuaoG{GfprsiE8#KZQn5D$mf6vFbAe2J%}d{Z~s@|}z{x9>%`Yx%f+ zKZ5U1fwGY=+F3qip>CO5=$%r$wZg_*hSt1SPLt)<=N-%WCqvG7N0#%~%dNlia^6;M zy~4}))^g8F{Jy!|daiux#&Vb?wzPOHqbXzF4op~ zMPBQ2uPEg)uk|x8po%oT+U=d>ZkriTR-O@&G%nv!CmWTGt#evJ_co@AKyEf?H^sQ7W#qI z%~kT&z^lPKht{xTKChtH}X*o*#z{IzP-y{68vG zUAKcTy+oYraCAIpnQwES{16_IUdlU{?dAS7UM{eu#VJ?^Gf0#VUe!IPR*E!Xi# zp9VSuq~&@P=>%vCC>yzo8aw4H%6*FGwM9yHBW}ELYc|O9Lu;<8px0Jdw}r6@2U>aG zO7!nsD=Ug`413pv8?LMn4Sf@`m{;f*RbcoRmL5Y~f>!`Pm0RDJPyLQp0NmntsCMmC zHl4kiQX9CD&pxAo7ULxJhLrlimDa40!r_ot^2|lZb5_Z`=kmwdCFUSN7#F3xZy9nHfZ%)8)R+p zsMq=vo;cTgy$|sJx!-Hu!|&_7)*ap{xAQ;+*GDqNFw*s*%YCOhAoilvNBzsTKF&j$ z&sV(+cdd_`kiHwV9+ZtfKJL(eK>H`$;&z3!&=*kr^T>7-{K*f5y$^=xJrMTXANI*? zewoTai^JI>Hi~a0ej3&l%;VD^#Oq0Hv*bIp)#h9K>j&bQ=9~NL)A0RRP&Rz;?##Dj zSvvI}a&8|Fdt1W|kB2>vg`xPv+1YFij|OoL8~BtX{+`%NlD|X>e{Ukq=VyL^yXKG2 z&-@R*{{+g0Ki?b~AJqLG=vBD+DSB;#UcF=|IGy=y*t;=2@7b^?p*bDJIc-q7L^8HT z@>%~%XFZ;WXPVF6>M_)rPsyU}lzhka_;T30HQexW*t3OmC$HGclAqlv{CKt@o(8G_ zX@0g;ImqGoek6!rcmAtc+#mf~-o=-ckI|j^nX~}QDfs?D(FF*EO`F4;>DMWV4XZJ$ zOs~@@349uMeiknIH0;?P#_qNV`sQKLH4cU45s(VwV9Xt_<{iot5i~17#vh0GMYm2D zZ*2u%5%vBTk$w~OE=cqLBhvm?F*XX~*XZvg-`5(MY;TB{({1tL%t+9u^{0lyK!F(GS_ehl;^NXzAI!+c%PAP~PMeIVs3 zyIbDXpSIstcCI(4DY>4NEy=k;y30hhiSadqJT|Q6t#bNqxpmD@Y(oQok9m^i7tuF) zV_`Zrmt$Mr0gT-Yn-UYCTm`p3wcrSJVpHMc7BX zj@?4(A-p@Ty{Q58B0l{sgtp}XY&HT^j6FqpNe{J67qYB_ygF{Q3F*IsegSE{oc32I zSql0ch+l{HuwP=lL%e@}U0s8SVNX7D#oUD>`1Y;)A@?K4A`a8WgE80fxqqA4VA!IG zt}lY22;h!@TU>-*UVqTxNMn%#Kj+Isjz-XSmh%{Mbw0fz-@h{GsqozvEV?9!#mU8d zYag^lmj|tD`EDSln2n*nQk zV9EnQsE4=MPb<~Fz|3aRKn=#4Ai<-f1d&n-uFA0aVv&=%uzRJef({fX4Fz1&F~lum zUR+_XNj>j^{>u1wTxG90$J1PQ1ded89DPb{c`L|onT_z|4MPH!dCW`LALb|o8wreiLuS0bP0^FlA9*iEV|0& z+*S+NxyW2Nz%#H`Z8*03e4IM4xe0$^>~*QP@Zaol&~Zr51kD0zK6#wy41C}DlH{}F zI2bB#agKO9r%BX1VgRL-kDqMi;hfmup=Lg|XAZ)A3e1;rU=;Q8=4KT&7qS_gY4kVV zko>gbU3y)G=I7Nt&3Kd0!IgU7(*q zx<26h&JKG6bq0uE9rZ3ArhIJfP*0}i$LRJ&QP>Sfw`#lS!_t&a{!(Pnv6#-Zi(<@T zGn%XhcTK&-{g1}?tB&anZZ_3Q4F4-kO4g7ioGlOto^%PUQb7Q z8K@DY^X0z-9pol_zXQatcVCqJcD0X%dCp4lJ6)dB4&(f1 z%w0mi)#Y{`#`z8XL&~`w{0D^ugnWhcZqQF4ZBJ)B<{;*F^wV3L#jiS^JH{JGdA_6; zrEuh!eO-{@81nYMHK<9UpFub|LaK?x)O%}zsjuH4T@P9W((hf0^u-|kK7H5uzhLnK z?o!qsk49wVOn&;)y_9VA<3s2m0+-8!#(4mCghzcn7H9sA#`f@eB=WwLdo!?c#rVg2 zNPhzQOg`g}Sp5%I@;h0r?)tra{DZe8$1PuRDq(rGhvWSL@)|pdRD=-tMC;K0ublsV zT)l7XTblG8X z*eOUa0Cn8IUdtCAHnD#0Vz}%>N1%u;SbRF?4p&U!hu^m6d5lN;7|@9zUM_%d%|m)I zXa$I0J84Rpj{xXmv4~%1=NvWTQ4bm z9kpc187nYD0IV%M4~KX-&hx}*KJna6i3t+U47SN)P{=HElu-#jl8TS|ckKK*59upG zcY(D2HX!{n=x-o?b<8L6Fy&)vhxtC?VMi`lJik5j_|eY4;o6(SSu7!*aU0nH?Ag7N zRgD?*EMps)^(-4q%yDbng)DHByJ%9?yRI>7-Rx$!b(4FrEKFFV3ug#RocM;`A#7rb zVpU{T(J}DZEDWadEBT?+bNF3bzMJ9}xu?`Y?gwc-pK%h#b@9F9JXAg}bi|0dGe>BJ zql_(Cv}nmt3HV?^T(=^9ALt>F#(xCqXFxk&XcoWG<7XOHo&Pv=ggWvL zD_&`XV~M{3mUSbt4n>oL85v_ z-^gME30Yzuq*e$f6(99@N6z?=6CZ`P5}!ypHv?a{cOM}A8R&D6mXrT@7rytHfBHx| zmKPiuputk&Ii8ruBTQwAbL2w*lJA@SzWu(lkiHOfHAt8DJCJ?|^caX=9p5V+rhGJX zsxOs2fgc2jvF*=Xu>xr11kCC_YG2Ptl=(=4c`<7?!KQ5BWj*qGz;Za=3IzThnDRzI zmGNs-8Lu|_w}4&&>HKX& z`W;Y5zB1S`ui$Hswu|QiPZh zXp+LnBm^BCy%YPvBBH<=%+GY62R2${#!_Y0{SmV=S#m2on$CzKM=n< zzF#~{`Dp0Wu4p^6j}GCREX^I~kHEwxC0Nx4R+3)zmFaf(5f$I_@vkCm` zcKkM^9|1iM()s%&(l3HsiDn^9m-W9rMX`mRCC0)X7(r~I1;n!+H5EwIRHCng55*))Y-r9nk19=t8(1*GJfbws z5Y_giNBtPJ<)=6>*I)d>r!a%ZeA>@T)9ravpY^MH9Qy?cU+qF_PUIE2EKPpV9paNQ zrfPrfOR3N8;6EFE_GoW%e_#^dC2?y%vGrGn^it4`Ag#aGkxqhkfU?nFqAUHO(c|%1 zzAXEaN<)6lb8Y;Wxs`b#f>7kOUjBfsG{QAv*&D{vAMF z%0sVwcmfXR(da(G;XDKmzN%!SUrWBp4x8`(NbiInzYC=6-Qjq4AgE(G=u~gC^Ch?H zFO2>QgF=ou&S;_JWG*;Cvwn=(->Ben{ztw)4s6}7KaTV!P!go|R{AO8)1bXU{OZVu zc$o6Bqf2{QcJuXHQ`c~QhXEy(TK>AWp3rOc97xrd+zYWB%j&g{HDIY;2JucYZzvJCtF5G?) z4h{;uM{5JR9Om#(@)3*0+zz-J<4hR)aPDt#$3t8(#pf=(U+cL~ek9*#gxjYl;b3qq__|;Jk@i67%{tof(_HwDWGPP%EMa1%g zt9eyhB|57|imG;;sAQ2IjxOsn#n?44WlLJ;bH)BIu=$eD?7xp|Q!)2~urKY)#lvOI zx|&bdt04quaEv$L8AU{&f^Spnq<+@{*q;Hzp<-$p{y<#G|0?_b(r)L5AUzs14b-j& zq|XIi4B}T$dWduwx9FsYcK?qrcZ^C81|9ZE!v8~uSN5iOw|#ptl^fXNtW>du?SG1c z-xRY#&-+6D7m6nTvsih77%&vh2~Mz%a4%5G?=;6+ym5ka0@)`^Pw;0cH`!&&-4E%# zK?j4h+_RCM4_XMyM((j))yq2nmER`v{#cpy6BFzR;kX?oGJRsKgsOdQotQLXTvif5 zHh)oc#HtdN&0<!~rJ;|pgAV(UJt+mpNfP9Aw`KAlcVd9z+s`k8fvO&ziep`^Qsd)0;CCWh% zyAg-bDD!NDgCONQNXR!?x(1P7q)#K6;2rzE|UKLqg)N5XCz* zAAQv`uUd&Eaa(hU%U#)+!VP}cvmd(;epA*{oGNuIJ8}($Zp6NzQHSKe5g6I%B^&!r zt=f_be>>+`5tWrC>wk=V9E-kC#9R;Y0~_DgM+oya1Mz%mHh)d z8~L)aKhfkUS%^#JKk!GYXWjX|qo3`m-V|Glk%Ynx?(RAN`FPRuS>cW%aku2R9=JL` zVn}z4Gpxe1)u0~x7j|aw4TpGu0Tlo*h4W-7oQ6;bUKH`j_a=d<`yG91-}F{dO6N(1 zANiqY{9ofYJ+@!K*G*V|w2v{brMw$Ez3HRO}HWk1^XIRfcJK?^|IZ@M4p zR!{=Oua0)A)X;uw>dJ4@{t^%5!721aDn!^tM)FdlcdnF3o_s!8jy5z^Q?;oQeB3Lm z16Nn$eoZx7S(CzClYipYw&#yh+jSF>;A6dd&e(c zHutnTIf`96@(7Ifoi0cFrY0CvgwejaB5XW$V>Ql?d$BsOr5f8hJy#7k`7E=RcM7Ir z@>zh*mEC}dU-G{Ld}%u(KVb|Qln>JNVKLG*ppNJ8bsE1{olH#8xtXFlyqIF9rw6Ad zn;>%wE%D_iFAo*y(J2W4cBHghb_y)1ZtMR;Jz1)e9vlcAlGldFk zj{p|~FjI&uzTEXYaalihoQIS?&vpF51*b12 zo(jT4Y2u11{INgVe%c_Mzj+eAp910+#~+&=zSxIk?x_pwuosD^gz)&4ykhD-abWgc zjtL8vFO#@&g}XZ^xN2?@pc4wW_J20Nne>vK-gz)#?ozH7r4Eu*`0IfmQV%oXXQy{H zShzqcUclu`y9xec+v&P*EwVShj{xzD=j-~c@>L81ddi6@=8W;b4JrHvVD&ve#bB2- zP7aPMqVU^*9}-Up*>n9&Thy?yZc*K0Z8d~M75+$!hHLyx`kDGzHsxxXjG=B(Csx0AJTXnfT5wU-?mm@=7S&2(|g%^Lj=Ew*_!g z;jYdJt`1fMxB(%Q+dp;9#Npci7DJR z;C5Hev$KcVGfuCaySyH_afOS8Z0+^ao|kj66`qhfp>Sis?K{7!a|Gn1!rk5jzuDF8b$+2QKRplBBrZc`7qUDyA7#yaohU<#=4)&#dCqHvSI4arBnY!6d# zQ@u2W6;=2VXZQNZhHu+DgB>f})j7bmy))Rc!rhgE+q-tw)<>#$mQc7;U47Sc2Cs}H z75+NlcUMljZoh55)zmDRgT>OGiQ7dQRi0KLT>}~j;umiZcc^$r=l0OX72X(OW^fX3 zo@Nkp2K*(39|e~7YkF62bih}}9aFfiz-{V=?{%BH^20jdPB=%+^pRjmg_Fkha)8Ud+^E89171|+d++>PJAYEg(-T_$ygl`2JL0MWp?D0H$^2>pes}rR zb-QZg+g>d6t8kOR)%LZg`Ih3vlBmKR>(2>pdq*&)a9eVN+n$ASg}Wm+xa|&SLg7XN zeXkEVXj01$yuR9j?Y+Xt%B6kn0B&FH!1i9@w<+96u5k9#Xtl z5>>dna)4`lukhCtZf(A;hYt|T%oN}J1<&v7&>!s--xSlCCof&PWT~hjLKQOq)&aje zeRSPU%w4*4?m3+tM-}0c2-uI13jcT z%prx_0Ng(Fo8mA>6z=98_|2+Zq&Un`g&V5sUJqH}rZ~(oh1=KzZrAm@?(a@n&{#Kr zzx~Ii$9>}pe>?ECy=7`Yy7sp+;CFU!NkY9R(yx2{^hS;pHb&<$FYH9as44 zfgeNt&#z4QUHiw~;!n}>V=_S1|24U@+Z1=1gcN>b3V!d}!_IPW-youJ+kmU>sITQY z)qRdC{E-84SI)Q_9aFeXz}0;B)&5i5XcAZWJ93E6-RXqFjqJ6jest%2)V>(E?a*G* z4&%UY;16*1ZXQKf_-Sr6iK+Lv26msXUAOa{-;?5AleogK2Y#;dH^t2+35CBoxA@xK zCP{@K9+Xr5?W`Us^SLRv`0bu@Na61Qen?mmHYUt4udVC(7M=5#gGUr@WN_~6Ce;m( zD*QO`b7eOv?l_4ld@>|={HM6(B(CtI!0(R#-ju5p_nagYej6n}-?LG#4ZI_wat8D#rj-TqLhZKG?cl-!e5<&pGhyh z(0i&|PGSmwH}G?$_e^d%iL3WShjm}Rvh~Z6zrrm~DBM=yHmdq;&*p8&{v&Wx-Ek5c zto*;dbC$m;QEK>U3cnHfx$ws||JogoEBx&#_?i6VZ2fQ>U$yE(q<+Q@&y63Rjz6W{ z4Uek##POaS*=ed9PGSn5>|^8irCo#^!e@vp+}Xg@^?o1AZ>k$k5(>W+__^rsf^QMc zPAc5s2%GP|_~9Iv@7O`Y(q8IwhbyAlA%&X&ZdBC+y^CX~`!(R#_Q`-3Q}`k5ecPb$ z`{IAM<16<&sqkaK?=By+^$&nA3+Pa(@9n_V_SeUJOm(+ONa2qinM?e3cbh~M{_33K z+gTe`_`7q8-|lXcn8KgEU(V_k0UlSl>w%jidr5V-NkZWV_s?0r3U@oHaO;7aBfeAJ zY(j=fd*9px-@PbDsctq2Dg5xL?)B0O{8TraL==8g5BOR7d!zR6;Ack_{tn=WR6Uld zJ<8J07Wk=tc1+<<9o@ZtdcnW&v*QXko;%z$KRcoD$pJRsne@^NzSI2dq{5E^KSz4c z=x39?mHp#A-IcE_{cN7U!p{yV+|Zbu_}Lsc&CiZ0{21_a&`!1E>oovzg`Wg|4*cwP zeC=nG;ZpCB18w|1_}MA=vKfym{5bGqs@!J6&obWK0pIqs6Y4##u{pETG(S73@at3X z``~ADIi$0&kJNtxxY~aDSbo#|?2y6_9h9?jCH(A&!fgbu=DQF68P|vKv!e>PEqA!W z&yFeF@WDCpvpK)EGX}d(D*PtkHz+&rgP(2VD?d9jLfXj=;CEL)X4!sod{IE73O8~{ z&hjzM&yFelcuw(cKRd4Q$)UOA-_F{E!jI+@-}bYU3O@n-kg}UT)+-WxUuiF8hvm#( z(){d@!fynAj>^CAvm*+(Eq8pU`Pos0Kl1SI`R+wIO7pX03cn?H_-TH2T;cEP0Y6KB zZ?tx^6AE|gINJ_0wL@9@*Bm$1y(US8zYh30uovK~StcW;os&qeaMRpt5>oil6#Ptj z$zI=S?lp-h`~>iI`Ok!(z24K^>!`vHjqko5$kMNd-sP;4n8IxYZlkKV`VhYcu5_!D z3bzfoxv`V9ur(q3NqZSN!S;7E@!j*fLlJk^e7C#PQH9@{;pi& z^P)3a>U-)DIpjMfOG65OT`uu$H=0BgzH6#oU-hj%k>F8<8wGB63-Hv)4f5e|ygG)7)r64v_Xfc6#^xXW34S zK?R^gcA_>6Dg4#I4~Zwe-N!0zTuX^b`pMu|q z_L=J?&7qDf{NPbJ(|fu@t@1LV-qVQpbXPBA=?8E*gkzmlxNX4AfnUvW)8f^UG1Bfw z&d6E*ra9PAh2H}F9K@^J`PUA1LgDX9!O!IXX6aw2;LG+qbfDBv?a?{$uRGw|4t7kv zrxowXQT>qSV8<1H*-RV15A7kBLpaz8h1&pJUGMj?{H8hBNrm4A{2Y}l>0pnQ`5LaZ z`R;=s%=IB0?2y8ZN23olv;zbB3GdUX!H451wH2ok=g*^PT2i<1{vD?+w7ujo#DU>yW~41AceqDNDZ^ z`6}bp5rrF`ofE$rxYDhTE8G}xbI=}X-1fd?Lg6QYp96nd<7;=CgbtPY8Tqx1-v@u% z##iy`n8J?(KSt!02|vqtbvnN7PLrg1kL$!-*=3qLO~_$VPxUGIeQ5U}hx7?T3O4~< zZ9jc0uW9Zyi75QgNx3Rd(w&Yf+(zJPzWd;ZK_4<+9aFe%Im4Cl>bSxUpPUna8vNQm zm$2%?rF}F3zd_k~ALf6w<7;<1s_=IJzq@)d%XS+0vVg`EZsa$)%EvT!n#2`;JeT+= zIthilJD0dAS(;S%vroyPUTilSXM9R~X$5{r*-PK*6A2ztxWPHOvXeA7nnVK%0DEu8g;CJoUH7sA+Y4<$L zTT{4^xwaiIh2tDjcsu%zm*y}>6mH~Ho6k)0XQ9_L zM>(qH2d*yPnQ*hvYr2E1>WY}c-wpik+JmnBMzipRWIJQZJx32Ga(xIIw^Lsb&qRO6n7awltFU9M@e(wsm_VhjVV>{p~ z2R5N_*8#V?`l{>pw;fj$sHB!3czw*Dbcc14)N5^hE^zI*Z%E;;%L%TXWf6r-7UY6o z+d+*g+}XelDf`;PaxXw*T7Ka5G5^yX(zurY|N1Pya6}UduNHWHb&Na4=z0k`XVdKo{Mvy;aeQMl`YtL-XN{ol1;l!}|?aSAJq zD*WKW?)8xs-)f362aYM+`dr|qc%X5Gn@GXUq>rrlP4Pez3b$-g4)mPC15GOYM&Nf> zPP+C#Q}R)UXhr^!Dbk+XfZHgakWBM)yFSk(1vkYLjVj#m;vCpTiYFRVxG~`FnIGDb zU+sxX;gbqKnS!6mZ|vG1?ToKNv*HDjBc#3~OZL<+&5W--)40Nq13#qlGn0L1Ro+uP z(}cp^or2rD_9=yLG}B3iH?_gGk4$`KRSr`<)2ULwap3m3oXF6t&|*m8?oQ#icm8RL z{4(?^?Lpzzo@wi0k8!1^DsZC;cO7t};t5V$WxYDL&u&j__lG1DE?K&#{%VRIB(4s* z3NAy_Wd22g-(CK7-M*&arxd)1!fgfap82n-xGNUvsxGQh@`@80B)b{A=Q(eF70eHaQkczGNdZ)SK)?M+V8k}Pb1zFQ!-|HPuKC>OyynZ zP5K|&fu14dN#Z>r^`1=Sr5pb_gFGpYw89Lj_e}kr{hnU7mzmzvem-_Y;jaU}ZZ9&) z(YyLL)zOxMMAdtOzt3fPv>or5!fyb+mZO*caRxov4tQMQx8)SycEl43f8@D)?1yLI zUwd=d@A+tHuPwlDP`1^(c@UY(MQ6u6rrxs~@9D1I?Z*GeActMR;|hQFA99(`cBna_ z@LO|@uN?NI!Y?~7$NcN8o+Y}1pb2GcEDbUbEo1=Qj}4~%0i0CtA9`#y1dJu*`J9ymy0j+mY&nFe96A^7Btk?EgiAA z?(B&ZCX)1r^H($wR@y=zVpmeyLPuRmz2xs~9GgO8jEt;tRM$>9@-SyHt6*-{pY?YJ zTtON%3Jot8%E7i$TEI#`d7uCt%N&G^V$)eIdw^Q_jRI1v#g3KwCKtKLSoN$L=@Fn& zAg%Ai|KubS@V!l)!>sc+o%N%IJd+oTr*%sqM#Fl#gf<)x>bE2SF}5&rat#~iIFL;- zf939H1UPdm*fo@=a3znE{50WRy1f6m#U$(T{ZWv*SV#XRosaOad8f{6X9IhjEL<}0 z3{mL+&H|)?ziO6zPuaz`+|5Ye1iA;L^Z6U3$t5mQ0OD8Kn=*f!+A`KjdSNMpLtf@(uH#yy86}l#pmc>u+lbq zmH8^AZS*7hD)m6wQPXX_VVGl#+bLOI!z!r3{Eni-+(-G${=xnMhv_p6(`C5La-+>=0Jv8g+V!gH{$X?`&6OH$tCCg2IlyU|j^I$(q@&bh(O`kA&|)s9Rp@z+_JcVGnYWFyC#mx(YCTDf zVylT&1}s|Xbd`6o>=zsq5p|G<_Gd3JY3465wu`bqQg#fR=^T%iBs2kS40M@?J z$*NhU!I%?2HkidFQCH{`ncq?9H>93vyT1s}w0-r$?o;$E?fw~N8+u&5Zx^t2eQq_o zNEK)>Nay=Zq|X4wK>P~tkon!D?uym++v^$IFJ9lzIu_M@oHo<7l*Z{tOx%{NMHauy zc2i@b`8*?2YuEwoD08SQ?6U?;tl7sZhQj)@y^ujQjv&pag;uq*A6F-D!M8E~@zfN;Y^~}%H)>V4*Xh=E>AQv$@Dj>e8QYIP4$YHtw1P*Sk8dOWZ_sX# z&c{Pmy2ufrSs;Gx=+HhZwb(bai}s1jIeX@@=%K@Tt(&_D(kAFGV*{lLdMn)^vNJ;6 zY=c1$VT;IW6x9AFx$A?jygXLqrh~nP%K=xUz*)w|I9H8sAscR!G1w&L0TWh zSGdUDph+Npo&0LExOdfFZyj`n`2X~LhhKKY#2F_nSYChhlEw4t#xI^fYwqd7f9U96 zp1R=l$&2SNn7eqy@o2%vjMfdca`2Ycq5WP~C*s2CuQ+|)qDf0uoVu{C!(ZU}`1$jp z{q;0qJw)kxS|>hF5}(KFPqvo8ZyRlyE3Cpu41FiDm5xBv(RLw-|(~iHd@Y#;0TxfmHI!Umdj#}qP%|2PpP>J zm9g_HYF@$|7o+|)x1nej`77R|0msn23a_L?E~3MoC;x89^5M&dEZk>89<7{+4CJNw zQeKMJ89@D?szdAH=CPZp@ji3h30KrH6?7)|7$Aa+nT8O;G>ju0`>?&8e$PO+>G6OZ z?gHO1l&2$uLjp~-U`YP(q9KKIM7ELW|EY3<{7!98-^BMno1%&5KkTtRh1#){gJ-tL z=6}FV6jw~)mtENz|LBHZ;&WH6zHaV(AjcK%YT(A?tH`TJCqdtXv_1c-#YIj9oe$#I z>zgkZ_o6ZKF21CE{QjKG?>jqS@S9V1;8JJ|r{@JzbVa^KGmW|PemSiX6h>19_@ zcAR;zQM`dwpz*C>-d5(?#YS9Xxz56FM3 z;i!hEUhRC^ur4tQ?qtKyXRfOZYn|aL_TX>USV7CWhfTI7kI1vCu3%`T)-qRN6*_9r zG|!Z;&>i5J(#RGeKYpU@6z5gcvxr66^t>1iZl^}07eD>dM7!rUO3>#R1JCXPX6CbM ztIT0p1#CEVpmYuu-rg+kUA05uF4|=w z$BKsebC=ht{vs=lzD(!P=w5TU*)*c^cQOa9MkzXwm7_&yvT9hRYoOD^4AZb&s|<(f zSZTn?zRGZ1VFb*;D#Lk&VWKk6cY0i&>x}Z1JaeC;W{DASRP*{+fTD*~4afXI(?bJ* zRaf+EDgVf8ZTXKv`ee{tkj|&$JWldEeBZiF%DqFygVGzp@URs5(B`$!_vw{9=rQO@ z>W6gqGIA@#$zy(4-EcF2X5wIW5bf`D7=A+PS&YKjE_3*(mw)G1yixME3-6YiC*+D^ z2RZ6G7nu#x`6GW#$U;2hSI2mQ{(pV1&6q#ne%j)BqxL7=y9M3;pmxP_?mFN%|A2T5 z{Zxxm@)Y{q=)1G+273hs+1PBLOCkOnWe3TGl5;!wk0>VduXm9Fpy41bXB6p$pz}cd z`i5LC?gLGE7hh67c4YMPRX)vIvVun`Ci3V-hjyfK$)0|`hzOMlB>!MO=54xPHKW)E#(_K3^cg6C0 zh}K3wWv}x1Q`xK32jM=W=*zoNIh8^{4?6lDR)+t0!oj;qBqKnBjM| zn(h}($7Zv16BqX>({qR8pgSF&m(8+=O&=li*g5Qa%7$1-OJvkgrI6j@ILPvtc?eYD zf1-bo^0z^*TE+b?F5`_ZVu5u1RD$#X(CZ~qZe1^@*H5x5bSzvp1Z(+C_~q|V;f1>& zLLK!nYi1-|gZOG54Ip~9A9eGph&#ZaQV5Tx@FgXZu0(wT|vnw{G*6B+WeoBF^AJ3nsDBX zBlLp$(}7M6QfTn_|Kk&38U8zwmjA~;VV3-TvGbMuH`(JG1Cc(d#6jkOxcq#4V_!Tw z1k};*kmDP5XO2i$M5^H+b83&q@WZ5;&^R}Y8>sZ5mYX43qMFvo1(J^@yhHc*??ZZH zm4mzh(tJFFXIns{JM{C@UmqTp%D_p<6WJ%wDHo!dA&m1y29y18;Sa13Q}x#;&Sy@ z>9>);P3u>t%bD7*S+%3eRcd<7Fmw5jL@(!fGdzv=GWU+SpQP^JS4?p@4r9OOy-RBuIv3V+l#f@4 z0Ivg~-RG&{s>xcr&+J8Ko60*Ei)H@DgxWk?OQaYTgJ+fRr zK+T8HeYI|)uDd7(r^4P79lv#rcYAynQC@kjGT3p{ZMta%{Wmj9S*7Jb)WQWa9F{Sh z4?FOVO&Og|NTgZjM{KR#?_7iQGoTMaIzJY~(H4VNgZMS|U6~J)MrF>Ah27USoL z|L5&Z;G-y-zwzmwV`pd2B)iEb1lS}9A%rA^E8O8wK{+CqfCq^X1cW4pAmByB3sE5= zB3>9#dGL4?6%`dF-sq!(inkBO8}Wdj`Y8G!zTcXj&Mr$3{Qdv$JNv1bp4r{*s_N?Q z>gwv68w{B1n$)TG`DfL>471Nyz`#Zo(Ws#!o@PGNe?RvPIHWf6Q6o>O%4$sY+gVAN zduEs+GsV@@YiXTPGs96cV>J-k302ee0{4ZHjm$6xY88)i6IM)n?m0Ys0hs{*$+JWS zJ;{x`x%vP{1&QnPoGNI(ej(+!SCr@f+vuIJkQ zj2GxDnK=a$(H9vH_%1NYt-+qP%)Err4D=-o$6Mf}cXDWUG9f{+!{V@LY@wN6{Tz`eRLd){siPe~( zmMwQ-c(|S^dw7;RXu(!&B?H0$N`J4rJr;Aj0A4IQC{z4c{$S#RDfc4(`Fs(K%M1Ae z54X`mfOMoL|(nGQw8>b62^AmVq4Rb8| zpi-#j>0|jTU^rmiWHvNPPc6@Ljk0zLvoX5Xblq!M6T(ye;^i$=iVM89W_) zkMK0X_rW&b-B^T*aox@P@MLhkm(BIT;Cuggu7|<(WT?u98e-`V*Hek>?jv*kMwhnY zFS-7M;JWT`9aK0+^d`ag$XzkMk3sqjKouaK@4w*w0Dz98@%_L@hm8}J3u`K>rek3N zOpj{@wbgYC;_tEUy@0GWD-5#M0A`{+6hr=dxc%1ey4z>Dm3y&%?d6eCyi8+I;?No7 zoMNE?1sD%$ScnM2<^+2+=1y80&6ft7wyF`hncA_P*#^T*Q1^1yHH|05zA4Did2+Jp z^=s}Vp30N?EUi0qOE1=Suy~X*I&CBeo^k=be;!Xz+ zouAWaOG`Gh&aXVawzj&qz&>Loyq?u9_9(EA`^2v0`=9z4~ zqLkkz@Kn~v^sq3}*?>*}sfYDIx)dPwFgah2t58}Dw6!p(UiEAm&2k4X_5+7knKC5H z@)+ZFFYD&=3Ek^nhHeOo*D!67ibthu1s+RLhSUe|LV6S634p|7JJQX7Nk0faUGu59 z#^Hg*8%GNgM-xIq$7@poKXV^G7l_ zyw0G6KuPb;Vc+kImCMfjJ;rCd@U>FfDDcevM@&C_=zU!|3HPG`&cT0vRNy7+F%G{t zeKmgN{yW%8W}{-nQSOwp8F@;6ypQy5z0Ur zQcUI(v7j!@`0JP!PzPuOp=u~6!5L{1My*uUhhf24zEK+Q5d3Te{*ulnq;~+C0g_JR z9{At@+5wJ*&TU7b(^*8f>Ad+cIw!Z#xfBa%AoP^LnPa#$*(P+3L+CtS(?TfCCT@0? zx$aCLdU8C`&FcicHK0S%dpXkU0RI3;Ip2x&kAR)()i%952a9W*9JR=~ZF5^MY;Ln{ z%iHg;yukpMLH4&F_zpuER>*xO$@{&`NDH0>l6tGFSpaSnJjh)hUG0#nhfTdYGlOhQ z)Ck^ZSUth&ZwFq?y6_^_jbG(VTTeub=n6v*|CaM{jl!=1_6$R-iUtK zI33q7+4RPmNZA6=yCg>M;FL6n-Uc)e5Ii|Bqc^uI?B-GD5PSi@FoEEM4#D-*KyqUg z4{m87GnB>+g5Cy)4x*(I>H7eW0A%}k2I)CtpZoBaZTW1RlyiZGP6%vwyVXN)Y!U@r)iRQ-yXj z^_Cq(iYbjwer0j_RUkbbF!M`5pjz6eKiEOqjaJP_l{r9rNA z#(h`u>@dC*_v}@H{B>oy1|Ex}+{vdrE+5I?bB~9E?iYX7&QgRM+_*j0Q zsHd3^#`q}ly;M9SMzMy&@r|Fy5WeS8HPPq)0-8$net~CjV+_w+r1JsY#4{Vujfdme zqW9ZOeO(1par%&E@B&?1bV+#O;|C3zc#O&7-v*r~s7Lu9*N*lJ=X+R>iH(@1AxBZ? zWL2h=sJRCOKcWxC_)&s%f52ezOz`86L+sfkXJi$uoOc4crFk^nSjL3Ri~Ji(c$r91k){XBNHsnj*X^g)5=wm3X@A^jQP>o`1{4zD+X z=Lp+z{zny9;&i`Vu1>W?7=lZL!;wB%^TFhafUNH_-ICI)BfmKq7u)BpW`R&m=5Bg3}uK=t9$adEFglK0qM`~x#(lP&Q;Gd?g;MC5pjwqk`3wy-1F=}sr+0G1} zOzjMijLxD}aO5~q!s*_ILu7dgLng7)Qo23#1aVG2OjQ2AiSr>STbp zje(nfYm*_znM1_~w!uS$hWdXwq8({;Bk{Dw4*e{ah9ESy0NEu{xzrsTc~lo-`@bj` zV*^zOb*UVb2k#?aE}F}KlO}`?GS>8tkh|TGE9qxqG=2da?M0>q8HcvZp;nTjCE0(X)4PI>jsO3ZBj@*a5m6`q zlBZ(!+sR1J2FwT0yc=}r7bCq4a2YMNH(V98LcP;>EO5iOIIFnlMD_XMAs zfgk3(_VFFkKLPdw%*^0n(QPmI0h{wZIrdSxLWf{IEif zzl-x1W#5tpI~j)uTpO^#lYCwp*<~=@1%T1LOnD8ahzsD6qb%0iN^d~uGw(a}QyEUC z`;q<=pg!G#2e0erQe3!~c*yz_<=Q2Vqrh1)Uet`MHc@hw@)@gAl+kb^C2#O%r@S)c zCHZ|C(v^VO0Lkw~NM8=n96R>5U4rkTJSqwFZDe1j4t4El6(zw2IffSu^T}RWE>VHALV6VZEJCg0P?Vmk!S|beNfQQ?R6I z_>5uP3P90?i1sxsxKvvnsJg1BJO z0TR#gNS6az#S^~%kP@49jqCvMB<8K<%A71C*~lgv6t_$rE4w<+hW82ZV1cm1zDpcy z@&as{0Q>6yE%4cZvLxT0LYnyYGOiMz50L&6aA1p&r$gj;Zsm0BR2eg4{LI=)k}umd z_7EH6g>Ooh*4^yGbG1U=)z_7L?8u~jABS@>m~$fU&x-h|yeo5&lO2xcT>_tyrWiga zB0U-~79jCC6X{uiR{1k;K}F5jimJ1LOG??)F_27tK9*Ud7%q!gCPRCoZJX24y&<~^ zSj|gP%0Ch1ZbY84JwJ`~8-TX~vfQgMX}bZ?Dj%j-m(4n_8WIh?X_vNwvj%>RxdKM! z{d|>Ac@C>TnY_KfU&yPqOM{Cl6u1nnJ;v0o+?`~0(0k~o1~MaXAXBI4oqgVPEd$@R zg_i-w^(3>M-Zqfg7On>_o|oP$Lr?dr1Mtl1lZhou-RL>iIs#|?_2#BsVh5G7lu>aNoE?I1Ymd@%2e?YllL ztm-}p4Gr`^I{p#2F#fp8N3%WfyR@rIQN9v<+YO#b`S}%T^Ep%T0VLmkzs#kKzcmN>reG=&x0j=UI zP)(_+v3o7}Cp@gS#m2)y9ILuQw&n}Ps1OrwbYCc^$tl--K2~m<`&1YH-wxyzwVx=;4qa2e#Qiq_=Qzxd z&89<)uwuNUkT?4=v)GAQJdScF-=Y^{?e7GnPXSB>NIEWf%}`Fmed7MgR`R!LDX)U7 zo(Haa`?2z>ysP1)%+!vv>%_)&kDzA*@|6602I*G-ZvbSuZzH`2aMW_gq3=N#ShNtH z2GK$)_erL_ui<3(eB;P15Dp&}wxH7H19XCLB=WT2)8yb&@?vb9AC2?`z+`~LN4?Ue zoPqnJ<6}3gl(I2)oz7NvX|u8TtaX<4`K?1g@{;Xg6VguswgO~%uOPh>(5n25tE-?5 zhu9}SVh{~QXDhGjZDA|Xq6r(dA>0W?-09o!wyA5S7 z?pyEYopQ0i<4FXyprSwK%3FFaEM?i6TlZMmS}JB^Iq+?V4;}_-9V(6wn*g{MyMlEq z$OBdia2$ypM?#l~elPg38D&a&cnRq*0eb)vuVpX8w;IqYKjtl(JF2plh97KNxEz}c zux9$HSUSBnc1LQ(<)*U1C^VzqAiDV!EdpQf6pqbnm~Z(kZgpiTM$jAStq!Ao>?vAX zd=HOCY{Bu;Z~Se+SNQlFK@hBA`e(RcCS+zPpz)CDHGR5ihILiVFw+Cb+lw`F%ffyz z{8#sy&|=Vy@?yO!b@e#IosquLJ=k2U!jH>v>E{N-0I-X8po3BO!C!%0$Y-$4j89}G zS~0tl*^vQC|3~m`A9y19b^vL88)C5lB;RN~F&Fo(@(r##*pGzPQ_p^5i>W$q;mS@| z7S@ts)`7<#LaJaO2xvWcG6qenEik%>Uk)$5miANrqrhV!%81$xTu~a3UIVxdAn~~6 zUoNEy_je`C7wvxJ5dC!O3`DH~PUSDLsXpa*>^@LZvtV7yV95*ug{3X%4?Gt>J>rg3 zRp>IK`4tSF(B(e~e9Tv3d})Jp7eEm};xifPNC9{f|9}|IV-H%4jI3P)BX$RMT~`#HUH+l=O@Ru;s+``KJE}d`e%9 z@of^)(*d&p5})&tz51JeIIQUNG)*uFTHjxkdpZ9dzLB-Pz=- zLkj9wG|9k-*B=`%j)O+jUMi@~zX-hEN10Mj+=KLg0m{E(cx^`d1wgBKIeMbRsGiqr ze^MNa{Rn4a0j--o%1X>SGkMsXgJ2ex=H>0MUs}bQfUPhZ?YTQ(X6haELEh8=DU8GP zwuY7t=QP-b@Q-Bro>at0d)^_O3)Rn33vq9CvXw=x(?F|xzTe~XW?fR7_K2Xge!5y@m#VvpeWWa=wJ3`|4KXpuKbE-nhvV=J zTk~Gd`?4>L7Im@yfZ$)`wOGC8BRv2x1R(3R9_ecUt@5u$Uz8kdWRB9f2J%J~d$p#l zJX~pnORlbU3Rv*h)~!xqUqnxV(ipKY3eY@GcL(sF_C`(@hmIed_z6OVJQFSyx*8Up zO5;I6M>BAg0`ll$r9C^#~>yZ8h`zC$^Q2AsJy9m#g0$SA*a`PO`uQOCX z%z9Ib`WKo-DWbr^C=kT<#rDk%*j=3R_8~9XKj?43cM8x3AnjHikS+xD0??6IuKh6X z6fD2mzO(sp2tGm;9Sh8VL2qxK z!|;Wolqw_beCvUSw3pm~^jg3j0Lh=bkbV%5h-V@`Laj?FJzkJ3$y z&~Y^WkNhjV9hLsfSlj6L1Mm7h?+C0v)VhA4bre`rU@EkY5n3U&jean*;0tk-s)FB5 zz(=-^50L&E@C!iVt-l4i1#|$=aWuTk{|awsYP5p(&37)eiXb;bf8bNT=a${HAvZ&J zYE$0UteslU+fr`Mv1d$}@8qw1nK!}oFQ2WJE?v%3)c}AUQ-y;1R;2?nL!@;k)!KLt> zhxcbkE6?sp>S1rE6^X!NOF6(FWl9M^K|m&ZGqPPk)NuL-lp*VP9@2{emjNUmw;}yB zK+5Tb&VI(f#>1Jkm%YrSljXb=WG@C&jL>NB$<{Kz_L0Z%P#jf?IuC$-+@@SuU z_-CHrm3|!vW@?$PtR?n0UwFJL{2agf+{3@}c(3>CtNq%S9=_Y-xzVrR?AN~b7<)XP zTl{*1AHUR(^vhfQ^qY|t0f2y_j*VFPOtX5W$dtmI=kMNw8n=!j=Re} zTDs>E7I^~$u={a_z&F097;j?Ut;~4Wt8KT8w=C~_UjDupXISE|;Kmzfy3TI!q~7e& z{$`EJ%51VE-G30Nkk(cP{Lri~+`5tBZlg#r3OM1(~ z?8?|-CcR_n?^-xpL#?&2rH~77+_DzFNG0xeI4Ug=8mz%e_#}#}$+vM&a z-R`g5=4)Nmm%C~%>~bt#a16g047Z1f_8R*+OTX6@xw~`l043Vo-5JqgdQUNCVl%N2 zm`rz8n$_Ey;pZb9WU4RJx+0*UUDw4GSWa<%SZ&_r8Gfh7T<079RbcqP{lh=?UdsMO zg9VS^ZrDHG7kPQX@MqX(boy4ge@Y>sfjw0JM`_VwJx zuxECxnXkXc^nO|i54m2akXHD`Y;B0upZ9eyVXO}#HOw*_Ra(sbjy8IxsCfwFpc&Z2 ziEv3#SGk$$LI^EPg_8hbmu`@m5wm3@3E$^M+WRQLg*!!*049`$z?nhX7xx@79oO@N2J$%-xIAkW(6 zV;}kSkC^w#fcB}^_`+*`={0tH5%%=)K$888+t2uJud&@1dEaNiE^B-a*^YeX({=8l ze;x38zxDB*KJ61P)(y42UcSq#z2|j(=Jn98_xj9_eXZp8fe(4u`F&6MeHWkKe|&sb zVt&?c$fhn!{MDy7(=QCWMAI(O^RxF&_6M03Y5MuP6YJ+@O?!!vxj2hsLaKRLwr0yG zf{2CmOaw9GMw*(=Ov`@ij#wdgrqvb;>KU#m8sc%<@!AQl6I$$9&DlbJb~|!Gm3|Ou z>l5r70!aNX8|lFSY5$Se7Wt@#O9XASl)FowB~^4BwwD~_kEu%LRJdmhM3AbnP(Z^7 zY>K!VL{$lTio6yGB2AIUC(*J}Pue(kwiL<64g${x;E?M)T!-`%fM);_&%NIp%J;aJ z`m>bpd5h*vtC%wfAxkT!kEp5}c{~gNMZ*huQ5VeRIs$S5Be338uWsbZcmyv+q%(sT zVim!_YBRPUTdcDth;pP3bHyM3;7(0UlJu~PRE`%7gPFG=p z9pEc92I+Rb7rGJ2B6DUp?l~ixLIz42F5W^wLTKhe{)%JJ|D0(%85DgAU2rAmezM5M zW31HY3I48e_(uHMfb?d-(*P+4Us3uq>t*Vrs#1nv%e)i!a;zlLw8`w|9~>@1%1ZTkhPbgtrG_F#I&dQyPJ{eDz%bGc zVjg}HZVU(r1UQRdO|vhx)ab6Z7?4Zz1^)HGjpP_EfXWJ_9|F7qaOA^KJ~tIFARRzQ zof9W-$Gd_a`(fN^rhbimx6tu#k#wF|StrPBWKRgcgwD59JYsPkV5dvSMd&iM{~7)> z0~0bPKFlXXC+ZXQiJ>zx&s5LQ&&+`v40hjj^5^+YHYj6ATMW>Kk%#beZa&n=3*;Ri z9ik7`dxbh>W~*6x|8TnFLv)oUe25Sya48u9!sLI}AM3^eOfMhiTGU>03PIJSWxpIE zpuRxxrxA41Jh>eF*@*PBfSmxzAO9Dok^?9P&~aGbe#R$5)*< ztG1e=QEud4a(gfIUc6zz1Ad%(M$(yVLi>p;)d@MSk~1x39pdC5u1C^ij6KiVzr=Dd z6U8vEy1<8ys%Z_>18PX?jaY&ikQs#Se39k&OCF%UmV_PMnGY{3u*BjF1Y`%(ex)Ft zUDPz{JP;z3F|ED2Qq^+csfZ_ZbaUh#bk>L`$|Fd>0(b-9$h-Cj((W%2-?v2cA9KDD z{BZuF?6^YAL#15IsIEN+;d_r?SUGJ()zF!<<{*R@0&;Fp|KzM*oo}B<{D~gp3iUc& zS%;3|W!Mo6w?74|&t4LB?n(jNeJ0c1b<+itAEeuK3|03DY-D&%~ab9Mfzl(xIh zPHuMU`N(v3Yw0hiRn&~H9#t{#oI{_CsG4565PLM5j9c@!8m!6K!0gMte5*nHxU~q9 zFru9oE8A^e<38Q@6nctYkOUu3zk>VzUrNx{I2s&}6nio(u; z7GzpJRb#4=&n>TRWO8k|In7kfsJ@JqYSG)-D7aMhSjEg6nF~IM-hpO6^I~&xI`^Bp zQNrC-<~h0-Zd+%Xr|Io8xL#l)A}Qi?4>3DCe78TdCb$z-q~hW7UmcFa&BJD^-T0aE4lARuCL+dKe^r$AuNDI zdqhjtU*qO0a5C?~fu&o2ft$~AJ=~S+?{f2P@C;bGRhlxS^cH3R$h~{Heo-fEE^1pG z&a<-3_H($Wo!M5uOf@f2^)3q^P|f?$k@>L+WH9%vSIs+d*C*mm(;HRuHdP;d3CuIT zf5(3EzG}XMyB=?;=6104;#XDQ%eYSx89b+&Tj^UuZ;NU^q3Tbn<~ORIgHX`^6C8%9 z=5MMV$k8+t2va3kZnqAu+^Ff{0uJX0kAA;q-lKtqJ2m&4Xq{uBCll9vST@CcY>Q?- zq3Le#3!3??ra!0oUeWY)tPj#ZKhw-lkWVzds7+sl4C&Cl(5Jh4@y-SUW+P;czFT)O z*NxnFIm41EW?8NPuCK7HfOi&JH|^&5wNSMEceu8U@h2F6TIDw}xR?!8|KP@}jF<4q zDg@v&)w`VW4>@A3@u5%{YqYYoCs@X{jMriB45At?!ljfpCe47x(Nk`&Ws2 zBK42dIhM_;A%~8d*;rwDmEn{ENUo-S;1BqAmLca8NM=K5=pZ%vscs^w)Kbyr`KPW0bL}3Z@XH}MN^i?b2QRLLuYDZ;xoN33w zm->6AUd4@G6vLptOyHgSZOk5e8qx~@R|8}}{V3AU0$vBu(fozrYw$~Pbx3ZxG$-`a z3u&Ef34^4#sYd2A-Kc8>DC_I&pWs?q;Kz6q>8#|Mf7-nC&ZG% zn{-BSUGhTit>voYHA|hOM8^uglztbpi=T@0T)-s&$(J8yyOe+8{s{mb`A(cZ8LzK} zAx^?HuCY^+WScK|oyFO;)$?nxY6&&vW1*(>ww0A$q^n>|Vrq$2gwCWRkoU1oaOrdx zLbYI*x=OBax#1}WJ@q=5zOdt6hJL5vTaQSwU5%1JnlDNtazDO;!&7nwDIEwTUIM3P z9V(5k`BY>@M|8ZPH~4*w-h8A-07e63yQ)F@LckV>&ct?Q(=%b#^b=>%ij5<(@G={I zJ2{!1VV~a=u4ZrJLbUy&DW8FuG`}mgP0~R44;aST{CFNsKbx0QC&_IRNWq!|V%wOd_vpSaiatqD9wp{t)Ar!1m)uNP75qK;`8N(es9zps5p!2?1J34^0`9GL51L(+oUDVGuXWhm5Ys*FJ zC+t@ePuZWhsCpDX^^kgm#@C=drmxUggZ7ZPOtEhk+ULpk`9@}6t{0a9_Dz?#^Dx@f zyG&h+B_(#ZwYXC~>u#TT0h^5h*oD`&Ovbf=!Qzz-o25s&l^4VAgbBHZc-`FH!|dYa z-hX2CD!7TkDrgRaZ7_m(bvTDIhM7IvB&T&S+ot)0Mz1r2Zl4+C{UHn~qp*;LpQD{8 zX`^^L44^^FKnRkEH^<`%6}T+usK+tW2qIWr#K=#}R{b5i41(tODDSk2cfX!@TF$%w z#yWA=ZU$|xL!_MQ3S?W@dWUByoB|#XW{$off=N0^E#;5WB(u;=JE1XyPJpM5@xPy1FfIE3BS3^Gh z(_x&}N$qZQ(z}}luGyx?<+3`vCnHRrU>o*ax?CoegQIkske>~Zi>Uns?}9+O8So`Q zwrk@@`1$}6_n}Go$juvFSvO}^)j4CU=hsz^tvq*r<-EGi1$EV@!l;7xKwyxCe3z|b zuxgc#@hHnx_}=v=w|;oEVk;yn4YR;>=0$r&izQHjA+^Y_sU1Sw7{D)MybnFaQ9fPZ zT@IY2KCl?+D*@L4B;L0peIMXR?GM3@T0tDB*0T%Xfx28pK*6zDn0}#4!S)f_VcAQX z6ziQ1Vg?*5&??1IDe#K?B*yzFnp+p77XmHw$t}>g%>+TPh z*^eZ@CDJ2?&`e9a!o5{vP1+guc}m=Qq@5P$J6PP!=(uz*JKbh0+TL{&*NPY<4osMKx)n~cl_2;1Z~QZYAc=S?w;_PN=o znP;Y-W}Frn!32LCN9xiP)$JxxSz2dsK6V z>rE)+f&-;<1wUv092<9@Li$a>y8x*tr2Yp!wSZm#I^KU%@awy`#8u=LI~OJB3AR1Z zVd^MsgQ$S_w&Zn{kZkAia9BVqYccn?BRRDEX3pHfb7q~5O;FG=n)w#(6Iz^~ZJ*OV z;a)IykFM-tg<8~~>Qgc5Seb~*p6)h+7=bZLSwpdo<%gXp#D)WOz4v>}+{eJztfXLf z)uVcSUSS8hiL+k#!aG5wa-2>xvVUt~M^9qF`R z;MW6?{JR+G>j7&4bd(*%UN-43_-A(kgX`*Q6FJ!O1-Lh{V$S>+i)-sDV(e?=>or@E z5kbec33a9y1sz+iKIuZV#zJ)XL0*zqDvwFr#ert%^Z zO(+9}>?@A_45s?J-KOgVpM$@~_&fsXIe@zWlFzRFh=C5s2GAk*r5;J2otZEWPn%O+ ziD}h1=nIuG%XPx-&^cI57*j>_FZ5)MPz-H5WvYGt7A~?dV2Yc56oaop&2n`!dsbvk z$u}bvp|i$Rq@I;r!OvoP8^fw_on^W5C%ewlE80zQPxhYG+ohS7E7uGt$ysalZZ~4I z>u9k4S#N0hSLy-oE)PwGts9KZ)6&MLPBXCyQJ;kUSL<1u+Zc9hmAJZK;pPfG|833H zM<3YkRn7gnhCT8pM%A=ib?pw_TBqme`Ym`-1sF{O!0!(Kui{z_BJ-E97j5k)3%=Ps zwJfG-xv-^#Tw}0$nN0g3@NN~$j^Cfp=mmh}sO(jHLntiFX&ld{#`8pdHKHEm-1nbI zKMB|hkmJXzNWTk6jGrmn{j9p`3VXDeM-EIKF=VV%@j94b7KV#*trN)-kpPz4#=K(8 z#_h49e1RxG`dh600;C55h5}^y^j?XxaDSxo=Q{{Avd2WdMyN(ts{5GI4lg(%#R!q5 zum`|p!AP8L`uWMsa`_1_thx8IaUnS@Unua|h_XuP0gl&@eh=_5K;m;zsiAy}`^0#i zk{_fN+B$wr!3TS4ra-S93%&Llbvskav#=6m^6o}gts_nKZ(x{u;Yvs8B7sNI?=imI zXc@`~+>Zh{$6K*&MKff&8y-#8Zc69LX~_=Lq--CDMdT9cFi{Cr5H-e9~gT9e*^z8@+!ZV*NsQUOUN5=>V$W4;f2dljtMa4N5m=Y-4I8UgI8l5UQ)iF zX$7xWj}fmI{sJ$^se{{UUF(bmPRT1nMn3><7dh=(aktcCLr_;H;C?b7kzeQF86A>O z@@hYfJJl!Xqj7RIzP6%fXvO?_m0($aFt1>0NEeD8g?mP;NDDlkB%INsQez-QPaQ{(jL7fh>ELh!lqagvnIF$XqmJ! zKT<1|<6g5xwRA@kb29Qy4P#wSqt`lM!?SUmo@*Ybbu7Tn4s1|7 zH;{~%C&E00#kV;A<>cT$4rw_v!w6X?D%x-4boEbm94MZEzcb~+_mPSLZl1!QW3VX0 zcYg~)cjDDqO_vJ(Zvd@D4znLe`endt0LhQ2i^#%1S zX1|hV;e&9fU!pEkmAlMB>@GDaE+A|w*|0T(h4y*5z;C!7>yIWOT?Lp2koet*^qqjsF9^I^#}7SH`zCb>->Ty6Eeq{)+pX{( zU!uNcvx_#C zI#FWYZ_f1U0<3w8DNXrOflnjKTIkg4Zlo2LTS*1TappXvmjJE<(4qAZcDSDl4zm{MC)TL*-Xj@tW)75t0PgNG`ox8SCt9RArk!{K5V{GJkLd(Oib2=QHu9w&7S8Cm*bA1jp7rC$}#qr%yyw^3iX-FW{9% zF7r8;nL4IDm`RJz-#DIEyCb4ZP{qxo3Y%6*g8e_ z`6gyxrc#(f>Ti?vq|Sf_o{XS5;2Pddvc)Pi+WE0t2j2JC!$YJ$W$I0cWhdB%`*%$J zL(_uQqBO^o$GdxaXhugwKF)6smkgU-7X8M(*)$(C%?+lRnSb1IFj6AIDMBaYlSP>1 z@LR`Hae!%#tWu)O1^;s0vG$!;&Xs<+9|Vx? z=0v2&0ye$4%>HZQx*)wo&~ay6qApZk^|j^W8CGFw_ZSR0v~_rx*3}96fgBp0a+{E+ zY_Fdo{XO6}fUGy#zZ}3DQc?kQ9PNFk?K17593bXwMqwCyWMD0%DVvdfrtk|s9RWb?2lb@9}?1O+XwS<{Y>I;gXFN$rMDplJn0d2k)pShIdz_O8^4^vfk-Ec&Fh0NP3^+ zUs2Wz>nbN<L8{&`Vwwo@>~pX^@^P6s_3NZ^s8lwK$B z*oZz^&gW?U8u7Z7On`G7W-nQVS$rc_jAR2UckLDVkWo;_uEwS68+aWQswD}o(7Jhs9x~wH zORN~nYw1$HQt+e5=WemX3`Tkq;0%DoV=>ZK19m>Y%w|zyKOpcJLHo0@&c@0H0EPl4 z0zzX5k?mYL2o)$9TTuMdt*Nf9s9n_3dpOTw zq0D2KRLiF2Lt7yU|j$9ptK=5_SH+5x+GZ zBHliOD=*>x)xY?^#9K^&3!HiIQoK@mmo2NM60bl^&N?F92QUyI>vt^DX9A8SXEJ{~ zawICbo?VwHX|FNmGql4%qNGuk;svvIm6i#qL;rb$;MWF}Dd)R+yNG}I?Uu?ad44G90b7e~T@#wy~p!-^?;)Q2FBi>mKIb3M@C;F9TkSE05x?eTe&y|5|UauBpTB5Zmsti0mFq zRRmmH4;pFmHiWk$MeV#&a*y9j{uAYNP1@?T?cso$JXnU^za$* zDY9o^--#Jm`Gc57EmePoRo5YYuHiLGJfsWj@R;vT*pS-xj?>XS$h2qdy+M%Mn+0A? zD08@j*LI|L0X_#vyrw*ccu2Sp`9%LE_e-?&2hdyV@G?fM!cDN(-Oi$DolDgZ8K#$6 zSU9H`koMb-`e;?Wvt0I8>TeO{cZ^$4EKPAMCj-g=viyg-8OoWsPplWr$j`#mI}d9v zOVtON@_?SjL-2M^*SnA)kK&U=9hBZG%GriIN*y|PBYhCiE)?s}w-+Mb9PZBn(6Q-X zq8>&%ehJQBmEe^Pc5*|4J+8W@vI;IBk}Z`qT%s=rWA?HCFx&U*eEZyvT)$SUo!z~y z`FMM1kX%N$SsiVrh*`XTvqP+&jqu3#cMMxKhC`<@^6bQAib}`SB0Z zFEU?DP}d4R?F0Q%9@MZ~$pG{N$Z>Dmctcr?`{e*S#uo~?SoMkEm;Df@=3fP$b|mnL zv}1CkvE60r`Evdz1%|qu*_R9o4<&fBoL;RenG*zaM%&DBgE?z4x|?BeCl%bWQms>o zKUWZct|k67F#ihT51w4kg3Fk3mCYZ-TK05Ia^s!3lm|GplJQ)3$){kT%ja7H$RDk5qhf59RYTVxhprgU@(@2z~qNRdA`xE#x zzp8~h8`;a^tQL@a}0ar=z>CH3q!N^@aT$d?DWSY7v|t!5e*6C!R8!BnNZ5 z{$R_Ael6pq@F5J$P+MB`j7s-eW`z&iR+JvvY5q{aBq3F+Q|lL3+sk35gKhPYn{phNmwNIRST5T}io3qFJr^ds0w zrqO~M`jxB2!tZGm`D(R#E@;m~qX{^)7bp+#EX{BAfE0NA+~wCY@%C4*nhvx>c>61S z*de7_j#^}#N**NVVwRr=_Yw*#M*B|-1Sa+C1YP@puN=n@BJGd3l^lSiYneZOn$H{vNXzQTtfRZzvWIK#H z^h2^eelO%YY^0_n8Ob3`x?u%850v#XMo|*ea1)NXetQdS3P|e!{F?3*bZrB^QvZJ! z>CXYb0wi6u?%h7!t>gjdSooM|&qvZ{`zO$4yJXn1acy>S1PXV?ABwZ8-@xQn?G6Y?T>${X!{85%b*Rc5%?X-a{Si#_xIR-ro z=qd+Yo#Hy~Y~>Bmg+1J=c|B2f4fkv5S8@L`_+`M0$ymjcR&b*MBw+&u(bfh2z9%Cq zOj=F&REjD~0)5#Mm&UsVef7XQvEEkTnWXRU*4y+1`c9ZO6DADhF$|xm7i_-p^B;kI zXfL)XbN(AQe&@meaN`%ko$952y}&EdCdT(pNcR8?2S~hXkiHObC4i2j@qOXpc+IV* z_FvCtkU*Ve3)Fa9pkf_g76P$?@m#-71>#p=bPHXohU-;+t!gY+gIB4>GBqp^r`f@y z&g|fL9)NIRs~vgb3b{CFnmB>C!#dnsRudWw@>%jKE` zI%iC)s+*%602Mtc*5dP0g`{u^fi>bala8jM~$-&H*uWU;fxcT67a$}F&%rH$sW+iU|}ZL zuQSO-Xti1kVd?6$QW_M&ni2t7P-2^1kyK~kgFR_ z{~D~yay(gQ25&QswPuT4L7)7mz^^GppfjWQ7XLp)6d4TJ355(kZPk5<^-{3J;dxF<_jFldz(>xw) zDh0*~kpHF!1irzn!}-|`&m_Lb%Fi-K|JBYq)GlW4|54WXTNwHcA^91QZ2yVJ-|PWH z@nnxD_@&4A$`fN~^MeAvjljieno1MWI{;tA;Wz&{mlDi&D{TODNc}vKpAAlZM-uR} z=PwjK0o@I~LU_3GDqLBln8QF`W8UjH&u;_zgZ zH@L!UG8HwyY1fH&RKA^Vxz@l5jn@AfnK z3G~gKUp2R)M)@76qa%5i5PuAahaT~UH+uQgUgH^W@CmQ+B%w_mebYk%uiTuNJ*PL) z!vK>260cV?4W%CUR|DusTpzU`#+^zN@PeKqUR+YoW-f-4sptVGg6BDL6QQWm49`?- zPWy)+h~EmOIee2pyxPz2^c#2igSY#QM!$pjiRb|fMGrU;{LaDpi_`(4z@zbDL6_1Y z#&18;Z2{c?lCI$|BVIr5X8`Ef@>=Wdv%wj^#wXG>4^DiLrUq6UYo9{H%=V?mY0f`` zus*P+u&0T%jUX-bPk-vYe*U=M*z6BJ;x{%)(#p_k3++~G>zA~TD4sMM&-zCMea*l- zYCnm|(~oiV{atyQnMhwz|9+27^=K8ODoCg2DCp?1!!xwGn&X5eQt_?`gCP5>C*6i8hi;CBX$ zy8^-614d)OksV-I`l!G!+A)ToY>$O_Ch_~b{A)eTB?NWTmCHV&^go!m+%Krw(0Y41wx2aB9> z<*o$0%ItxUr|rOdgtjxbgH7FTD_w`#ZMrFYlC!iVYcy$HPZ9o`f|$!b6!bqFL`R4x zTY|yIgT`i1;1^mKsp=~oljU4&_3CAuM%^NIg2(2!)Gu}e-sO)AdN+WMQX&M0r1x<= zll5?{^p-jFZce0E3{2Ogw9xWzqUBwpaMWeV!urW0nN#1vHwmEf0)d_PBwNY z2R~0Xz90*ZJyo(%Yi1#tcM*)e+<|SpyRR>p+6#vKb^Srds%ei1-CWWdD@x|2Y}8fF}o&gTEyk zzuSf=)B-iyJDf(llxg|0O5p8vXseCdcsFkm^p-%5XZowl?EfeB0}W0;IxGRdvuIs%7m!1M6>j{4kh>Ykg>DR` zt_txxLdLpK@V1b#mQbVCaWZt79zvI~%ZWA#Jl6n+L_9a(ndIZK;*-7@S%|Lute3yPkC10S5gqG=ix}73Ah*_@!RpEp)}!s zJAjTCoVZknt)n>gJUn6EJcIN`TXWpU_K5YRnR_Y1K%2dGbEfqrGJqVuOkiR8C89PGGZ2Pyc`l5gm7f){!`natOaZg5Uwe8fG6TR^gkga?MdZ)rdMD(r& zy&1QJ!)wC)?y#{w9K0iJth4F$BA^`nx_W>_BrhitbK^*amAd%_L0`06tpApJO(C91 z`u?t7vnr83u@UqaplusSpCZIJ0rB*Q!v2TD=+5wDOE~HAu(6p?mzquUivqtU;3E5p zok)KH*dK>q%HxI-Ep{ti0Ca5F*}A+%9eLZ3fFIes0RNJT>xo} zWFdsE{xy;DF~|sg5RSYX=AVa+FT%l3!bY={x6`x`OfSPp#Hzvm#08M%mjr!{z&nw? zO?W2xf2{PCJM?WioW2%+0*9kv(DlYuMNy4*nE2ewMg{kIgR&{7SpW z{2oq0x&m+>K;pL^>4yQ&0O$xg{<4h;aSZF7_-#89@T+a{i@-vAv*-xV+zU6v)oPQ_ zXp&njGToGI5mMhTqA~uaR8X-p731QH)Z~U#2oj#$oEp3#)mWV>77pRdNUi>P(rQo* zYZ;>Z^nx`Q&zpo+liXsXp}7bh<*x|3OM1lgxD$~+6)*=N>0ae_DL3POJ%Em#nMcs$ zoOQ%666hv<+V;EHfmfB;6R=65k86x?^R$qduk5j@uEc<=Z6&H72Gto4riSlNEClXM=dy=|j2 zZ`hweC)F?cpD#;yY-!txf^DE6^TkyEOH^-ovLiM4uT#aVH2zEA*VGDrJN_5= z?Q`(+PCrzirg|X!8YO;T5`Mb~zmHP=9}|9f@>Oc^(^TWLc>G=y_?7pH$;n)#Y2I}a zu9AN=@45%~a^BV9MbSSUXd(VVq6mmUKuf#MjSge*KfF%YwDMRyZwYY4R)BB?7Q{Pu{^7zy4IF>Z|ry`g@)z$>>z zv~xnOC(@;W;Q%gh<);_nPsT&+yvruh4L;=wq0*^+NA?Nw8eXc1RalZ-R9IR0h^F~?jII;guS)-n= zV8ej?z@A9EoA80389ogA&M4RfQX;2%r@UaFSpT1ebO%5#K$cg;S}m`a!p}jO=P2W4 ziSl~1lHU|`E=3uV&XEtQ$}PCRJ+9n_1Uko7EQpah3R{0#FHh3xP^rNGLzK54d6zjv zNxpjf#^{VD&>1VQm$I>?Fj45CBpzC1lT+SII4?yWJ`YYtCcSX7n-~<32)17r_>F?v z9YF`%w~_uB@GXG)XQG$BI)HmNK+t=%{US99^UoG~V=nC67wm~wxb;@7E(f{i56mrj z_8)k~p4Md*_gv3?tGL%py@lSnS4l5->w>K=KJ4R46|Wq8N8nupd?emiA-xiC2Ou8r zjktdlK*v$>K2kkV7!7irS)CK1?YZUOp_^*~v z;at%t<)P2TCQy`H{(-1hWk?L)wn%pb6awP$Ey4W&z_H+a*7{zoQ9}GBnli(wxu5mewGc5;aQFJLco=Pcs!~8SL4~S z;JM~6`6=*31fD_%Yd1ezSY&0#UTJ0o2T z=m&_$dpPb#0geUlpm~k`JK25a=Echag~W;RxTHmH{X%YcYZT$bxKW3sjuCQ;7wOn? zTl$gU^Jd^7<$?O6cX9s-ARb@pk9Hp;zW4t%pPdjsR+s%8`%&?Wf#Oe zz@^c_tL*p~UNuNB0xSo_<8>eIHv*1@kI@VMijT3-C=h^AU}K>z06D*M|9F5><+kH_pjGockB9k#B$@e>u7DYc9S#UjKh2|_|U83JLPw7fs34)I3kRG(b{hXs0J z$6?P$R-b)#CG9sUKi%H3YAH9XogZZ3jqLOXS<*(-!OSeKCh6%e?vDfz$v(vj;N?<0 z3!&*?G~s3&@}X;BS_v(?bbwMGW}Og!E{V0Jsekm}g3jQHhtt^+&;Bl*MGl?iN21dX z?0p_kZXbs-5C_VIEhwi7qXcnmm%wjhEBL+izre4|!Efo2@T15{%4OK`s&^;+wt^+Irke+};`+ciRS&jQ6uIqj(U*x z0#@0_R#YLrC&w;>d&MGe7A^ALs(#Ouj4Z=cJHRU~8BwAOlHEpjnvPW;n0{zg2P?Nj zv0>Iynm-Zz+y`7r1a3-j1ole}A^xKH%UZJo~}=EViIu4lF6lbeCq zh>f%rEt-Y5edyQ@fG~}UG92$u#iiN7qwJ&@KbImMJ{fzg0J8lMif4~R{1X5jFFd=< zzRG>j_D$UBbf^ET!*r&gs&XMBdQBfXbAHu1iv48PykWD>hI5mBgC)b-x_PJ0s+&2o zdO>CF&E*}$t8I_avB z!w0)mKCO3Bnc>0P^Y~zGfYHGip!SU#e1I|F0xi`u+q%f&C-9NR|6%Pr0IMppzwgZ4 z_U?W6)%5y8AdLW_gkC~5ASF^XfUZFZf+8SAvB!pp1r$_3l!%Ck1yoeT7CXBZ5OoEY z)z}crU$HK(?fcEly?KuZh`ZkooO@^H-OQY6XJ*cvIVYrFYYGmHH}RMX;@trKR*V@J&9;K`+eG&gg@K!6%8Y66wC`{3BD1uto~& zC6_|M3W3=*xbNin*pB|@og=*oe>30{fHyw)r@tb+$epje{wO*YlwVJt#=Fu~v||9^ z=S_)=v9O#F!%%QwP(_7qk{}J5!Kfcf(p1^Iu`Y1)uO1QSpN)n848Saam;W;O*8t|l zB>&x`Ga~;Pvu2&?-qV5RbxuPd=8WeweqCoR`yAKXalxVt5}&p;1bv~J1;ICgGJVYC zmWg>@_49K>PDEaDbq?P~VGu+2P7^^#evtKa060|f%7FVF{?JH_VStC{O!&_SguamD z_WQl^E%1av0~{9&?w1o%Cu|Ap-+wpdr*(>kbGw45?C4Jy2}G!`^%ox&DY zx`m!$&`jvrvVgORYZx%@1LeQq&LP|j>Th6H1lr(ngaboNrTuVX7%9tctK~UHfZHMC zw}AJ%dT;hpsMm+K^$~1>9_3erxvM%T%eND_d;Q>D_zwVn0C?rg9tF8UK(bvFS-yF* zrVC0AHTb;#K{r6j3zmQYu4UI!Wi{?!v{fwc2>%|_nU5@n?U{#yX60Umz4;C~O0JRb@CzyL*875YK>-1rU^H}Kw) zQ)D5ZBGR)$K8blox`GCT|SW(ShKs9{IzW-{10a)P3c zIoh6uiN~pW3VE8SQFj_rntzt~SJcGow?F)&0Ve@G{MW#LFQE17vfmui&&P5iFRehJva?5DUr%TeE~D+{54Y^0v9S^i8FCKXJT^wn&ryeNe2FhguT${Yc>5Ukqj!dl5b9w7IwjIoX|h#Yeoa&8>$p(G)oaOZB~_?PnrV z#^8@mg6!s$N#}7z_xi8>p3!NOz7n>?Y&7XB{QPwfg@tlPr<1R3G~-7Ot`2UYc&y36 zk1sQ&0oHp?O>4J-ns-we*JXDSvl*S#{FJaVGso<0UO}RFQ}aTt!n~LG@Yw~XVHRsq zEcr`e&M6ALZR=>pZPa%=&A5T4bj9DKXydafDPTpB2FiYiN}@s#@jPl=OJJ|eFz0I{ z#h(}S-b<{00X>_Oc+#!FYy~0&W4~#*SS=DU?}N3H@_bg}yg}G5k_VMNVQA2?O;`xa z90Q|}X==#IIUaav@xNI}eG`$2c@T>2Ea-SXt;B3Q2=-2(iOM$!L_&i(IexDqNb;&! zKOmxa+e6HEd6EASPiF?q>zRv**ndG zX1@~cHxl@l5x7rhuady8ME#9qUSP%!E!sk=<%j8@Qx1>M$kxcfQ`oc$oSmS!xYZPa z0LP$nlfS%-YC%)&cDy; zNnuXd$kA!Vl;q@#G~d9_3HyvVGtHYwOOv?O>-3%Yxv;FJM(LPo$$z_$W_*OE3(-?D zomkX#=2y_{bJFRFCm#M`&Ku&xlg`5@bH<*0q^xQt?j|NoH?xYmEZXy1r`=KMoS&VO;0KWDoe|@p0R+veV)@4M4CU;R;p`_o;dD@w5 z5n*o=b32wju#mVCy7$YdYHCYK=t1Iu%3Vjb<+Su}ItF&^5YD;-I-o-8NgHU&{Zy;5 z!uCnFR0z0c0M<~tqSV3!Cw-K#y;Q%-(z@#3nDk9TE66Ei1<@zlqs&PV?lLQ!QRZ6& zYe<--f1Z%DtdTSnMoP(~S`QG$K61?CjV;tV-nS+POB@hu*EJW&d8)29elBqZ{5Jts z0lax?7yR!5egJT{{CBx7X#Mh`^HjwWxzDcXeVzDDOh_eV!t94QclAz0#SdrCpT!NO zVZQlR6Sga#gb7PN-|RJ%udGf`5M(-iL&4yn#I0&@zj$G->LaIE9@HQ7WMAOYl^(gR|Jb&KY*^?&MBk%6en&*cy00-SgxDuJJIWb-j z5%>!MB>-<7$nOacM|eY$UI~%@+%uWhgr4pa5$je**^*Y(7+zse?O1xeA)??o` z75A0_+`X{%TJe6e;5za49C71?d%b=plFh|+G$?p+hLNO=0BKt zJvdmr_8Q{#5*4qTiFiFCKlwtwo{+!2Enl1E>s9&Ms);{%MTWk`p9jzee;p~zFBgzY zRhWTd5C+>spn#wVx`Rfl+i=21!;H}WWS}}8Dq}2`2g7p&rmhEKXL<*v#aP2RWUw1K zNPXtezbIEjuwVj#2Yj@Uz>10no(=?00F=CJ&`J`4*7uHxxWZ5aB!ko|2mPIf_us~< zA7`O_8Z)=CevdOeqX=(fC%w%|U;z*-K@x*ul(sNn*Du_pqdohnt<8qSR}M`2sP! zX`0dZ@J}+#sYtO?s8U;k89J9+KYyMamSZRO6k%A0?jxFRVHP&NCP`Vj>6i~L;6rqs=;L94FlbDpRGVaDS?)x8`l2wdST0$5<(RHDFwx zTt6NjuyCUm+gNk>#dk}N_T3E<-&>CM`_)(rhRlK}a7_^BcUZkmQeO3TSZ93W~ zXTt`L?fA8EneQL1ejZ`YB4$Bju#XzQA1$2%EaI$UBf~2AlpfMn!xk{p3wAaCdR2MUz%b3Isw@Hrcs!;qh|ZJT9U;MPz){)f7nk>&!?$s`+e>wU0cSV_sJ(% zqcIZhz&8X&_$Y&63DfpK?$CUT>Q)4l$Xd!!H-}WRiz?Gid|aB!Q}Z7swfmW7Mj$T< z<;WZVARV-cZm0(4S8T~}{NPVDX6i6snbuEc%;JQkQ&e;mSw~M{4NmgTb`}IIhkriC z!;^V*C;9WU^X&FCZRjzRy`Tt|zxndt!011eoVmrvK2lR*lbP5z9YTCePVPGCXvclT z?5x3FJNDM5fx=lZ!zt?i#iJ#O9X688B=&aF|x;v&?=^;>~ z$uMNL1KdF4b*5)zT$+$Fz7)u2=$xR5D%Ax6o2WUlP@!tAYUyrqYMxTOw6Z&!$_eU|vPGb4~eRe#EAmUAm(t69M^mJ$eK*yhqUR8@1Z;mOLe z!1?-_q`jb~e?r+lTJ%>~_`x=*RhZ6sB?zU?JA*~f2b~v!DOfwU1<#Fq8sdL`9vugU zcRB)MB{|o+6>wv&x%Tuz}zYLjRx<-S%5u ztedHZ(H?@5@`+}|vS5K7hU%bj{4RamF8qbDpT1%TmIpGn6HBxD`Lpd*Z3hXxM$$Xt zScC61o-nW%@sms!Qt3!CpB9jembes>1E!gSJ;!|$oOzc#0uwSWoMEcNae}+%z*))a|dlzT;Pv6 z`GZ?$7m}iu9}5SSJtI;oB@lRX|Dx@{vhk(mWua27%qrEuT-5Rx5`QPsL39@8r-bR^9}sD(|k%RfVW?} z5dKAg)H6RDA*gKMf~R9il~KlW|>q~bmm4|KT(rWUY1>LhNF;|>S` z;LO7Szc-VdEXv9 z)^knG(!!cAjo~atmg+?=17LEsi1!lG5f)GoFI?;lS&q%XvC55m3I6v09|62_Z27C9 z_|Aa*MbbV!UXD0RpDP`!dx}8ZhAaAy0XhL%pBe=6FpIJfN(^$$QMxY>z!@kQBYHu& zy-{EUAle;3x3yuU7+1;Ws*5O;zPqmJOo?YLaPZ_Vron$c;1Ym`C;$9bgp=(Q3Or}j zPnt4)=IP>~QLVU^pRjiVba(j&3#qadhj7t6mg~bL<>*1!tkcxazRnKI>xV7@y6xf9 zJVWBMA9+={k-x%koC)32fJA)C5kBI%po`CtIg=*On?4K5lhlewI8yv@_9dwEPYJ+k zjEZH5s;A(*6I_2x*M98!X&Y8nr6^C4mh>;0}R(_t^<+Ie_fmBGMeQno%ZV!AX zwaf0*&hPL~?k6AGb9SUw?o92xL;T6#?ds=KD_=jxJ6BX;dtyLw4#<O zW;Hj>5#(H78Ph~v&$C|4-#pJo?W$Io$ni} zpQ4{>_n`Iu(~L4}MsTQpnty&6&%i1Y?uAcJ3pJ3`9(rA*k)-#~C#5_`q7i@ zhYPB;Y=~Z|b=HHl))-<{`np&_7^JMV2L`%0odZEEN^8S&QYxdJQ}9Jj~Fam!rpa9^}2d-bg6T;rTHt#LQ+qu)YMzv_HZ9a*0%r}({Y=P1< z7dx9w98O@Dsad!!s}$ol8yD#puI@saZv)Z{xpB+jza6j^;N|;{Z72^QoIH<pd-W3?)G9aA^mO6eF?qXR#jljX1x3|Fm4B!QT zhv(UU*Ogre|8f0{t{0Pbw5l2_l-zy-S(pa)^{@eXc=hlY{Lcfn1H5`@hW~3om1|#YoojzY)1groa(;#=kJ19H#ZpCM<-(|#gT@N+qYl2tngH%)5%wDx?Pc^d zS_ra8t>9Zs<(W+i(kL-TD!=A0Yj(uwh zg#R1BPXKQnY|VvDBtR~JyX1LYL?%4; zC$59JGJI6kQZi!+7XyMoXGqz|N{f4xR4XL~)LcY+T!|%7uA1({!iU$?BCzjyHH}n9 z@U>7W%;;2$)ax#l^)(H6m~MXa;lB{j0PyPTYWSA}Ji6k&MSV@?_QI#)7C;nBY!j&T zR;qF#W(cd%vZjh;O%-7-acip4%~zQhUx)eOZx84L@aky>{0jgJ0o)x;f31tUvODn{ zYBpD*#%pRbxs-0=O5TqX&lK^@6iH<~M!DiG;u`R&4 zZCXJvkCNMI`K{1@5%g(#R!VVtfj>1pD@D(DYxr_*)DHb}1U9+!IA-f}xE_OTgqD-e z(6LtUB3O61tnY&;k4NA9^LDCpS4I_M4*=t&m zts__#X+a0vVTP(phrCP*3v<($h7GnFBm=O>b~Jglr11(_&voYCDR2|rGpZNvuSFbG?s+T9%QM}g; zOg( z+_;ALHgz3)kt*Av{XIzgV6jpTN@nQXAlmJOt|Iurb#+%sd@2?kj?W;({V9B0K`{@b zACavMapuHqn-_PjWL zQ{g`cZ~?%>kLwY>7vaYM+$Gxs^YEMPt`BMs!w-UWVh-9OWl?)@Db#gr2~|FX^emTp ziK=9!8loxw!1b#CI#pW=zKZ3~F`_hG$9eNj5TDc-fY6X{lJRVY^0i0=S z(whuUyx_e+%GF0C%mQTP)t*{4N4`EOpDZ;IMK* z!q6+)o3d!fFToVC7?PhFM3$mDc~+L{Q8o04s%?ctu>rXl-3nr{7;$o;+=cqXso>nA zd>gn$bUp#!$r&eZSvL}O0H1|rSwkr6D(C?~VG-AOjV$Lu;9ub)Yg~Z20+0gm`f(oo zrGVsi6XQzspFvzHEH@xMehD$rGV_$JMlM9(GrdXqFW&N{Jz`?l%6w-d&6u0-CGal- zTnq5>ec%OMxf$W3=R3ZB5*FUbcd+acrztNW-*z;}&?5aHY%Ln5(ao^fPVstXTKeWyx?TNBd#8~nQf?*P1h_zC>q01g7U zORg^wneg}}aeXJ+uSIG#z2+)1%8wS@NR<8Pg{A&7n&Uv$UiK_l2^EaSzLLi{40ab_ zs=4lZS^m0<;_J{k@ShL31mNMh2L5{i4+FS67M`Jc$=`|hv!P=~aa2JIW`9PoVOYnm zp~^zDAg#2GQ^eZj(VQYfg+U=)I^m4dm~CZa9Plyml`DhZSCw+l!yu%)aN;L)%| z;vc&>J`O72?+)k#@bJH?i=m7|_!PjO!N2uk_)C#uY&;$#5QczssSn$O2i&3u6kxBqSC(iuGWp6V!1JX}$3aO>xiczzw>?*Zrw@bcsL?uQ_J z^!!FlpE;jXiu9UG2@HU)A)e3iWmL&%fL%|3*Y0i=D@BOo z?r8JtQg?ps?za0nmZJP{MP`M0KEEK3I964 z(d(1bKTb0^39RN9WY)3Ml?ERtff2rI<~zp`IF3S!{nT7nZJ0lo?O} z@bX^+|Gj`CuBSYfs<(jQrvN*sSW}G`&&Sygkd1ZhN<(=a1|b}r=rAi_;Kq_}!_>3k z_=~D&MIZAVt|BL>PxR4IKFg184#ABAt(BJDszg(J>IK*u!7eOGGXr3325AA#tYC|r zq***{)1ouY8QPiV0x%)#@hs5F94#EQf|l(MTEsrirCgtapULGyVd{7U8A=~lfm_4P zvi%RDjNZOOyA1myKnlRC2i`=Z5k4YcW$Ib;I4_*@!5`;*@EjBw2J`k?0W-zGVQUK0 zStg5M$KcZp&VOZAIK&*Z2``StV0qUJU~Gd=o^D{m%rk9_g})Lt4Eu@_zZJ;a+uyH) zpYMzAPr~o*KfupDJ2)HIRVd53jR@R8qJBT&+eqBO2=N<98D=mH+2IgYOQzpIP2B?3 zak6v9R!BTDE|2dccWSEA3E{2)*Bxf3RM450ev)|BvJPEwi;bocuF6e+K|=Zqa;UNl z;amQ6`Xi=Kp5xxnn2Rs8@nN1Nd|J^ErfLKT-M7#?>Sw-R!1YjWj&^^15GFPDOa~ zpH6qIXKY{{Yp*QyTNE2_Lx&ur5?dwfWoJVA7k;QIKO+1yAen9#E|g{Q_(so zOkAJ0^xM(1=6g4OX4KD}J9gTnnbnhKOht+BB+7mM0FGmI)4+ON&(&kNkV$nlOULTM+-w*gJ7L)9m8E6nJ@b=XUrv0yY6W{DV65&LI3FfV&Hm?3s#4Ja)SB z;wuvG4~unO|0!UUrB@u}gsEExzGr%aDtr9}xXEeVWCd@wbX@kq9at*@i7!96XnvL4 zWu*+qRm2Z{C5~m9Q6FQbuHlBF1xXMaD}z-8Z0c(XwiMV(&43Z20Glo`sah@DW!hEo za-9SJI>0>uuUy~3|2x25B+GSj(_)c8^1i9r2{De(=O4oF|!`@Vtj6 z1i-As&t-CN@R+JBhpil#wB~JvlTu7Yxa^zrie-ZXWK-uo3zy|TvYd}CM;}Ef^yN}B zN6j(P|6ytGLafz%(*nohW6R{1F!`EtmC9!;9JPx}m{*F`+IoTuZW*kVU}b!dtrkpY zb52nNC2Y9etsj&-CaX-D3x5OPN`N=taC_#<5I*9(KW6$&2yb&+YUl8q>Z!0EwT{7@ z`QMxZ)ekwR!7P=xQaf6WbN^Jar>$Be^KC|&$@zZqXY(C5bKdkB%EyQ1;ufM&reAk; z{66QE@bmtB6W(6C^Zxua!rKAdo&3R(+x>vspWixCe->NK_zD)X)$YkIbjIFEza~(d z?f)9ZUK}W(8E@zrZ|NCt>UnSJQEI+uo6P~=O4j#9+jz+yxSILz;A`CBu*FITa9dT*nVPMPLccN z+4IC6=0=#j4dl`@<+^)heBHfP=C>7TdHeGh;eQ9P7vR+^$FBMsoW%gR^XQxR7Lf^$ z;feba@E_#v9qShcGQscr*8nomt-e>4t+4Kr2GJp=bG3q&KZNBMq!hW|N(bq%{G27h zuzCp$^bOOuXru4cELBZwMPsTK!L4908w{G}3-bdnnDxcwrclw%TE;yZj^pyR?(AEY zKByISwQLaf4^$del&=UKO>hGz1BYUi^u@SYC+ndR<@4r~d*I&!cpTu>`*X_-amdmhVma&BV?_Bo?C?Z@K7{yi>!}03D_cOZ$8}r0>MogY_iN+z zKLq}vfDwSi`sdYq8sfNHbQC&$z}+8&E=;Cpk+0W3_QU@L;A=oKe!lDe7{3}9zu|}B$443Xy3>H+ zH083e@g1fHA0d8ANHt=%aI~)+Z~}lL4z0MrR{$&~Lc618B8<0pFg0^KKxMW_}dVOc-@S4b)<%j8`i) zoB6;l>4c$7`PgW5@vd1CFMqplRpnHKCj&gZ-Fi^YK^%8S+c$??ybm0Pw@}Qtj`dV_ zg|%Nfo_^;*K>ruV5c_F08#8h7Y^%Z z9523~QRqn+5+^D9!rBc%FwK_+ja!4p>R{$IK`_lTIL}Mnb6F{dFI?zZIJE0 z6!}+)80e~p|3$#x0bc!exdFce#sIi`A?e<|`=`omcRValye}x)eFQEyO`9}h!1S4u z=7388o%WmQ8Uj0aPf6AGXNo{{4O$h82)2?GuOfrRbCmLJxIouN_(rDo*4_`M(v+uh zI0>Sx@`JtjKlXmm|6$OFqdu-aH~tE zAkobmWj*dj{dnW&2lz8?M7;sLdi)Lkie=Cl0O0OG^J4K{_nv%JL<`Sh%1N#M&_vS5I_k!ch&sU#H}=An_YR({ZM1IpH6JsXqjL?*?%J zXiW&>3L8WAZ6W9OP#QJ93rZ3#rO0st>l7Xcl1#2rw>5N9|2xL>C=- zxdM~#IL+l}vH=oCny_eM75Hq^F>?JWs7F4x;N;jQk=+Dhq)wO{ndX-{d30-1>@$sU zR&i!QPH}cYUU6@WM#531bAU*Ol4`xFb{k`X#_i>^cvP9AS!9U;cm zakEYvJah7_De}))BV$ySB90mUUBKlurZ@Xf~xE!3nk-kEe=!p(&|Gh`uG6lA;|n{|7W-V z%GUkGpbvG}Znnchu-|Ku;^!jWd3$p@@Bn8-p+N?qc5*sJH3@h%D}nm~w>qP$IH zk5s^)4>7_fVm*VyG%#MtNpyy?3r6MISOvf9Cp8$EGz;S>l`AFUM{vBPkKq`Z$3ogm z$jH?4;|TnAIz5TZ@bGDVSeA1$@ULxJ#>VvTmD=-RvoP!$3oYb zDA#IPuJw3@P^2*{N$D!`6;ZzA?RdAp?FK*nAyRrU(jB*UuOv1Q9|&1RTb&D*YJXmi zBJc(%$Tt24_4WGo%Z4bt6Vm>Z0uyN$6ML?ekk9&eu2vU$S1GxkyM5<7axIN@H}STa=tmvmyz+=b;Bf`wxI318)J2Kq5lbG?-S~B5PHmLcDO`)1kK)cFSm>%@ z05A1fWz2VcU$_oy;WQ3&AGL9lsRV5O2*0|9I2?CV$i33&NH!t^ z@rmC$JidmFI%522c0zKzJ)Er69H=S4238MwTUu;vk-cgCTeZSkBsweoYLzWkOKa6E zDAJ6n=_Wo%Q6v5wv;Z#Ssy?0lpBnsL)xJ}m?^WxKNW}EL8X>Pmh+o4Dnhxd})YrWp zDR;85ZqZnbYq<$%Q7x1Cw6n|Sn2vrt)W0~|SbL~=kG78!?;3ls#^bB?KH7!s0;rF3 zw9}lzT!x)7@0{!S;RsslR|EH(z_M6FX)FF)mAKS>GM5F*L7^sUy13;t(a^0NoHm^5 zBYuq4R)KU>XQ{IsT7QCC$O&4J>@bNCtfi=$j=5F~Uq=GvPQXgVben3}da7na#~jXk z@plfv#;xutxBuT7-^Wdae;VK{fY-0Cf`1v{HUM{vUY7kTc^|jaUAIm zBwWMOvK*n+@p8oA@65vhuN)KLp9+`_;O=PU*zA@=xhQ#_l)PhJh_?wZ#R>dT4CRvR z*f&&J1$%Vb&t6gf9SP{|uCe^rLt(`@b+lc=8Be@OZ@H_cOK4xZQII6BNWHK){s)TH zB+JtbJUu%58T{V^egb&q;bpk_Ht1LbaF@)V5|MapcK1C?6We9V^mA~=6?%a|L?7(N zb?iqlhtmA2_GuLSQ``A5ih+TnJe7f!(oP3Q&Fok(kIvOPv5x6X4~)owwp|S7AXG4d zn~Y7R&fi(;EH*_be{3zb580+J;&n%LQmg=!wy9}R+9uM^2YwJ%t#cS8H$}qro zf^`2S+H6cc9gR%wlPLCF-$%_d&DIDGu=c5HNKwMz$2#n06--~oB=@M{Ik)|9kDuGL zfxiMU5a88U{wJ^pjqqsz?xr0@u1frf$Ld5nUQ*_vDX=KA%2a!rIZY{Mh1uG4AX|vsx-a^nnGLg^<)tfH# za%=^DvSc_5fd3u9KLD5&xT6&>>dHq5C)?Q&)H@MRPaRzphXp3k9Ib2zUhVm*d6C-2 zJv9eE&ds-CO?>=R!#@XbF2Kw8P59pjcy{i;e^b(d!}4v>`Pe{Tr@wPGc#Tklhu32H zsuqDs;#tP^}k=*qt#U!SH%LTy9sizuW&g1F?8LZLLVh^13#3gs4TR4dirDHBUA z2-EhlGJP8DbhcU4mj9;O(9Jl+M{(+^>Ya4l7174kM1yv*zt|gi{LC0Dfp+RAlwL~s z&DdhGs()G5dqrcs|C|8-biho2SAI?h7bAScbK}H%m*pQaea_r@l2emjaV0;p;a3$7NPMPMiGdV?cx@^em_aLA48>_&X1)ybEcFEDW7{R3JAng6f^g5=r72${ z{2jn`G+EyFu=bhiVK#0q^sldHU6r?33_a1bbPWTo&dsOpj=0{>GvVI^cnaW+r!&{0 ze*hK&xSRG5*&nuiE8i|YEw9aq<7wXc;@U!fGh0I5XQUa-3h}y%iPv2+^sy>JR>BZ{%DvuMc?rmA#6(~!M8+SMS`v6}6yz&mb3wKTc zN4)1M$~$_#u-4r`f06|C2KjnRzOIz75rV#U7tc#&)Xnl$`8(lF`W{rs>Qy)?PBYU( zKJbwYaCcB>e~Zr=&aWAQ+G0hWhQ%r!$~3DtPFH$hR1K}{5$b}nQndDbWU^;V*;HqoQ2-gV25vQ@d|Ul0FlfL#Eu{MLHhZviCh*G}laTtgUEVT%aw zpvAna4&jg4{E^4wT5+Ea#`t>n7E`{2=wkqyxGX;xG16#jv{@l+IWemzZmVm)9IWgu zoNi%-j$J~b8f#M+f)ak&mXEpIj13g~Nf2X1OBdTK%d-f$R=II2;J*{F58%;B{?mPT z!X3)mNJxTFYJHez&;Oj&jJ)d0aO(4 zd`K6TV~0B#iUPQR8dgbsn;__8E;tqC;I~pNJ~EZHCM5Hz1MLn_ETa2xN@Dmbp$7%3 z@z4<>7{BN81s$pkPt%u=VgIhJ?y>D$b&C;xL3Q@n>|NV>$7Z|jqIYbc)%tZi`kKvN zxAXsHV+l`9)zGnl(deso`71VUi?~blk`ZKQavupjiSNrv0`cQuCNEiFduZBniV(DwPwbX$8y8%1jUo2X0#%J<<*nBJ311{0sNxs0Ojy?2);I847E1u~) z#4-pcw3qpC&>e!%0os5MynH?+VZB*{TISv~SCb*1i8sxn`8co5pimMezoZyv_^#B- zbL>>V*Mr}2`SN9$U>+oxaMd|9%$ar4HGU||TXRpG4{{p(HvrZHyng&W{K`h?W&?1y z*IoZrzaq;mQ;R=eNSyD7&zdyFTR~&&Hj|$36VgmB6wh5!&2vs-V5kUK?~8ibM=l98 zklUGDV&fKYcjZgelLM0zIeLbks&~=SEV9fOxZRhXVX={x9||{fLBn&SeXXD$gVyl- zpu9Z=9h~K^@#Q?=)9&{{IIuO*P@|{{PPO0UbqcDzguqnQbQLGZg=B(0n)Try?Bjgt z+nHFjYkd2xG=3uMZ5rysTd!XT|8;;H0bW1hHIjAL;daJEKY{$&yh+n%&K)#s9{zK& z!|@p6Tv0m*PQRe6eG7ymQ!o$VDvKU;g1T(Fs(mv5{m3iEGl3)bf`14I0=)eBz9$Xg zFJF@FxCjp*D@cCHj+7OFh|_SmJ(%nE;O|u$H{7;q&-}X}LbD404(Vx1qvYxfDt<`{Gz5kE<}| z*F|sw3r(V3agiL1B$Tx2Q+NE|7jM_G@Sh5p0`ThfLiiT}lFQ|e{{;NI1J|0faW^#| zg4y@6hjEn(o1GX=MKD@HkGZ+d3^nE{_rBRZx-O%a@QQEPFY(!lyga$lX81WiUjP#E z`3d18u9HqXZ(hB(ACz`zx}0h&ukt+?Say|qC6_Wf0hiPiMCeC-nF;-xRh4$_SRRn&KpKX$_T{2RB zH&1-94ZrX{^+=KOIq5`Cx}X&{qyCGtFD0$H=UT4wnmV;wVHTyKP*829&1Itl(1=25 z&m3@eiK_ZBLxU5@pIn#zBdnwu%4xwG&W%LrQzlSTM@+AlBmFwNdb^}F#!U^DHl zn~&2{p3jps_>B0w;FCP|MG+g^PD8KA)OJ$yO)yw-l(rihwnbcEFAdvHshX2vJFT1m zS;_a&t7suq3}8WaBMDteN@*a%!kKz&e~wCSW34b;QhQqasQFLI-lObJ#H7$jPmB8Z z(Olb%oX>iGK+R^Vtz_*g92LHv%UHYFte^81?b)R-)rYXb*|!p-k?4T}e>QCcm8TYV z1s1>O01Yry>*@4#3TdwjpYHTyRe4;6xQ`ZL2w;nb3W%sZ?M#yy;grJsA%14JHinwc z)ud;%Y@qf6R1G`*Og%IRTJx#-6J_5L&F=XQ#!NYi{|0|=r{-VrZr?<;XYuZ^jhavJ z_ZDhD!he5&+M9S7s)z64?|%~`RU5-sww!a&Gurb?(*6qK*gel}e~z=3*(_k#9ihT= z%xsJbu4&@Q5SCidpqmMG;asy-26&K}INJh0rG}hEe{q+(w=(s0m2K4YVf1V4{pc5r zJ^*_T!3{KWEup&&dLFmrb2YeW5K0@wuBYG)uOntQL{3{7gRG&xpns4(C@|a!h57`| z$k4EvGB7$Wt##_hBx@wG(#;H(&hn~BTJ|221KF#*cEn+3de5wOnJmLe|F;eO5oV+^ zY{HH+8CSW$eqcc08g<&zmD?VN<#`Pid$qdZ%W6z&HbLuQUss~MRMrUf@f6qo5W9fNa8oafm9Ft zX96AsxO7QZx8R-+;4%Prn?ICvY3$STEg};hU;ZNLQu$wHc?A7Fv>dz$A{wkecQS&n z6Ufj`^;@<>B|Fs(hIrknir4Al`705x=VaVxGE^<%HY332f~(jaq;f48e+P-ICD-Y9 zkgjXV*gHsYEm?wJI(dQDiTF(Za)TxUwc>BSCCXP?J-FjODBI3S>j*B!i7U0rRoeKK zT4b+k^>)C##lK2lFfgo`jx<>X>)@+UuO|M*Bt;Kg61j{7ZXglRqRUBSAqiYbB8y1i zDiT>t0xOBLnv~IE8XgR->7ZjcjKJ={)nKOr8u*}g9CzJuQ1PqQ^*3$ouUc@O-kpvN z#9kx**Rg2rKu@G&p3plzsgHd^4?d@lckGvme>+IR_)U7{W_|1?J@|Jubr&?XM4u=A z7cdzGUL^jPz#1*=W_RzZJ1-OSIjn!}XGoxlzn>zZr-}9&v0f!wHyh@5p(LHymlEwV z{=NhYL%c51F2)nLLa*ha^R)A|(*E2H~Xx-0axaVuGF@}2{(%`AZH~Za5`(m|@R?%Tl z?Khp2`-bQ}X{B*33)~Nb5BeF}Fg5_;ZjqB{wXfJe)&7+PraP^DACnZ&tBXR@gUcYj z4ISMK>UGD17ih5%w$wsCB4NYh@LLGSu`? znwA<$(W0S<77m5fUipE6SW;PBTM{WoH*2y-icwShNSr=Qga2Z{hcHjri(W~Qa4`SdaO7@am6o$oP%>y2`s zV|SyP1@U|X=OrktX-tE{Merzsku+>#Nv6?Xgi9q=TNhs)GEyzDXpawSvKDdf6c}F zGeIl6v8ySLN`ZiIO)0Y8>bA=I;E}W7zZP%g@$Jg=h*~wIJmgAnoiHT(T z%;{W_I2Xf(ijfDwr-X`;G2-d!8|ltjln<~^RM^+^mry+vr2PV|uw_ZLEAec@f9nMXJ;7}aWRurv|FR_j21bz^Y>qAk7N*u(H&k1gZ3N~a_x-4JC z^3*0OIk@ICv2IG;m6@Yq=8X8qKyYTu82eP={rBCwbE*BgX<2uvGg zy{1O5Hu`>Q^t^|hvynvh8{yq{+L_^xeBsB`99fF)7>;Ew{K`lc?IQvqXg&?0cnw$s z^NN3uP@G?UM!;oP8ggX$HUM{zfA9qSF9H4r;N^q(2Yns>J%Dvd=ansXzD0*RpR1~e z{KWCtW|oYy6zc|};L0(_j^aYDU~HTX5j*HP%9Z)Xo`~oBU)dLL$rrX+dgmTL{u8<&VZ+-=u;U$u zKNoOBy^9IyVxw8llaA%fbW5I$W;UE8#p;R2{{GlaXu7^q@+~lU) zfOM#hg3*I)&s?Xroxuk0)(``;j_gnbYr+tb8;PVv5g~r<@4dGk>cgg9C$i&A~Ie#5e zzoIkn`_`um@?hg~*;2^=S-n=V;+3qAqV{^!D1OA~!}VWvhrmBHjuaDw;2K>#7a@O- z54IBidjVSj9-dFZza6jxz}>O%OgS>1@>=*9fu_EM7#^CrD?u^UUhf*Ydkm0HKHRx_ zjxfhKpP{09(OgI+D@s+Ntk;ZZ{s;X)ss<9}X?D|{osjNDrTSsx{tEoQ1Tf=8xlTA@ ze|L?xiDI0OVx%a_DQY>dc%`wm#Qz}Ds1P~huHJ`+5^Mq;19Dq6lWIv9$&APx+=-w*5!gzDNzyp|$AuOF<1e>325 zfQQ#}@V^dt8^GPM`a$m_;U!f(q}MDWqiBsj%Bo3Itbwc8$dx$7R|oDgN4{ZpmBe5< zCkEM^sdplaammh+1i!#l6-j(!&&KJ0!$HvLiMnzC;Emhs5Vr!5jOQWa$5Y{fber9D zS0G)F{%?T)QNW7;uO7aD|6hRL0o;B0yzCFja*8#soZ{ZZ^DvJtimO3&+qj0`R;OBZ zsD%?(vGFTe;VPE1k`*cHi8qFWiT8xCQT2rRZeWGW!#T^sMY5tka4YIF0>#=fML7|& z81k+VcFt0kjunFMkI$tmk@eT`T)e%P!+#s#K7dzF{_(2_zX9MbxgJGi!lQShyje>* zr79=bj^MoR){50(6>Gne1y?a=B}-M*4m+`;lG>egXmxs1559$st1A7_XLcU9LEi!P zR&a*Rpe6`v3iz9AI%{;xQT=?JeoutI9xxM-SdPUAF9mSt@t2d^Yqr~7ewOg{RSk*=Ng&Y}A#e}y19uIj zvK`D9;_Z+Je=(pdAQAt;2oD2rmn=6WA`>1pM;dqWy`U57h?dAxtnyV14AH3ekaOZfi{U@yhn^R*qO z;zu|N;Oio1xLh_>$$`9{<@L-8lT?b8r}Srm*e>lZ>uXA2p0en`+Xha+aZp- zW7Si0qI{?~ZsTe`6sxW1>V=Bh_9Y4(IP4p<5FA()CJ+fciqc-z%L3%*(f|K?{Vzpr zd8$g2o9+Rm>(#fq-KT^BSpcuRd_C9$P>4A0j#b`kk5u2TiXm*IrlVW<&@JR*u=7gR z5zVmA?EHz@k*}#b6TF10>mbWL8~J+sxIdatlj+YP(R|#oDP_&FwZ|HW|K(bzg~#Me6WxL*xTT-l@YOl;gXBUpIow9BDom_dzh5*YcBV zF`JiS(SmvW^)PX-#Qu|FG))!rHiUd|DHgwT*X-hX5czxKAn*!wTLX#!iFgh`xEjFS zvG9ELi1piYCDsX@1zckS*S=!DvlCVx;)<-kIvD*R8~|0w(Edp=*)1djeVt#H)iqeV z=L)hhNeb7{S=M(G@bk*?HvAs}z65yX_z8aPRon#waCfY7nD!CY9SN%NR!Bl0beGJ3 zS&56t>cD+Q23LyrozYXS2JdwD&?l(%d<#7g;x$9?AzWP-iT48F=BK*6&$q?3Sp>LicJbVR{JsA268t*=y8(%Ket_^k0C&g2bMMjc zJVa#{&>aYLGsU!>v62-7-AC<=M{KC0Izh#Lx-G^u|?PTxX^`!n^Wo_+}5C*H0tsF*F)mp{f#*OC%``eFd2~8 z&a)Ao58y629}$`G$T(X4daBUS70*WfVvmlIGk7J)s@nGNs9%f5_Sm4Ym#E-UwWWgz z{M_}Fcs3#bW7WfS)WuQgM!cTgHC2O~ZuedBbVtKK5pV_|vHa&Dd@+E#W0k-BXytEN z8=(R!Q2`~Q0<4uRWfe15f?ld^FY{ZM`coG9&8z$%pC?KRV&Uk(Y*DuYsw(SaC-C|I zs1Kp+mfO>q1N%d~Tn%ozC2#(JrR&bH2`TrM_%*0%?vli(Pl8GoCoO$9BjC*v5(9@_Ex-H zz2P4Ps0DcKeDM>eaw@`80o*0`BR9F0SJk2Ie2Cu$G(x+N)o(4U zxr47w`wgoZipA&!kio%vz8@rq5JEfjmH2K39$x#t1pnp$Rki@U`r`Un-$wjV&w(a( zJVjKQJPJy$8Hu_I09r!@uyQ%quYk9rRP~ejWxO5FkL!EsjBr-~cNmcLPvs+zek`n{ z#2ShJZG)SB1JdfwBj8rU{{rBz0A3zAn)i2~QU+*B(yKFlCXP~OPMXnS_N*B*+7FyH z`#gS-JbKd1({aFsle{OTPPjIL6P_=g$BAbLFUD3jvnN%h1o9-%)k05$`ma+0wc3a% zRMNLqnNPQ3IqBIfCk>DSh-9-tkik?7tfFwNKg+=#l^o3jXnc!h!g9luoHJuw0G7*; zu5l%*QK>`7cW!~2`pbH4M42j3zy`#J-oZURzzG1}@8I-h@UH=E0dTiGUCvLf9r+fK z36Grj<+^rCx%~am`k8!4{T%vRN!gG=rR~q1Gksn?_*Z8tNBWf?%^%^%`Z;rOBr}(j z3JO(hx8Qgls@S@V=gHzZ^lO}?Zf1~lA;mZoSx<2%W2UD>R-cXBMaO^?TxhXS+4t> zSNbn*rJtgnKo>$p8z;EO+sla+*)7?Sk9y7Hx}o1v*yoJwlH0pio<)Q z&dqQ35%Rm^e~@4OS+i%&;fjx@@!T-J-2C<@D8q?9luYP8!;eSe;(EvMGw>WD+kGo=;O+h|!2iMj3n285I9<5u0$u5da9;p-2VY$*-ostF8WBi% zRJi-yMO)=}hn7c<{ULMDgK4BA{Q|v{bLyv}1J51}A&pwV00?SqWnHyw*q?zuV-nD9 zEgwSASzUjj**`&6Hbu>*eZQx^w<-NQ4g5^)UugcXe026#htsZF7d^D;RK_Gb{?g-%}TvfHit72~)uUh!00nP$= zc-^+tQ0_qZKg6s5%qg6b^0}+#?|k{fr{r8*1bB$uMU|xxdgK^drb8WDOL0K5G;|%9w3m)hs`wE zGa8SV<*0ryUXC;1KL>CDz$?dH@IMIn)Ac&%-1#%zl2onX49toBcqvv>J1BaZbqg%SO(llJ+t-K${I~)EY zKpDWpyDR+t0m=7*lIx$ZAzHdZ)hOT_hNZ5D*o81{mW!(c9n^?naD*tG3Ve`!2~DNl zTl5Q5jga^(MP8=tBFZNCw*j68c=&9G|4qQL>XGBaS1se*N{m)@08Sy8T6>61P+kX4 zMYs{?Vl+r%)D9A7Q7C{#tkUS>6Kg&kpDM)tH~5G(mW$B0aSUW>N6PYVM%ppA{QKbl z5s>vk3;ox)y`vEbPXut6@y25D-oIYHMIhl(>(c+^_TtOtLG^PdbHWJWw?lu%x^Cty ztQatg>gM3^{91m3UT~_TY-7dCh`F3}#MZMFwhFk<3&}N|RAMQUzl;m{K=y>-a8AcYx#MQB`yrjbZNF(mYRs|Bp1-Uz{6ZgHs? zsdvO}##U+?<0sJ}Kqb0$J}uxs3s(m%WZX5Q9=vmp><`g*0bKy@I$G=dprM?C@H7B- z$@kqwWWuB2nDqstZ!ij7pHyX78Jtr;XP&IInhBuZ^8sT<1yN^^JE!_fL|Ftf9@cYD z)2VM#N}a0HS=L~>l|X^`^t74!Ss^WQepi}%2`FS0#E53AV>q!K1<_pCrWcpMilA0d zN5zlfmVBgZs*&}uALaAv!T1RLen1hxtA`WdKLs!yz}>OxVT*e%acIXVDU0`;b0-O* zNm16j_>?tvl!d}x&I^0OCQ`haoTqG%B_4nh_fM&U?fFqgy)5z2)RFp#P>FH1_3AZ3@r|qiix{{C$Bi+f$LYmdHU#VOM@rRbS^n)P7vE2Dw;%ok zfbRj`IQ<2F<71!F_^cc!o?KCK`8eGeQ$NpJK0{ctft-AaiGrQ0EQNu_Fqdk7PXNA@0A#V>C!u3ofg?{E=VP7+LP7BjoqVf1lq;d>h!3;ktN+ zO>Ta*pUD0CzX^s5NUYG!G}^T+#iu4b*D8lxpn)&zIiI?+BkR1b9_X?Chs zr2WQH?&WsG_-y*4D@nhJsAtjn+5okfiUsWiwTSpsSScZnnQy$#Z?0i&3niB!qb;=6 zrg$sd=5ey!8c{znl;1`@J_Y{{z{dcu9{D;_`xJI*0o=`S>HN{w`5RoiHRFi&DA)O8 zrp=!>W!AY|UvdLEkt;tqoj(Rb&DPVbtDO&<$u<;Gk$~Suz*ahS{g~!|;I8RM?4_1m z(|<&5#D7Au#_X?7puMz8tmzTxifibXLU(e@qMmPTVkgP+E=4(F2s4!TUihB^yaDja z%hyNWBK$jmyJMAir(51Bf3&ZeJngJm{0gvJ+B#m^0nk(;OMAX8+DH`EQU-P1Me}#K zg&p!YZv*oNwGscO+`!Z(Zea4s`A68mRMpDzHtd)5IEeDz3jb!nivX{@`{4f;@H>FJ zren14LASiK|G2!eb(a!(8Yj)S4p@R&Qx)6Z%3S0{{OUp$NZANwfv9D zKhHBCGOgm6YlJ;qWQOaoUd=(b$)+!EG^c0#P``}x~&vKlkq82xaNtn{K^;c`6?6s z53F*m8B?%!3EkJ0Y1VRtJh!RQ=RwOY5#U2#_Q2|+0 zEUQ@*W!HwCwGkUCqF~1!m33KcsHj-^elv4#-b)Cuf4_lq@7#M|rkt5MbLPyMbDa5V z6yj3>?d@~6rPGqWjFiVHs?G*8kN_~i5H!O*sj@f&t{LG93C0y+0Z8|#u|Zf5fvrPv zY?Jq~o9x2RlvZHDf6tQTYewGA`tUczjV~d$0od)MojX`py5PAKz=N|+an7BHmr<9= zwtT*$^3ShXibgrN9cLHK^USl?=+)7f~ zy1{;W9;87SIJ667pJECq0Ha*($k=(Ys!x^m+=}u$_52j^Zvo`1NIi29?*=Fb@Nlwv z*4h2Aqk2k7L;Eig&aUR{Mf3Q1nCfcOl0!h$w2$V~r$C?;fJM&!%H3e?;=B3AgaU3e zHr|Ty!eIoRW)0U2>UW(T>|`!vOao*{=KFlYEfp8+qq#9UTJaUM(K?o`}AHX*@wEwihs*wLH$0XE?S-gP8vJ>og$ zDLAa3Ut=wR67%JxU?qV`mo3!VO3Ut~?mK8;3)OC<^oFRbW*QpntMv@#{*$=mHp5-S zdpAkHhm%WAovyKu1Wr*G!b+hw9`=Gt%>BeD#o7!%sh~Mz2-X3Q5{v=eEHK+Slg5fIxn=x5h)a8RMXikP(=F0xjgmy2t8~8EA_X55JIP|UfTkJgmh68xGYNzZM z$D?nDjp+) zuoBJe6mMr=Pc5wD)VUYEH&0JM@0|jj?f^INX2VUyEB2f~QgV3jWv3K&&c`Mf^&TbR zM(Q#bVVcguiZ}Hw;(Z%Up#DJeeuM#i2==aJ)+RRPdgi;11+Hc08m3(lb%hu+=o)48 zDX{m*y0{B-x&n9n1n^sS&t0OF*@fc>kx7E1{uarPC-NWgudrfME9cS<{1g- zxl=43daenhXA*8A6z+e}bKU6YyiIs0UntvU;rFonfahT<&Q2r#cfdOUr(OCShV2`` zEC3IOhn=`xl=CEgJA9IM8M_pFj_K>!TiQBC*0UF32?4ip;yQXA%;`0=v%Lkr0U#2Up+&9L{V%aXr z4|3kgN4wM@z7}vlz-gD>Kf>-UU?zZvlg&G`2FP|V1{MW@j8r(dlrlS0bEko7%r= z&gU>46`uw}*|2H}1K%V*L5nvLbH#c>xU*6h14js3Yp%XVZmPXWo#IOllx8JEc*&=e zExh?50YqrPD%_kc-li^qh7VlnTA9Is;y~TB`xaNrKu#cgbBRQpR=-pc+r<9}r z3X4R7E0V10YOUfq~vrK5?-| znUVVs;+D!3TZ#Kll70tgXL(0`fnyQd)Kk=H6x?r5eLQ_!ay|F~M`Jfwh~6T(o*d&> zLSc6Sy_9>pzz;+yKS$zqJ@B;YyV$>cfhFt9-ixQV!UP#yjuKj z#mVq0CyH(G{(B!K+vr4bT_~=-#dY+3l+PA(y?MSeOyc|GB%Y(c>(CEAVSAJ(w7Kt- zypMp`Z0fyD4d1T1Ol+`rhTRF@@67uL$L+Oh&UMf%;kX^IPKrx3s*FA#5}O8mK-}+> zocFkT6`unxG2|SuOs%H>AzHpZn_fW;J}=`3b7>wa!@Rst&dWK*yAB25bFd)x=iB2S z_$mh`rT~%bSIGPUl>i5?n-RYk@FIYRo;E+U;>a5D-2SrK#%uWr#{b}X^J?n&0`U;x z7CE5uvzLh5XmOSLKSJ}TnN9I_5xmha4q7)lX!Q#&{*X51LD~fqE!2IypQzzaf!02l zb~6p;iE*=p1kdCg>n_#%1eAf)_tl&aIDTWSIL#|8eSqRtxyHEqK1}Ut_95zjkfuN2 z2t{7A`JaVYoXya#CzwlAy*N zNpHyY&O&@8;3j~B@0Xu8lqd1L3&2CmN%S)g*z{I8K|h{UQ^Pm3o5-2Ba@$xJit7+= zd$AeT)iQjyISV6B0xabrebC=nMiZu>Fmy5IZDBT%yoI^{#?o)*>+(@B?m5EI#TbNb zwcDaadA!ms7t8uq{x125mr>u-5x)>{BfzQe>xh2@_!hu}!$%x%epzhmseN#Q{>9gn zFbY<)g!i##@{U}r4fU0xc~=U(kjE?$RqBWI2yOfiD@{S`}7 z!c{`_3~d2*+e%|LscX4J;%oUIQhwp+@B9_dcLE%I{a^KW0^X|xUZgDZ?DAi5qVg|onA4!FG)li=>EGE!9s3irMJ$Jy8}GlCYEnx13R%wWD3i0# z_7>v*1$+&_w9NzM;}H74C(+LvwyaJF!^SVESY9&^y{+yvNMhF$T#^CY$X7GlOO+;= znsTR&N-hR%hXrA5>gb;CL0B~B1~GflYlMqLk#W=IvV4umo0qR1JpSNYq>d(vRgtoyjxpl)19)yyid zA>j=$anmcgwEs1v`vzpc%NPL{b9(qoJr(z(_e+T$UKnA`ERH; zncS_q@qQQI@EeaEk68|x9ER!O?)GF-X<9AoyAU&uBfnjV_|<@G0d{>gw#ra$!}C7@ zJUH@OXCGR;jJhm5ft-3;WPuc_9zWYevlf6QOjeEo6} zF!3Nm_zGEGuBFW7lJn3$0}j7H;JiPh(iQ8wJ-9GC(?4NRfKrHIU zOIOy-zL+>U0-MKfLe57i}E)y=B=?`nFJvo8x9 zYeLIuin^ZAQBIq29`*TS+KktyX^q4~9uf%S8R{93VOTg)X(^Wh96UUZ_zQry0X+2i zkHo|A`28pB`EC0N`YHB*_!KAl>Wx%%(`<3g6<4_C#4K;m@1+o8bOT#jf(E{-EoL^F zrSGT(@4-10SpCb4@Cw6ars=M4^_=g(fOgh$jLt^vyQ(eH+^4$VQPV{=_-KB{mcT`#1y##p|sR{*9y%u4he4aP`at&^OrJ@)sml6b4e#zq+Uh{oH6c=u=V=4p}h z+;bb0``M^M&s zl%I!Kkhl~;2Eds=x+2~eu=RxJ4i=pbM`+kKs2DkJ@c>Gc`nb zSTD<0iQ;oQejf@{hxjVMJpiYjQ(!SW4=@10L*5s%e8=PeX4&n$wL>~Fsb+~Z2)Ojz zr7(N97^*l+=JPW&9JlmJc9e^PY!4O{N3pH_9IFU+jv3ZZ>z~#ysehnfV*fjdE6dtM zf_2V2UC`nDGRIlj?S;|GJ$%bX zN^>ej{1fSnVA39_pY3EV?k~-Ku(q*k6$(Ill zR`xA7L^c_WyQcHR!)|hnF;0UF!p*QZ!ps=0yC0p7Bkgv+T*E?Q0E{LLg!P0twFmT9 zX+C5NigKk2rEz2^Liu$PF9%RxhtCLGmQo5B2XOi)=QGa1^W^{@PPQH^yyz8jChIsY|VFkaV7E^G+rzg}d0 z2e>LL@7e}eg?$gw^#3@xdQ2_*m)iAH2UGv0ZhZZMGg-s1{YU#pV`{is#$YMb-e0mY zb%VrH2{bJ@o_YaKOAucHxEbK!=~=}006qYSa1wo;+ig5m|4(=tgE<720gvrU_J;_D zE|(Y@9_dX32SqUC7rkj<-+eUwEgM7Xi)z^mYS+CEhTfrme0`T=h>y7YZN6z3+-zf1_?QM&%COmHk{8@ap*!U{>pYS!ZYHWSYSxY)VnDb>9>l>e* zFmOa!W!HEy(h~f5wvC}03J>@eo`)$^d;ecs^<{y#^BgqAznxN#M-D)vzzrzOivmy zv}{;cI1$Hgd6FB??I?^_j+&0>3-1$;wDt*Tc(oZpBaRtC;lD{d)%n}@hkNn76X4+K z|2lrA+jz?UpYSAGy#q|yA6{jBlhacMR+J6znk=xA;>UA4tf+6QWpAimqp;$4&Msj- zy+zh@df?c4-iUX9ww@d9diMVx>v?u5@A)0p)c(|lKB~LavX|P>N54ZKMR!$NZk6@Q z3tD1-{dLT*0}!79I1OM=xB3l;Zviye`yJU|u8|2G=eq5C`MoV)I;n2)qIsN|Yav_J zj|pudgFoim9pl8+_c5l(z3dB3890NEfJHRROvZ6r-G$|CeyRlpGQE3(nd(k6phiCc zV>m>;#W=5Hx#>`KNMg7Au3X!S_uyCU?VlMulb;FQM!>R)(6U+9Hyk6|@flQbJmO~o z)&ZP$dKq@AzlVsz>ahGSf& z=j^ddv-G4T+UXu6#|&~cT_e`Op5!J+RDJ_>-$*lB^#`A_amIHg6SNAww>DTG4P|{} zVq{A)P9Licz|k`gHYE>(UA4D+GHf9Z@YK9T;zq#w0I5Qw4^ev z%+oUmSpL!3_W6mpkC61AIfi)8yu{v+;8=c*Xqoyr`T^gY^z#nNmj!$;Vvt^;jRJ~q za<(Fb?|6ZymaP)M`+zr&-%dd2cZkQuftmrF@j4Xo@qlc%+(%Q7tPy{Iyvq|Ej@KnM z=S;?l+Nwo$7_?A=`B7@WyiC_|U6_gDS}m@zToq<7dstVt)5SR0?K3scz>t}uCd`dX zqM3R>xC-DXOMn_m+@#Rie5CSiu)o^)fIj^+I6sJirS-mSpHQ0=MGfCA>$??o3EPF= ziTG=Pw*hv2>F0=l2Pk}2*3;3CYD<4?4bMoO;;S2=-x!+hLD}yj*hSBP^PF_J~bvx_s-AV=n%ABhIkX;1%T7fcg2G@0EEDM@UXE!wnz5U z@-ALRU6$MWFP;}=J=&H(N{)urJ^Z3xZNO4^3wuu!w=Uwk;A0k0UT0I&3&I29vl5!s z_`HM%*Z_UtxB;$#AHo|PXA8pa^gwu!5}pd#;geVyL*Zq4);TUK4&JT83GjJNJSoY# zTTjz7a0JT-|92rZg@&2e!dbo%sXmruc7|8J`D`8|2@AaW{@&UUY&FMZQ%^x&zNeF( z55FI=W8!H7e6MqW7d>3BI_!9r)U%MHpm|IOP{{87<#e<6^zJ z{OCw`$oAgk#v6(RGp^IZb}yGxasQCaaSHa%+B zFDjpSXub3Tj0MuYUc4Jub55IAG`*R=q3)ohnNAefokZMT$IY$0&Gy0%E;L{X9m7UJ?TYi_PZFhn zv6^7{1FLE+EG16B6$wST%%gyupW?G8j++aN2hQ;Qnck2(Daxb1A0NCLe zdmzZKroMjUlC$S5UNm2M54O?!bMxp*vz^|d4$>p3CrhuYrvA)DOP4OHU7`#W$tf*% zdgb`r_T?LM()22pFM{vBa-P(a62GfENay;KrZcX#K_)dsmJ01|LVN1F%I@ zNAn#vNU45Gwp)36B;QG4s!YT4DF6pQ+a6sbqR#x%KEEkTIC()f3Ch{L{Ec?HO-RGR z`*y^i1-t-o>f?FERQBSzy*+^T={tsKXhmucXRN7k(wLv&?jZvuVko8Fj zN9xlJ@qU1EfRpboH|)jWxkEfglf(!rrO_{r%6o{if#3pMj-=TFm(6y*tC6PDehwZt zqTB`C%R| zM|=a|K7cbX{lw#$;D-S`c|5$q8vO$QJ453^!`CU^pVIJ>0Da9Hc1yq&|(%6#@B%}P6;7Q_z%z5zJ>mh(M7<9XJ8IUXJT z1K@X3!<_nts`}b_HA^{ao5;1;I&>>7oa5OAJ?R80IGvt!C@pOr|1EaD3$r5q_Pl3J zWh0(%1~~2h##CMTC!X&I@X&sKw2Rz&yFJqGpi8n)oF!XaGY3|3TFo% zmZ90MM>EP7w%@&h_zdnupe*|DD;)!tEf5&@w6~Kx-L} zI7sY*ueH_)Q^v;$^kqd0yynrS5-NJcRUPhH*TEo&ONV#c0osP0DnJaX-z$+ zWo;VB{fje_43tPo+#Olh&pTb&yz?~Gbj0qx6sH{S7Z zvhr3Pzr3Kr4K-&Tuf$6uUq5ADQRaQb>*r<1X;e{Wo_~0!-TymB%H3~|ri{V!R6zT3 z&&4|)PFC*a$1gWHvxb`a9bvgfet~+T%I0XN>B=2cyO=%2v?sX^CO032vjHM|rQ9y} z-l%f({-NhVHv-VU++FbQe<}A~TmBwBZtXrWX&H9IK?*zgwAB72OYWV^7%W984^Ztg z!X9B7thDH_F$QPBmk|sMR0Jj2bq z!^!B-_73Q#Q=fA?t_xn%P)XlnKJ+tT5HVAEit7PhK>}+?GA9CjWin8%O!6cx%EUR8 zOi`W|yB-VkBlVc)rAiZ?w*s7ch#%wf0N(M?o{tePqb|EoSdS@7=fKWqM-6lCQu&Ry zjLKq^*Qq8vz{c=qPzi2!ctMVXa6x3gycF>!z)pZu?xHS`qXL!yc-Z}dTt}z8B=2@= zN?q-G5xv{iZ$a6XM$f0BtWvHpe12s6rrl2-@!;T`v(!X7v-&F2lFZWSqp_ruMN=Qf}g=4(Pzw=>6+o4xr5BmrcP7y z)DSyNl%BF}*Pm;Ao|;&WQDg*uRISliS&x z8<-2Og|25i$R?(}&kAVQ%Dicz?g_|hv)XL5%%iSN8a`z8# z2Gxx+jbcPK=ETl5=5(5?&(Y@w=LF|g&+*TljP0~pNxq=w?vRDW(RkDHO60z%;ekBX8;ODUd`W3K;7-s#G>2BuV!A4U5-jH`s z$OS2>ir6cmdXN4aRhfD@({?lKC8q6S=F2$wHgY|~+bfy2mFah}ksI-Z6PlX43!mwE zFW6)Y{C^=}%R(^sFhHLT@eQ-oi}+7QK{ffXDiaB^SG=V$D>Bdsj%@ z;gwb>fc2#Hv;A`Mn+TB*)(N<$Q_GM)E7+ z(&C={lFKiX`DGx#EaI2J{NlUV3+z@sND>|*NvQJy@($^|kDU1+Nq&f!4-zd4Ps!q4 zsd6bPr2*aQoKWH+Dz!#xV{~lT=z5sO>77|GvvaS`xjis$dle@a;9yj;7SlO4In>!# zz?Bi7C2A}%b_R?c-%4nV<~Do7ELCsj=O3%!-ysQ#hzAI4s+|e_I_|x_Gf>{y1;17Z z6I$ z`6Kla#Ge5;@|A`EY1>W#d%hL{$;DT zjKI-rTFgf!Li5|Qo@Vz*J-PfP9nV<+J9I36iOSy=z8r)c>uoN8X|dB?jWqh$iM)pR z0l?1yXPo!xVJQ;;vjIHxJRs})guPC;|Frd189mM;`xSDK%jbCuYv!FXRmgv%T7ygV zqkqN4ZonND{Cgpa-@|o2ZPm}v9xT!hLL}nF6m1YfN0Von@+l+%+S|nY7IEE1jDHgH z4>Ed#mi=$m^-Jc}&-|9feuoa2MMJ6u>3q7beZhR6GyfMXmxTDDj|)(4g_Sgrj~E(D zlIae6dv&pT9icZ9`cI*&A#%lXn5d&^w$@cYPm9%85Llq`YeP8_%HNgk)dGB#+gM>e zK}!Ms0S;a_BmM&5BLEMRb0yyvo86Xwvy)JMi|}vJ`#^(j@+mQkgX~;=KYbhBPsJJ1 zADOs~6xWRVAWt|5OBP--A2OgK@H=6!Q3pX}i(ANFDOT6TH24(pK1p0=$V@V5r)+Az zM9iIpxpnw2Po~=Aqz9}?z|>6W1!6U7q5E0a?Lcqc%gn!2FnAk=NZ`C z;67TfW%dt^z_tRARSDS21 zqt_4|+Mrhwy3{~rcu$=O?yQd7TZ9^bBPMb8634<~-}9wimqNg-Br z8Fa{IR`GY1$tyP*M-xglMCRczLLtwuxkZ+{5;C6sh&fGrG+W|}j@Ziv? zS;v`2$9xjSkH~hEXE>rrk~povX;Muc6io!#0y#QFo}@zk=_BYxK_e>kjUY$)t>kDB zW@M)J%}PL97(#Oo={R z3t?%~)x6M%HCN#`rq_rA+Y@V=n5W$UZb-sSTw0|P-Y?tl5Za|&v>`8e&@jv&e=yf--%5%&T~Nd>;tIoiKASGq3%b~iC`UXaFa45!DS<`L401aZ6^mMLuFh}y`|ctEzx>axiA--P(XfTsXX zyZnK8d_POc0r0T+%{AhlVBf_<)aB5z^wB;hz731!`}m2^$Emo%7B3z`_ko?4llh}6 zl$}5umqygrp^MwD zq004mz8PTSk8*!Dx8r%SO$VKQqA2nq=!v~pKFGEZ{!ouo#-nV`xO(M{s(10@cR+UJ|hVS2wTNq3dcG~Hj$(l$W2A7f!&G`t(FX~ zBt|j;=hN<((Ksh8a#v%ihehSCCNShqEr#5~U&(gsGaxcg3`2YZU@E|Ax1M*H%0fJM zpkFh-u4W0x0xI7~F5-)!+>1G3*hQ49KzI{qKdAPmssT0L@8t$73g~Rub8^DhGXL#J zJ8Zw>`a>__`IV^r&w!8e*YMoozK5KLh6?Mj2ibJxUUNDe(_%NmP^#_p69z{5<&h^$ zr5MjW08afH`WebUx2VV1laA__(PpF%vL(u=aD+ID%Tsx1vGZx{AfKE5&-w7V-_Z?F zzKhPM`WuO_10Cd}4*I{=M+~*rj8?iL6>72bsY9C1{?N6E-wt>P;Eb=mh<^q+2;iaW zQ8~UGy#Vnt>XP6;hJTnO1)?yax`dB5$V{a{Rc_a~WC5H|bOwZqJ%Zd}F2>3>3v6{i zWrGV;?_JIHo~Au)c7DRldffDEH&Z~-Kq5Fr>kSD+7IXlMF)YwdgXnquOD=|s2CqR` zFz?*YDO&R(iP!S-$a--C;dv)@&+g;JLlrKB}KcbDT6(J^&sHGT=4gOLm@8 zmP}M%Mf1+xEa7h|C7uoUmiQp zJJyre!#b6qP+KLT3W*0?Jt%1l+dS38SG zK5^eojQDAQ27rUtdl7#D@FswVS-WNbJ>EQBer*38x=>naZX(xUgB?e`?*Z|} zO`iM4mE#qsg&m}>a*%o{U(q~5yfAyE4}Xz`o@cSU`I`GR=6{`qqY?KqGj=lVY1RgD z2O{%zxD|1yK${1P?g-w-W4ex2&trad6=4QuYl)$zA0&RNhDQ3`e8evSTn}*Y^BUp@ z0Y3qFI9b12cr1P*9uYA)u9IEwz7D!xV+2L3v1}Fn?q-gnn~C=(plH~=EOZx(eSo9r zQRd&?cHMkGGw){Foo!Jhq@B)k`F^`^O`yx^4SbF4bgi+iC|b?A8iAtbpCo?jhDGQ> zBjPs$?gu#d`2umZ!cu|&9)5fE#QkUevG|$9y)|*hVYwKHceX=~8@*?d=sf4k5edx} zI!t+lW9Cxgy#$!)x{-yhVX>R|QuuGoe+vXQQKRm9rfp!_)i!3_k~Hp-ohO7OlOdu3 zu>`X>0)q~xmaoNnM-IADftmBsc`lW#o}-qlH2*B|vmW(!_B$Q?{2lN9EPl2hi=Uyw z8qOhJ-%9TY)b>3ZdWXh-gmuD_&!~St)(KIy{g7%nZtw<`Lk0Daq4`%?-tyrQK42Q+ zivVWe+ijdh2$raXvqVB>9Jm-02OHn5tb38NlC$jH zUER1PALIP83u3hZL6+x1K!Y2fqry0xSLEc>_?s-hGUC|sC*$3pEx#E!WI#PV z1kU%-XJRlStx?bv6P#e9o$qR-8MYhmuZZ6QxDDXs``9z4avz?bcvjMjC_NtNU`uv7 zR9ypQIQMMqBJnlWHj=8Q>Vq^Kv*#V8l;$B4J}T>Z1Zg>Z8XE~63P2*j$!{#;HGuxF z$^6#a{DWk;Z1#OUvFY;7pwe#S`Za^{Rfg(|4S9>PKc}nd5GTgPCX^SX^`fz}jd2t< zhC)+1h5YsGEX|WZJ;9KM(sAbNY@D?)A)I`VKs=fbG=ad3rAat1M8AinFB>;uGG4^? zp=^#k{lAF+3`iRl>DSW{uLE2N;Nhx0vL5a2XNZruG)LBPwf6dCTGFgi3*@$0yGU_-2^|vGL5D{ z(5SUrs3qA8H59=KOycVR@ZiwhpAol4TZ$jxw0AGW#{%}gEAiy;GlEZDAkIU~vyI=c zCpZSiSFAVMt^Oc`VFS2P^`(Hso@H6+`)RH;So^ohtj`7iAep=u@^)V3upwhe8of|f zt_Lln7tv~R8vl_9&8jSS6Ux*oA3*$ZKr_H8_v?s%0w`>^j)>x~mrC<_b<1GU%L^w9 zpK}?(5fnIR2GwqAqF*&#T=kz9E*&d?QCJ{D>!_L=AXgeSS&qsvk?~oJ_~n4>01h49 zf%rRse*<_(d0OJ}cyfd35qdST=FHl&Ba#9*2*Y+(tKeYbneq(EdN@xNSRW;>!F42n zn&i(bobQ@v%_sAE&tF58tvD`gK1&LB64&#@+D)*b+xt`EN($+lrLjCMKE{ag`ekK5 zbGjK91q_h#o8hz)j&1YQU4%QI%7=;utd|p__GU;Pq1=%8m_9Z#{?0{wJ>V9AgO4u| zhanCn7QjPn{*~f>I7{BeL)4`)f)8ikg>SOh5|xolTQ&S?dK3Fqy_JzB_Ba(cA7uAH z#l+x<80`3U@qECP%~~<2UGE$tSGKehl9j ze_BF}RqpcY0rUhIy4EYf)zz@crV+1sQGC8>cu)g*@)ADY8Qqg*rD7l@wFOwaQgB6h`$K<7~tSb9}oHkC;{;B!53@9ebe{yE*_#T zTO;`5lW*HDgM(As6fl41+_R&MZLFK?Yc1_SXqn>Me{LdJL}Vz7)c8}}VV z`9RBs5T@{2*5@!~*)CWD+st4)qoZM3zAHemGkhlUkGpFwznp`ClYvvhGUh@9(Dda?L~sfGsHls#~>%I`wsBMbSTm83~p+&z!I6 zXZmt@mgl&;>dhnrE;r%4lBbCUx=1s&iRk^tjKR)E7cUKDc*EkV^KwEnAue@hXG&I} z;*ckI0sFR!1hDkGnsgc+@EX(o*o|@Ln+4jxi03il4Qi}QoLK~c#5DwccP;6n1uh|; z)g(aZv|P=csTp02zQNL&COV@wyGvXe)f>?5(LoKAO6PS<>aWv&^(Hh2v6UFRike|h zs<{$UBogp?awZrs*+5n@B=xd|px@u}e~HVjzhoc1j3@45r2U z@nhKA<=rmS#|QZ^H2nIdc#>m0MebXP>BY{qW{gi8M3d47rv>9d)1T2xj7p6A1Uc>-Cq?+ss}O%2&r9sUhNScudIL&2V} zg?GrwG0T}8!JbGt)hfxWWQY&3oK5|a9aNRv3^kAHF;+ZFh_^CLT}2o36rsT^GY{Q6 zO^cy1W=v{qew=<8VKLceSB52xTT;9?7^q!0;5rwristKyt#!`VY$0lpFNG1Bl4Sj= zQTDKSgFPL@mjTWLIQ8S7{)*=g{B`36EJrjA%k#w2#m8q7NB$<}pb$T_9U1M3L4eI;h7EnL~O6z#QwHyn@H z<8CbNi&0ZS+$!9WB*V9hTFy#HvU$1KkcHqa1Ln0d=}@gyhh8k~q9z-fm5j6TsqVGJ zljy>3m~V);)EDqNip4Ey67SQeN_=u;%|-l7z*zvN-7i4=N`NExcJ9C{KWtr80-fyg z5m-_B;Ij5NtSGZnt)8G$K9{|a?2cMks1_1Tr9DZy-9XjpGXEn;+qB;Wr-AnegaJ;w z=Of+|&=0_alfQGf|FrdXIHGTW`E(gnW1XEX`w<80`qi2!YEDh^f=rf~VHl zf7SMu62U`WLA`K1wk{60 zs-b&{Z1XW7&$F{B&yaXqIOEuIpO1Hcw%jEhmYcJ>N*|Q2k-3h7L5;=q+gdtVqD;I0 zcTgVB%s*eAVI7u7$d{B+DA3o`^;Jg&YPQR>8hJbNiklJN0eCB_Jb_cN_Ydd};GuAj zY|qM!OMZ@Mu|9Rf*h6d9NAv0QEz8` zv>x&60XGAjakmBWZGh+O{vIX&;>TMs?&{8{^(c+%8loidQ}v^mn5-$SlXEmbQ{S7v zlcB}VSDAHezAn7`AMzc810D@ZL2G6r;l^B9pXEryk+=LE@p}Q!0i5wrb}G&s0%iet z=+jQFCtgNfev8PbAdOkfmodEFyuGm>%J)GVaFlH+)TbubiX&pM*6|j%*0G&nHFc@l zNKTDEjh#CDH1$;PX}8c*-KWJVNg3E`H>t0`uQ}wU*6$(qM<__4V62-A!X*P-u%$lD ztd|4o?tu5@KqC4@$OqMKcmRZ>c>(^;&_sgeR7zcLKN52>y_1hYZ&CM9PdbR6Zc-eV zT^p+hwWA@pb(1}%FFFZk?l`WK7>{lNcS>NR)Xjvw3(VJJu^x{lL4OcV6$($cP-t|; z32ZdyN&IdH-W>Vmi-^AuXaP9w_8a1{r&&tXAvu2f9F%t;${8=R-#`c95aAN{CH#^P z6CW#7=r@NU&%yy)3sR93!GD0a%cFVz0!NI6JrJq=l10+7Pb}E@GB}m*3STpH8XplN zG#1$X8)b3&_h!T&06YtD`osH(e-9ua!#w1D+P2){Wz=PQgdXv3C}ow-lx){*8@Wsh zE*EpbP7}l8lEy#9hNiLHN+aUm4MUf!o?VTC_muHhVmgB zvUNoZv8eAwA0xDcSFayUfiilIkPjg>JA@1E{*QXJukS{@`?K|39I5Z(V=F9&;zU%a zJY|47FsGlZztuNVA(XATt1Q%e6|HSgRzg zgQC?+$R_x7?9)4`RKG4f5gMCP{~ zY4QBRXy~gDzZb9_V7I&eE#kie{BzNT5MqCmcwX^`yo;AnmqxqY54|YAWBe|3%7r-B zB~oZ+v+<6)!ywJZAaP9+*J9`DR@SK3)ARx1FPM@vMTdzm73bV=x>TjzJpOyt0XL`t z=qaSABVcr@P(KSEK)CIwZtmpxIGSAK)v~a;AXe}<+TyK?)xXdpEl00|)cQ)ym5Opb z&cG0)U)@9EVLIyP^s_S%KOfKtaPa;%;-3OK=ntH>3k@;n02tJrKVf3-9=HeRV7_M0 ztJq-5P`&u4altz%Y%G?7C>()$Dc)s*6@e@SJJz7mRP6-WlDiOg&0^IcG*~rwvv6VO zKnn~tV9h$oYj~MA&dty1@u7b^gdN-n^HVUL2nA@_7|p$8z4GQo_Q&|Sx-vWu1lWN( z*bGj32PG7co4o}+;Qw4rr zm{M8Z(*DPmci#!pebX*k*#7&3`H^25+A0Xvbkak}y}o?iku^wG)S4Lh&)`;E?b=e{vQAM*+#`Ad|GNiXtJC?OqT!9hw^U!sZwI=|8I?M?^gR5bGaMiDN7m_E#?*- zg!^8azLz~h_b{@T^%B?2J>Y_`Qd>y35#5Wsg}PTMCB;@YNfXhg08+ zepi*#@q7lrp?~#=UkK=6-ibD9w^pr%-$5`G@ z4ea+J&VIkgUZG}r;kc})X?A9ppm7CJ^2Vz9uqRmrz_qe`kd`=d)=*kb`~N~F{)}VO zK@vPAb7pWvC&Xg;6A5zTADlpEfn|;Z#ploolH}-k0i??5cy9oT0fhn_TYNtd&Sz)P z2Q{)n)}(xZ#Gkn+g1-lG7UdbhP5}QEWF(ZIPk9?~`=hcyI`wThx2~qBsOGGqQ+l5Y z@9Erywg};YGTmyV+ef}q&O!WAz)FCV?i$3e1@wGMrW>`c%PXi|viMxF#IDaTl1ptG z;*7ex5j5Vqi6|-nD4}#I`UPa4)71i4I^&_)&c6j|my0*>J&X8J0Q*ZMe*^ItfajTG z@{i;mm3gh5`OU~2ATn=|nNNkS6vrHwQa#A7Khk#kFm)%X;pMDi5zGFVC0l%ERmVzm{ij$K?@9`f9;m zqV16-sJ8R(vpCYOgFZ2o=^;Zo1>oR$4Bm~yZ=Cb1ZTfYjzBY@e^iuP2VicjpPJdHW z`u8IKIA90BNq^w|hVm?)Pt{9tqiPylH zk$PEMbtMjv2ypUEMLZvHqI_G;$e}*v!(_S(qtg9T_C7HV9Mc#aPfd2Z+oRHb)dQV> zJiiIxfzz$y(yK^%e6?JoSVf9j1pM3SCM=2IA^Rp%Ii-`X%mz4kD8{>ffD_@tR#X=! zlSP4WX{wO;SRR%B)rfb5=S_HjqVzdku$TH(^=RFBpbg6HL@gnSt$bY5aG9T38==D? z#JOv`o&bmcn1T2*z)AoQv%Z(~DEx`Mi&TY4CFD;WoCi`BKH8gK2LyK|f8mAUep#nNp?&hZGO+7}{_THv7a3_ZsIm z;vrxeNU~@ebaF8J$(d^colWP^F@{?otl`IhP|R{c=%6DmLSv<@?_Siyp}U-K{|}zO zuVJKemxNV@!dcsZVj0qk%Le|Su~_5tzr zYJxHq1#Gs{U5zvxy4{5Mqkty>yuTq(5^f ziW44|H{UueZ8pGQ2h0g@fkK?Bsv3M&=fF!QBPL8VTd)^J{V;=PWnpIm*1BQNfjj8& z;>072?ZW}Kh3{6_mu0UM*AQ$5ThPFN(mpbGkf3=w!9%9caU)edO18&plszn}q9}JE z{w&}FfYbj-J^CbI5P*k`-R1txbKT_K{?pc5pG%}3YE=E}us0wd=Pp~YpayLyFalIG z(yatL#Zh&$937HT=;@r7e ze0KNTZWHoCG?2fSB%qL-6Wc)0`P@Ll?>v%=$V-IeK!Z&1$Z+r8mG#p{u(S1{Kx98p zfory>hUWV5*R5HrXR};&0?X1d$KZYQSc$Lgz=P9&|AY8Z{=77TuK|e92AmJz;biz~ z=n!A_^wR;>T4fKdxVxVp_-mw3w8PzS;4X(wgf1V{&A{qP-1TlKbN8Jvuvp@5^x5Nb z#!egt@8uP{NzeAUJH&C91KjQ8xT|P`yD}ShWu7jsQ-HgSXxt44?sC+LEKSFTvP6TT zOpy572fR4r>1)J)2K)|i#?zfEO~uu4tliM4{)Fg|+FBSKgoG(nJ_viUIoONcpk55a zF4-6~UDX`Ep~wAr<>A<_T#q!``7cM>PX22VzZI|x;Pm&VWtMU`;BkOxzqi+jyQAMA z9-=NsqUbFavV6SbGG)o4dHj?iH{S3w6(XWWm7jv&px(wVqy7~zVSX94R#5FS>J``c zs5svI#4So@z$J5vu2R3pD%5hiKxR*qxbZbq%7c@)5R;{vDl~DSqoe8+9^2iJDNzf$ za(>)lI11M;*oU(!RO;JJ{V(BfabyYy_gq*ada2K1xmv#Fffckk?K#RGRgHs0d!6e4 zQjG&ddz<>at{15G9@TfK#^*%)i0VJ6#wH5KYnd9(qG}$^50B5Cj5xSh&^0wZpyg^^ zG?(9{O@$tmOLLj{=VtC8^Lf3@a&@u0*dLIH@oD1+ED-2cO1i3(c|W<0;S>XOYA~*$ z(x+jfk#{cvI_wahBKuMJ?1&t@6!Fo3DuC0EHl72UVSqy~%l325r?%}!BbPMPa0gHP z#N*G5Z#3Mh^6LtAF>k9KZm9R?L30O`Soi8W4CVM^EjX9(<+)N^9t-MJ6q^yS@B&KB z+r#qtAbU@zXb-|jipQf`5DtshNa0r>hc^gxsqqu)=dxq$%&4kprDPrL62vZ_i^lp` zQ2RH5^%)!iS*p&YXl0egmarZ$^MDB&asHgihHArDg=WDi!T>cz&tRFnJm~mbVuWA# z(sk$>QBz~s;lUJdVe`hI$$D%YgDpHRwidrs6Of|6AJnN}2nhWJzP!~zbO?nyMl90? zsIeN{m85slinRhQPwTC@5}}@?9TB=kn0;IU=60DZ34nwC%#06tO}FVzaGQmx>(J>1 zN!|>pr)YDgZq&$V^%F@NT4u`jJcPD#_KmFN@cRhJ066Vgf%pVKseQz68 z?y!sMi(x^RFuKS*2^izB_oixm?)M;WWX0qqLp}~~JID=O=2EZ*yQQvz6UiDlNqBo8?$XuSUxvy)x`m!J~sj zN|T-ci%2_c7f~>c*4s1&wCe2+g*pzEcF~>eAr`j3Z zPt6APd6<0DGE{d=ya9`7R>(#6S$|tQnzxq7+$XcM$g=eCMd2uIYA)jh7#1B`T1`;$+xiL8zhoBQGUvR#e<=MMkW`vQz-z-)js|CV2ADp%ln zGk}LLKa%Ye__w@^mr<7^w!TH%cAKCqZK$7D3#*KS>#!3wf5NibhMLK>gC`9gIkKZq zqLFuk7X>4=9*xYKxBhoB0)XbDEtWMn-{9xV7a6e<_=Qme3pZm6Cdj75K;hzwqcm)kfaRZ z1vY+AZ-+lIFT`FsAQ9m7+l8O#N;f>8V7&oU3`K?NR+Cy<=f>JJuF|al?PPe& zcnZ$I$_0Py_M$km)$TZXhlmI*b~&1mhf|INi2n%CFN(C&xlfu(7|+E39!@4#3I~r_ z7oeOI=5^E>6PGPHQDgDXf@WSNXyy!9Y}=p?{hgHkK{|_Tp|ah;xb&F@TpPWhmc6ca z{z@(TPE8NOT)`sHE{&DoYVfU#p*T&_83*(VMJ;eyDBGb4^>XH;7Q|H$<(>eipDt~* zlxqM_0(kgf;FaP&zpuQDhp5Z8WBGj4fB52BsH(K>tMZ-f&utb=(z_s>3+?lK%-~$; zYASA3;`*U{So$&J?LGB-GW9Td9_x5A9Ol7Ptk(lzr%Yik7S4C@4*De^+aB_!s=RF! ztLPNE=X-tXk2nbC_7qwLNnvm<0o^m*bvg0&&w{3qHe9O+4g`I%w61=S?t;G}9N$){ z$7jMjklM)|PeUVv=5TGOIm+y3`dO}y1*d7o6>DsU3v7#K(b29BzW3G0^#0yBD}Zwj zVgzxe^4?q*)MIk*RB%!iXT-p=UdAUz_I?9?fPOGW|7fT`7z2+OfuD@{9}N@n9}VpX zL;KF~eqxN?Z!YDzGf)fY@!jq5DvrE~XZf(5*dVnK;7^e+Gp+Soo#cm;ut$r0)(HjfTa(dixPi2801l zd-g|s8lXMhl?AV>!!oqKhLg$i+X#wWG02S~UkRR)X81y|)5Nvo(ot@U1xN}B7#3f^ zbJ0mRNcc=p3pe_67b1i#yu^3I;2*y6na8;tZ!4a+)Y;4*M4CU9#u{lKDRI zzDIBjX5c=j>(ye=WrE}nm0fQER1Qfx|5!j5kxa4B9>^bMXg){cuLfGz-M zJS2~IDgE#~zqiEOw*9g^9Dl>>u>aX01>pyQJ3bVyq6$9`@1sL;P|!!axqyKTItHh) z^Jyt(-_Dcyu11u4qjd&a&3E<>A@>gt(<9YXoGT&qCE4s>FySm>I z(NE|iJpxr&JuE~ zUp}12=al2*^xuNYiS(nYrYO}H$@E*0meX&(Li`tibwyUB{>KpC z4R{UUjDP;y|Kj-@01xfwA@MTm(lcuO*UhQN394mhidnazwsy>%dUy@MX{wC`$Eo50 zDF6>@^d3<@SA7szh0Hi3+3$;i?R+aCn5ufAUg6`!pU_Xd02E*@|>G<>~?HjQ2@ z?h$;>oil%GP5oS9w)cp%PPxbArg}NCe1RmudxH7`(Ox8Ofzx|+8p9V9x?qtN$WK>>o_~oejS# zR$CQt?{~A$+>oeVtzJV`s)H|A7ou$vR;%PF3tp<0U80_9gjTBB)`)`vOK^64-7pBP;D!yJs|ik>uA-1AUdbEC4KEKXWh4l-f5H7>39yII;j9co z$Jy{*s+McD^d3S-vj4}~djM8ZZ2#jsvpZXF*?Vtpdbw#NgakqlJ%mmu0fHc)gr=Y< zC?H^aQ4lb+sEEizh(16>RP5L=cEkdf=TlLP9eox=P(+{pJ~O+UgrM(z|Nn)vJG*zY zGjpb$Ikk*#CTbbGg-dLtKH#cEl|qQ1bZTpHA#rH0fpG6{ zfrQk;H;_=@xS4n@F+$=)(eCl*kVu5Zl?BfsXN%Po^WXJOyH>4kYS&AVz6x*+z?)yW z9@tvkw;b0^?S>Q=!+=^Zcz{V;bZ z<7{l+kLN%e9v7+J&38P$>E(M7(u)CC1H61+NBVuhf30_u*kJBLIIs9S1CwQ)@{lTh zfzhsIfe+t7O<{CQg4}_sadIpQrd$+;4cHhxbb*gLL^@Uw}@8{8kar{F^wBGBgPvNqp+357HJ2e zi%ECHw5J_7XQ>=HYN9AOy zrpg7fmEA9g!4(VoC~c**f_%}+=oe#hQOI&ovWrWX<6J|10>dLwvhfUy9t-o0)DUk&gAfFDZ@ho0}`#>o{Aahf~GSx*K((Q-ZE z^U~t^2+@0bwaZZLj0XC=@)0EsVDgGfwe^u$Xm}Iumy&BcoZK}Fi#jkO9sN{Av_b0|qum_2F6g&3-fs z-SL=Vd}-*f7RfIa!D|8~kF!34oKpU32WU4d}E zv|+d;(>(+}u@CbO&eygwTq8hE2V8C34}v-2VTzkhxZ%tku4=&-z$os=gK@A33dyzZ zG_Z$2jiNVrSQo3MtU{kahO)(aCCen|sN>jB^?Wu|pGp?O*ET(uw4u9%)yDbYmyslB zY2?D4^JBndT^7|PwUn- z%~KB}{UYFB0I#2pzZvr?UFsoFtR`N<$Bz>{d>GYd4n&bF2dK-agETS1!J8fRLN zYrvZX3zPTMoDbAdpQ;^#H!9+C4;)i z6k>}ln7N;DPXG^UFY^aG$jhYPeuN!;TyIBP*QADQFPcvWAlAaG@p;qL(A)xea(TeZT?AtY8oyN9k1NH}@RdzJOc4{eiY5EQ=@KiRl zgGs;@p9;>yBv4OKjSE2O&O_U`LHbE&RtQ5QC=Lz;OlVd4=ezG+W3|2l)LE8}Ohx z0Xf}i;nE#We;L2Qq5m52crMaU0O|oAy;O9oDGdQUpX>CGb@|TKSxh*6F>}hr(_siU zWB$xJ)4>wXI%vE}4jAO1v4x6DRGe#_n?!N*l5>+FZa#P3P7*iwI5+1VfTOc@%G-YF zcfC7;2iaY;XjeO&XoAu1PtQp6)1Dw|mk`Jv6XbY(2yPbAj?m~!N|R}qgaNn*by!H_ zSz?rvztsfd@pw5otvxt{8BqL4r0L8y6MT?f$hHB=3LKT70l4>@pE;+u7aCRrV8VnM zgV?1Yw~4eH-416PECnO{Hln9%Nql0ylbGp77z+iqiX+Jd@ZcjzJb~!geGZ-*fWw%0 zB1u0Yov{(VHUQqZ-28X=1qb{L;Kx>XomYmOJ}yh`8^rH*PqiGEg1OfBQt)as>L`3e zQBp^1PrF>l#KU)WaT&P8l=&qR+DA%WB6J@yUM5}LVT)zIM2ywd7^_3XLqQ9a zzD$buljrde8+yEt_wXodKe^46emC0D_B2)TC&Ty78jGDu^exZHp>b<0y3#UsCu5LX zG~q7Id^8IvZ8^w)(_%DncQO{`z<)C3!T}IAg=Bkb+_Onya>#4>civL|onBg>guhC{ z?6E^*2QqzT98I&bV)%ZYB4?#AUJCU_#R|bzOS^rj&XbPGT5ddZ1 zsHC=-$=ikC8nA89-#tPTdW2fxoR_S|(Oj)vIGtui60`6mJKP)RKK|r5nvfFe5~hh= zqXiL~*d|n#OcVO~)01dohQ9;+5p@j3$J3+)mgj?yH`_oY4nKT7(|CAvz6KVMVQ#_4 zXO(sYN}T9(*eTm!K?k_o+C;bmNTTm?7t$jzlYFd0JR(dT;qyL+U8Jrj3{EK#Hc_pi ztQ}~iuC$#oSv`fghS)FT!*{@f2BwoGaD!!F7C|%>aP0Xik6FbiQ7H{*tGR=zPK`fc z`Hz&^RIFz#^gKzHlZ;d~r8GS&4NY6U$LYsAHZ`p;Pa%C2khZz09|!LMFX?#M)1MFB8xU;{*q=tvIhim-X!@@8J>!Rv@n9J(z(zh!lFUNI^}l z@!6Rv@#{uYd}n18-vKRx&n51M=W+4ydR*{l_k!mL&f`b%&d1%XvIQejai8rc(7|-7061ZZvZqA?5)~f78N3rXX~tlCoO&-KeX+ zvnd7jn@4;9WRovb?Qc?*yOQayWaYkO`arV$ADdN&l>3wEL&@qjA$H2fuj(Vo@^vA$ zBBVSFQaV|_KEzgqlt+{4s5UUL-k0q;5Cd)U3;GRs!PRWjLByyiv_=MGFosXGU zXqDIm_zE}r*sZ=%n|=CiKHnWaa=Y(Ga?(2M4{IH{))zh~`wqzJFIJ~(@fX!Zn+WYA zvetJUS?AMl@}+I?;ik<`7CT|xNtXKbCBDdS7Fpu!wajN<=bNy~ryJ}VU%1+*|6z?h zXh9+Fvi3I+bDC+BkIj-VOb7_Ewuj>MND9VLB9xZMw`R#nvnM4#n1#p9X_3@KJp09r zoiy3c=EUDk@*g+-IB78DFHQXjKll3$zTNFOs0IFP>L*S6XEUnjer>X^%<=|P$MeMR zP4zh5(LXYMM-fT~zl=O-^0&V*6F)bjZ<{^eGHc0`!RUj*HiylK#{OxRA2#(5&ES(D zY%E3o5!$chUuLiOP5V7_!WX9AfGrPuE*O5>)Za3P@3FFWEAl;fDXf#{tUJhFtMVxz zrQRY>TkCPBKV_Bgw@U9P!S&RwM>c_ zEaXmWv7V^K?X;BJtyQAQ_Fx?lHUkcMqHAnq`D(_L{eSXe4%+K%uca||msZ_?OfSmEhn6{n4X9{ON zOXMX)@5Klh);5S*!uBW~p25O~AX5SGuCcWF5gnIRM+RRXHWs@S&Qwf*3St^wB@bBP6vE`o2x|FoO!^%po>iq=e>K} z-{1J(_rn7}c0~75tZCroI+SsRdp#dtdV({F>p7D+8JSk#$+I$kZiPR*9y2NXvxg8>bZ6sWtkf;>P z1B@Zbi>wa*1%WPdB7$2-jeN5wV5rdwj=Qo<+vtpIkvY-Grst5(=0MKu9l{wA*jJ7N z1)yndbzNISAkugbA$z!Pj>UXFfFA&6mH@xCO19esKP2J85DSlj-lY%wj(kj56XYqu zv*Op0XgJZApeA=p`kJI(2N!gyX(=SV4U{w|$itKLU8J(L| zl6fb|Dar2DsxbFPk{`=s`4jU6wXH(afH0YYC0gHtiETl(*MA1W95$ty8a#cBcs)bwnHu7wK(>7iD#k zJ13RgLAq9UUx+C?u^Z{R6h30Q>D|k_ts%XEh(6J>)_o_I4eM){8GZFKU*9K4|H6L1 zk%3(XY$JmgSLHgkGL8$`=56}l@sYL0OfZJ` zOYATA)BF4Sb*flGM&?%7BkhW@Bkv}oKOmzLD(@g;CY%)qMk zI!+nS#^sGS#?kRIKp&^O>A(c#Yz2@rNtxL8T;m)Co{&^PPL(nFZ&N!=S>zjRMZlLi z?9gK++ne}@gOIKSya(|16;Iv?IVIpd06#qb@!Y*N;@&$|3~~5}>smGKE5MB5ew)Oe zBB7!h+sld*bR?&->OkiV0S|&Z9L3IF5o{SwXJCN3lu59WCgH@^nbc00zuicN!2|4R z6pi>6B(Z<`cd`AXNczp6sYGCxu5B{{uY<&ueQAcgG2Y;u1cPGtj!g#KJRLJk!T0kx z3CoG{g->aa$w#sZ9iBs$-Z;zOLs(2UwG~9UpC}Izccy=sfcfXn^p6q9w`BNDGSoQp zRPZ#q=a--w<|MWpqQ3>*IscC!U24lqY;~FK&iTu2T4TF&{z{wH+VWbP#;Jz6C!X^h zeoccCc_*zibo@N&!JT|UyM{u*neR+p%M~6UhAVCcNNePn0}7t2kqOcsl1n9k_-Ns1 zC>T~kO4tm+u_{0gh2w&eP@I8iEpkK!83Si|A{~Z?b_y%N_JznZ(lY?MnZL3#^d zE5PFmoPbHuv$*&AlP9O=bi9yfqt}dBaM4s@Oj1j3f&K&7>lxylC(bc$=eJJTMx}j$ zOeMwSOh^XH4W3+G!*X;#3hF7Pb4p5PP%s6ybgo=V39+%_VeB)~~n2O7dTmt|T)O^vhv zesLLltBJDMX)sBu`qU}kLFDbpi9bX71mG0F8<&YY5vL2#8^Dj2ay_?djhFf}iW-`iHAMmD-0zo0FOHaH+ZHpmITrN`nh6Lso^uHo_kP!ub!_V&Fgs> zSFfJGA)RoyDP;lp@xSVMFIo%3YVYy>fWaL)sfFlpHtzgV`bFK-}s-F zNxgh3?r98hA%?Q)=LVYTOY90$_L0wIA^N{1C~Xh6(}Oe#;Wra@QML`HAF@w_%DwfhbtpJlS4s9L5zN~W>y;3hXFsFpnpo;C)5<7EgKwlCl#~G3dwj`iFV(Smv zzGb!_vk!xgfx;mu$9JICZ9GE-uWd$9M`$X9{BTt5Jb8c`t(oj!(sZvEu{0Ja>aBNU zKNDo3*97$Mz^5ZW1_$o3;r9D`+dgKKCv4ow>P#+bddBYhqV1#PC40bL+m}dQvsV&r z@LoIml%4siZSR#W=tDf3n)-^p80s^LuiMzsg!b9_A-td;wAoAcxmx&DTYtqK0cD7l zaU=1kI-;}T>-DCS7wJ+Rj>rdHf-vm3U`d2~ycu)_rjtWfS2GI7& zJ;7dKz?5#nnxs;ntW!0UsUv_1u1Br<%`4?mdJ=gfSn+&Ne=gYD`PTbEJ&Ex12OKTw}h`Tj##f&c^&H75?AAaSPi_d98w37Z13(ix07E-OSsSZ|ZdvX0 z6sx_JEkm4VhS0e=tLl9fBZ-sQcgX0I=RvA=F4k|`UbIT2`Q$Z5dxp;*s5Gh=sFsJ7qIs9f(Q!A`3K|&MahIrtXz&_wd4-I z-=R=#4BfGSbTOrNN)5Y(}AO($?^snetGmrKho;$YYtDTmeG3Hy|x(3rQUc8o9Ti zlQ)y}E7FS6!{ExBY5w%VX;)&xIO)tY((Weya2V-qKxcqA&n!UtdcZ9JeiXk^Bd+TX zI#+SSpS$x+{V=gqK33 zdjZM-9$qd-dOct(fFCnmJ*)hWPREP)Sv&j}c!ByNw@&`laYXwyE@B;(r8~lzv=0WD z@r#}FyPcvX`HX8q>DPwLYeHxLWSb}K@}F#Y9Qco|{$%@4*uLMv>J=&}oJ82lBXe_+ z@em**4_9tv;;4_O%N6Q0#Oy`6F9i|TOG(EHbT)KI${|2hF=l*(E9U&>_Kyde==>z4 za{&bakKP%G^x1%2e@gGbq6SMS_e#v&4G8bVaJ6zhr|7%G)5>~o2+Bht=FnnHqyZ%j zv#Ex0Y@)O(Z!TV!vrvT&s6vs`p3lIGJE$J~D31R)<=lZXl)8@|K>A6*GXSrg2arAl zsQB7xpST~LE2kR=&YQ+<*fVPBZ)7dy#o7rP4ATPFNzS5OHJ4$&6g+K?#F5Gf{M3^_ zmP&tj@*n(Q6Q0H)eJ)@o!0RtukLxnrUj^Vt>`^CwPoGLWY<3#lOiwevVBS0rdy(SG zb~;L!vy#SE(KgZ%SP*DGkk~PjL!joB4@n>{IWTf)BfeJemTw>O_4IuXA^ij3CxC~a zLq}98_7H43o^#6O>BBqxaW8+@B&CiFf^fNxOcLkypp7sXln>MsSyDV7SY-CV!oenj+m=eJ`-i|^unT=DlNkOl>qnn3;P3J zKF6M6y;2B;@(@_ZmiUt@_q#{%4Ub+lABG$ZkOT1AC-+-J>W%wy06+S_S|hH<4?0(I zgOfeDRwO5#(LT@y7U*fh*o;drox^R~>&QHTD8D#YIN17;NZVyD6ofBH3_!%8&;x}g z6!-@8KnV(9>)eIq^RX#O2(D-n0>TmLDpDcx-+*#@c)ADahX8v3UVFZX^c#TPk2>Y| z`dL%y#$J3W#--Duk<#7>%(h-RTY6JYqw$z^V1dq^H*nu!{V*8uS%@&vwP5Zb@-KPB z8E^07ZZOgupW|_L^H(`OXXF0lQ%?SGzvx`WALF#!T`#=*=?^=XdIc^0wWNKZ*1Kz}o=#IK$r^ z&v(|mF>|L(;~LPh@$gX}Xab<7hDZ-U1=>RR3su!%=p6I~qd}=kcJj-Av?;$2*UD07 z+;;$MVF0fkyerT5;yLr3jMpoJq_>qcNX>Ky^~IR}@P!;MLw8DE@8-7$--@}F z`4H)20J^8CKc9M9lVZ5f1Ms7zzeo2^8!vND>(5@#Xx)~lgu&yiIb!}ExIhFHsUug3 zHi?OIA#a)WO3A0h_ze6D()lwoPx=Xc+V*LQMuL*q8IrFgB{SssV2C$RrWwvAs06!j zg?7gK&QY&8_}qYUd-K&xNdLg=@>o;-o<{l=z&n=Gr;tkH_TgUXLK>XAm5_nX<({gIApjpeQe+7l+ zahy0iX`BZyr}7FKT~3V^bRR<9&0c|ssZgQLq~MeWxzCSRm8>9l`LT}^)P4i55V*gO z7onWP-F=)?EHwrOkZmg;$?~;|oEiRHHjc=NSd*}BBfd}@%Tx+e%}gUM7gKOCDDXD0 zn8Ld0H-!N)!qqR9yXJFqF8WiXrclmDw_P`zO`uPCHgT-ZW2Lf%Gy! zEx?<1??CzSM6M{AHuJ)O)-4X6Cl6HRnQ2D*GVIU%;}ZW-72#gf@I4mU~l%UYG&rSQ-{w-fKEy>(vIuZNq6eNt&mm z%3UxoTTZ#=B2RZJlQtrK4`2_#>kr=|{R@CSh#*(;R zHZv4GADa`<*HeAnhzSBps|Lkd1RPap<;dq z4~tO0VzYn- z_~+}D?}_xgnh&+&)L<0*AVrURO4jRT^0ZUI7v%!X3W>a+17U;LA8aWkuW~Cff@c>C zIOR{=+qC|)Lb^Sm3&87Nad)cH2;8^iyNPx7;^{LMxzp?1K~UTujHr|Al?RBlQ_JL& z7KP0^IQ$|ej2Yo$^-}KHg6H4h=D!Eu_WJjJq~8M^26*`6c4@w+;j;n2kCuK-#KUH% zU1#7AZY$<3;L)WLs-}ZE%Q>~3Ci8N_hOZvwL`XLvFb9Sp6g$XtzYb46!R3m+R3S@1 zD4@SARcMs^fI*iig5p?b0ye)SJ%~wIbP9N@MSX$}K3AZeF}FOoAbkhmPJmaRXOMmk z@GgKK|4R?O{*3y}b;5jBjY36=Ci7hm!hC)vD#FzEnsS38=)U#P)S=#9dI{%~hzjIl z3l_zuUAhQ*X$Un@XFKICt#2C7qmZ5im;&(1o$#;)X{*E z17TiLT8>#LuMh9XwM06ur76*WIAz`r27!qC+KQeHBRb^N0j2E0^D7NGNI+SkGDA&y5>{0bP`OGYUwMm zOyY&DhYS23tLRzMjZivmkKUaqr-I=bHrJAES`SPiI~3%}*<3GDh>czIu^Ixk-5J;! zpG(qtAF8KH4+2KATaZ}9saHM9>+J(RLpu5_>{9_=y=Ei52yh#KA0GZ&&ZoQGdVQcY z=M(dK@y)R?Flw4kr%anRV=iLKfHwc$q0Jv@B+(s09p|BfG~Dd`uoD-Ki&L;r*SRq$ zLNn+o2zp{D0Vc<(eTty{5FAug7V29B>1ew$W(E_25EvPqi%RfIucDGr*P@aDBGDq4 zLwexf%8*Z#z{kpf7dh_uvh$&9om0T|p1FTU2O=aJ%(%vT1)jX{2QPC^2O;RShtwi? zM{B3`F^i0lugD4oiu`5v5Lh@42nmGM#XES`pKF?Ll6e|X1n}m&-fyT)c=dS;=}!Sa0Qm8j`*T;GyyA@U$@{Up=o}<-zyUr; zJ`>!w^~z_cju@d28KQ5(B9|$g{tnCMyrlCXx}O)7ceC%gW`{dO3($)n<>nG%myVrJ zvjJM2?9{93g{FS980i}T>j7T9>XCjGa0tMU|3&{EIb(hl;}abV{udC;mM>RNQcUrM zfJzq%CkN2cK8MK_>eo&suaW$>Nj_2ad`*Q3BenCPW>;k4tO5K*T(>ONF99q8c=bGhbOS(n$*E`D$r@4L`KO$# zxWVb5+aHf!zFItL8b7?AF!&REy0gRt`+zJiE8u#852WweO4@4`e2%)L9M=l=(H;;| zhiG>=)|nmV6332UH5xjMd(^}Hz2?-fHk} zyiP@t`Sg5(kqmmBOXgK7ZQ(Uaxr}sH=4g3pS9v7OQL9**%$YWBXWt3GkB9=U_9Er* zAUgmKy3pzQ<`?Tyrh~U?;3Fn#3f?r*I{{AuJiO6;rW65m1n{H3=E(Ps>(15vQ>obP z_g9`~C+&^nu~4aKhPa(rnXo|Za}l>4i0iWo!`MY!z1f7gHi``&f{b(#Xlu*SmQdgi zsKf;UxQyQvVn(N?c2kP(54^JU|2+#oV@HF^k=%4^@2JoZqy&7>n z_>OZGH_c8bpKiI%oyUDZH8GvOSH$}Dyt967cP<^ARqQ@p`rU+A&=WNO6jx@vMjp3J zwy%?mYh~3$tn3g%8m^TKH_G%@xu{M?81BNYGW~~KxC>lRa-Tf*AxOu{9+Qh|w3Y+<|Un#DPp)PzJ-UvehzO@(zBibC0<=NoXCqlNA4anjEo_!AJ z`E1%o$!6JD+p=jpkqS0T;bUzvawCY-)sESEvDV2fggB)L4BJA?0fj+1jL{$IA72#B zPAp0ooLrQ|Qj1asrx&FKrRvsBI~4D4n$HF!eKz1+fY%P2k=_ZY2k@g)E4RP8_8VRk z9CY_@Z=Yt5#rx|t$7ocv1nfF84$?Q}1C$)3Z_@)}oF7!BgH}65e_1YkRnDjOYjXaZ z2&2}M<{y$f9+uS)W$iPVu>9>SxfoX(SglvA&;!f$QNL+{U$rsc@ovS3^Sv&`FqTfI z<6)^hh7L1Y`9{$XMXd6!Sg1;reE9;1tW}U}*AZYW4q=A_zEM^nFow(daWoV+ zJDvDm0u{@DP||}Qa;2|5d7x%^mFqr-MhhL3yP|G9rHZ$4~pm|W*@^wOWk?gon_mEM8CVx-DR)GYXA=|Hpm z83@S)2OSGBcMWqLch|Rf+x@ks`MU(^L4aWZuROO`snSf`w_Mko)-|^snsF!Y7Npiv z0%Q91%9~U=<`oDQzsY)>ANyi;n3iURW#DQOdM@;okP=6%qf@STk*7Cbs;|So8_*Tt z^`GrXKLI!l;D2ZZe}(tu`aK_(resvDLotQH#6qMXYv>d}Kh%)L`i5fI{-A4~uyD*9Q5) z=${lw9s=Ok0S_6dIiFpxmBFow0QR|_;tJr~gDIovC}t?T&{rXM?5ngw0QvJ-zIv;O z)j0{m5ZHOjL`jxc5jYu$(9xKq!vxBt+6XN3H%K+TshN_(hh>g2k_&s_HD&(`&2#Ll3toQW07Fo^QJ+%|E%+Q_+40> zU%i&A#7z|EWsZK_i@5O4b_ydB>{1wZu7C;}I2@U>0{?MbU-Btaf7T?=Af_HT8}%l1 z+g~&B!d6pO!fnBV)HB*a3o(bb#yQ`gi*qu|z&Sl4$A!tAe%=U~K>KzRe8K`?sv~#c ziQb?*PPm#AFgcBZI+Ltl&RLRXrSh97H{1 zqK1<63DTzko!@N2$M(0tD+6?Y+rh`y7o7FE2UT-<>IUPQdd;ZL- zRhB_2Kx&omQ^n;eeXYXh&B=PVDmmvBqQ6S6H(w#`UnPe_D{0SF@XjbM z3s=&vt7zFu+IbcImFqorfUC>?@uS=NX{UBc&qtwC8=+@d=XwTs!a7%b2kz4oeLap4 zZYOd$5QM&2Jk(RKkS~`<%fWn8H|f>*N{GHqpmS!@b5yY7)Q&)_2CKKUN8+FW3llyV z-C8SC*)%=NPdjJpQ`r=|Lx6Vf$Wy-F@z4a|`-mX}WZdUL6RphRzrfmomITGy=|0*u zLp(dnrlZEmQ?)73VyB%V{8`rtwz_DtJk^+@!$z@F3V#;C?xau_e*tIVLp7dC>*AxG zyNYM6e6(Ah56|p)n|4m%&wN9@a+HnY<rtT4UbsRntD#f5)B2YRC<$v zfCbMIoS|f4)5eX3L=i#Omlv=VjUjwLko| zXO0Z}Ko#2a{&Qe8sD-YFP7h245mZ+`9E$H5-Aa;sCM`_wnpPGch7NLES~zZ;GESMO zjLN{LFfK+q<6|M0*2B(~&z?ehFj$xyb8-G| za;_ke_L2LM!$_iS;G47<;@d)OG!Z}2O!B)!dl*JGw|XgqPlqaRaJM04 z%6TvlRyChgU*VK<1Ikc>Qtp5d?JlGr2fP4q=QFkU-!{Um^g|9|6I74BQc`{ej<3Pjf)N@JB$4XxywF1cLOMH=ILe@Bb&@4@|(|DV6# z0kyA`PO?g=%KfhNucm$zk2L3RXX5IWyB*SH0B?Qv_}Wbbk=V$zJr6$d+jA!NcrdY( z!NfiWMT<0qG$>Gl4k1Ee{lhzn1#hNex7P~(T+TjCi2(4Q*WK;ATYB+7XXcQ)vdrgdYT@>k#+UOwj`{b%`H^1t%&#P>Y2{>W?~ z&+H-u>q-D^=^RyOIr-H$%WogzQ@sWFSF<=(OKd|rg!`8Al*agSyk7I>O}T7_`{8lU zcN5=hs>lDY_*eY9jp3BQ-gmz{v)OmY#He&F?w0}F=1TL$H}OX?_q)~jhBt5Diu6{%4gjV{e$Ype=Ewix`=e*fn}PlrvHJvx zofyI9T zaCUt)g@|E;a+FFNvEO22eF0-=K+S@MWj4&spm|19antPdo6Gn96MVRlFR_m8akO&QX*g?e3@i(S;@ zd6`PuY`0!efA?dDPzW^Lnd+;-{n`-3ibQ;MMCY zq&EZZ1MuU2=~+pURpPzBs#l{{)n(Y^)RPSk!z=Vbty!uX;-?)c$6a}isiJ1d$WLA6Bv3R zW*p4iL%9FAEIJ<~Y_Zqm-%#azs4;aU>9BfBLxFHqPj=h?qb7Nq*PjRBnTMyA{rQ9a zH3AQR)t__zNBfU)V#OZk*Z`RsFt!rWjkgi>UtyAjiG#w zuX4Xzg>P_rmmdp}UIthR@aUPXNbd%;)Gt4?{Jb8VAXp57V8lpTp@JybsQf^r5t%ds zWu@+ryeG(9x16#W1_XKqbIy_jySF z4X_fxk410Sh^uG!CvKXZO3r9^5C8nySI|$(xWY~)VAx0>s_-L}(p%TkcGA>LC7>p& zts7P4MP=kRX6ss?ncs1^C z0eJZQ;#pN1kLQ!Q_A!o@d{gIZP3K{MiBCr(&@EZ(wKT7m_LJ6ON%y7Y#ZnZmZzt3M zoo_X`HDSQ}9gz*?>m8Y*3{>xbO6g)6O2f|L1sdXS<5&Xw2b&?f|D>-i#0c z7~2sa9Nhm6;FWV7p49<%0r>HJ3w(-)%}y16SFaQe7D zv%LiwQaJ}xP%Y^z7VYb?XoDfsfiK+~l`T|S0ewk~4w?c}$q39`(kYJw3fph^1GkM- zCMKnI5Gun7EygWBs%AOmT!Aup?OuoU-GKW59v%E3()`%^s8ddF-*;v?Pa}hUK^;Xq zU%Ho`@6f&lg6@SkM)5W558eFX1g3GmNJY93&>rC7sTb0t09CI$`Frc0m;ZKvIORLFyj;z zja}&Ar5<^Dc=;9S*q6}H1$h0e4(WdaP5}5ZxTiyRmA-yDztgR*RP`tGiBs&>ZKv`b zRwL1U(Uc2ki~()VV+=c1PG0pgYUMY`TA9?!?>m=Yn7BLS++DU7LuHe)%_mK0hdvoI z!B;^zoJ2JSb~%+xq2FX+Q^KRp`pgKl@f7H6!$Fdor*0vb{lNnF%h3AHrGBWX_?2|u z)S%yYHOy-_Ky04TDPE5HyN0*3$=g`ob{4md$=g}zQ-zWENSMSY!RRdrB@QkD-Jrn8 z=^Fys@zo%a!hn&$cOvY-^56`l3=RMc5XpIP*)jrcfNj{*L>WteC2-qHuOW0Bp)U}B zik$`_o__=%|C>yxp5#F>Tn524k>j-L5~rPJ9%-6amm+-w;7)+oPWQK0rN?mpEPx-g zU#Jn+Gx+pNYQ^*U|D~PWjwZH2^Y}I>`ER_F^*uqLUEFdpfdfU5yseQJ^31lSJX z$ExRQ#I&ZAqA3K45e#{($~@L<#oJR)DcoHQNVdl{Z69X9$jXC4ciMqA;25&&m*1mZ&Ny+ z>x`qyf|mOk42l^e5PTIL52ka%tbrUR9}&_(p0~v1!jF(6hJHZWi)k@rw^HNL1;L=F zmxN6Ch+ydm_!4GvGITo2XdbLJ<7o;OoBrH#0nVEu_~*Bx_}cAtjZD)rA^U`a?G-sHf39Iuw5J!WSVz6wEJ77MzKYnFQhe5W3ax z*GVKp&317Ho8b_44)NG4#zS#81(NBJAg8!IvJf0QK134G*+cL--Inhu{Lnl_!ND|- zV(bjX*n(dd$hLwI*$Cu+$=!#Sf=#@e%G1@w^arBBP5m0;n`E2^x8ktGQ zJJ3M%cgo+*&}6xj(T2q=!`H@bn-g$6#cB6tvT}(N)2~H&F#lZX;Bg=D6BC$`q|cB( z4)_J&_2TL(ZQ&v|DQ67plQ%N);s$GOg5RN$JRo>wqC$MpM zITO9$6%=C9-9#R#3RPfUjfSh;{(&-k(x@1hK)ZhucW3K=kgDgiQebNg)e^s2juiv` zee_b=3S>oP{%%Gw)AEfX18zYSR>BZ4PgmM&^z3nytW2F*ejoAqfgesQ zJ?84*RYKnvBzhYeezeEXgG4I?(+Te55Vb{;y3i8xj!MUoQV)Ms%boTZ{6kZ}nTzx? zz)FDEZ}uX+AFwpr!T<3b=jtI6GP^&aHMv!|pgMurI(pLavpaVScflLKg<8BXYtkyU z8?@kHYV6k&g9!)*W7VF=S7BP)yum^AAxQoaX{&wYHL@j>`0-a(I* z2GU0exQL$;%#!el&411Zn{|f*d={cUrE6UL{Mgj~Gm*X;ums?>|2;@Q2Jq^9$Z_L&i}+cfxYbCYrljPYU0mILcizRV&D3=de1CkTN84JJ#5;~urM z9H$vU)PmOtvvuh0{1ix0M7L-qKjuP+Kc`CY&Q-*TAmYkA;#YjSH^f>1I= zy4L|7clBZ6dF}y1&x89pZpr9{B)-|fSMg6x{a^ypmjSK-c;oynq(1^22k^t|4`=8V zy5s!aGsZdZCoS=LdT%*Oo|V7nvA#0JdA>Mb2(OvwGhZ|K_QxoH5H6_I2jEyCx6o>1 zh*7|I%V4qaNglc~jEuktXUqtjd9b?8m)ig}(DlrMS38!B+;#$_fYOkIX&P8<0&FfQdh}zFsFm><%I(sO;BX#sQSBu&syNg8?U>Nei-mL zz#Fgp?H6&sVy}b0mh zBmz?lA@D_0VBez@-%U{3Fd!Qx{sIvtQDUgQf$2gK09uw?qgh({czk$Fjc{y@mXjlT zM2`4vZu+J`?_&vsAR-P@!zKr>M^N5kw_GQYR!^Cd1@Q1X5b3D^Z-3O1zc*vi+}YUd zqx)5pT{367vGuI20lfOW@{A_YU;nzF^qaGQo6IE4eS*M1xhtR;V9w!nI!L8TE{fq{`rze~VgrZy zISV?l2`tVkVVx7zG?WJ{{0X$uT}VsooN`qlPp@2=cQI)S?q>kJa?M71A>gn2Z@)Pg z&z~~u;&~{>`O=kXkWoWa$CVaF0x|eVe<&n#;a$C(Up>C%<@YAizTZqK1n}~^bh|1o z!Tn$5H*Cth`9rw9HgHi(?vdAWj&@j_1LAzXICp9GysJn%)JVx%MElyT>{Ph|f*Mdv z#Wt}9!97nH&eC*v4G8*rVot`Cqd@-xGjkGLyYML1C18_asgf1UNmwA&NCbX^&8&dp zJSSicXv~xi7K%XYIHE+15;?AeMextCWM3Y*k>}7F1>~6{nAqyz|0wDjbNhMRf6(Uv zeF5J1y#(p20BZsKXsOrb{%PZ7Q46_*z?BgDcoc$oBE(R9Y*g`~(Vw5n`6( zR57`}#3>}m+(5&10IMpIc9#<{r0{D!lqX3UX5I+=(FoS zBR+0M`gXu>06$vx6Y;RwDXztQCTidJ;>C^ZBrqK2O`A1q%nU?9gXx3QnK^gxX@|2E z{fP4j7m9O7aUL$tcpdlU>$~4D9ak1X#qsN z>1KRuOOHn{0@uWd=Pq6O0`xUZ@i?738Elnp1k1P+Ca#W~<;$3pUJ^s2?oJ0kCsFU1 zTfa^}s#4$&Qwjsz<1gjvjpIhN{AQ(-^dhXjF)^#o-!{14ufn$myC1s*={10x0N(ig z5b0BZqi@%Uw_47-;3kZjJ6mY>*OB++E!in$`tj*&1JKUqV*g(w2o|0~Z8+7He#%Woq-|5bOlGWJXCL?_*U?IS(@5@Mk z0ca_=^hT2ixC^r|45bIeGE>P%P7 z5TlS>qW6p>w{pD^yvdK!dz^ZffO%BxJd(5CN90CbPxZ zz}!ucu+7rWQ7nx*mCnkspPdJhtz;QFrX(*{?OHYVi!xvv ziEbyO#hD#d{NM8@{?YJi2!%TkJCOdSy^9?j`$5t1^M_1_;vH7+{j8mw@++$xnzoA% z8P0v#1quBe=45C3xKABw3rlOMv zOhU7)WOFgBkO-VSE+hVAMDmIS2GA%Q4PeX6Itz}*0^z3;@*^B~|~do>JK_YU)vyZBtW+q#jm{Ot-5&;&-|l*qY0T+(6U?Y@QO8uT&Pv zRZ1;YhH6WQ(p{a+Ih-3I45e8pq0{Li=yVxC1siA@Mm3M`kVT3ip&3G>{%*D%oS=f{ zV5(h#c_=NE7LMT>^haf1oP}s{AZOzIfMh^Wq94i&EL=bO4+UxYg9coiQ zd=%*e03SG&UO#*m>DK|@a_%LLbvaJ|>z{pkKP-0p;mMV!&pY7V%$Nd#KciayUj2iT zYWW6PTwWl5h`zXjuuOEACkSSp5lHB8tqdC&8LTOT_rVxO6l1`;j-O0SI4PK*7^_mt zrCRuXNw!s`l^MB7BSR8~87nk)jg~PQT`BBzrM;^5lsaOs%3jg*SK%Zxp3;A5`Z|I) z;sT+qq|?n{NwPHr{U-X2CLhuQA85u)s+gt1 z08QJf>5r*8G9nB%PtNSKLQ8o{%Q&L3FSXL4u@zcO&WyNS<8>~g!YuSSRjw!ULqsiM z*AS(hJVl>O-$LwcIECO0ayUdL=*Xf^DBR%{dW2? zSHIPXX7?HujP+-nyRt zBQGsAr#uDg(E)S=b9CzKntHe9-w6*=aQO#E=o+~y4tl+*JF&B24{D`5(Ly@Jq-ZL0 zo@tw#^w@hEOUj0V1!w9ed?J3=UeXw%Tt~QO;|E&o20iU0=b`XL7J}D(HoJsL65zrr#+;LdhU1XDM8ZVt$*=I zX94m69vwFX>9K%S|Ev+%Xen>3ymZz@qi3BzbG}2$CCt5s^AjcsKEfYF+Gl0LWUh^_ zD@icx#!gQ+u?aRUaB<*wLo^eowLfu5>we0e#MKRZ#VKDs^7hL21=2qNegb&yaN811 zGJTd50r2Cqr=4e43Mmptl}zka)O^@=r%^C(;#SCd(MQ&0^1`OV6cux0jT!+S|Gh~;$0o7}kwqiH(% z*S+fC@d&={<^Ma<9FGAQ8hiP7K)N@e<^I>pf40Xb@!EMgZ|5+;H^(Q{`QRv#@J)%} zu>%7LRtcILwq9x^D1+(hZZz(8Eq}+;D)8*_u=+KpeAUR?qr0vDsM5{2zZKvff4L9k z{NcW1`VAR^4L7h+Ded<`6$2EXjL=QkJ!7w=>fjhOxcMB#Hxu2DUWI(L0M=Q6hlhLd z>~+Aw_Zl=;w0M>S^9cGzj3K76FAH=O$HKsmko zczApq&%Ac~zu-~KQ_>@ih2zMJz3F^kx0~ksWTe{yIs&}!A84mZ({TS+`*w~G*Sm5; z)gA(Di~*Dbx=iMz{%%>?=g(A=(H~7S6k%B=Xdp04Y&%JYBN|y9l`zI&U5p-bPb&g7 zt!!ZC8VVPNZhvZc%PHq>lwq*jD)mTz2>1lxm9sj8c?GcIJEuQQ|F?7X+O0{hdXQ8c z;KImly11Mt&YPi$BetNH^f*N7+1Ok8%@V@`GkBER3trm{8KJUM0}1fkA1`Y#fW%lw z07nHu>X6c-eh5$yK#Yr+#WRyXi-+=^1-DeD^PYGX2bWZ8IT)t=FWn~ysy7t+XI$;y zZewufC&{t$6Lmjg74~E#2~kQiWyo6cfE>PTRJ+t0VjbQoL*fEk>`PgOG6j3g>h~Oc zH2@dhymt&~D{M(Nz{A&kq^kkDUv}`+lK(W|k_A&{51a$NSk97pNq8=q&u8BkM6&R2 z99st!X?+lLKv)fh!=?qcg=WGuJ}wOXJ|6yE10zZe8cMENpdgTniLE(A1$|k_3>*EK z66~n>6KJa>)=J4`t+4(jnZ9J2tuTagK~$H@c7t@w^rQt3x=uWf6Xhb5M{zZ#8V))2 zJ&L+`_5B^`iz28Wz#I3I;w)(a2i{<_-=VLk$LtKm z-w5f6EQz77CipXW7?-yKiSKYNz95TB1gkKBjVjb=a^idSDRaJz;H2>^8VF=U0u`@x z!B^vzjOgHaNCXq~R67ORBzQ;#F$#%dvY-3ci>wZ&)&}v-05cL1Wj?^Ahy0s=i#H1J z*YSpRXCQA!AS}mUmS*{h-X`4&GsBZ3*1Mb57pQVId6UwQxlR!+k@LYG;&VbZJdPtg zE(Y2fq9FbZ1BL!Q(et$12yeXlPu%{GcDCKtEca>Be{fHtP5qz$bS|EI^oDo!+WYkN z&NcJitn`q+^TpF#{PCwwJ~Q!6FCUMOd>x*7{mw0d^eyt@hnJ6c^?vuR6)o1AKPg8C z>7)lgjrZS>*5a)*^8YXO?FgINik>5Nbi;8{wSOF+ zd#ErP#Yek&U~7f}IMkcpZtC%;7gG({E79BxU$-%Jg565EExTr?=ss zcb3u_(#e=44X4uyenUMzbK0{c!6_eqwh-xS0m}hiJ=P0gS6%}$HX zs7I4FiLg5udGWLvMdw}AOpfv=FYuN)2R25DBe@RHO+>0_MO(w)tO;t<;v_F`nT{Yl zxp}h*TPW-e3zS}RAt%mZ?#h=cr%sP=K)w%kv;r7bL;CZ<)x8A$%6z!6KP_QB8mkS0XtPdL6D-Xx*8QlQ6V6@ z0%EVkj*5yMT}yP`wXU-2s_X9R_nTXi7+~H1H+t^Oy?NX_bEcg+H5fRaS&^#X0T}1y zs9>1-sXA&4QIGaNtF`#wUBWv9I&|w#QoD}(8`^!hOVZZXa^F$DzSDPir8cI8PuDP3 z)20&#$eV7&UGv(TENYvlODJCqtObI3I^h#b`+)nu0V@5Ld~nhk9$#VZltr`Z7T`k! zOZ`L1y7wBjWd3O7a&SO@ZRbDk{x7(|9MEqU|3bNV?AKS3o38uM6q9B@mc#T6Zl+rw*@PAB6^4!NF9WeK|t$%BU&Q5gbsDFq-m2s_3%5t zxDCHIP<{w_9|-WfR|#}Ca0sAMN4tLib*)*W>GAP)M&Uv87R=oljZ>yB1cNT38pPXD zQ7Y%T*Ai7saAdsM=q5(VIS2^_n*H_Y!W6I=6o|cD7(@9e4U;_2@67}?7? zLL3S9evf#zn}v3F`pD41kw|2dG?&SOTJP6?lm#Qb(hbZ%?7w7ckol%G-;nk`l0#u> zSyJ)S6!F&R^KJ&qOARrZ3F_i_PJm;>4h@3ySfF|{4yGdoX|9P8VfI*IhwzZ%riw2- zyl(-Y!F+m`@}GfE05y+E^0);d?Hlf|d~~_{AXul`@QMZ3Ve=L*n1+r=rhWq9V%8cb zYe$xGuFl8`lWCMg&7*5DpPKyd)|a+@_aMs00@HxtyK^Zw0KK37FW+TUH;O-aT8>>+ z_|CNkr>G0S@j!W|^3A94H4Sm3GFM6nOer<39^3JS>Th0Z`qIP0HolegpM6648{h{Z zsE>+A5h_DA=i?z*x7+X#%wOydA$l|Q`{K~0+up1a#>K?*U`@@!yAE3zhDetvJ;12M zb9%78)&I?_$G)r|K|QvGq&A6f9t{NbxDx;WlXp>%wZ=i(I&l!Lz;IFw(wqGE9oO!C ze}Bu?w(xJa0>QjG@jgp?o%=rlDs^n1kCN8xVt+qcYaD_ng)+0mDpKD>Jx9~ZVDNJ1 zrLVm9jOo%_cwiJX=ic298l@EzTf2QRqTv+wa~F<%EJ0VV1B%d`Hf0# zOF69Tlo*g#t{9a=jIN?Wc2ks~^4z74P#L{Dbd_JS!TEe%2?<;>uE4W|X?)%CgQWN*L0RIcV?)h)uXKao~ORTKY z=^{F3y5*@(?2Fxv+m~ur$OwoXH#PY6%T~0_+dhW*-Jsy@~@A$C7y-@rg?u-;8 zGW&$B(uBEZ$>2g93BxR#B~Ql*8HJ&AcoyXZc-L46D5ruMy#|R)z1F zl@YBH=mrGsJCgG8z&zl8Y2UWp?(=T{tM=9ZQ7~W!0A+jQ$VU0{_?h-at;iA1WRv!> z<4lQ9P9A5L8e`-%=XfzALn&+(CX6FWN{#wFTFH1;iyiUpUB><~@)S8#Jse0YQ&>tQ zk+L9x33dsyE4#1}8ltuQ-D~#_+7q-pR29+kfnp$NcjHh?o51~K;D2fNyZ-ri=YD0H z_Uq&J>n*BZZ79CA#x~;p$;z{ZLu~YO1-oT~h@pp}RK3t|!rTByzHvQ*W}kz_!AkXu ziv6q;qSX_uAlWK6K*R3?RUu$ED-P8Py*Uks1f6&gCDZdZ=83@+btA<(Hx7>Fe zw>!6k{&Rb8xVz3vI?o-PLfeKO63T3{n~@8-!>^tYlMFsLHGJ#Uqi4Hyd2;87HWN4z z2=IM6<@13{0F{1=-r3^MbD8Nqi9mpBjfL7@W!ZzmBPy(94Rcx%`NuFmXe&M`Li>YW z=E#5ewapUfrA&57DpiJls7{w^S~)~c7fj!zKu1lbCcnNHP>+Cbb3NrdflWZrpJ!ZU zYg@Sw_|gHNYUlnOH)FwEW@Cd`?{cUtI8++nkZ%N zMEPE5+o@1IPsck%DMz@p!-@=YVPGm1s?uRN-(8Jb8su1>i9_nHFA@0%M z8Kt~VhKa*&;sG%y&2sbTaLNqlnPwcZykctEKp3SWbjym$&F&>#7%LY_W1`HcQNvfJ zBK-EgB;pB|PPz=PwrcfY8qt>At&5a6r(L}CGPe;c4uzz+&?|DU#I*}RVXEI6Ut zCTQoIUEbDpmjE#D(K);KXHj5n=wj$D1;9Ciw>0_n%I4bY)q`>$U;xlsuhVU94EF~E zDh1;xI5)Tt+g<8+s#kC}gM^;Bw;EZ?g|^P@{zr*_@KX0s+9b<~J%Jw^hFN#aXK{Wn zc;cq|pFI3E^Zlg%tcCKIKwkH@dcJz3r5(=w6hNhp>%IR^TeI3fcf?<=$DDc7PQ=r- zd-w^)7~A;$bNl8LLOxi~h-o+#fIp-+|C#AUK%4eAEk>%vGw)MV5K z*dH3lu%@9rk-!irfv!wifY8QxVkRF;Er1^`8Dm1CsW0xy5oozZxP2<6iXu4U7 zn-rRE))TiWBsJ;g^*U$@f3MK=N144*p$Tj0B=ldh6-}yREJAjoNsj+pEJ?$NWYo8` zhJSnbA$npfeyS<=1@-{~{G7hk(xz~K67av^XVy;Rz2i9Y&yT&2#E=8u*-U`mzX+p2 zW`7Zb8)QmbYvCSrrD?1t6t=jVu=Q~(!FX7TzN+hwVl=MN(gvn&>TKB?8}qDck)iw; zVzW~5AFo|4)Hk4mWG(A1PzD6;TJVge4dZ?+pi;;E%6-`G()gS0TFAM0+&rbC+xu9W zR#~Tx>r4=uR`H7%*dX(@O{#~lHH{{k2GRSZo}_Up8n@bQ9EbM(Q4UJOw2X4tXQUf= zJR9 zpS%Akh4rUAh4p%+nC`ZaCE$c#L|%jJ^NWZvwto>!{jBzcYVDn-v(cO_#VfSccvY{` z6VK|gt$N}GHF`rg(r!~VbrlaQMYKjm3B?4-K*6X7)=4#1Z+(xD^579X?W^yh<93EqOYmd>;voYE9x4>T5Hkmh z_;fU{EtDs8qd)S(Jv<&ddq83YA+hz=$TuU==gt5>oSR+_cm5%dUnZN-QY$_a`z z!aI$2hvzn2Tq9VK>UFO^^}XBHpGlOb0W$$rA0g<^(Y3qzSZCn&eBW#4#X`D3Ke}ArTTR#t{d@wK`2--D|@|i%# zc;q{MXT~B$#4G+@gSbGPB^yMB6WTiCF`-SbjF#&WUB_(N31j#ii8vv`fXfI~QB08; z$TgH(HH2CdIRLvAtgyZIZ{gd)KKK#kuYvD?pnjqs_yPWh`n3`^4Pt>38bsXsT`#mV zD$&l@nU}t(&61N4wWV3xsL}oyyHcZOzdn=H_k85PKG4)=aX$wL>a+eSd{T4Y(I0v% zzF@~I%6^?O746P4T!q8)&Y@sOrc%A*wY!CP1nn;DkL(WE7YNqHb17c|+yJOF$=B=3 z`f^wIVY^F9`~6YPlVE(HUe;Q-lcwM^yW;~Tp{7@x#2nWy=^qGUqg1~hrad?l{jnh` zV#7#cPei;Gppd_>fkBp#$?{Nzo=a2{v(~`Yty@ACRx2?_A}6bpMV3*m6brhc6x%0< zO`O>+EMnbZ#HcYbjvmOv_v`1r!Oliu941dO`D;B|cfa~D;UG~O!*P-KX|K^OArFt* z9&P$hw{((PKKDg{pLX)g-HHnzKl{vHw2;Z$z<3}SFKZ~@3Ty&Y`mOP@wLM+YZoCYiswl)JG0zzxqAf@HL+DbYLkE;OiO6?*JbI{|mmHr5C%u>)5{s&6w%p z>vR`i{{mmCgM-O)fLmD=sHko75EE44Yu{htYjgr%WpdwSyD4N=hj)#!PVRW|rcgEW z(#05qW{Zb0{@Na6RWLsk#;C-vFvic^6!-8oZeZIynofB!a2gPd_iHI{1~xvq-2G>} z8}Gw?pK@<|MELFc2k1?%Gj2Dv&)p>>Yn?9&QZii_arT#&3QR?k)};cWZn6k&f?64f zN2YwWMeyN6AxhzVRfyrAfJR;`46H9slRFf1i`1O->Yp66bN##U?EhN-!xm4~*uPoa z{(DUuGTwJDX!qS~DBle{(C)jhQT`nGExaALcn;%e8${a6O1bf2FG~=81?(-?l`p<< zftSW`OLY&A8RI14L>N{|78=elK zJQJ7$1bAwsycX!_U%2DAr@qjAE)muw9%I;J-#Ws*_PE}y^UG5E2q$V8HUNJmDT`lB1{0XWd+EC!tTN~0=?;^_GfnI=L zZ{w!-L)vKW13w5`e7$71@v8kFHM+%)?bv7bN%Q8;p5h_@YY*!YrAxZbxJGIh>y<_y z)Lk9XoiWix4${#RR}|Jw!Wb>9seeu&r^N}7AWlh}>?~>y))&IkY|9u6k7&lH8*(H^l4jTZKH;?`_WE zS+8EXp>6dVO8Efb5Fn`6RLTp02cGrnwYzzJ=nR}hEXT?QZvO(&81%bDu%VyZv^G>z-d6Rzr9cS zbKswVN*~)7x%rkcUhdv-Y4YdA8SVM2?dX96=lM*1_v|%i_7uniLVs>1=BFS6D49Zb zgMeDZFjBmLjt%EdZ^uib-IRn}Ows{aZMA`kuPi@X$GieU!O3N-QJBVwWY|PorYkEh zssP~%GygM+h3`w}T-||^^tZulbame?A6l|zwO59k+%A)^imF$HMbrUzUaAhh%2&`? zA&pvjl$^l+5!zn~q4VK?gmAl(WiZ?(jBXUwxsZJkdtV2InHo1Q#Kfn{_KEX`au45) zd$*0(TPQyOyaxpMR(4e*>Lc0&K&9^t56^3&zsC0te_U+c4Zay43gxoA5kvl!DjIMD z(UcXAPj*q95tm6H#Zicgq}-<+ieGh6T<|!Qvg4vSQ=O3ZV>+@BJ;wqZMut)m?QK`d zsyp!3S_o1f_mLb`w8lH`y^G}BHm?kluhf&PMb#={DaCfRC~p_W5%N$`A#=oMJ=aC< zaWV~!hTWnlfYaWJ+Gs0ka~)9IQ03uu3-}7oLGM!j4EPEN)&a8@c2dKUo&5&gF=8(E z${bC*tCQcUXT?C+(?Yo&h^DfMOo+h0@Ze*6)6G^qj9orXC*bK-`_9odZE|O?K9l%X zP@j2}PX*2Zg8H2Nrll?AzN3C)$90MNxTJKZLCkP#a~`TNW*LhPE9B9HRV8y%7w@|} zcxQ0#%-@GP0Hr`MzWiTny?Cb5Z}AOxoal|aj`i{m@vTqi%sW+`>3f2%p4wMtyvQge z1hRn_u6#J#nihwbQlR`z1mCas>)S*+2ut*Wn!L z94p?_=w($il)Z`}FKDgUWWQbmN3_-J0QP`!+>Zx3;&Cd^{uexEcT=zZl)56QTW1`I zBgf31_R!oOdVfd-#cH(i%#Ak__5NnRUbnWZ*R+__9_RjPzYhHDH~BpKEo{X^iFTb8 zs0C>){`-56Y+Ij>raTK+1O)9qpYmnEb%07=c9>7@!*-Y2-L#wYtgp2lC=k0Is2l_Fg-dIO%rzsM!S8jG4MKL^tDFx8iOT7QGK0Hv>Mw z4V3Q!?gN5)Jw^F7;D6y0;9$=?d|v=P4N)BN4wLr@?MEsz7urAB#=<%Y3PuIQf`U2L zILNOR>sYl{uZq!a^uYkiqk*wNP_HBJv$eyye`=eDuRzYV2l{>Z5anlqtw8X7@q(qj&;9r({(FCoo;_!d za@C^{(;$|p)xF&1>pdv6YwSuaX!ephJ0FXFcWatvKn%O1r@L2=G5fZy2eH|PwvhYN zfnZ+vRn(U8Or_siSL=5(FJSVhKs~MKQ>?bB-m`UhD(TL$e~%r*Ie@Hq&t~oS9?@TZ zJ-4;1=cFp>@*V%qvjAU;@3@%f9pw?NAN2cc{DLX7=U``X|MUoYRq|r;)*BY%^9` zV0ShZ9js`)jXDd*`vULn(1<{oz@%^(u{{*VL?nR54??aMD4bUG^v1!s{o2-#>68}( zF95+f_|N{>O^%Id69JXh^ts5*^9Fjkd(-Z6Pxlq>T}S-yJ7T07sq#Mk7v=BvZ3kf^ zO+H@{$U3y}BL>vl9#EYo)LTBf<^_Hpg~Ndvn9FnByWMY;*IVRToLPE8VS8ou+uv!o z;!m>^=LJiOt87$h%R~3r`WIGVHO!vtY|OoI=)m7L+modJJkgp_(|p+;FZFlq;y>Au z*KP5dJ@HLD`i5Qflr5gL$3J67pSF|5#u{6{)Q;R^+jrZh%a3wa+vX}j^snpjEe zVLO~NzqHId@c>(0p#HkjcDjX^^0(w=wqo}F>X~(!9a~}R_uBaBFAr_9_0KGqWlSAo z2wV%zt8HVot*^2p?p3O9cE!5#9^0%`-(A=G&Cl`oqQ04Rx|!AL?-$vo`fAGkYP`c! z_1!Nm`%>FnV;2rMU{8jQ+IFrJ3?1dCdn$M>gJm+7JPL5p>YoWYG_r}yE z(l}V}VjY1O8vw;eW2RguLTf~HraE7is#DrrS!6&R8-)ZZ>+Bq5XTuXi#o)1TJNF=nh~o&oP~(@ znh?c_c+yDd$q1kh1Ia`p=?ObhgKW|>l zI-qTxUQ2mB@FWn-3pL2|4~%FffJz2FKM(Vu_r6S=!7F7^qqJ8_9NuKmhgvJOpBw_Mux1o| z=oEeI*nf76kMS3wJF!)cSnYJdYhihhQv9dksn>|WZZZsu*`V97q0D?~M>`|tE;Mk; zRUOUXepf)UeJ717#dSxG9woyJ7AAbhOM7^DYNY*>{6`N{-U7r9YQxh_lwSe90#w@4 z!S96sPg}G4_uAPXsk60*r^Dw@|2398iOs}$3zUd;@-sTJ-(Tc$>Uh%&CPp`4zME&@ zC!knu${N-})D!@d2r+ntrL3OU@;pvC+4?-DwS+2-*vn3Gn}c`9SDfUliLIG<|9ILggHQ|=bRm7I2HeoV|8)fam;U>n(rM}v$q{Y81FdofAKuD-7){} zRDa``Z#nor+3LjqrhfgrWB%Ex{j2)*b50cJT2A~6o`<(M=7&!8M~?Zd`t>soM>91~ z`!O%N`cpN>QVH8PjtBQRauP2;P_2iWhFfGNVU;X1d&@nDcHLd5ye~?0yvRio)@1YX zLDh|z^{`tk;oyk`Gi^M{#u1u^@M&5dG4W>=$7K+1UE;MdY;yH@(u(2CGFgrFZB)I> zO$`IR@z8|KInZypkMgsCIj(IyTtNA5;3Yt%r}psnk?#h0x%;r)Wz6q8jE5PEr_Gr? z-5(EXN4dptca&$y)F%40bT^BvxWe0761eSEtQX0CsQ3cIUA{oveHO4|M!V03X^r7Z zxxcYinBlNxh-IOCUv!gITgUmK&ay^|OL&^!D$q#?PnZLr#Fg$PT+sw~zI0EOU=?(Y z`-^vBz_-b=kmr;HuxDJA6wk)#AMkx|Q%i9~33Z5QI+gMbifzcZh-E}4|188H)x+bB z7leMkuxjKCdq8-Syi35;d041rw@J#bu5c*Jx_hwG)i$HPsK^fE^UFsFf{&ytL4+G+ zx1z*`fn?@yK?u~Rju>*A@K4n5_fMA$kAyL{&|cAp@OpN#0bBAL;d3#3QC-pWFv~> zOHt)IO5Zpca-TcgtG}yUX9cOyn!|mq@_#sDP`=A$vU3^5$Q$T<@09+~`qT-Y{^WEr z!drwh$TM1@e_wQ^_I0tjIu|Y z(NhmG4>g^T5pfbm;q1_y^5ObHjsb^D8uy0v^!D8Vy9&uF!Uq>V^K;xA|mdE-07sJFhy^-6il&a-fVTOxB$ipcYbWP5eC zyP^H$3_yhslfh8LG~pV(7m9Vu%ED*DxW_7XZ0C!xBPtm1DK#mUM%tb#D#acAH9xic zt^3pw$ydX;;=Wr%ZWrc%!mOGPgw0pN)p^bqUgxBfuY{e4!jXr=xHs$K+!r=O&eLsw zb@=P6Vdw3z^Mw1GcSHIDy|-S+8+6_)^uBr@lU>mqrk`x;6Ah;pd{1HFJ4OG!d5m+p zLjNlw(#6alB5OmLOG32g9`}7ByFR3!7c#3|dF?muvu?5TL-A!HSg)V9-s{Uk_{f7! z%WwaCC^LQ=Q@_nYk5T4#@r_}7&&Xob=#*2-t%~4zSWWc7Ko$Zxsb;=%tB8)og9iG> z@=gmE9yRi~$XW5@a6h&~q>*@iBr<)ReeEI6`!c>z&XA8OKh9_4XtA6(AA--UGFAdL zU8whJoSQ1$E>=-vUHAPWXkDT zRF-xqIaZOZX)R|9vp6OU<(s9|AtzN);N(XNWWL^sEh7`_R86mE^PG&Mp68hvyd$KZ zlJUBj6Rm*wV=5Uv6AX39`6kXL?U(g<+O8F4sLr{+ptdpPuFj>|V%Xfom5sZbn#X$U zQg%YyxxOdm5x~JfK(8&PdD27o};D2(*Bz>1E|9oWLgN z%%P0O%KjE2YsEikd!crq*G_F>TRTfA_X8FHK|75}=y?GB0F~ws_1dxYV=r%Q;s9^` z{qo)p`>WqhnC<(Wbjpkc3(xwsQ{8rrLt-A?~j=OO{ zK;PG08Aq}@7K*h>_TdT?Q1@}zfYn2|U(q_FP?rBQEcQi;&5mm4iqwKRx{^h~!8(kf z8NICT(U>JZBUVP+o@;*|$$k;>_T54>YCLrSe?f`5d+B4597a}Vq%=`3EAY){pB+JH z#a#F*TbTx38j_kf`RClNo^xHapQO-W_x$l_e3u%Vk-{TJ}tf25p$L_|9q2>SW4BO}`Lz~_KUAJ=>RTk+Yi`?=Zg=N}hz*k|{j zGHu?}**NRnX8aI&*$~@|68Bo_UVkxPh8?-iXz_@sjM&$ustU!=x?3m^W7r3E%<}Po z1j2fECjDu%oSr^~JPm;)T(yEOE&z zclSgnmUZt6)zD~+#wD&62WIw?m-4&B@~AV~sjKAZ-C0)hAdD!Q4KQxnovq1G?ol*v zc0~1Cq6Pfx#G6s^R*C<>dxMF42L%k^C%YXS!foN${g2Xy*6bgG3Zal@}Provx734o2OH& zM7WmoMAAVyMXm8VQqfxcdNxsq3jfP*Qf>kM0tE9matwBh zKpmjc8H2s~I=RF8JlUVOvv#u&`Ssp|dJpvK-EpTnVBsv5?iPK$`GKxjZQD)lx_F&6 zPL@9=X2|rT;#k~kl@Nod0`|SSmVy3?GJhGo&leWYXZTzq@LSNGU*LkC-*i=L-*rl< z;z?a=KGy4>D``)K+agVSl=4e}eQaC1Z>9Vw@GhXzqJ6w}Z=d4vMf`uNRqX5SOU)h6 zeg3}Ww|o4&lV;4BGyAYvQ%*W@q4GFxei{D75Dn&A`Y-On)r>weC)()-7B|u_-+h?`xFfjeieWVwHm(iHN>iaz5O|a@i9s3^Bst5%5^d zP}gwWxj-0W;qQ+vfxiA{tfF<7WN&nm4hVQRPEvwFIF z7H-gAjq#RXf2wYbo3F&&6XN-*#2$5lavx6pw^7omg9M{(0jg%h%9tDE=_zC52fh+y_nC&nh$L(f5GM?3ZWEM7xk3A0>HLe7z zw|hOpy?XIHoRu!owO!ZN;jFD3CGQXpng=>LD{RzSwUItCwHKMiI0mz8^h|+M0xHz} zeb~oZf1FkxtRk}XGu)%M8iN(Hy!JiOZXS=B?5#JG$o=-o<0&r&#&^(LQme;RtR5Jk zMmAs-^AS}$l}$-0S^LY!5<0KQqw`KcT|liKq}kqgn|Wu2`vB`X%3lCGfZ)6FDcJo3 z^B-UC{%UvfyhGLOM{n*0`LfcRThppHr*`#KhPH!x#f=4Wn4E|FEDd|l4lhY#2;(QL(ZJJ*UVR&>OF*aGOZhF}3m{nU-nqci3a3W2azLeyavJwx zyG!qmd{TejBeMzEqBDE>LL|3=L*4e}yVw5i^)j!meLQWoGdb+5xDUsm=2Af3Q(K+H z4&H$AVH6m6Yz!N{aA8}Q9vI(K+D8q*Q}`YQ!{U2MyHc%lKMP})YH#ana5P>37p$jW z=cYOSe4u@SymKYxn}Iukpx<7k{236$yAAw;c3ibRy=b>(-+NtmmDik{*PM3@R6A8% zj6Au3!8=cdh*zEGoaT03B-QG2;Z4>1HJ!i|$ef7nhZ6 zo)!b}L;`wyRgbcUxoVvGPL&1CG+mzOKVTtuB4%g0K~3MkD+*odbe`eo->5^gdZ2i? z?8{dO<22}WOx0$KDag81s#xf?vtoK%JNKk~G%y7S+IcnQr-A>Wod?ZR%T$B>qbJh3 z-D~QPUQ?HOS;TAczeCz}skRn#I9CmgMmf@ZE#{PBOp{;vx6x^>P2a?Z1TbCg8W) zf3VUK=-AE6JWOCVskn6Kg|wD-joy7X`^`@J-Rv)uJ9YD)ycQ?jZiXe&zMEebiZ~-R zpW(IBIlir(rIZH)djml`PoaD@@LTPiG|y}3uRFQHYiK90cUR)-IsIRI*B#Go{Yyh5 zZR2^Tu6;e|S{2K^Z9KoLI`l;)yd||Xc}m3*a2%xVGhQ^0{8U2F_|5F9%XS2AB#2^*X(arOoBOqrc#e>)&`4|4tDv=Xk_R zClyd^qk+y{t@6GyPcK&*ZWA!@CL`4^^}hcA@2+>BU=B|CFTm$O@cr=3A?*k5mwdV0 z{naDB-a*Ij4|k(uyZgs9cQ^dW>&fwI``Bm@pKusB!ot9~K&p|%Zg7==#_txaljnQ& znKiqupU$GZ0=N_i_VwQlx3n9%zYS2Swu8No`>@^Rj5*%E(Xqa*t}Yuzzm-bg=x9aN zZ8Yw}7bMmool{-%%8u;h8jvqG`l_9{8x|pXgk|*b*W%arN9qymzljq#8v>nx0DtpK zLRug0NBa86fjxC=eNJ64d(jNE_ZQ8c?>aZx2nDx6V4z^UDzqaN&IV)A+L;wNArgvJ zGuoIStIRyVjm^va@y$0X{AbTm{u=lX5a8{t6Y=W<f5ZBJEUK@NIuWiDN^7=&px1~D zVB@0X-bEG>%-ReMgrpqKIZ;8f21%sRS?x{2sB&;q6Dx6?9+i<|S?XAJg5Y}jvZR2h z8_B*-Kf^ZnMv{U{YP{I+?UW3~OpZfwJeK4_@hDP;2peXy5~TrY&MQ0RTW(H1a=v|t zIgFa>I{Y?*NQOOi+&m+$2w8Ppdd;T`nuYN@xk1Q_@b;=0?uj$P z`n)~m7BoA%)6S!OHP8eE<8CwM z*MP0RvHmVZ@y^{*mwJQ)4hW!|4#dp_eRaF8eTB_PPmZ!ibOr1~*~>^8T_br)mT&j9 z8A@sqeoigIvj{%fmmBtt!H|dF3!T4`R(tiWnAH8pT%z=I^e}FQevs8m^-3Z_OT48M zt2rIlqro}yYZc^CAr`SztvtYtSPRtpd7fnPnHm%t0RI32JSXR) z{|zkd;AgpYeERgvzVoJzI%%30;k8M;?-2>zTv8#bNQHZhyCn|vAQ$?}fk;hZZ@@el zY|BtZvbE+!a&>Dyp0y0yE9FqD;tH>xOQ=Io&()M~1J(n<_vpP6>!&+P&;|V+&oR4e@q<0f>^Z_ctWM5QV#J> z2@SpjzIO$V5oCAO0d8u!(reGaliS)ep7Jzc77*aQ@w2eDko%|nbz!IR?brXnDGL_P znlh)A3|ioB>ie#AYyPgXiH8b0P9LLo^23eatISQ!S9$el;Tr*7f2N$jfbk9l<9#UQ z1Arp{m3qIk+|952+{@h?E{#5Z*Y0M#AAZu@U)8ipT<(pLE4(b>4U*j7#i(z@+v4vc z@eQM95enN|#i$oVot4(FWUeG$BC2v&`@Ain8YpuP zz1nN%7V00g^JB{20zU#lJ8xWu!-$30dI2hR#Jl^j-Q|JZwA1g8{SQ0%AeSul`U94l zngW}JcCmoUsy3}*Rh3=4cXR2i!wom+NjJP?RJsbf>N72Vy(d$jpx$Rvz64ke1momJ z%69_IzoEa}ihr+WnfX)LOHzP6Pu1*7*EOw?wd(aW&K6ykQw+s}v%Cp;C{DIqbZQOP zc=-I0ZwBXz$fAf=02Bj3y_A2{Q-F^Ar`B~t@sy^`nK6aqUZ!^$T6RT52w86&qFe^| zRI9U>TKw$i`04fGyqiN&-HC?y7q}w(Hrq_b#-x65cQ>IYRHF6c zqp{&e50BcZZFo#kt^@i50Uj@>d?WBMpwhsHJp7F;-!&c|*%cn~+2Ycomw7C$$9xSt z@9wZ)=-Ty0qoe&>REEnH0y3?u_BjU)T$?4{6?H#|IFsFZT@Y5cx|rqnb&(2(enDTw`k8Yuj{D>+ zW52(P+*itBxQxN}+o7htB0%r~?~eZ4nJD_1)=)9tsMXzdqAy!voe>wjk97jAZHD_c zK_Atk*5i+ML{_(Xc-cmK$M{YAi1IhU4?xgQg{KkY0obxD|L017cuo=_g{jfD(8-H4;wt?MdmS}Dy# zdCRut0SHCW+?D$)RtCp-1Eq=5XhNRpjMk?h-LU?`slLxcGPXh1Zj|}8rk>F=iHMCZ z>I>4u-$I}%^OXvp$pUx3FfNz!LLvSvIAF=C9Ie$)PR8Hg-z@@4#ZUm5&*o608aa8R zhqqh7N3fqgM)@V+H6ZBM7Rp}&^}ZeXPJF-Q#Tn&h?tWv;TX&l*fz)qkR=r`&%~o z?dP4rxPF-OYryt)-#PI2?1zByh&N7_KIrAG^h#^{;rMJsXL*AmHX7@rZq{IIFcNBj zr-^aXm!Rn5- z<}O0~yIveIhRIOg3~a^W$jmdQlojR;%$WJ})mb@qB3ihF5iN8=P9ho0*s&rXIcHTAFf0=a_{%e5&K(LO~Hh>S{WI&}QpLpY8O|zH#|Fkt5c;+Q; zxwRhdI>Iv8`e5#qC2cqJ7cV*pcA^?eEpc}YovXXqlU_E&y}8~SWurF0L0oTqUx;JO zzTqOQqKO1|)X-VsaI9M_QtYH;UdV~&7V3+PWtKxEt5v3TzLn4D%g6&dRbZUe*{TU; z@O{!v3}#`7U@ke_nkWbEtB*0mgXQmt)7vEE_*8u~8&YaQi2rmwnvJjuqrL7#lc8dC zFks!}i8XrnOZ20!6_HuaO%}97gVkAtzcq8;n^}?QZ2Z{_N$VrbTZYTyKE!8x^}8kp zA1|AAubLGiy)ZY+>TTDVy_|Aqad?ez%ACdW*gpt;sNSR;rNzwdtZ^4wk)?P#A@bXK zR`db2VyNN$3ffyfdAA5f;xB~Kx0r=;?pf4-mOK;J{#T0pQ?gx=)1EI%re|}g&#Vh%TvVC*T7OHoUGIyc}PV=Is%uyFCwrr6-$VO*j#{SA=_p288QEeL4ShjZ{U$ z5$u|1vK1k$O;nLSTJv+>_{^T&b{^?Lc`&dy5R9LtlpBE?0F_?4%M^Vji3G81?m$F{q(b8Aj+NUR5QvR;;kQ=vgP*R+h7ir9&&8Jm#R*GSW0 zro|rX9BW)6L0y|%|Mv=|`#FLD|>oByFe$+u;H%LYG$Ip~u7$Hl% zH2FoZ-8*Pca1OK1!B;F$1qAEFLds_W-wpQW$sy2L`+T~6iGxrdsArZD9eoSkBeXx}xWb#C7hMek5bXU;c*dL|E5E;TR>N0^J z_GG9KxrG^y7Oh?IZA13`Z^U4?F&&9bwxz$aFrmtN&^f0dJW; zaQsr?4|eTOc2<V+S|e-Fw%vzyCc`?jFSBrp z+;^?$euD@m2hAC-Z60VfXn$ zS*Gsqbpnic6~`Gj345%S6VnWAt_}~42;U{lG1gIXrI05nqnxuMeX+^aao2}UIw~k9 z!k$YTBlU8$?5`J0_&TfB9E}y$AB5A>u5;?_S7KY#&1Fb~F4o($}kZ|d(4zVj;Fth^vyH!D|o z+41hp3Bff=U%y~$ZxZbq`dv+RjLj^igYlyX;hE#Q%Dsc_HcR_4RH=edN^w!Bs3D|3h|kW#XPjQoI-G5v zaS~5Erpq8;B)ANMn~!k{E066LI7GJgc|? z3(Z+ZFXMQzmB1$oPkUitsSdEc+ykt#fX>1R$P!dEV?Lt>h&sX&je^whu|NNpv7Y;F zE~30AFcb*zbv5N%fX4xqX8CmFca^)I|D_-A8ec5;jX2s_sf$Mae%;M3_Oh~-?u@@0 z)s}FbEOsUs;_gt<%_02*g|4rh@n16$$+LfR!ma44fuEB6-bov${4r$S97@ajCqwZn zcKGW;VNr2!sJhr`3YFaulDCE)VZc5E!i->cnA?_i(C(!-_4bUz43EMpoWDxI=6p-Q=UlHm*u4}+ZieA4 zd%|Brgp&Q3SRRO&KVS&ez8gxt63V?A%F|12^Q}>%Us6HmTYR#W}?X17BZQM+tJQFwr2-+E14$mDJ3aE7JcCQ@` z`+Ir2Cc4FqyP`9hX9swmh|8Pxb@pamH0$g2&AK{t&(VcZuu4W(%fna6;?;6lNNb4H z85t{vVNw3duvil=_)9qYX}Ch}@maX|b4)R#*CQsjt_vd&hEaC>U1lGsFe`c|&Wo&d zVS@6?{T(s3`?v$3@&+1{5@YWQn@08R;W9IPRXEoaPGj;fpbdC~5XPl+xcst)Hu1F-O|HA|Vwg&4rc zFiS~k+T2>k${2nhP+dCG4BI{=k_{M&LjpZv9#yEpAF4ZGs+(l7fc zc1RoDKNCshq8SUuqesn5->5f)H|RK#Smu*Gn{YH2sdGwbRm6%#Fg}oJD=i&@Kbci} zTu(Ti^sv)l<=tc%Br`9xmow}n!Z#W?AD33KX%0H3`|q9eMRGv8D|RxvKHfP-ZU12r zu|h^CE1!)22Hu8~hn^KL!O7E9BAZ^4C#vXHw(zQ0vx6E5CtCL=4b_&-)M4cxp zKNTqxi$*)FSA_D*(F`!*P(-3i{-rvw(I>0+h8HYOF#aXvE5dw9GWte4*SacHZwMSk zKBp6v=^}zqz`3r}Z)An7_}=S}Sr@nU$2pWQ237$_C_8DKl0(%PTAe&}yp)UFrW zins2jKMtKSZSjJIvrn0!&=0}8LB^RRH^GOC*0Bc_6Fs`xD&<&lhky=(BDqvnuED98 zyh3)rP9{q@q^kBdD0Z8p9L^ks1~|Vx-tH345AoOAby+F$jP81wTbao}dhHu?2|C1n z^^T{!99Rhi?fYOwMEe;yc%?tyrh5Cs*6Ci})-ta&e>eMWYx@pcGy`U7Rg-md#r2kG zvfeY?Y_5CV+r54(uD8^p_-amDgEi2haxmeb!wxdpf{H>XKbwnNMON~)DAWGcXyvgJ zGEwwf)6m$KwJ)SakyXG9bW#pRLes68@bu;y$IYxE@+z7q*t$FAsnR^7ng}<8owAq_ z?-z5dtn+6f4=t#=BF0NEk7YT)FmTN)VwSPTidgvESl!ZCdNBV+zA33t$=m98+xcCA zyipi&_xm&D2NwLIOW}wN3zWQhqafP+TXGqllzR>X)!cPZ5 zR1d_at>~X3cd6(;J@+COdGNU;o7i13+;O}q49+h`9a>0)G}#og2Eur&GjkXr9n1cF zw!vw3EbXhsItt_>f!J@AG*-*rT2mq}^ROO{-y1g`ixFEjvcCYT3UsechCUeuRyj8WwbqN*SE zw>X>Q$MHQx_^o*DJMkVO5$g6~+^dOB`!+;gp+I8NRY5&X7=4%b$EQ?lEah$G6$*!amX%VxYk7a$@^p zH)t6H^jeP994Bhcy@BIg6SRa~<#e+XjsaO$4sl#&_b`-N7l19@3KFXa&U|IoBTkC7V#I)xAPG9=3DRP$PZk0^e?vd zo$7%heTP{q!-Fn|d|?y3G!a^z(Bn{ha*T0U%DgDSvElMW9XzK*?&3sO5wp8pmN5Sv z*YA#}o8wiFEBrUd6Pr8Y|B-l)RpFS6e{~(_tV%dd359E?(yWSh_Obtwe-^t_;;!Ua z48`qZ!2XwT$klO75sa2GNG}7KkE>Hhe@K-{F80&Az`lxS4+so0&E8sbzK8cF@EM%r z?xFlD@D>o@{lYcaU;}pnDn0d(H!s&Dz1)4+?y_!I`wDVuWe*|?cc9$*wGcZ*rF(r{ zce7W>)IRcHNV^d`gFG3tQgNlVJ9NFAGl8IgUPkNTChqS7R2pCE^-CasaUZt3l^#Y$Xil;pt=;z2( ze{9sXYf@P2hV8qPk=v8jElF!r5{lAgoOQ8AhRi#YU};?vTRh@qC381Yis><<^4?_p z#w2aNMHt7*C8E1(|3LN&6=LU7g#&c@L_Mq(o_ndj)N60=%iG#JlJeoekwDPiKTy6I zxCc<_o&jEacegJ0-c5Tw{rQdJSx>_FD#9q$6l7mg)RMwf~!>Vh$4z z7D(FWUy`{`+daYbn6pObe1Olrbr)tpCC_}kf#%56#!0!4Mv z9Aj^Br0No-Go&*jCeT%RbX6y9a#yb%pHjbIANe=s*dNe+1%h^5OZhh7K|rOadj9u$ zn>%Y4`}YDCs(C87YKwTlaChx5*tM19^g9tEhGr-fKHt!`U{&IL7_I*(8h#~JunnGb z0Ekl%G;c zvX!X;UAY}@*{R8xW?!eq`6OztiJ?1BzB(4|gFICY_gfKTtKoi;G*(J^gnUA6+x)G& ztViRjKtItbGSGfE;&|?oI4U+m7fVcfb8HCppqVg3Drk0d&L96*V@u*<4n-u&mjKrS z0e?KkkcNcNbQ zHJR?+DEE4Vd%csts5NArzDjmlEmI7RUy8KGOkWwlEjHrzSTx!*nu=CN&F9jgSJUE^ zbn42q{z?juMpvdsR7R^Tyw6zi*rS=&7nEtEa7GFxM2v-uYb$2rEClTBy4nwhLK ze{AGfC!Q_C4MJ(Q#Iz!%Iz|nOv~bRH=ICj?h`T&J6M~;*<)MX9=om$LSgmDH)-?xN znUf=DW>ID2^(=>LdHH$5eYny69WljNOtTWdu(uG&1vsa`eiYR|G?Oc&|&Ph92{?^;MDXrAlYPNQK3LCa1Wn9lz z)Zt&m%FPGU9e-aQi_Mg|TBpu@G;MXU8`HJ7rE9bDd@eYbx;NJ6_L$Y*>1TEczZjF> z#hk9ri?P@(hI6YScf_2pV~$%ko8!?d)U;QJ(ffUCEZnQypv(QuO~OG(;5{k#k8Bcg zieCwNo)H_HY|+KRsR`)=Q&)(z8{N1b#nkzjv*T(4M@2bvVYE{u#bmuLjV4hRW$2!^ zqDd>PLRsUNvc%`>4vWb#dl)!jA%70lX<>7?traphNb1R6sj{?8CDT}HCy_DhXal5G z?uD7vn)dYOO~Z9<^X6*GcK{oKVBUF_NSTJp;^?HjSIw{(Scp8=nzMTr5D9w1J9A1*Sf{} zgd8Qsvc|T%Tq_X6n>C@nQ3zQ}5xjd6ze1X$4|pN3x-lIAXzMjd?4|>*@jNc-S z`-S{k$oqx(N| z5LaBCpK8g|hq2h_3NqP94{1A{*PccH8h@$)R%lXaJ3fcL|{9;P89BWAW zc7bEmv=NP|og!<}Rk%hWvUc2wgg6tZuIB$Wf@egVkCvt)D1G4**K*hfu%aqH!`Y{F zY6QvKKH+Y5qd2>O^nNP)dZye=rY|}3qLU~`{03eW;j#?7d&f)&Q>VGPm9=wkf z_P$t!z8q4gMNW4^WxL3~|)FF@yl>L$M4vYeV@qQ!a^}s`bN?UzfY3~$P%CbKf z#HIC7foX+pj8i_$*FVlTkX7XsikI`lhWT+mybsI95n3A3Pq824Y{(=3qsC@S0YS=N zF37!EV48_ySo)F3C;8#CMX05K)+r+B6ZH>ZzCqd?A!mwSI?so}$S*|4q>F)-g<4K} z;^+mrmx{|lUADN(hX|_%tbxC3Q#fAFQ1+XTz6y%;O$jag^ z1=(i{beF}ZWX!J6^mb#^+H@2;qGlByNOP-0qcN2n6Lli9;iFBEqpiG$3yU@u>YwqV zlAj84|Kdf3KNV#ESLIOIhpzm2P`)Q4*StG$#%4IWILILWk9k{veyED&_+8*2w z0{paly}tQ;Y?Coio0=kiuyUrXzJXR>+LXzO}N!bj5Y+5!a!t^a0{Jvg<6RthC8^GfgNx6f-(?FH3c^ ztfB*wXVT%eF%Ms%vcfqUXCYQ68;*8#lyf-3%B(Q+k)+LYCKpdRc!HxS)^5qSg>zbH zzUesG>?+|P(d2p>KJ`u}7P8^YNq2&`7)B|9>9|bj>@m`roZw;TEHjBdn-R-~l6auE z%wf)0{eVch)hW)8$Bz}J|Mtg(Ga@?4ED86>52u_Mw!raD$^5uAESWPiSQXZWlA$wV z(^DbzmE)}I`5|*S+&YAZP6&_qGKS39B+8D}U8WvG*BPOuGuK&yqtfA+nA!1yuwiv0 z;4y{=hIx}PJ2{2jo6tZKP9Z+qQ*j9mqf8g0#hnse&6tB=*s6{1Yj#Jw3vXA@@H7pv zv#VU7WNi2N{?(?yf!kqTN{{+reUJ$!-;gEq!fP2txJt724G})cIkK}e(h^r2 zX`|1u&Mo?>QH;U`hNWy9PR#t9&idA(XFt%*e!0HskK`BvgP0M<;%QFzY<^Kmc2Jqx z9aPVKq@=;jOQ?^lO?)5V!A;JS^?J4Slu$eGQHV%8qp5xr_Q2;$rIHCeDV-6bvw6Qj zSd~RcokNKS1o^3DuGhbfx3}?;Zm0Y(@H7zYCs*Hr9uDvXppxV3Q7kgNe)eD2nl;Sz zp8smTPn)M)aV}DOG2}*hgK=~C7hN1>7YxLVA@ z9FDFOxJM-LFl1SdiQILZ6R~mTSCFxs&avx4FImJXtA-{%k@Nf(19VEb{uN_qw+EH-hqEz;qz! zp9?8p2HXIs^jmh8kL+sxab8eHH7L!!CR`1V7cq@Xx!g{Z-x%Z?1l3d zsBze+-xPHhnmbu&&=1Qfjj&C|CE^lUzCx~1jv_(`Bu;<_Rmv#J*K3jO^+nV>6evVu z(VfG1p|K~Va!l{ZGbQHU%g@u;DPJNBR><5XGH->6z(da!l??LM|X738#P(4!MyC2p*^j5FiR9awxdE zMnD$<0asMSgDj#Vy2ARZpdx0~b-h7XS6|m79=M1HcrM<*Z&h_?CPM=3e*T~T=l%SC zZzoT8b#+hGQ%{}!)N{ZN!X0|f7Cpu43AG17AkEoj*I zMOIOv;q&D~IYta6&t-zgiT-b8e$TTI7>CHs(QbJS{9 zO0E)49wCsO1M}(F`t!m|-34ZV`|R*!qNgVt-8~RpqNQHj0FM^uR8Jb@nz}(~r!QKz zA758CxB@oAveXGBs`n7=lNSe){K)}qzhRpemBx>n%Vj&=x;?t?dIsSS0S5r?dN0#3 zmDC-ur_h;Rm!of9J-=aE?X)>dT-%+E+AW2B{P}?xmyF$R+MC(j5xv;_&^g{UaTihe)$xh2kwz!cg`ie>?x4EUOYLfV` z0B+@?M2d1h!Y=@R0J#0^kw0M`0DKAHhyAGRUn|~j-_OdO^_?{&uAlLBUSn+G#=+xL z-TVo))9SJI%R8+9BXCG3hxM`Ie5Ecf>4zXd^Dz58OSqnxCmUZAPl@qI!&79O1RlHlChYtI5(wxPda*e0QI@opO4R2pJC`Q z8JQ4Dl zy_SZP@dDGVa9T|oTnpI1B~WaIdwLTgbUhAA!>2)@OT^;6p6J&V<-=lQymoTp773E@jS4&5m&1kV*Vz9h5zikDqsVg>Y~0!=xL}~rBsBVR5gKJAdq63ERIPIA|ADA{ z%2tFw24wtKwEoX}5ccx`djb4t+Ar&UYot9N%697ivaTKQ&)e1i*hSN8G3w^+FnVn_ z$PVLSU0hBP=R4BHB{k-Jt0r!CsN(Vp*^VCdFniNPttWV{C-B7s?u`SdV>fPVz!Ney zTi9pQU`syH(+_-U?IQgG<$hWT@$N7gZzhMsYzoFzJz%V%-oKE%iT;f=d^0`-Y>3%I ze9{hmN7BEAiP2g*iVmqTZl&Q*P&46#p}n9C5y7s-@5miAxfQ;*3Cy{rc1!noGJo>J z@b3*iYoyu>E)n{wBm7Xksa3n`w_C=3OKl=*U-d4__|oS+=rjImg}a9P*;gcdWHIXd z(DLe4`2L*@b`q<_Cg6R<2%i_O(~Nep+ev1)S9rYkq!rq4fjM<)_|)yBGMrRF=VCHR zv(meyb)#Ter5WkAKMCzOoE|O4zfK~-PDzkaa74g1P9EmX71@_%k=-&5!yVAA&e zLjAegJ17b!@HeS;Gs(!PTT2tV(5&-z&>C*NkGId=6fB9K3vqI1b{O5dl~CA)z?v7y zAK(plEV-05(u#z4Y0?{%%b|fawi1j}YRK>lgg!v%ati0N9JN|Jq!^#D2RDWlRb=z+=#rS!l3qZSnm0>) zT9FryCVo1E@YjGNGLA=)eU6lTTmH}soIF_p@s&pzPNOky@IS^kt1;Ap@z0rc{4f5B zuL8tzW&+juTl~+B!y1B$0ix;-iPz$XkH+hFGEU&7kht%9sImgDU3?tlfB#?M)9T=3 z?2O`*if{%XBI5);)g93ZYucMRop9PB2m--*iJTynOQ1Gl;B&)?iy@UDQyZZ`CWe7p zY1$&`)EtACTYuYQ@M?<3i_>X}vL4)1hEDmPpu>>_=g~pYK`3&k#HV~$luoB5Qe_bC zhsroXr<$YinLTIn8KBWQ%5pd-<84FWgbnRZ{yMV&KLRCD$lpeS9S9Bh`gM9nd-FJXwG-Z@8%j^y_mgp= zyoclSl{N$><9p{(FbhWWL#u;84*YJ=d?wfz!b;D~WL z6)YbbKdQ;DC1R;x0nK zfMYIW5kd)pS36wgdk(fv<;zr2-FTZfQo;uelt^%t(DcAp;??w@Tt&0xY01XVvEk{1 z10!hO4ja&|b*rpr{taTHc>;UaY;S)P5@T}_`Hy!D0C&lHvoD$dNUo^kBVAID-kB#L|QFQ`fnorh=@U- zIpR@1J4eSJs@|6VwTL@{cRIDVQ`|K^ePMu2se*PU7|%7^WI3vKN9AW~5dQTWkD`9- zY0IY`aSH+Q_I29km!tEUuxK8n`$IKno(~QwpV}xr7sEW1Kl&qY5Jw#M%KTf9Hstul z@jk)_0iOe0Ivz&&N5C<@i(<<;@xuDXiH)_>IRcSNY~=+NbE?9oN;LYqXjpA%)HryI zd_2lGsYCc8z#@Q)#}b6E0=)g_l>*n})aRsz3!(nb?V?_e{iAgxQGt@A!mXuj$QtRgbzv_77{ z*1o=CO!LuD)!0F_N+0B@0@3>3#ggx26$XH{YZR73qos&sGyNNB7bz~6O*6+3HY&&%MZKON-p38 z+sl;t84F=JK2&w;AQQzFjde8Y21~O3*zpM~%}&0vk!F>H%QxuPzXHHEXe*}&;Y5Jd znH{ibD~)z`q*rynEZ4J0-~GPck8pfH^q^0Ef%txLc4yn@7?-}=2`8U#)S&0}lf53} zqmy61mZ<*UWQ5NLOar*}R)=sS;HTZ4wD*|&+I6~e+3C(!*x8bt+%=BCKyk9SVo&ZTU;{m^*%P}k(-vo$84ClP)D z@G`*7=M{wC2Al}J+ZyfAK?d)ua}_pM(t4yz>zuX$?oYCu#ZN~2>0pGC%4mvw7pXu|LG%jrD!JV8mF07wkR@`;yqWKrsRm1Ad z+yy0N@~Z4+Xq6(7RwtjxQ_*sbMtBn7Jb=rWnzqBEEW!Os06&)Jt`b+4F0bMSr**yM z_sLdgygUv*jSX{_%xjoiU(4+*HPNpqckjF#_QHb~+P4v9aV|b@Gg!(;fvGh3VE`gS zV9?~VR4y{q9WqRl09nw@&*YnDtD#qk4K;~L(|^hOIskkl4sr=k0X? zG@akbqvk=0XEShc$N$F=eg^Osz@@)dhII<=$zLS>BhL8MnI7>GM?Neb?@M6-dhpDd z^?YfOT3w58=|ST;s(s)Cxi`QAzsFucin0yD@yX`|rt%eBDodIkIL|Z1I@8O6o4)Bz zd(doktfKS*Yi%OxK0G`tC8`?!53dw(Td4C?0ZW+f29WfD0|q>3D($WI{Bx723tw6u#E^8;LW!vO4fsPcQK$Nka7;3fK&#IAPm;w z4z7cPHXS3*5fZW+(Dva?Q}{eDMLyj3c~UTltBecbK3n;MRI)SwK$15@s1R(3^Ia#$ z+}*2o9;wAnnB&L__cdfXb{*T?SZULWr~9~%*CI$ePqU|oT`zSII>P+`NRm`QYgL5( z#4bqW?{c4Z(~Rld6RdEL%stxS-Y6DtbB}i7j=vvr-zWTS8-8QE@wee1Ih&`<-$mFa z_T2Cea0JPPQRFkJb^)0iy$AAhNCOmU1o8>+!KKYBZs5pgaO8>6Xg7pn(Vu6J;0Tp* z^bzLh1n_pa1e;s)j0TR7bjT;~jp8(cl6%MBw*~r$5$LzQ4U}b3`4giUZ&r`MP&A%q zgPWdBIgH;+(&6(L`N1o$$2Fga;Jr&iVOWDQBWuyW^4Y;aVNYv@JcIs(}^j};BUX|-tmfK z>x03Y{S99e>o5tb*Z7lNu}$~$Ez)g% z5aVwC(;xT=_A?UbTNK^2nZE+diXqy>r3}HPJQ-US5!w^RfQ?C>{81mZv+anu8c$G{5_|1jKIyTtrGL&h2vrH+gpp6+~V|U`FZ` z5iI1=C^PA^1lv@|pb?hOZXz%xuYk3m3?o6(IU@#1m6!!yPe3|t%=QvHD+p@~DlF`i z^kA_q2Fjpqi-8hiq;}9w%!18*aRpbu2Y0yWHB2#B!jXWy!!QYa+rl0UnXs=W1{a3C zLRcGv@g#AL*x_U|>|&FfO0wN>b{6T9ES$?>=*%zf;)8?waK7C|44oX4e7i7)fy2-WOyr$`KG3Z#oaMnFYT5!5ykpqF1enTe+A7u+CMdd{RMt@Xm0Ira zkysh7$mvzn^UOXHCk&^(w~~Q{0x6Jgzy(7Z2ERpz1hBF045%D0xZDPPXby0y>}&Lq zr7k_m=wqYKyU8m!n!%n8l~zS*rBR`kTRlq4{H0oVt6NgnU3)QF0(Jx3^#OT7SDwLr(yJ@QPhEN49-~I}|LU@EI zSE~@ar58c?0?UK$_{--#L>?^;KK=Ga*DEs-z65X(;I7|3+Xs6iufWDEfFI2-$aUM) zOu0^wsX=bCK&}sdS}pf0kAv?Ffp5$bp-HD8&;0*f? z61>D7g>^zR!DLww%&%Ao1VOv`uAZLH1$bX&XXHp5H)6)Tu;fv){OtcrgbIj zPjj|eaGQCLRY>zcr^$!$n{0L}RD&Zw({Ph&>KRw587pwjW@M?=@T)K5dNuh*+!rvU z{FSf%kT07>?pBkxsW4^2syx{1RJA)*Ym1t6&=>d-PoY_HoocO9gTMF^uCc`ZGSylN zZ|EZYBMtljVu{~_lU*k^|Wc?;XUt z#5XFC>~AJ^I9QOFkx-J<5XwkNP97PqPdz0)Iqf=|i> zfv_b2>m*Rap+TmRB1k_@gh-9%!D=OO2=`Yxonj&Zd4;|&I$_m&vOEWXtNT5c@;C5B z0bKy@_;S(Tb)`G*C^j+>;ou4W@obPmN|7sBxkMD@Od37-S zTQ{bT+d^)oUu*AC(n`m^M`ypH+f zyOrXnlMG4EczQYhAHXDNfiF;@r=8E$w0E<0s&WK+tXa@d$zok-0?UMT&VZ)t1#}4O z4h2@28HLn1@b1YThXfCLc!n2u@`&u0c&-BuE}q*Eeid*S;MV)Vzr(*EU@m|k-xNCa zUL>zFwdgsL(s8}F`HMhF4wLtoF!ZokoO8u_NKCkb+ke>2ej)hk$_fqxLnPhKN4=}T zA@(~Ym}_65Y}0b-0GJF)_h^A4I&=?7f1G6PA%m&uT3-RIDaM;<`<5a4To+YZz> zpsxaO`IGK7z8!F}i@|fioeNaUoW_psGWnb`woVBi2<=kD%`X?JXF z`=C^k4ehE3?C3{mGV{S&epnB4u_`{sA9TJBr@~YC*IfUSkCClkOL{c}*9{Jadl23W z_!i)fhh_hOZDzno06z{4l;v{Q|ISa_E>k<12czW^EW^B3Z8LjMBduC9c@L}qJ+Qf+ zkepZz_&Z5RNDL-eiC#bu;eZoPS2j!0&up4~R`OJ?*MrdXl1Vj$z5mXYGlM#egM*Uupm5bk#t_T*6$>}c7P5moYKC6@IL^p0JndBh43$c;*;9bDSlju(!>2k!t(1c zuvE;e`4Cl}0Y`We?1V&Mn0gc)@7f6jvp|&LR)S}D3l6J9rsjymuljBH^Ki{?|rW1mW0_{&6M>q_D z!MSWO9Ht6Sw~-$te#$#hd!8i-_XP9@=`_&!MbJT*f>*hCLCV|-<*xRvy(LthYrLcd5{@MkSF*cmjarqwpr*n}y z8G?eB#sVk#6lE~GouG+ey#7hz*YDlv{tdVDUyb`o0Oz1x;{3gLgct9TdtmARFuo3h zavD`yob;Px(%*&fp8+Pswr4t*?&q;T|SnvF=RpxxL76CO&Nguyz#y zXA~x_d6PMp;Utc#pJn+X??v~~`XM|FFbd%2&-oH{xPSVu$IoASRViPACQu&e!3P~M zBc25_0w2^Q)k*>$0o4bUE?Bbg2Kc%M+AA;^DiHcb;Z{Yga83Lu(iSn6)Qle>K_<>>)>}MwXfLKxp(Ya6>&u(E~Iw_qO_1sbYVqA>j1hyClgHITw#i$kM{3oEFu!r56pnT)0 zWMDV-$DVAO4)aBKE2G%!4G!9@bk~Z9+O1hIT{FJlg8ycK~U- z?b7ox{A2=B04{w;BRmrBavXByE<|L^X_JWPIu#Z*{P^>e-%#$YvJR$^iTCLHAf6w;3&l6L^^ z)CGT-M))=31n$z8X)~z0R7+f@`FFYGfITmW6kwwk!^*WPwos~hW4l$Qvk>QZsG(Fv zBt7;y<>ZCoe5X%v{{_J9FaH+bsj_z;rG@jFPK)wPI$<@n=E?j=9*ow9;D6wLF2FgC zE4SB(JlpXw(SVLG%KZ z`y4O>nRpb!K1$XXY6Dddso^hF-&GpW%jG*@21#^f6~a`xTl}X`i-|Y zDm2iy&C@xN`IM|m9g&jR@2`fH55DaR$58uXw0 ztP??No@~Dzwa*cC3*}Udk+&6Y`-DZ#g7*lxg{*IAYSQnatc*{&l;>5jdjF~xV(D*c z1s-I4A^Q#5rAI}uFSe-gVry?nfBTDQ|1C#&0H6xsjvs%j(3P>c@9xN9cJ{qJw|2?2 zI{Cpgu3<(sFsvQ|q07H@;neLph;{DAKOo&FwV?Ligk1!iN`MFai&9zv_tS& z3MdA+bXGbSf?dj+wq1@LgU(oI6LmTEoRlJUCKP^<^$-O}AQ#uTk z_=k%i*xaX=%U#K9Hf}RF85YX(*x{WLN~Sy z{ePK8u=%w$~Dbdjk3a-1;7g@FX60ocfNA z+pwDb*8nbl-yp1f3I5mnvff>OcYGbi)49P3Q(KY>lRDYr}0IhD_|MFt=`i zt7|smGiP=3TU6ITdX384FOHT$65mGP;?7T-5Z(@G0l4k+)fbO9!;v?Npc z24xLFt$ZY(O<)`DTu5y6huXS{#xsazeE<)%>W2i^S^Qf30ZF@xJ16C}^|lUyuuCx~ zbR*fk)!jfK4@Tdo+?wVhPL7Q_T|lyg9TUDGB?uH5Ch0R9<#y?F1H!ihHaoOHv5))R z)2`g{A3&7tBa8MDg5qAPY=*Vh0u4^3@ihbuQhd9GkU_J9#}VY|j?+nBtREc=U{?YLX3;RoN+OTF5R9eSLI_?5)?(r(Z9{~OdaPdF+8~9KLxcf}*H6DMy z&p8POLHPQn*>ja|lZP~WayFqFTi7f3Fv6VC9gKvL`0I=Pn;b9@Aio87bQUib>}5< z6Lacvb;tYV?b;RZPi+HOJjJFpG%TDwr4OVjn%Gv4*d43>6`BDptT!w>Y^19^H`Bb0 zbbxZBrw7&V){Xn%mnfwAC=4yC=7mA9m{64$m^826dx5Fj_*#HqF)PGi19mGISe3Sy z5IXQqNNIZzk=zX4^rTJ1+e~s3EX!-aFmj^d#|Cer#{I1HRC^6$5s&?fp7$0c5Hd;) z&WYn4wMjU%P_X8tuW@0S1PJ`#QEy0|&VrAb?pUM!O7yb zqIBPi@GijP0GIAB{|kB=fX@N^i04;|$e7dOBWuK6JT;G^yPQ5coP>{lcVWZ)Ixz+w zHDSwI>djkq5z-tf+yrgT7Pc}+`M^^N14nDgE5v-0kT*!=Z88D^8~hJWxr;s~ za7~hbfRKNZ{DXviLh`>LXJMUv7A0Sk;_nFgmgFBHwb()_E% z`kY)t^H;+eBF$fir+9unEx3_O%jIvBsoX|OAUwN?=HG`7MHp@HI(J6bq1T(Y&0~C=a0c;Sb$tYZ!qbHln&rF)Bi@W zLjkEAvIh3A!EVimFOGbyi}PVK-73Z)pARAJ{NSR*`~;GeA9{?W0H~&6wVZVZIh2DZ+FlaT;lI^bPh-gnuys<2^snU%5 zEi#Trk=CC(*0bPUc5MB^g>&jAHSn+WCt)?A28C&H(tR@~UCwVig8QFh(rr0Lx=3$Q zgG?Oh_Eu(Nyn;fxuuy=j_-ng&8UC|nd8)sUmS+yajetcmPLyZeubr1?T*Jkjy{ABC z8u{XMBOfX8=K=Ev>g+gk9C!>rO9l71GZfFI5)HMZa&mw) zfOC-MW3^MbNvAI`&=;o-(!d2g$jNfGqpGA+ETuarwM*wHT{!JUYz!6K>Z9!$-Z+Ya z|AW(tp-(5Z#OtSJuB@L8KS%55A%q_R?2&PzAMQKG_{I6N+&a4goeWeGPFuKz3LxM# z2JD{kFh|3wv<|D~=mv_nk0yD1!O$JrH9lS}rDdKhSK}|ya;-vmEnvNj6Xmjx)vxR3 z)bU-xcX_FBdIpX>c)7$GA^6yIO31GmG^Je*#I_BIXfp2G{FnSa=N_ftwmaIM3al|t zHAuXRe~scj0^u=$Y8fZ+jvNOn?>t;)nTSYaNkA7@%mQy`Wy09qqvC&z8C!@8?8K(@@7+`ya+(%5y7#H-ux6Uuj+<>-mTlt!G2G6bq05a1T+>7tN2-e-mF<<8(09 z%|TvW+~erkDK*?Wa(>;0uajyFO=DBUQ}O11NTH5?`|nUqQL26;=~m^jlp2RfGZAhC zECR?;Qz1vNA;6E8W6Y<5lQDS43@N2BcmX`E<*hSr4Xia_51l<1P*!JDLUgq4&z;nr zg@dy!kdr-MB#3vpkvaM2cK0Zyd21Jc(N5{S+cxZDxN!1gNysO{&)@($A>2bRm4keO zGY`r8*jT9jiFZcvtMlBgStrJch2JIAfDj;&Vk8`YzLaSP+#Hhc!m)C^o~iy9U6+SW2~C&GB! zX$1)JH${tapE|$fR>z%GoVu57M4ppG%5IFj9>4X(wj0h(jj2K2j+qTco2hM~MoN>n^A32$FIF$hStJa2kKt9Vjsn%0W=fuDsKTKlt*%d;Bj6 z-Ik}FBvKFr#joM&UA_tSGfxfi0w3NWWd3OQAVBL{r=43?oBrBq2u~~4m6-s(?*I!p z^en{9;PBV>eE5#qrIho)fTz$<6PbC11+`|WRdpd4UCD|v1$vW0`vL2Nbu#}QNZXYM zdj;XQ0Pg_Y{96(J6wv(iiRSO>KZI%~oX-sonwKf;GKt0%ppk&}EzpqGeNf2jBp&b? zr$lhekEsZs8S*IS09-uk5Z4Gez8to@{~V)t6RI9D-e2v|Vl23_0*SB+f0Rv5{=1O2 zyN|ZKNLO|QsPZ7d&7ZxYD{B!SzaP?Ok1M`HL}fT;vQVa~-^+4F0@3;&jPNADnE*GR zsR+*oxb?mDElKzIx&(QGOy6IGtKj{QW?OV%;ZT4t_exr+G;RTw~wvBD02p`sk* zE*-il&DT5ikG$Oae+}V(0zL(}_#8s`M}S-ZCxnmN1fT-*9yz3v!>Z?yY93aB(w~45 zK3qXw}_pjM>jbJT8>cqz8kCLh-Y;c~!90KSidHVqCr>KgZ_ z0{G#|b-4J6$e2^46M2+&_3Jbv>Tf0I2%(f@#hmycWBKQutL$Kl-w?*;q~;L`C8gg*qhdfG0Y$82v$i6k^ZA6D$#>2L_s#6ZklASOT% zQ-~vgm~Rp6k_lpB-FB0tXL*99R0)i+Rfh0|fChkz-vWfM0PJ(dpML{CDUtvcr`1q# zBF#+S#LQ-n+nc}*AiBjyE`tT!;JjSm7P(pC)q*_xIe5)JOI3!Z>B?{bKlr$O>v8mO zTnDHJxaEH0A0A~c?!Woq*yC=yeA=OJ$IKCj~1qjUG0J8Fl{2wzg>3JM^f$StxQi;+)+XN=<}Q&%?Oelvg{ zyxxwdKh>@r(ec7eRhY1STIJ+(0BQ1hoFA=;9_16j8{X)A!Q($eoZJ6Ts65f>LTPsL zsZNUa-^&ob_ic}|9pJW;YY?{{;P&4W$|t`6h8kx;qgQXR8l5?^0RoswigE^=)?$w2 zN7b#8zOBfsO2k0c1`bLVbkUVd04_cmh${lP{q^6#NA_2k=v@jQk8t&?lYRJ zU0=%w9^GxCva&mt11 zaz{R}SNri8;y{A`(u2F|UbL`&PT4pqh*vvf2s~CoXkiC@Cv2x=2MvmI-gZc4JJ-{?^ch2D@>h$SIB($MZI>`|5HmXZXpzPWtCW&_5x3b$+MvD#s~T*~~@r zrdMDYSv7hPsR|78phydpKCq*N?-e-IBJgs>?MQcrB76u2tB~s^iB}|5(wQSM3gI&V z7XjS*ScC8%0S^KA;f^QnRYb;|e%ilU+&SaGg4wmbD=N#llucQ=BcuTPy|d~Upq@t7 zE`aUe)bba(_T42tg32?j_)1zLL}_3@)Mcz zJu!bEmt2X5qRAD)WM7(qB$^808k3v`A!M_BIcO*Pa!z@RyUwVeRkr}5X%p%e)M1O}dliMl zX&2;a;X4g7FEM`uO-csD$A*r2FAVXtm|xKl$E9hr#PlFpLK+h<UoAATAJJ(MSttupbC;xxk{+eYFobIXO8_oi z)C}zT015#7nDfT5>2hE}J2{@C=+b6)#Gy?4elWdZ-hxGQ>&`%T?$ZlYYa+kXu7J6t z)A;Ffej3A1)34xt;XX~!Zi+Y;i1W2Xc@2aN8U@5>`*qeG+7+d0c|vI9 zj6{EeX3IY6o_Sy8NAotP{iFWe^`Dw)DT#nAfLqV!T&*iTaNifekLo!6BM}*Mn%ZIe zuWf9sn>VAa`gV}%+qt&Su8SbVcaiVyv{#rTpBW`Vfuw4 z(`TSq^%Ljh%Hw`XKO-mFe+m)q3m6D++u;g?*8`dy`-ASh(Y_r<=e;TO>-lmEtHWQp z;@UdQ3RoVZxY#>uVz~@jxwH!4H;TdRhNiI^1aB@3AzO_WKw=*@A~9NIQkx~fyjciV zK}=KgX%X0KPS9Yk4$k0w78kbWi_O0|GU@PFdjlc25SH7fkJz+BmhS*?cgF`7v6K=( z8Ne;yX$YSUP@M7K)sO3}d=nS(-H9gsYx=83n)LTb?Bx}CH}qGa@eKRcfdBJKh|5>e zld+66xn`_$dX=!fgxdn+T=jiiTop4Lv>4z^Y$mzXfjPKlwpG|evOI4B*O5*Yevhy> z*HXp+-0|*lgr5Vv1K>xzeGKQPZI@x)jyWgj~OEZ45!h6m|@0C|O4FtNzU`&11We}_J53Z@J7 zOWbK6Q}#8#9-!=7emzLpzxeet%D(5o9S0f9f=vcL?Nx$H9Rx9GGKn&Ek~)n85IZ2}@@+aDvO=+mD$B%+eS>7T}(zjbI?r zNMK1u2bxkQpbp^%z(RoA&Xyv)1`uB#?b{g$jd2wXY#%!UJzX1A-bR&; z+8I#L4Zzy?1P^9EUWg_tr{A>%u~WEw{4S?`AdiTH+h+*>3-A-b#pCIFbj6bo+~VZL z+v72fj~bX+sz*Y1scgKdJj>P-r8o<7OCMIoyWcn%BdcJZw&vONh(iaY?H-&zF&+1_ zqH#z0NpGL8aQgNvD^R-dZ%2N#IO%Rh8W9l#C3A#d0PF)e^+1vfWWMD;t`%>!Pgj&@ z@VH@o@s-zXetP*P6Xs;thTkfiVdH^s1cB!d&cA_eLB4(H>;bq}U4@4NeA=G)KYM{t z{6hym_Wu<^JN$o*&^P{P{U1#Omlp07n~DZ(N7z@o8%tkbE{8E$Nv%g@dpHyI;?`Ru z!bv=}&at;Ll&iIX*wYabYnV5=r!8k6rwuOyQ!2M# zfj_3{UL9jAvrxH~*b%~(Dt^H?^yDV6F7}a}v$Lr#@328Bp5e@|?yq?}!M-K-E=Arv zCHVdlY4W!?PQn(7_zglb4SAJenG*8nyHmW~Vf$A6p0gW}hLeg+%So9(k4;Iu<>1sV zCH_`NIPp}Sl;yR3Y8!tOHi?vJi#cEJZ)bwN{yFe}Uc?=H>Y%{~_g@BhbfR>S`2>hcV{4Ak~nEiG519ot% zGGw`DqdXBOu4@fdR^xshz+I1y@OqVzi2p2ZJz6&pC8?h`t4HmEc@+>MO-vLMuS)Wh;d8I(q-Dhz~(so(y)Mxv0H`dNyPzRYqtXtbZ5T9juP&cb? zKEAfAZX)3S4+dlct|fzlAkH>b*^TPevb=giI>6+sT9^=r;}Vry^MY*-A5BZnrd_;Q z$t6&#%nn-~EhDE3&Ya7}zpz)pp9KFlMMYz0XAF-nghdZ5YD-Yx+0bImArVmspQOuT zl-K1)Ux)BEz;=L3m%Tka$~(CKCx9QvqsxY4(xoRBh^dYD6YPp512P==J!vpOfE|#| zNo8 z!$VD@pgYLhRPY_wz=|cfNGUm&^X=Vj(50TN(-q&6=-*Uo#KK>Z9PXaET= z@apM{jK$e7<{iK$iJ_Cfv#C^lon=rmB58EyD7sM#zQD+f>^!Qzz|x;*_WMlJ^6Qdm zw%=;hrzg(P=4KPCQILwaj^9ye!=Da)-D*G<@ln?>;w0EJ@2>XH*AT6Tem_yCxg^cU zH+Ky?U(M8$v`n4CvkNh5J?Vz+DW-#vA|FM%@>bRylyu$-`nm1tDTMa{UI)122PfJm zxR3v?Zcj%Tl)?~#;iPOZ2+7I|vGFts)+oK8+e)Im%n5=GGH~O>)kC|Y#t6e6UzIKK zD6fq2ktQNM9WWc<;<2x*S6PSqc>Z!{cuc6DH5-Pu>t}Iqr;T6n7Mut0h=#j|Dj%WN z;5?3)W)@6Z@C`?(vQmFgwP6%VHO#b7F`@kH`*N7n1$CPeB%b?#gUj#gw^LQV#r^jH z=b&@q3-*$!T9m-{;G??>S!YZIhvfjKJf0_|nib0^K* z1%DDdskV!jDDSF8n3v5v)XXhvH;-YM9`5r-V|_NMTC=K|>9AxZhPZDXBOFz%H#kAO zq4tFJ5EysRND0siIrZNwx}QD?;fn#k0l4-562kutcQ@sIG^Yp+`|z*A20*pmU{!jTL8#k#4fwy6g_%!VZxtfu^Wb`a}HGaH;?nvhM)ICKRRSjn)a$BXiu3e!a> z%3jP?-~#fBs0zz+*nOhq=!WnpKrO&+r|S`J2J8Xw<5tJ6&hArJiF@}{K5U%`)?D4` zczwpCp_A(8a+!m5>I1M5NMI_Eh|AUTvT!Xr?#pc6y=2mTWVd<`>A8)xl6y(ReHi(+ zYCY&FI@#fw^hZy&kwO#oA#@M?L#TPY`7WnDzXun2iZ(vfZ;Pkl9*@S&<#M5f&z=3z z(_v}jG8oY63-^BwGzCsmz*L?b8md=t-uOH^2|2Q!YAewj_)c9-rqjOrM(HyV;pu>d z0GB>DBYY=d7l0q%luNq2)~7vvY==HA9rk0<{-tYlqZ^wFL!h9A!BK@_bYH~Pbga0~8u12~;cfDuZU z2Md86vjzCO<$MR>1Axx~?zr?LWSxG+eSG=alU=T_2lsB9PHN`0q4^T_b3M7j3WBv6RWNbPeII>*lFGX{7x-6|xrN&~CaoE&zmu}Zr&{JdK}(eu9DU$e|w1Aq?G6JB7B87 zFD2r#MqYlDm(xVtC5H$-Oi~UBsy*iuR9ggF#<@n0k*HfR{T+b*Pq0$U@EShMJ|@1M zf~FSG^0XeXhK#sm>bE!t8B~N1h}s|k^U=&guQkM*Lu*H9b~df?vC--%4YL-)sl6b+ zq0i9HQZv<4^s@}Zn4nF^?xuxj-tigaPoAO&J*Q7L@{LlRTZiTI+0zzk3)W#DpzLEE zmPsBVs>Q`E@c20&>R}NG*G<;LVbHh6p<~yRA+HWN3*gqnL4>~p_)c-^VU(ovk*fCN zw_6omITO&ep8UByU%tD?=KjC#;~PSE!Tfm`CR-kRY&r9}a0~n8WwalD;OJl1OvXTU1}h#fDI@=^i&s80#AUuX|6UW9Lo$)4{xE!vdmr*RY z)MeBXXM{iSJVkpwO`l0vMnlW#XBakc_Z5CNHTts$)hlV~Qo0tu%Ue!=q|0FWs4dN9 zNV9M`UBQ;oUdySy45pO%Gg#7JPNyuRIm_u=`Z5@%Yx}|EWi$;rA+e((KSjRdqHm59 zKVOA6$44WZkY8juJseb?H7i+fJ&&HNm#alyJ0+jNU?Q?9@%GB5eS7<00ov$F6U{1k z3^lwP(#U;q!IE*0$KII-BVAv4z5nzYFP5tN%h-*uheXzU`?C=@rfGMisV_&YCn9P~ zM0+mMW1^a<)6|pIQ}Sp=ipk7t;Yt+UM=xzMW_36GzCU0+tMBuudp!wI_k(9XnsUSw zyw)gPV`v$ytL`^av=R1@95^V|LL-^2YP#vO6PT{k)06Cw<~2gbooc#%((`Ipune@9 zRPvnOYlBIxb5tm5=U|MmEI%{xI-D|VZ>YI%tNFgu!*;4K+hp8N!s(&pB+c_1pKWRC zZ#=f4^)9Z#_P62$FJ1I4=g6zG{iln^uLE;VbLv1bm8{ zY2LCFjN%Xu2*M2i$=(UNrRx~tFI4C0FVVD*ApX{OKlOb8+xv;1Q1u|yKjE^*#@N1Q zmJzc3flxX?_kXOCzu1X~Eg*ll-?TCHZ1ihJvDaordRU`3`C$Q>WpD5sTJm*%EiL;2 z9#S>DKO8m-wUqKyo0aq80!vNzdvFKJ`9kJ{1UuG!rzAqm&rGy@a9&6C;;TrBuT0a+ zL$=+U6S!M<*8QI_iQoHUkDi3`f9c^Q-3>$PdO#PXEd9|_&}6{oHgfEKy-~c*@c+ir zN70KoZH!V%i4N1@y+}9MMfB06BgtqxAzI|Noxhi|5A(J5&F2AYuu%u=c_$fRO=ENR z(bBzqw>;o8EDcPV9B77GDZ}4N*0y=z5c%$`jih@Yc(f^hk*94((>|(QzH6OKhri5! zmr1lQGa((J8Rej4f8Ex!wWK85xWf-G+_tefU<@^G@z^Up#;LIA$6tFU1&RA<;m=w2 zYbm1LYcaSpx9Pv@>kofL*1i-fkXx2r(j`98ry)X#a?q-TlFImJdvwF^WC*;GR5 zG>6_!AoSHwE59*S&^ga(AhAS_jxQjxq4_p)6wm@*$?ie-6MD(-Q{!mkjj5rwAFuHB zr^C0Tq7h{5&DQ3{<8wqaJX(Q|==xN6Gk|I=D^@o#HOO>-sy_nraauwuZUc#0f@*0H zRMmWbrmE^5%mu13Wwl+j7iChO`1bXkhXvO)b8#+YhR74_Gh$due(F793p2XPX9T_CFJn zO7G~7^~nwo*zI?z2t7r>mfpc2^Zp0!NU+I+_tx^zYUYNn)e*Xy#|anLE&Las1?AWA znea4-(QFLFS=8l3y@8mksJFXS%bCj)SueGiodw^w!)TdSZWM+rYZ0r%ePJ-sn$IR7 zbdq`=wtdcHS9mc+=CkvxzS>-M7qRaq$y3;Ns%|FX(Z2O0aGL)bVpj#HB#uk_kz`H^ zZ6>6cj`ju8%luv7u4bMu(V7IPO}Uk1P0faTu!jiUL+Sb4Z^R;a+44=yP0!0>)#k-H z4-joN$(x*W3n}ayydrs&?@y%Hc?l~>>a_3`$;(OCzM=Dz?2PTC9|`x=|p4=xe?e9#~P*NLAAWJ$6B>eqgh2i z(n`Kt5rbRrWu#K0xjkQ_nc)mO+s?XdAfY`^wg=kKCynr^<1H*Ndo82><@5sOryvws3@lOJC7AcErvql!pjxa2J@7%MD3?Nw6_;KV zf`~9$UZX@VkmcM5{A&c}it-i0#u!V<1-Rv$jqqiF)c}5EI^T73;=b?J4VC=M#v$^% zqy2Y9n-;^A`h~NrYvv&_i@Kf(lgfR>c?dVG{4%Rr22;^(E7jdtc~&fk^7_Vv z9yDQFNDS0|n(r^ zgAtwpSOReAH)|Yp5fwDmCJS`}j z*f^&iyH7dmu+~{i$vXPFwHDu&``GA;@s(PIr}90u!dsaN*r`<%RSvGuDsLneT|{7s zvWJY`sE)r$)i$V}n^ZrRTv#zov(II!X(U7Nz@l0H{D8)~V{Ho63c@f-VRsf5D6+(Q zH6b5w?$*55X(5%}sClo_LOHBE1hFevub%ilHt@!PFeR+47x{vZwZ3d%AvQy_ z0Sq4qFo|4&Yboo3K_3w4qV;6u;LVon87#3Er6;BJ2Rj4~mCRpQ(PLV0h}Nz2E=}91 zv0@AzrDYS0Uj?_Pdipwy}Ut&X{om4uzHPSCdy2>9yL zwQ>4XINa42(KUoDqJJbb#M{DX$Tfh82wicQ_f3Um-<_IXqz%Tfd?R69)WI01m!STt z7RvSzIW4+xbvnXp0Jj3%_F$mXCIRvR{BZRVI~zCpb;vJ)H=8r&)nANOP&;SRlE%8W zwg45liHl~=tX~X9R&@)(D3R+xU+22TjT|zbR!>Ic>Ft2W1<-c(sujhR!z*$t?`Fz< zG#CEU{VXIw&*HKv*?AdxY9Z_`>u~Fomy{Rmr{~_qitoek^i8DSTt%!(#S$u?l0Q>@Q z`N8~=ry8=*yJf$2>#_5EWPW_NxCFb!2J-Bqj1ABfzI6^gREAAZ992&KBawDQ#3%~) z|1t~r7XqB)DE*M5^Ig!sfIj7HAAVfpq`w1cl{<;Oitsyt4*+iYN_A8D6!)%Q4cC9( zG4gQ>m|8s$O>6LY6Ghxal$u;PRTk3lv*0UC=etJyxKz@){Pd`PxLeL@#5u=t$|;A! z*n-aIg~SnY(%*ozT>W;poR1+czMKc1m-X0jIos#s6m)T0!H{2zlb#1V zBPlmbJ`<~HT*T4z8%gJ8PZ~<P}cE0ed0r_vk) zMM*(zMX73%<=us{xZ~(Q5dHx0F~Ftw#oKk|3)~-D9;UeK!rEE0>Sj1ZFA;+(6ba9U zBH^oSk+K?#hSRz7I6OJf46a1j;^g1&jA(tIhwue}27sIYx=&2ya@@!3m$awvQTe-6 zPOW~B&wIKUjNT^7dgR|5%mb6ab)KS3LNJjBl;$fXJz9{LOOKZ=U3m}p9|D}?IP(fN zF#Gg!h~P{tWpE4(=NXhiP>!E!DW6v|~@@*@7 z`4UVtRu4X8z(e+vC_2m#fIAFXV6={~M>Qa#L@kdq`>rIsNhwNKdM?($x*S59mpc6d zIJk7VcBn`B7w&%qoETlEwTWst+ikD1ZqPu6x+2;)_$Pd8RvxWRe$`W=`=AX7F9G}( z;L_!n-*}VWlgQlf$;X#tE&RJFW*xWoljpht*ra7ytV z4mQkSfa%jM!$SjkT*s~}g-rn$;mC4HpU9cfdFCX9F919baMyne&$5(_fF}U_sB-qp zUVOd%__4$Jo@#yPTJc`{c0RZE!a7%cFQ=7m*50F}l{Sd;8YV7p5pg+PoUfKQzW2lk zGpZo9u(%*BKC;4OXkkXd9i%Y1z$|D zKYHOmVG`O$j039qzUrl)5V#E;`wG!=gWQ$c)ztVM4X&WDAf*+VX*w$k^SJkj*-8TM z6RiTS)!cjaed7O)un(#4BkDECKDC_I?N$Be=x0>(IrSj%o%BAn-lf5JsMSJkt@;<$ zcO_POVyIlfFjQ7zFr9$!LCAR|iJOVIdBG9j`U7PPXg%1=)ga{tHVY`rJ+^6Fd@O*i zjAADYFLNI3{#phY_BW8=6x$5mK?9aQ2=&1l8_Xbg*jB1m$@bQGcC>%2Lih&2Mu6Kt zo;ai{yK#Ra{iAIQdVyp(gI7S?wfx z9m1OdcLUt=e}eG0fMd(g$?+jh5MUp?y6@#CDBe)_qICaw(!B)(kL!Gz)oy8hE<{lRzZe;M__xI9km@#y2qDm-kWsa0pnv zT+*-fl-M{@8|KPnq|X4j^lNM{q~Z85$(2iOI0prwIBZL=VcvD}*@$nt@-90N{|N9o zz@^{98@$SoNY}l*#LoJ_>*D?LtDk#Dtf6^#;BXpfm%yYRR;y$AB%Y}nWWC@ehOGd+ zj)R}F!YTKuv3c-R#HRsf0$hA*+If`Ak^cL2NX0aF2_jb@DI-=98VUCF{}L?Q1C3Hh z2Bkn21!9Yk227xld?eh#`0ZtYQgfBWXDjk5c8FewxN@3L(Eu(!=>t7V8q$BiKZ?Vr zo-!-g7O1ep$yWj?vTqarW6BPobr#TS4zz+WBx55rM@&{jy5HF^)m$y{T7*1Zyw)TB zFkmyloo8xVdzFuo{t7_hQ1b9Y56EYrd8S_6$~nM?R=`7~70|gC)oCwv2qZ_~{tVJ& z0kIp1(_aJGt8ZCfqYF)#H!(2$aqq>4F#N_2wb#hD=lR_zy$6 zaXA{cd%|~6oY+0LXcPTq4JsXPaftvH`iFu3~5SKHEY7(+y7KHQ8hHxHG2GJ&nDMULG z&ciE8^}i+Fvw)A=f0rY^2Cx?3((SW7Ugb%oJ3Y0;#_quU2|4}^sF*jq-?TH}BpKYJ zjqE|G4KVUvhHavNzW6J^3#axtuplR%DnCZQf{VtUJr_XXtwZq7=N^!r74zoKnKq>ylCdKv&O8I6v5^(*1e{o{VAgVM zGq#DGdszMk)>e66%i`K+EdOKHl6D=vM0>jBfS=UX&R!|IR^nX)d|Z0&Li|g>K7dQl zE_WHqUq~-{;qT~4?XX^47qaB{OFMjhN#=T+Sk09^AY}o3yumny-j7zjJux1waGf;* zrOMS3pAnPx$7eR49X>vd#bf_$jDk&w>g!~_dyvOiNg5?*vQOy<7y@wX?=r-f1J(j4 zlzq{V9?nneZ;1=5?0CceD%+h)sRR9Q^tl!FO|k}hDz;eSO^{I}LKfV^+HPQZ_b_z> z`wARe-0p|#cL;eJ8(G^s;VThNJx=mga`i7}4pWbxd-^%$x`@c`bEPvL!8Zz_08 zD9_+W2+^Blxs39dy^vOj_X11;xaGoQ2a z!?fj$3h1Cg-P74U%-q0yc2g*_uUo_SFl__-m$F4K z;>N7}v)FX=d*=Irsoyj6Ql`#lId=R+Y^+K{r7=pPOu)zgX-;5TIvcO|ZeUy#7pXv9p;1kUAI6J$B#UzB3;4*ecL zB49F{&Cx&Rc$sf8vvxrN1V_sw{dyn>NuRTW*j(``!B8ONHv+xR1io&TFBhEfOKhur_5j#ZJIqm-p=rcLb?m&FRcoION z`eZpyjEKti;QUnC>gL`lNhxww<^n?)sSg$s@Y`>}31;fNI}{*w;&C0y=l}>BYjn zO|h?o>}!quk9_+wnRr&-zLPg6k8fqlL1lqVRon|BP}{_-Y$1;<;?adT3FiyAxB@~} z)*{}2A@?of(7MA@hM15s{0Gnci}=UBD|p)l{5rgk+x5YPyw@VW3g7C!i0_rR7jeVG z$AxmErvnrua99L^IUE?>Z;}++jU;Ak`o8ptcP;*%D!y0wN0#-Kr+=Ntx6i}B^N5cv z{+Z?b-XnhUh)*rP$FhF*@IO5M-#yxcY{ZkSkarW^P7jut(7g(%CfG8U_}V_MMxIbR zcq$o;dE5qOMK3V`e9jYmdBoZX%x9Saia_JX@D*?^p!&1;HdgIzyT{ZvajMoFo=UYRjP8#b&0mLTPr+-v_;sFO1u|RL8p==#`9(v z()J~=zAE z2R~ylxSF_z!To&XA(lKSX#;18thHFcQu8uei)^*E7^bF(wyDjLdW6xf*}$}l%;6cQ zrFKcV%U-iUt_8v-YE;)@Ys9bhhu{j=6A;Hq1k$^~G4T9gm~Fk+313_58WAY)PY87O zzvK<|3a(-a-NVfjiz3B|T_PpP-IHEqsl8I3hLI)zTqqvtr}O3dH{a^q(FZ zN9N6do-DwcDaR4t=W?Fuw@HpC?l@BCj3Zm-9c&zt;?F#%TEEh}MrYOf>*^YvSL+Ly zotkc6$J*CdxqTaMUq6ulU!d8kN%r+Q`N9T#0UsJ+U$@I=r`d_lYjD1{O_VO=>LRW$ zFM=Y4^<+5-7DyQBS2UC4Vb;tREfyw4&&dLhqU1cC8|kSPD8h0&U$0>S3N53goH!rKF&5^)~t#t(G&9 zCGyM!2x)3sF+@Gb1UmXHHO&+78(qSkJzcb8G^@25@CK7cJmPJ&%G*Kf+EQzwwN%3a z0rQi&zd7FF zKVke$&gSB`yaxWH8M~Q@lQ>Mn_OLA9Df~2lYkrQoK!wCgYu{oHmFRQ$Wz5sV%H{nN z=SB+53EssmRspo3QnwS;`L%Zi}(h# z=FW?Fg0jVeSkE7+hZ5_#$@?UPdLT3HH@foHe8A;t5bep5qCJm9HIfc+1$*|YEDJ-Y zeT4Bb6X#%IGL~P;ym)1QxtYrCZ19?&IKK2b7f`IJIHCxA=8`@hhY zK1dG&P^h_mNrStch`Zc)z;*k6=YfvlWs*yn(++E12>y`O!%7U%WXRQb35^yLui}MQ z^A@nD-#k!yFDu*#1FL8@|3bUPZ6em{EtDFk9MukfgvtYmKMQyf;Orm8KE$)nhW&5{ z|8-Bxa@3<9l=>nK*G9HIHJ-hd|E9gp*;XtDUI#n#HSw57S-?-c(U^jx{@(bT$}h$o zGyxm2L>6}cZZKf;fNHA%%=Qt0zJRt4jK+N$pp+%>_L$N_FuzFqv9A104W*YJlGe+XImiwEK??Nr6= z{;cpnBZAM0uFnYmtT3JtnTqKC3irRvgRemRj1>OqNIq#h=R3Huoo6b_)i5NfDBZzv z3__%m1V1yC(p|DVC9`AvlH~6^N=rZ?fa;0t*0)3aG`!Q;o_xc4X@GB>DF9_8#BbB6 zxV27x>+mg?ey=0`^>|&W1GxF^!n02Sjn7-_{hc(-_;k`4ijz%)iN6vsT#F$~gk2@) zGr$-`Ef#~z;j=k!mG8=O7te{|(fesj8K0~x696tAMK>7A@p!(rk$v5IJjQoE6{nu% zvrnHmxjcH|#cq|B?v~%*i0?<8hwmW%IpAl2+fICQkv|{`pwPIy&QI%a&mN?mH0FR8 zwCiun6`}K%JwiIFCrvQR;DO<|En*RSL1CFJQeDtppJm2D! z?@;|Yapp8%$n=;`I473h#RaehjdTgX zrTgWZy~;W~zZXEEalX#y>Th2kgzm^a-a-+IO@@jTv+J{>F=LM0hhz^6pgUrF6K^Rzv3-qxLkZU!; z(nh-ULrKRiz{l-ZUm)JxYbbdDcU&S|zr*v!{i!jX8sJ4;hWkT>K^B4(aS3_ssdn-k zdtQuA7bCs|um<4LX(QrK0GSy=w9;f+ERZmMhRXZ)8XHGx|CthJSjYy#1MWQPWQ9W$l6ItKy=ZsR|V}B&+nRZ(oLaf41Xf147E-^mBOnjK(3pOW>n&cA zrk+mtYURhWJbU8GV_x7>hV}9)Cji{?q~KW&;85jhn2+5+BE>z>YV{&dv3*+1`Ej5q zJeGCEUK}md37<$jW+9KL^XMk{g=y>`<^eogg|)+>${R0f7+>CMC!d|j$L$YK)_Rne z0Ivev@@_@^UBIEr+c2M4{Y5{Ov^>~L<8y4A4!J429)nCf;MlXrg zU)>p88ISY?fD`tU4>|{aGAd>lol!9z2fghoW}i`XUXf#qTi$4*l|~)G)5q3%CTbQd zr}8PvTf(mtWA|@|8Ylni1LXg~KbrsC^7H17npHj-2G1rdUEKU@o%{z@#>#*57uYr< zy&T}q_k`?vJm1p~b~+FmkE`~>xXTEqUmXUW@(V$!f(E2ATfZdK7s;z&>o#iZR``DD zSF*l-IRL)h=N~z~Q;r7T2Ba0OJE-jmRXbEW_$~r&?)dOL;;#bU08oEI;JXn26wsKi zO`=~r?6c=hn+~lJ+qYluQN!8~k{);>rNt0+-XVr4?Xn>2Y?71#?SDIC3P;wRMUb9Q z_*&An_|jN^tA15e<{^DPz{T@Y#2>=*e-uyR*P-r#=WwNa7Gz)R@$4q?RFpCYzpco> zF&+Jv#qj&5>DYfJd_wfAr=3#c;4v0?y5rKdh~EUbB@U0bEst{Zd{`!ZQu41{IhVt( z&xuo}P<@Vq1x1V<-1cG%Ld{TyX7OIhW{OjNrQgWoT)*8}n6 z07s2iZ;6-VD`xP6eSr3u42(fT~qf-+6hu`lAOON9x!XI`kZ2+IGCy`1|SD16&P7Z#h z#9wWltk;r-G5WamdI6p_wO(f(Wxdw(b(No}A`R0kYq0WLiQgXJ;`W z>LOyk%3Hlg3c27}Tikk;w{cUj56g;S-q2JohZblg1hZCPB8U&dG+z8Sdou}16Q==6?JY>!21aUu;4!!-;!c{m;3{wCXk!*1 z3z!1w9fKs6_A4RGn$?$2v{7X|R=Vg9vfekLKBG>(eS!GbfI6p+@t8N3yKY*AOq&D! z{%KQ&59+7zQK0`QWh1P;Wf@@z|G@BV7_LQ}JS!bM#x9BB;r5T4@vN!+W8+cwkH&mZ zWgC_=qcPn$16}k-Nv}HKlq!bt>=G8`UC>D&WRKHLyOVs%coeB2fJx}R zc-zerJ);QbAt*oaw|;iUKhVQnZ_T^Xr(6ZNAK;FEzagHz48G3+6yEw}iG9EP2YI&> zahH<5SK6t=u6O5%nbu^Dq`RE|D2M$?R7D>;U`0LDv5WVSmB$wL^)qSsF$)4RsFO}= zchQ;WVoaLPU*)Y|<6U1R0iGVHDA*)dmZ{BQ(~;d`bYBAvo>Zt3`gMp~2EOIyFWmY= zH-FUA^ybD4)Yq8z)Yd!Iu5YUa+th5h|I)0`eayU@Wj%!N*tK>RDhnTg6+lQjT~31? zjd3JX(F&s<#<&wj7xiJL&)~TpOPr)y#yC%N*cZglJHdWSXFUL{EY%eAIkZH(!f$gA z;GIR96*83lChN5Z_2t%U$K{y60A~Z-dVLh}j{v^|C`^A%*5`SSA3OOqMF~0M%Z;BM z;k@Sbha;*zS_GG%_G($h2Mi{TmkRrqXkVK_MGUn!7#bAqFmE_Bo61A7Z?S6`M0-u; zL3FL4nP#v%xc5d?KRb_`=U95G9<}GNQ{=pL1ygBiQ!!<^U2P#d4>e_Z%_`Q66gS-I zDTmJIWRG)uo~%?0$MSX$JWo8!Sb@_A*T8s$)R3Dc;Trf;T`HYJeEqRX^%4FT=auSP@HmOo?W%oyS*Gqridxz5#k~0vp0<=vwzVl?_^66! zEajq#i=~`Z@j;7u<`RA*Tg_gZN|$Y5`bD;7|Jfm`=mai|1x_J-L9 ziQc22a+nd<`op5Gr+Ykorpsr;wM)b6b%O@0rztJ>|0%?3E z5+Bi%tLaIB^K`Kzoqr+JfS^3Kk72Ww3=4$%bl5QyA7PiKEnxb2+P}naD)qMESORz< zXPO36fH8|k&_4HOB#5vGvEU$hofOHTq#IeH7U`^8AW3`3h9^_=5_mDAMGk|mcuDgN z-HLi>RUsfOD9w}=oZ1qVi2Z=oO3R0eql~9@(yc-dvNPd&+xbez7TxJ9{kx^>R*C$d z>3*g6q@MH@g9mzPrSdB40v1?S5XSpJ?A#{5v)t>Z{sH zIeu)QvSB}3hjtcZ`!B~b54N+QYki%Xa*LXHvkEsXmhI>s>%U)__6xJ_;%!nBe7>-= z2)UWuuFwK;4DJ^0gE4VoV=@>{I@Jz398Y)9m%>Dr2k%dV0gA~`=gZJjv_u_Zt)H6^Uk`WyK;iCpWW7B5YQy?j;ndH0hr>4p zpSE%RIDA_85kAG%6FbhnMwjCtWQX_?230hoXuBcWuwZ|^Uq?OIH`?8nVS*P3CWV*= zL(20Q4G+uEE`y+VAFQnF&r!Ux+{)^h+{>OSuhJgr&HyLy#_~)}YcS-r%Hd4z5L1;K zwKUkCHKEoaT`!>;pn8ezByck&9vhLTyWVr@zYot2m;M_a`d1w#{T=@0!;;MBYZnFQ z=(9oQv!hdO5|&vK?}69F=yxIFRe-Ai?!J=fco)(x-|z7Fy+p?oXU=hWyrQ@a2krp} zf@lcvsQ$bb5Gh8IY17vTT0u`xXz)#n%_K%zce}1AK8bJY^|AHLsfbfMpN@MxotGhf zEr7zo+WAIjeO7&hbavNib~|_Z{2a}FqTSFZQ$yct6TicK%yT~r+{yIytmCaLX)WWo zvM{bBpEpd5f9Umclov0b;}W!o93M(;19+Ri`YnoE@zNQN(K0Xw2~iy4P;xmt8S^R(z7wg=d&&3a!8BnC1}xd9r1NvADFADgYFC3(b_*uk*xmo(UXtSBI8V2QDTm$yCljO?$ER-jp zCy5!>*9JDb^YtLSIv)p<-d>OD!yX*wceC|$HE1-$X48|D%v1frw4kYW@TywSWbmcL zj1~@sHmM*H-r|)Gr+lzDgD+FL?e!Xbm$0N$*-bGztwa1-zz%@huKq+kVGU%902D+} z?uYt?8}5r%IP}_mgzd^5NA^EAsNpa2&79Tna{JoKzNT!pj}SJp2N*0Z=?}2rgIIwm zOFTs|8re=ihO?Kn5ie-^7A^Rc)&t`Cxq53{L*7(xDp@w5mGG4+x*MKhGfP?LaLr2-JV&@1X zr9Y82!}hlL6pDE+p<(Aau59#BD=HB|NL*mP=J}8i55lBL8nn+MJdxT(Hij7DbYBJ) zJfwXb6U|_;m$??)l4N;jp)78BA4U9Sz*c};-aUx_0XSHBi~p(e_MYSN(41oTrV^*y z$77%3@JN=}W(07(9hFE82Ejb0GJ=sW%SifasQtlkgYKT7CChRSyg4?XlHAP#q?Z7k za`J=8-85|-6yl3Y4==ZwIiS5A{cV^6^C2@qt2Q(%W2D%Y}h4+ z@)HOXhV3PE+`%H%m9kNyX_DTXfP+h?UlBKMhdoDt+itHp%cB$^T@0YmxE@`6-Fxa$ zwAZQ#QmXqqI zQ8_DFE1DwuUV@W!D&VoaG}z5kg8eElq=SqzP;IysLJ2*HUB^Hhzp`{$f9C<;68i~c zcoF{uaQ3=bx$eEwr_=(h^|D-fo#nW)XScjNKUJb5CBJxJza!@t$CoR{H?CE;Yaghr zR=v%zZz*xt$6-D?#x9=Emhv-+`-Liu=NEgbI6NjuPO{Ak&LK808FjVa>%Ej0S8>B; z_7>TE<+8;*X$kMPn5Qn`Qwj?3Nfi@qAx!we+Z7IkNP(_5AU(hJV%WOsUIq15n=||b zgI_H0gukKnwWZ*AvPFdwc)weFn>rt7(3bJ;jZ`O z)WyAIGiSkFHEriDE^`(yrDuPcUeD9+9weA8(7d+@47V)58&l{kpZ?!Z; zU(8G6(bp^k(HHXCFx}|zhLOfY+1{v{A8eIGXEiNtgMut2pWB86Tc;Y#$WN3e1Q9b? zXsywDtCg}Xxt^~~CgbnR(ejSvsUo!~bC0FHXmwJvJke0Jb#r2@(t>pX2YVLwKr*4% zi_0AFXmbE>kvU#ui$g=CcGh!jPlom5RZP#-mSR`n`4{XMs<&|PZhfPmPmkEBaef(6a+(p3*Mv?rnhddSoA-u)GGfpAvW-N?e0@?; zzR^k)h6>8sK=`3;w_<~b+E3|J0ORHj+71jPg?Jha6QcEWZe8qEv84&S%vs9=?|m9W zD^ey=1J3Tex%wXL zuK?cz-1+394L;>Mz*+!>)lR)`+R?CnM>z9IkI$>@_YQZyCq9QgUmorcY27Oq_lNsS zn!>;vS;4?GgJGbH?CW|QyC6|3Zvi{e=n6Yt@(D~M5Lfv^C~#Uh$W0OhRZa9!Yst6E zb+Gn_dWh&A5rF3}HRKh>Kp51#!Ak##dG$0+-Q?vLdM;&rjg^t8R$00cyu;Fr_O z;I|6#n*p~2oMP~&Hk-=BNH^9qJu>`;&mJ*xCe#*qO7UqxFqW|@cb3>B`%LRdLL*C=nZhkiweZ605<_Bxa&`sfA18r{iZILOUgZULgi&9u>jB*oD3*4w$H>l<wTa@6E+V$7D>*iTmK z4+j2a16ipeTR!VU63~drN)eJZt!4eB-XAOXD8wfN<^tSu--7sm0b2nS4pnZkNS6Ds z>ko1@bjH%Y3o5UMfsqNOCh<8<)ZlfJWF2iert!);kSytH)pwO@t_H~>AX!PhbXiAR zS}7CKl#nh1Q6Gdt*)UN^$HH}^u9VNXw zcChJHY}4y@yM6r^MNRmFTfcMjQjjO*;5`rol;rsjW4RcEwZ4VeNao}WfSYD%nCcnPhK+I2;yA0)V+riCeYsxyL?*_Q@K*33v2atBx zneMoHq~k~lG$P>_d&KN=I2D3I%uX>zSpk9De5o_kQ-md}rOd%kd1Qb52I1NN6@Klz zG(zzD7;;p-H4a{@4}jN)|A%;W=+p?WwGHu#c9!*7ylH=WosVb#SM=)Gp%Gpi8sb&y z;I#)h5FhDwK;fg1T>x|kIC$x|EHstDNV|Npx1K+6zoI&zu+H&wbJ*>0^2F)Wq0KpW z+RS+qiEV~q$97va04BwEip@-U7+y`1TSjt04 zQ0?>1H&7ZJfqtsnw?g|`WM3b~f2i`V^4c=vvhuE!V#qIX!idcbcEl~?*cM7&Q_{69Y z!H!T%#r_Yz*1A~Tw9+i9?Ef%v6Fb>nn`HKfL$!gis>bv*EfuoPNy(;H`-``FA6q1R zyak!tvVy`4dqP#Ho;2(glS?vtTOBilS=p#XVtNh0-suq%6eF##^-}#Sz<=V6TL#Il z+X_4M9&yA%m;j%5&c2W5UvkWEt@#ORD|W2$I1EJbT+1>>do14uW>MR^l!0SGX3e%@ zU(4VL&O+N+M{Vb1v`M$^bYr89Rc+ zr#W>B*eyL>j#3z>`bfM>{~OycUx4@(fW-h8?|&n{7O>G7Z;xhvmp(^fs_R4?K3KzP zzkH!`JuIc;c$yb#&W4%H9)d+m8|H4Jr3yP{6lxrNCxaK{o;QRx`;-ho6yWyLBE%_l zckYe#r|f^mT}qwv2JGTHVHe*e=u>u)y5-;uu0>K3!x6AROnj2I^5ZaYVBm>@wH=0F zs-00;UrEPx$j`;=BgDT2`~-0E3fK6QJU|-&g~scpIF#(@=p#G_A@-B2u24J#jf2F@ z`WFRIs^^$825ZUe^c)BS@SqmVXqT#`cv4lv@H$MHi@;ie9Esib!HT&Ld;yZqYRS`K zn6e!@gooKy-Ozse$#PetJZ`z)Mf^*^K7d>9z*CSN0Tco#9ID(?kFwnMKD2m@Q}9vf z8N0+Trc6h{JExaudO8)neNj8Dt*4zdPi;`wpzS&K1+w(8gG=>X!yw3v|553WLk67BDlALXhqKjL2k_5s}TN1ldVazGJ)!lBB4(NUJ)-d7f%?UcV9PPovVIrt_Z zz>xG|n%|5#| z2o6!1b2TG>x^qcDY0kYdAJ+M4{cUw}l^t*DJn4#`5FPeg_l8!I*kK}IFrYL@ zgToH4Jdfi`oQq{$jU}$vCW>|)%Yd6+9ESxURTsDozTyh6XP)brr-}uxVO}GD5i=Gt z&jNSpdI>0nS8)PLu1Y*G|DPdF;eXEkP;$DP zoN)j$v!k81+Y=(Qy98`1t->-ITer-QVlDM!YNwH(> zZ^S697ds8-Bf1Xv+s>i2pIQ2EDD+S@^gYY3W5zz_`I=;=skqo9$i8R5L@^wuIFr{z z>;S3TAxWWjsI0G&7i0BBe$UQAx&q*YqmyUqKV#P1^HEm;K+q0Z zFZqE5H|l@R%wK3)V;Ln*xs? z-hLXCUv=wKRr?yQY*LJF;AIZ~UDf_jwO>{3H&v60uo3k`Y(%ww@w%|z)04ewXrF3+ zsb>913sb7^FNmiLEp(~AOuDdAjTu^8iUPj}Bqn~M4bA0hR+a^a@xbmuoGjvO34jGp zwhDznTNM^DTKkDC%ES(p!)3j#c)4M{ZAV&vC01`U5MKtk1wi3Y_10$35!M^dU8vrq zev4CYJD^ISVcSdf1fDX;6<1H#5!;NN_j|70yPkZ4}vIY>Kc_*s~c0+U}d>ObSy|MEH1FMAff5j z7rJ-1v%-{uP+fev<`y9Wsv2A~}YOsC*XveX{&%mNL^h(@7NkbsJ}(C`s& z)yKZ3FbiM$P_l*OS2{sC9C>)5;Fk&OCXBC&&ttW73Q?cOK&BOh3xfnWjPn#|ZAQ1& z3r!dq@7O!F58Skw*`?%r)qo_Fksp!1S9?Jn@Jp?P&-?=B$o$OsQVV^oflIhTjVx7J zm6{A@MF^w7IT!^_wR?UCTk|~%qXmW)_QVwAhrt_e%6n?TPBlBC`m_LiNN8&vJ^5^* zYItIW*Qma$RRhP1U9{#2U)Py90G-wi)}XleK#R3)-QeZ5PW z_Ag_O*)nWDOn4A%;)L-4uxb~w2$sAZp>a_x7`Tz-wKn2-t-YODDR4*=2}JxXqz6Nx z#dILm0mqWP;SUupCukISz%vYL0n2RV&B2qf-xo51CfwYj&BM*D-R2!9x9N_9+Z0Vr zHPRxPAJVxVeXUal%ekF3_vZMUXXYj5vX-mde0ni*_<-pPoJ(-{kXH*WuRbqsw9wMS zX+bEY1zTpa%v?{7re#`)F~>!2?F2dQYplhZM!;)aT;x5$E>$H146( zlLA4OXvmbvpYBogXpD%_Ns^w*n=yJ0Lwo|@e1J>Odk}vK@DYH*q3HR_QPQ)Kev(U5 zvTg>mX0UGdvAPCy*deYWnGTcxR6pcm#2y2x?tzuFN!vKgwuE4|*q!B6@u$`~^YOZC!NecvPS+YC0J;YHs98jef@o9mRvmAF zlRWk&Lj$K3)jlIBn2^eHe%8#j`r844Up=y7D$dJZ?L!o+;~TC+P3)kE8E^e-7vbaQR^a5g!j| zTtA0B?leB{udj*H)8QvM5fJG`$7}Ef^j}!64Tw~(84+ISF@2FFhfbr>ok;-S8IVh0 zvnyYi_E1|^AFSJkmc>%81JtI3<}dyA3T+ zru?Z^oYwtR9H3Dz8H%YO0bpV%#L-S?ev0Jd&yx5J+!Z^&B>T``zVA`K0T6!V4~TFl z`_M;9Z z)ib!v=m=h0bHyxvMTnP${}o}rEK(Fv{E;C(H2ixE^K&CbQI02v8pSv^r`kuFLiB7& z&(**;YClnwO^81O*aC3tdtM8VvK#3||CROa^3NO_g5%$W#tA|vKanm%pvkn;0k zqTcpD18J0VP{-pTjj~((z?7A!&LpFqnduFfQEyu{NE?L{_z>(4vC#)Uvn4*!-7z|^ z`%zQ+Ag%rG_ArHSY$Ole7?0W0&Y3v3+(ty=gzV`ku+;hfB7Dy+@12N02zV6WmUrrh zy7C;-hbnJeKJ^72hXQxA3;ZxsUP6Jx##yqUC-M>w9stpKjx1;KdojO_BM?6wa4x_d z->*jeCcs?)3L+}!x4g85^IX}+754vsaN5(~(1TcKkkweBKEs!*Y=wHEef2B{Uc1Fg z4quifYB$jr%zwH1I~$%23wiHzp}$>nezyyJ!LHLhQ&0w4odTKop9|o8TaLcY#2MuG&SZJlCoJ4`Ool$02?a;B0_Pj}3@F z4R{?u;qE<4?E7@bpSYcfyA&@z06nHooHKQF1yu1Mg|U@w=dUyDQhSl9+ufpHk-^)x zD=RvsI9wDe?ownGn?+i22=+{y^W0it?DqJ1^hqCCaEw5G_f_18Zabe=_1T0OtT)y7Yzk#R8+}m_wtU{8 zot$1id&b1MgJzynK6?&HsMN6?Y#)vxc8iupZHpn#{|ktqeZ$(;u~I&sMyf$D-`t63 zQnV!)R(Qn;r58xNl@EQ&2ko|{SSlfy+97AF;D6xXSI zJeysl7YCH9A$ZkH&oL}dD$Euaa`p@B{Ugi_>tMCzLxwKAOYOvudsj``t!BKd!XY7s zvJ#^R%l8u1OqizT@@5}byJ}p(Hy@n z9K90EMaVOZu++pdp>@f8sLzKy)Um+8bz6^~m1HKiBW&Y$KFJ z?OT8QnsYCQrn?x4(PJkos$|6}MXALdixP^zp#i0=$On-pfAIqhmmmm@oj(YjdyN`c zrFxkM{c0$Vs}p=Zd0I1VFYn3+U8bg7uBKk5X7JR<)r9}5UUN9cqf{KPvAWv z9hS|@VD@YXKKK?3-op~~6udkV$H6<9sAL%aQ`L@ePW%mAM0?60ap8rB;sL*wW0fqwHdiWROO8_?jD6H5e`>E2%zMS(3W%_}}uQBDbr=5Pj z(xAno&(bp*&?ea9*>U#u2C2wcA_uo2MMR6c=)%#n~s?8P(9Vwg7v7(*BE9^l{f;^$t$doJJ^ zSMZESg#US=JtxA?3%{PhduXXo34e_UZWcaVf5N+i=JV7`dB%L6Ux{B2`ipg_U3QVP`#PE@IwPGsBzjyMkFA zd>Pi0%!l(Q&0^qND3w*R-fB=ErA`g(M0_vcdw{b}6nlTwmB{Cim2>Pmf8uR8zEnH& zPlI}r?OB>PNU4P3yuTQ$< zgWLw)cmiL_cy~UY8YzXcWzP5roZNm{iTF~$a)7(OC3_rekpBAb*U=ZigA0l1!Jc?= zAw+Z1i?9uV$&PK1f;3^;Frq`ImQ)9aqRr!`)HwO?!MEMjdSH)FNdlw+-289tr7JCv zUg_w;E^_SGHlP#s6LITnn)Te>ZlrVwoXPDLXTUu{Ar>YuFRdZF66|(^6j4f-OFGO# z9&Wf4@nwK(04^RkB7QHx-M_l`26(vJ)x~nV>PN;9XSkf7TFFLU0{?!r+UbtHYN5Sf z&7%Rg4L^&KugB;LiN`OCSRICMD&%^5zk@4VUP5DFuP4>v~GMwmDsjR6Q=1&3i^M+H?n z`PbmvuH5w8S7BCVyRQ5SaLa!&o-G12)-!ETeGT@>wpZXe4oMZ|8d=W#uVVXx6A&K< zI1S+DQ~HLXOhUTG@kf2=a*hJ$XyRDUQGW=*LuW`W+zKyLt?1H{q0+QDNZ z@+ftX+g4*K?*l#pxOjYq_;$RrwaN7@@vw1FW7e!(}~@i^@p$Xn#<%54C*Tse5w7SQjRCgszh(H;4Nel%7LwHznN8b_BLBXU&?zA}_uE6Z7iJjOcD zE=7D!p01n=aPe4RW@!q=+`UnB8JP2^{Sb{NQ`&-_oj`#J`AQrqMP6mG|f4DQRT_^J!i#*)+bu{CD zeMYwDHt;>2?{C8Q-S%@d<9^)t-M(T|uJn3Yj%eLKGrq^=6YDF;sm94?5%T#b#__m( z>iYy0sPqO|o;u`HY?lyhPK=XBI*!NX)2MIAkJmc+m3F8$7 z9a1V~|B-l%KpyV6^LOL-N{4TDC_L&L8C)2+hw~PO79*(VI+cjv6_->_> zPwMxvcJg=Q^Umk~Zv2eVv%WE|k>xlK-*@>^YY;z>ALMm-ex9TMeJDCLYJ^x#Osxpg z06hk^l!bA_$+r&QEOY1=`2l?!kOy$v$*muFlzgQ7ecrTkH5!nY$pJZ8F`q|+0YHQ{ zUyXvU)w0}E@oje;z6SACfa?KH;CTgO(r*!DtxqY`HSc)j04O4VQ*49$!HV=d}!^z~xeGgpxUwwHo~~rSuj_hv-kSarX|y?*lvx zaQPs+pY|xpKl_ws01AiFJNZRlX1~`&d~m?6cqoAiU}6%(g)tmixZEiLF(vXN28 zp5gnz$gU+TS}^(GgNjBLbXK95DKL)N60WdHMlmQWJ0LoQ+k2XcM6JCiDiUzb5g}aj zM8ItC!O^VNK3oXd!1m!bLf6{++F4}8EDgQw6)t@%wgx-hz@fDXuQDc_93=5zIE>R5 zFBCYux<~UG9#6pMvAlt>-x~@Cy$QZ>J0e`ot+HNLfZpzW_@kmLn~{FjsRO*WpFRF2 zk#9ix=n6^gS}|XPU4)c$o&uK%js8ga6GFLA)ut=dxSDi@t!?Q4;@yzfem`V)!tj1q z_ztq49qj)mr_Ne-yQD+)FEKj2f%q=K2LKwc5curpb>(NI8=vboksgEQ46B$qu6%X{ zTFORt$-UT<)RN(#E$|EEhb_H4*d>Ia3y?zR;Rrb1y~P+?raO9v#INMn7=DuxKOJx; zzzIiMPs7U3Q25EfX!z$2Xq65gHOSMQ|MwyOGvJRnJo+7kuk^QlXh3=2 ziW!rp&BTZzig&6-G^)I(DoaElF@zQ$N=~%@(n6`+XF?C#FfL=>L_{VDpITwZ#)06wM0{L30QWgr1QGpV&mnT zh`$H;2;jDZi+_jSG2mc)hC`GSt0{%&RO%0g|I(-ld>U51(>m~o@CoR2Li-(mI)O(d z>eQ{_4F<=s03YJ%z99jaGVl|m<5Ujj4Ip++=X2=z=`a?ZtW8J|2_garC4>quO4N=C z7T(Gdv~Hoon^;Ke8Z2DN0$OKJ;mu%rcBB->7*FAi5KS%e6kf%2=$jT^#)#4o9;!Y*TI=Cf|@E@QUJOtuNjey3aDT#CC~B zxL1PF%TwGgWVG<6_6Qic-qa!t)yJBKrwDfIJLm>Xjlq&{%|o($n@|>aeEbaYeSq%) zZhQC(ar1xR6FBx8_c(GfO)BSr`f^5USkY!?pqPV|O`a@NZ~&Z5Fji^b+X)qb{93|o zP>`Nch(0Xw8;iVM{EQPcWg*g402jX%UzkeG(c;$ucnCyT+$)+uNWN$B0dsu62PUy zGl;(qXuKY3Qu{i+d_={$9XfRG&=HiV;V&B`15qn}G+?p1i1*SnU_8U6L>9zzq6aL$ z`%V^)9hyd`v=}AkIh1(Qp~U5kZ}adZoJYwJt&wzyD*pO*d^+N@0P_GY9j-$BKY+I~ ziou)+hKt+S!D7; zoWQ^x1KLZL4JP^PBc}g*B*cq)9fnt0z|PM3cp*qfGfMSSPWy%epW81NBTnnxYjAhh zyAL3~8So;2!lCpNGaJb<{Ovq*@bbFrvL=yjKslBz-dv=A0!H%Hj8+F ztXSIeHaufF#6(h8czjoSjAb6!%nhPAbO8Mvhww8f1o5Dg`Pro0SjBk@XnYvVX8cB< zk#s8IF*<#R_#XiB?cma>IpS>ro#_(@jr)Q9Fz)ii7gctuNd?R=?0??e@|jc0ryQ_V z9Mv~QSH#*}({>WH3YvwJZOG_<3>kfDi&t{x6Hq%J(!uOUZ{7-1EH_(RZHlYR7Qc8i zNXS}J+tr5I%P>dd*-2O9dmF>p(}hUG7EvC&2Fjm)=3)S26%C z0Te1W_cPT!+c6nfWkFMxD5kB9*vQHWDB!dK9EtEQyI`l%=?8ZU8nYqA( zXs#C}qu%$lqx&;Jv}EIiJ-1v&mTpRcSM)hq&K1DlEvMW6w&R&w&cp40{r;J9%3aYC zE3OBM>~MP!#*yb`ImW`{lDmKZ330#fR}uhjIc6Y!K42w)f-vQ{D71#> z(e^)aS-1Ef=pToIVqIE z{F2b~wV-Zj_c`;FQueZ}w>s2^+y44}%avn1ex(%P&Sw|h?Nx>$efOubf31CA-kU_F z{6g%PW=$`j+j!t=FzD2Z2bl7kMV+V}x{*nP#Q>C}CF~(1Dns-YiElMKTBZojw?@hbpJ04{ykBmOX8WvRsT?%s!i?-`QM2a}i2z}ThD(@~XbAeIdBwMK(# z4g?FGMFA}i%nbX8O6OakFF=jw6v)lw!|zEUBxmH>AsE3c2(L^0L*D)IKL*d@@gI-$ z5#mq$!J2x6+W?0HAWTTMfh(!~kp!M^NIW;i;kgI#?*KmoTs)IZztS8~a@-No`FJv( zX){$p?Eiq+H1=;+mD+mTC9#{w)?jI91~d!Y;)rFrq>jB3gaT*HHi`dvz|CzZF8=HA zEFS;oj}m_(_Mi0#OJc{cfpIh09*ESj$Alz8NGkLKSoSVW6TZt4)vG5Lz zv{@FA<-#2!s<%tLe*sQWLJr|VpI=!DSOIY7r?B6zWC2P56h=Gq(-vo+Y(I><81t{R zQ%x$@apiLT9y=TuGjaCZX)~HQ*&KYC>`X!%^LCr}=CO@=z3$C}8x`>qTBpAQ?+seD z=MKmxw>v9=sI?wqq99x%@gHgtqeW}(p8!#gOBuSmgWsb{hHqkP_1LZSfM%U zR=3j{u(o+;;crMPB7cq>rfG!8$Gt{PP;kQ_kT*O6fw@i^#ej?43Y! zh~MYq&m6%q3a+S#1)>DWAusKBXE)0Q#RvJP1Ef_8S(YErH<-K|%X-Y%DFH`t}shK5Z?B*RokTswlvUoS%6&`$XgM}z|tygrTWpvXruQboo;|Bbu)vIaW8%|)28uU zy`2aDmb;LM>es2|1ymneU>*y4e7+w3mQbJ14e?+~NDZbXv<+#Z1H9q`RW^ylCw6 zusTn>eQ4s~{(F^G!44P$d@$h>0#Q>`oyEbB{2h|L4- z5HA573vla;C(C*YIQOj&EwO9IJ%5d>Dm(f|`EvW-q_#h5GWhEDDk7)WSljIbgXsH4 zH)TPn1Ghd7Sf2!fZwAyI0qgUC;MSgi^>#qr6^OhOP}0T`+QO zP+TAM!H8rUCZ?`@8lTKBhM4qf2Hy@4J0|x7n;7&Npr_AIhbz%Om_!O7Z5@FMNCB=X zNQH2!(C`Yg5_sbNH;74#?DfLyy?yFS2axg!on~a?lp=*^;+ojtnt|U(@E-jx6YC4O zpu&|>0$-=A2T$PLUckSQeg0v>SEd`II2hyg&w~eyn>v~}&PHs22~o<0uJ|?ZF_C`= zxuoLi1K|8o$b99MRzZY0TpwfE4ChbyubWQ-zUJm0h>Hgs#ADc+dPHJD*w~mP)MEn0 zuzF0OIe_(pRgVeb0Aq)^`W#F$2@{?zOwh%}z{G)}iuZ7ZX+*fTIv{xP9PD|3Fw782 zQ)+SJ-^|m;3|X+Ln9wr?XjpjA(@ey7gxNEJTh}mOcQe&a_Mi;uK^HUAePG!SI+#)U z00&W5G2ci*zwVA-%cT83J895=WfHhRlujl}>{pkw6!}X^Ude(Z1rVUB12^$i9gAcd zqV9I|w~vLL#~{qzSb9*i4Am1cRd2)x!z}^fkKlYek`N9D6GbGF9L7|qrlh9((gK+x zBhoCK6^V-MNRDV8$qVNuv=l8O`QcWP!f-*bjc6Td7j7FV7DcHYeC>msL`SPjxU*Fv zx?0^uw@8ohF$q1zv60enuSlP8?_fXCH!>jHKQd4p_eOhX;Hp%{*fAp|Hg9!7ycEzM z;Esbor^#_pO_%q^@)yqM>Tg@~4r?47Sw3anWMbB?P`^x*=N>E5l~0o4r{$?6>*=K6 zeM#zrNs;H0P!G>1g&s;$HzkE0Nm3t63cZ|!vE!Ab;NwZ+ze%CjDYZ2z^akCxC3&7o z63-@C+bOjp$-?iqBv~&ciI$ zeWy361I0Rhd{>L_^wfERb{C?q7OwAV`0f9)0^>~ zp5!U$L0qQ?jq#wq(_@T{b^0*XL#&rm(i2PKoITd*?N?)+9?2_d?C5Fdhzbe)3{IcD zwEQkja^ku>oj!t2A3~=O{Xf>;1F(uJd;Fh!r@VRdN?v+TUIHofP^6boLRCRP#IhQM zP?RQ3EUOV4A}G2REGtp5qk?O}N^~u(u7KsctF9%w7A&jiqRZE{{62R|8lb!1|Nkau z=FYtLX6`+=opbIf4Eh9vK7~P_V9+NR^dSa)gh3z4F|s3hMsB1aCEqF13nR&t;z(&q zNvNaVAyJ+vOH}F=iOxo+NY~UZPIseQq)PAcmrUrGm^XfRFfIcgH$Ok7bp-|k!T41J z^F*#^0V=iE2XSw_c}8GFR4M$OfGVjbf{Jf`VtCjja{HFih%7PYtb1L~+z&s$p?|I}^y23vPes{0( z?F`%xTOH>6_-+6G$#qNXmTB$pE^+TFWv%Bh_{CXI$yWmX4&`}zJ@)$Pb|v#c9$h~i z8jJ&*62(X>Srd-T27{wY#dG(O8wEnX5`IW(Nb&I1M0st$T;GyUD~xH`K)^>IP5v~X zeSc|R!rAlY&qo%zUfeD(R{{0%++$z&SdY@9Lus!!v@JLvjLR{Q2ey^zsPOQn94*5H zP%uM!!h(xtT#0zRm}8_zx>7&OE7R?8GJ?UiS0}7kl|P6$#*gY8!Dh~td!H|H{JR`t z5c^*AAX5GMQTG0R{bEHitrVyLf_6KW`~u)iK&AS|HSRgkCv`7c9qJF!Zpy#KL7HvG zEP~j9m#UYP1nXjTI0@*0+w2dF{knD${7oDoKdK*?)mD)^Cah;zmKbi!+!3Zd-As&3 zA8)3Z#l5Q%x#@un@iT!D{{nDO=hc0@yhVr)1V$WG{U3rxUVu8lDU`c0-NVBk>KEw4 z=MtN~8_){~@UW8nTHq=`rAh7dq1>CT4j=maG41=osCi3gqIh}$Dn=}yjrF>_RaUhc z7hRx0RE+{U_rO30R|ndQ3KbT65-EC`*d*57dULU0i}W=|nloGh%uf zHkK#(mCE$+gwU$Fzm$=m1{@CrczTU|qy(N3P-*jvYuxjmL&ybh9J$W@ygi;gBSpr` zY?GNLQs4WtD0oSPpAezE5PREv@}9{3KqTK6;g5x3&qEQm z2--C}#K6qbhnd}T^NV_!x|15uq)kegV-;!#g4!gg9qFGZb5{8R*z<{;)7|@F6&b`K zg035273ZS^#GL}SmYaofhmd^~kKToO+cZk_B<(PW_?SuB;u>L>gbM6%Xo-^>PCDW6 zPJbE#g}Gk)Y*Dy(F{){QBL4>P4iL1DD2-_uKrx`w`t4r(9BMpYb%^n-+DC1Asp02{er%jW|6AVyk( zQ97Sa=YB3I80pug$*|RDk;(>)Axk6jpU}w( zyMUNWRbR-c=@5<}Ma>DSneI^2X&LQwiN80~RSpqsmi~wYS~V8ZhA<0P2`Y9moyi14 zs(yZ*stbKQb!=(3o4+x%L0k_5{B(f-pZ4|l+RMej2gW4r3tEUs&oIiWZO~$D@ZX~` zKL_;9pk1c6ns=;i^`7>6tREQZTD_UBHTu8b%XfqI&?$>)xj+>VwC7di?*twKRQk2` zFu7)}`@Qz{QSCVe*Gj5E>%}d2Ija|6i*r?LPP|#lTja2trE!bYZsYn*Kb8NRIBk7~w{-SbUIQB?Rv>%<_aGvl zxDCSyx2+qwdM4joCvfeXvM zWuES~EK_eqOzQ-62ZDM{BR>aN`S7Lgx0aQ7=l1oQxoE!HgBZPJ#+(Ip3zsd0#or=d zHC$ID;rnF@Ra#?e?^^vVS%7LR!FRM>VAjp;Ep6!6H z@*TClK-Dfc7O=bHlDZ%Crfr&v}bv`duc;(+f*{b|oe<1%X@BWvGHpDED?#wsx`Z+!8%vhlg& zV&efm>tk@>@V{K%9VxuJ%q{hQPS&190i^Yvrt|e~eR&G4?~cHRM)n z?98lL_KjBT25Z91*mstFt7YbvvkPD%3Qaz1nKxMGjh20bWvgdK?y$^!8__5<#bN!& z7v8SqZwlG3`${omh(jWeGA|Z3TD_;rIe2uIy(2>v+E*#1n1l%9im^=OGR6zp?#T-$ zRQ!=VOIV%F^}-xtjkU6L29`ZR#=>X_Mi<2xSNKJ?O&k_Rd*P|LAk-Z74pevb+Of8C zOFOP4e+_U05VYez$tSwd7Xg(f`SZk~=*c?|L9ZO6f|gBHW*YV4V|TV4ucq9;h;xyZ zAEB~2=PLT~N=w^=I&^P_nCT3T4vuDb3}u>jJZ2U+`BBtFh^ZoTozzEJQ*#@w-0fCu z8}m&ObxnKRGM}-`keQ453m!eHSef!tx~X+Hk{fVmYl%kSl04lx;{#m2?bF~xFrT?*rH-3qwbC2YWje)6R(a*v&pU$kHjpR8 zx_WIza~)Ei;I>B~Pq;Dc$`VxjR{PJL&NBi!Zz1_JfO7yJ4{|+ul>#|Ju-_EaFL=Hu zFCeL3e(DIfb?fCCfq@CZA?b=y8wEk#O6oc-U6)(mSZRQ>7-qJbQRwP@bR2 zC%eV8G9bYBG2~ALcE7pC{av6}*}goZRd_{^y@jxe_Y1K_baIa!-DA3YEKpWNA1KeW z4}@{9h&$Ecve*iTOq&@!#M&(Rlb)axre&sw(#(vktcax+0JVhQB&h?R%01$5Gj-1F0~w%hO(Gv>{op&WgD5KdP=>g_$(?%wj; zpg)vw?>~JmUD4x%_jr9G=|6Wh&j{A5o5(*1JPHK!gx-Vp0m=ZCevR*ZqTM_Z^cVN+ zw$E1GZH*({W0BV;&l%c2+9W5ZBpWNE0s265KvqVnVLIUwv$Qj6H(~tfrN?TbdL)*X zVuefe(p<ehE`+D`L zt!m+)&Ln>ea0d|7<1O+Z1K$EF{Tlz2$Zkt-w`o_OfvOkxxD(w5_gJ9jJohj}2;(=_p#S6ntAOi(01rcN2x*^i{ZBxp z`F~yGo)5JSe)vo6={dutmoI*rIb!i}pkPs)=6p`q>LVovUZ9Mq=n!&l7Wyqh&v70V zS+~$f!?z0a7U)XV(}{UtN35u2rt2AAAJ0}XS=~OKYaot9>V#F<5P84Ed1jW%FQGTb z=*=1Q=Bs=JR1fs)->aqm=aJtGYz2b)kL(@OjscbeDj7d{_0O={(sS;I98PG*cLn`G z(Q``8S*>^SHr;j6wMDq6-+QOc-l?~HFZ{DR7hh;=ccXBd^G6YSQIu7N!#UE*vNH8F z?9(#Y9%qma#YK7;`5cm4nTaWtb)mFZNnO{8W@G*+A|D8+0Mph)X)eRuE?Fm|S4t-u z3uihh!~;AmC2W`pboxP}gG<2tB$D0KoGhn=z(DD}UlI1Jyo2BsNG0Oo@{ZvOfByKF z1xmSxzg>6w__x4s>0<)>$qMTdL0lrUhQ6v>YBm`F5x$XVyL#M+=nL?f_Pn}y${mxn zL%nv`4Ss`l?j!Phfp39eol}0TLVaRddw;#bIyYtJjD-u;Mu98WO`kN#_nrG84rR4x zh^3Zg6__RN6p^Mz#4LzPPMS8%d;eITomAJPJBp-s64x^UKgqf6t}$VUi!r$?M1HJ`*}Lx`BUpwF2jA^DcT2AX0$*sA&(AF|^N*07Y!oZPdYZ-Ak@4=GW%iq*U~nX@6nA!NI|H-4tVj%L zHIqh>%#T@dd}OLmqM=Fg9*OS7YO@1g-3*KBQrU50;z)Hzj*E{klN~3-#uww2(V1ZO zG?K`sP;I~qZDxE$sZ3p-vm)j6#0vNKN9aAwaJphH1slM^jGzxPJV)p#$w1IQ1`is~ z>n^i#N{Y%qVT##FM9b|)p~LTBd7eX{nMf?A=R6~fzY3i|c25chh?$?rD}G$q4ZXY~wG@tdqA9lYFxtVsr_apZGf>|*gD;!7c5ZJ%GAaZty2>Vl1 z^A%1#SU=HicvU1`qi#9Zi%4pEh0~#+Q(QTESL_-dkJIHRNG3iK}@` z1Ub}tJ#o)mE!2tbjcj?Zim~Gm#(JmnYMjG7zJiMalmX9W&VbHEj5t3GR}(a{Zmo!xMOzPccU831fgBx5k+CJ=Zi!lj&k+4F1MUk38+ z14hIMm*B*ySk-6*z}*mGe~9N=Z36}*5liVkIeNm2ViBW@ltWpkQxD(M{(1BwHH%@N zG{GxZVnEBduOQzG=nDkp8cqJ~p2n}2YszBwhE=hu++u|&)=F)QTPoCO;93gRq@&7| zOPN%_YE>rxHONE#@-*_kNj~o0A^$C44Q!c*c7J1OeYma$RNDVZTYULHZ9Z*iv#zT7 zMoHzC`ZLX}C5sl1oXpxixY($; zj4(=ZT`$h;*t-KZJZZ91Vb^|LWUo${oh~uV)ezWp8BIHuI<8i07Wu&_KWAYT*BkOW zY!#Xa$jr$Q7QP?+>La}NXz=T$DsT_^ZNM`?&>mlt?=&cOfWJtup9GE~x0x(sPl$<% ze4q`Ej=t5V)~`CB%0ZD{CSAvZ4v&OLfgT%@YMyF;m$hBB9;0n3%|RI)#`Pv>8S z1fHl>paf8Nmg{;7UI|7!hyJP|D% zF@Mp_Q`!FAV@%WftBCZDM^iNT@0r``J!g@>2w2nVJy(<81O)9IoLj%gZSS7cw0by9 zT-q?AY#0l*qC$)w2xk+tcUQOXWvlk?$u4mRuSJtn{C4LZLAx76V_FK34g~ehBi{*V zPan1}$H_C6c~+t6Rg;S&ogu~ihT({rnY5o2=b;~Bx|M4883Iaar#j%R(GtN~7S*cAbgk+Hr4VkJ9 zMiO{Fq$Q{N_2=0^(uc*gIFJqm?=K?X4G8cboLj%&ZHHdGw`w}okF&x5g>j+Kh8Eg2 zL~V&H^=&oqM!F4=t~9tS*;n=&qR}r$1MlchK`;Z*J|Hiuxd!}kVckIfQQ$*BrKtlw zy0Xv5ZRyKNeb>96Yope==Y3th&s&$bvh%FE6>5+uy51cu-t2f}-Ra(4fA(*_c9n!= zag+Wx@t!W4^tZj!{od&c>pk3=e_>oBUy#lYX}loq9dfTYPjp`;wu^AWiQyEbYxoWv zWrR~4^932+A&-1P#&*bCg*N*{*(Ewkj!QcEMuDB%QAV;J6X~HndUry)%HKLe2w54c zh*suWx|zt&lNIHUi(E6WJYq(j!^|wRbGXbFPG%^>G^=B_3Mh6tF7qRQKPU8Rl zC^2f*D~4}uorg+Etl&&MJJso_1b)5X}*mvRq~z~d_i6$Rv1qS7fHP!p6(d(dfmKM7aR29SL@+uVvQcV zM6Wc&hsFs~|GQBs2Q?YduZ^yS6)&3RLzYvKx=N%Bbov!4gsHx>No|)utz;duvBH~< zz+{ZaoSz{3VmrD+n7!?_Lf#?ScjqVlkU3WvGn7Yj*)vp+=bo@|hKF+*2c4oPMP^2$ zktvoEtfYz>t@b3Z9ae(hq>sca$=?Fp0R;Wy;v2%+Gh83SAGf;>`}1(%gFAcionnz( zoQ*W7d=XlPN_(zrZ-U))p@ZtMPKu{0L`mQ0FU(Jtdl{%VLD$XkPrE!kd_Z)9htoA2Go>wXR3B0xXX@>Xy z^*me6iz+=xzP*3r=ehSP(C6TBk6gS|c5XLv0-x!qBb|A<(mk>+7qRssOBKmdVNOk1 zqx;7CB>gWt=^x;Md6YdtQc^Zboo-`kl^yrCr zDf$RciGW)c)QViIEuz$obQKvJPxkP#gLedU)F0p#eGar@XawwB6u4xrM?cK$Ej zo2?GfrQUe<+pW&?ThhJ7bFv>jDSgri%GqsVMB8ihy`Ot1WNDOaGMcM18cf$M>wU)@XoQDBd+KhSPIY%X6b zkD6|1E5)nar+Agl@F#nX?}fIq1Y?-tauCaj(yxrELaAnEN8RO?hhlWopCRipezmi_ z@>PyG5Wi!&7tD+QEq;@Sz;8?WJp^Bb6e{;OX|v;X#V5OHtn{qpK&zAR1sdKdxYu6}jMOBG89Y=!$@vQkY|6eJU-&0mPHA z)E*||z%S3(vFJkh{pT$5Yk_Nl06%w;e+YOUP-#|`*S``UdFAte+I;Ho%lUtyyefoF zyN}&KJ8JW7jkf|m&q?*nksry5kL4v&dw}Yc4yqX1|LktE_Vj^)XOh48et`x+pGB;x zW}$JaF-&R9jD!pAZ>$sgD8FRYb3FX_AJ@_!rjefmECGW0Hjv*8Gy*E^_v0NMN}k{6 z;Qe7?-RZG0lWpwLS&?(I>p`;g^!7oR4Xd$oZq?LE!8&E=Ta0InN-%)W5y!&`2z;b_&iNJ z#qa;)W6geh2a`VnH~|Ri`vCc;fX@Jx-fr~T_lKvwvwO4Eq4wbI>%~PXU2km;Uv7!@ z)+^$2R2lXf6FT+md{_8QS)gkAgj6*Za|v@aXNYku!?=n+uij~dd+IOyK}i51yX^KuvqPHrr3p;AbmfZp|8VPc_zEv<2php*`qTI4bd$*%`)2LgQUBd;9} zFA1phU`G#6&vrUEzFzm`GVMCNA8ALH?hYNXD;h(FEO+Zd zW%2TYvgiduKVJz9aK6|+tTR(5rW+_~cqKQl3^ui)xy(rL}X&)P_#YaZ| z$41wWjINq7ZAAr(WAcpr72figa(Y){9~v{QYk;6$8_C}TJPfEb)vwRm4-a0i6aJHW`Ab$ky5fqzPn?@lk0j1H;$vevZS}F? zd}LH<#u3+x3U>u7xj~fQD4ZJvgpykEcPJgYBvYKxmysO_btdE{)-24{*!oA`y2@9ydm5h2YlhzCy+%g>q~)3cL29bfeW&P= z4vYeUOWA-lLfOMB&o~wAKGt^LtKI6!EoS+03m=b5h+0~+ z(7l*6VdRvaD|#Ll&;aTq#7;9$rMI`f2g+Nj;7cxc%ZsgDQtXz~cS*Thj&k!u+~fRR z>>hq&9Dy7+r^c4OMl&};CO#l*CXIel)HtIr5H;rLesJ^)v`K{EJF&oupOkHhH!MOg zrpe@O@tjy@+|-LPpdN_~z(f((AYO497D{2{B^T_yWPBy`t8he{xC%x)6bVQe9q#8CHG)#}I{h3;-j@{eO!F-+S4i5AVWtnB<^ z>lB10YzA6~V~^1@)H4|_>5+kXhBrWd^mQ=q5ynSSku0qqqSe=X^=+aaL48Z7z@Gt& zfuOz*lYa)-38?gX-0O#jqSJO9*dN=~cS_yTrSldoglr3X;nL+NV+7cJiUyN%~^9QBijX&=i-6dt};X*`&H6 zW^Z8aEwC2p61FnSi!Jf0>4gEkHx@lTXJyLiX1!2BVdGKjA(8elg3l9VX1Fqv7w){% zH2=rUD+woiR9c;Uyx#0ng*Qs$A%ROnMd-UlppSJ6MF}^ULNB{aIBDS~)xo?1_&uWf zd=Kx_r?$+OkC1;2h#cL5_tVJ#7FZ9cbX9i`?_Yej#($D`+Uv_N&py13-xt--X0_{Q z{uFT#Y)^6h!2CjU98Q}}T;euLz1(B`jDrO^)>ROh<{oo@hK=~f=t?6T#)P0kpJvsZ zF#0W;A+%YB?vc5hW%wSM>K>z;WtV%TwOKZ*<|))RKoLZ)S51=XP7hmzbHCcF&U)QA zMw+h~qi8_WIhw8V0xST{3uS0E^o6)W=B{PSTKrC?ZjfQ3)i;Tdkrc+w(%K}8dXB>l z)P$={^GCxGc7DVg5_ee47M4bDK#f!FpcR_`mZweko6GCqZ^qWFaOm=WE zk41NCI3t?g1*MlpLF-~%>ZOK@y!Nb|)*^QqP5xxy93U7+yAbj}$n`UTN@ur=FXcV0 z<@iS1^+vVlLeJB>FS^I}2%U3SxhLfPN{_P3{X%XLxo$pkzdJ67%0j%HSV zVV)CFCQHXD+sy&;Z0YVmmLp<0k^SES1CvCfi+-*ueeG%wAN`MsX|;ZJ=96C!>;wXQ zoH9M8tpKh9R65(|hpsB~`nms8ZA&1Ze}A2OtJONEsDpzoa*I!$xl}EDV~G+xZsBZn zo{M+Ot){zr&2*1fiB=2RcImBXE8Q6cC2@E`lL?vi?0dDIok=I{f3GaNPsTryefCK6 zUTNMZN4xo+_sY`ySPysWJ!I;QB60(3Vc3ZX;|<-LVYWu>f6APF5;s?l`IR(3mQGGF z3|W*_FB3Mj6bX1Q+1DV_Eo)}xdEt9f^Pu+W^8G1yI{Yax0zDqxFm&yAPv#VwD0?D=m>g@}N z`&)t2l-Mx25*ckDd#qY1t04osxwDwfES63`zfP@{->bFqOn0diPFXk}?Gr<}E!-Dz zsEGGT=@~6mo6}xuT<5p{u`T@FWb$VLe+Gi~KkK-db}?`lpwd-;^xEC<&)(CZpM5%K z+lm9`H;=b#?g47QlvchfszHaN=_sJ=r$boz0aKH7hL4uGvEqpZl#P{lvhjr|Z z*8A=Mxnn=7=RAgHTv|Ro;33`ItfzG>ehTu$eo`JIYtfgBUudLWWazSE7(+^gYeh8G zd|7d+VXif7JRBKU7||;YUD&oIyIc8Py2q;4Dgk}+IJY|#`rV;#gKpj>?e%(J8aVG1 zI>SKj^o^illP=g-{;Q-zY@)Ne9paj(*CGB#8fUO?(^-_6ScI;}N=8P86KRxAF)%t^ zDX>;E{U*_2F`GL^86BgiQyYE-U;8o|drKzFlr#cxY*VXU66NcP)ObeUOcSs4{n
z%@1`8md=8w`(ViBu zdY7a}(<0_!6)8g#75T#!qWF|)i)gz%EubH6+K#4~1MTUlNj_#Hbn-L?ZFy zVv+dGZmjjcgjl79qG3mw+;p9XzYXA}*1Mx^CBGlY`b`V|?k4{*@EV{})sx=%dN8H! z{OUhKOI&&=`~@NusML*yOPPM_5v#r3iwgI6TC2Qwr}c4+`{|L2l>N^5m*vU~Dj$-C z56kL@WZA>=3*n;u3^uAt&^bzjrcKI>v{WZGS{BWhrA`*=Qz|pm9Y0vv? z)GLcCb1Ng2;mY$w#p05L?${m7tAvSix?LQ~X6qI0xoAHcOEH@_`EsuTlE*zKv5)S` z3_31SV0H+k(f_QV$X?@aRN0=EJ|fB%yF z_dxt4uf29W?zdNV+xDvJ=ka~}&s*m&5P z?CTyw@3|d*;5ew&u`h~olg6JQB4cdXb8H034HjMI_{D&u-Oi#GVG4R1A|Ux`#6!&ihU`|E>2654gxM*9C4*9#X^? z9ll=3rSfDJp^XBSzdyRD8;I6$H~mFnO)^g)+y$kHMC`)QDzzDi365IEyp+7f!{=h~ zl<@B=LPbN z9lqmt(tw_AKMO#WM9$vR_ z&N6f~*fN69lVS@Kg?svmlNwG-w#q>d$R2-`qn?#FN*cA(=#2Yi;TCi$jjhsv*&Qdx z7jKnEk<+eZrRtn*S$Z$xMN9E0`qUksl06oWS^(K${8^;qrSD3$bcGcqp21$MOh?C$ zCG8K=cu+c5>hfYVxq)!VybW_k1bIeD0}prCmCO7_eo+`>&>g^zE_AAWq}nRL zrGQ8ngY+KODDy^Cz-1>KSrnT1)5qD*=?YSqVTKWldB6f75S@pJzek!Xzljc7H{B8yJY7qThux|fV5UgN= zJRJjdg*S$)JB)I06jB>H5fpceWW+h9{7$50q<$&lA>9mDv3fgN?Oh&z&qekWjH4sd zq}Bu;zW@UKdaa>pKl=D>uU8a&9-J%vb$Fj)GpBSKog5>z`L;VlxP2Ps&&vaG2BHBLVDxoc7 zyTd+0cQ*>hz`lToyFENK^6VV%UD}=BnpzXr9|C?lz&^k2dt1rMeOdB8BNq+%T&>=J ze&wu|`i&t!6PODG^*ieCVeLw;+so?@`JO-~zxV|76`m3t;>{ozM;1BM_&ce6f!rgL zpeQM#(kjMrqK(rAd|3N5W6!Q|j=a z0R-hPCEp#mAk!Offn4a2<@RN|P2v^dQUwojic;3Qb?p|o(j1sr>tUhPkkq=1xv1+R zpNE!r}18|d=0!iDBsiMzX8nIE&aD2`H{eH0F{!Xy>gEFZjF20{xIo4 zIaNk<(+KxiQBywJ9%GKR$E1ukYM>Eh zH}4vzJzmq2*K1;T+2Y)+rR&J;e06K9|^N(<@Igu=^u&MyCV6X2)`@(1q}PkKEr;iG|-I0 zC*(wPp4~~FBd1^?e_|+N&$3UF{}jq$Lp8RpLog%2asWw;i!Xhxa4N#r=+TPEhiUhF|j?pdTURw@cD@;`K|5D(^ZJcFuSFlPC;8#PXdvh} zf4VB9oyqlOfJ$5b=8dnxA0K?2U2vdWe8HUAJ*Q8c+?pPmw{YgjMaxypQbb+5#i#l% zfhu@Zxw_bATx)1=nI$r5%Xli06LFp?L%oa2bt;u@Ir?yv&5kB|ToR#OmStC(=}tVV z=DDu8+qqKU7oxv7UQgI=`Z_k)wY3W?&13|q{=Ux}C z`v7je|LE1XtAF+%rzL#4eZztEuJiV5s8RWtPGd)EtBKMT{?X`Ek6vbm5-yLIWipp> zp_Q{wGSYwI&Fbup;}6@>M~i(q+u;ZDfoYL!-=)GNUlP3*>7O4J6a*!(MSm9K>ecV>z2G^DZmFh}9Xf zdD6TS2f8t|rDloHT~@rDQCPuGL`tPU_OiGt&rl-{L4{e4yMdp~cql=@ztr42s$A;&hbU zugk}T+$52Y;^F-Q5jitHC>GJQD?{v+nliz-bTJ`t=+TS`qZ`7!Tv^F68?QlLBi26c zwd>0HE%M|`$=?T<3tHMWbs_NwfT@5=eO~d}b#LK1??L{tX^1yZHy$`o`}+f$ENwL3 zv$va~(cBhxPXpZJRQEV|J6&*}amtvivCqjdX=A_C$K;Q_7E#C8lrizK@5nI&$1WX{ z*=DkyS<`7WlQ$DH-da?Pdsj6h{B!o`#rzzB#_OS8MmIZhc_?drDD0$;jt`9uv3uDA zBK8a%ESU`8g1?8-K1Thr2YsL~e}zU*S1UOaRyBqlZc_dI9Lis1hphscOwUT9pc6fe z7z34f-cZJ7rf&WiivAEv`FAMZ6iSI4IV3AlY>c39rrE^HBM6ISM=uQxyeu?T7GD_3 zydY$r7izUCTo6i^<(T@~#rBfe1oQ87x$Ji>^Zby#DrBD@N(=hhjUoTu-z+qVdRAW9 za!tS6jbzN!yOs>RG!$JEN-^WI>-NCpi{O{ zmo@Svi5+!0)t#KuCoP$NMAj%dId^cuRM{Zp8S+H=hLnBFc*H3f7e6PBH2q86$~Ui- zn7F23SM-jw2Zok9naG-(SBUV;XeV4I+F=_ni|&emgqo{po~-n9*^$L4e*1E#`j@ArCK@@(b#}#iyPHv(TCS$fBaNex+ogmeIB``H z?w9e{CESgQm%aKlQO_|lJmC^g;mjWti3tH@9NSuF%!dCpd5IgQa3K&gq$9;z#GiqM4 z%@^$+xV|s`qnst}=h4(he&Y=1oM)fF6qN%nJ`$4`RvTPvbi?(Y32~VsXB$<~(P<|k ziZ-R#6;68<-+z9@eoqFkcEs-wx;5)6AM<2V5b|??u0_L*c!l zsxz5!3(YW2u64VouO@yI5;n!wZjp$)PnGlJY+QX#m)|Sb?a067O@{25&3CKu>XU=; zS?%9Xrbv^V`QeY*nTv$tII<$%f}Vy^B3^Q%nO_DQ`hjR!YkJ*l=R5uOQ*F47{GWi= zfq;)GSc1P+AbE$^U)THgBCY6JPc0iVWPil%hS$?4)o#LbwtU49HwmplABW4DIYtp` zTV>_~yDT(^?UFgsvdFz6k(YApE0{Hvk9Q5_J~paXiNW^Kq4;?s^&`VIm>ZWVQeS0H z#^Zc07iMY<$4dG(!MXfcF6JD!@b$+u(BZdB96Qd z6(7RQDhALAJTx#4h{KJxeUF5)*$&BgBVW+tHjQ2w$B5VaJVSPRRXDGRtWefY!87R3 z!;D8@%fxg8SG$U4%fuf`?|S&|zpRD6eDp_Co51xXz)v!;4+zda zzEyo^*UekHtZqK_BTCzRW^uYh#K309u<$2_dk*-@Fjo;m#s%C`+YZn7!ox3H+;7`3_;{sLHpvZpi{d|pp#g-ur{z7o$$#IZ<;`{1P9c9La4ryx(Iv-M zoT7Mfoasr(Weh2cpOUKI_wcfpG6dr!Wd$~HKn~!iUuriWFXJfYyczR-^x)uXzrkT2 z8*hZ_RRN)8eqIbBCp*VIlF!HrY1JQi?_bQj+P{C@fA#*+c;NQm{u9c<87ThpGXLnl z9!G2xMFqj3-hcP&%{!8QC5_Xudj(Q~pnXD1EUg>Y#x`&K9;AIvT{?NuGFKvyJ!z|o zin-r-O=z2S6zSn$l}p7m8JD4uV(F}SiP|?DDlbJBQN1?#p;wL#ydx;btK>fbeg=a1 zWWpH{ZOY1+RtKn*p?mA~hd+4ba&NXetZvKqsQ#f&T~?+5keW@)4mIheJ7#c$Sf_JDblFSQupk7LmiSQdH%x5_*DC-&$$bxb?Rb#>)u!Ft;pMJH-?I-j$*sG3M$&&z z6VL4LKQ!k|Yw(dVZ{~xSeTSNm^ z8EEue#>oLy<2TB*23*%nkj_S#jYCct1t~(mmu7=JRhtSWU}YDkbq-}t$Z|S}d}i?e z2zH4<73yRTPMS%xL^b(c0^!dnW4y|bXQc=k6JR`r0gsFh4>q0BG*3mYx5vZ(e(KvE zFI8v#-|?cYZL`08!1(fs&R6QViC5W^I*>g%3-oHHOgMQ(O*=tJ%ci!R{gnGp5)mF_UEzO^V2W) zXZ&xrE=H&BExmfPKX7XO_iW*rLB0P%{_jBOw=M0q@2;@6nCo)^mG=A!ofZA;AnkTQ zd4gUI-F2P;-Sx7fyS_JuZN#g9M3>6CUfzQ)QRI5nh4bmc*Gc_)iTTtP@W&>^8R_WE z548HFdK8#)@8?oq5hQY0QX!mEgi1sXYjZV-eoOQnT-rdERXF#cc8}W#tWiej!_AI) zCV!a)upMsy|Bdl`T^5_&aAKp#vSA3Ol=G5%bPG0^A%)X()*FiCrP?pOcGJ#j!Dl)7 z!+>LfV17{e{3F+|0xJC)K2P|M@EME-<*l>cP`dj&m2c#>7`^*IjBXR!cOFK!i*(%G zM67rVN=to$wx5f{691~NdljI&M}X2UptQ*O+C%AE`0dltfysl5YlG4oJ(S+u2Bmkm zMJZ(>)qd^abJDpj>&>x`+uCVd{}xdA%tHVD9PX_Ft^ibO?~ls8+3L`6ARkcKx%Gvdh_PJ3Ch!2MK$kl#>bcOOrc`E!l%CeJ;&du9yn)a<%j{UqNSf5kRqfLPVASlPG zRZ%U;b^Ev=2gR3)se(6%K0RzoF%b>-iJI2S$Wq=XYW?@kd}=4tC#P46+QMJ^{4aZ}uGmiy`b`fFAy~zcF*bH_ z)gL3WbjT^0%l1dM5`U>)wNp9GM)wSwGAXzEM!X~C?E*8^QEX~mEW$N%ppzQO#Y+{x z@$s?WYv(5FTIJu{OJ1Ch|0E#5$7J$L0OMb4+;7d22jYW;l=09YR3KK&FGNohI*lMA~P~^jHJZ6h)+}TU7MCP zO{>ta>?o9X3y|5@06k&eZ`Q>irF5YLAzgWN4|%>Wj}wmzBckYI>~j9?)pHMZ$noo` z=wRyt`Wq0;^ZzX!{CZnDc*)E@2cv_3GD<&@1$&@_`#=Y$6{I%P!Sz3Q<*KC&0sfbh zzYtgh1o+?51OGoe_B}SHoyBm5?ffJDl zb}B4^hl8k&7srH^o!Ki9RVF1PR9wY@!F<&4qgOud!j|!pPrei=2ZHkTBtIB9l-!8& z5%t|?vI1wtRRfs`elm{MPQkca1x5@r6Q~(!7%j<@d!l3KiKH~`C$AiL@{V9$e3ATH zz+NC|zXy7Uv~Cx%egi7~8Xdo*?Yg4y*iue*Db*6QY)RdW1@!EyEefeafX*&bhukVt zHp#PZmBpK6!u9kuR684<%!=2f9G(HE9Rk}baE(%fNkr@fHT;dDUE;^dD0s&QroPmi z?nG^~K)BnJmq;`~X@ep3|Ffl#ML3Hy^`X?G`e(2HtEpGee{X0owY#z{Z8Mui!e~}dTS}tS%(Qp%|Eom z9L5!&bVNq%K+*agSa|iAySinZ-$wpn;7uUtzkM&kZwN32Q0ao#y><-z7PP8@n|-}) z9B8*5Fvj!d)Gb}ssrwN#mT+hO2sa2@z;n6ZnX!0r-NIQa5?gOPG@34S(*~uQX{O#cW@3rhmwhoa z>|69S>0W#62hYL0J9!N@f52Hl&>rW~ey?!-9-z{sSH1QK{BF26TOAtSU*}%=^K!fP zIC|lNL$=6F)Kr%*sPmho_3vAK(zg$dRWGI~R^c999gkFpF)$7FX4h|J#dq>`-961G zpaDEO4Rn>|XcN2X^S^^iS(IuG})B z6)jhEmX0A5_L3dhY7+cXNoPnHOe6K(Y0vzliH^zR1p8-ZJap#IU9 zZS4`Rp8{0+H91{kSsOadEssm5rB}^l@vS${K@?pGStfF0u;Ua60~V&NN;y%@T)ojm zDOK;Owmm#(>srdEp#EtAkVM=ubnBN$zAUCq2I~EB9?ZL~$79R<8hFuPFaF{2fG@c`V7>X4 zp{>eU!QcrPG z%^cLzLUsi80dGEh(hnH(qcCm|XqXsD5ZL5+?b1M*gL*wi{zYIH5Y+2!@_PX7uWQ_j z2J_d!>*aE`C`o@Rwksv+kxDVT-u#Qy9!5i>E9wfnTuFdZQhd;N88ge6jT^9nfNrdg zdgYpad5ir1bn+JiTY;cIYwOut0I~s<+Vhd_%~prStJb+!2QRm4_wE;7{*Q7a+$iU6 zmo%sOJjOtFgPh{7V)`MD8-?~Po71UAHqDeRdqtTJrr_6nY&Rp_PBrjE%nEl7jxIRW z3%2?=<8}OEb;bb(j>s+CNOZ2~2Z@gdod|VOIC_o^FvXd$U$|DvJe}Lm3wX6D2&Qz) zLSfGe5wr?g+=@^Hm8#<&{#JsQ_U(ET_x^9~+S>G5q3HsYCB5cU>#9l(Dc*bc@{Xi? zN7GWSAPsg-x+-hvEVJWdGC^L%fRCab% zP)QA)JQk*uLz7aA+K3BNaJQHJ`u)mO+N8bZMuht{nRn( z-*c|S_6o=c0z7>64@>LMb$fcdb^knB`8;LauH7jRDp%uDYL&S~Y8?ru6DQ`l2w7>S zWmU*&M)!P~;|NP>FJ6i>YBnglqGh0(rXj;CPd)Dn(%fTp?Fz21Zn<};{YbYxT*Zb} zW|@{G)~^4&9sD-wKkz;Bp8{XDJWpuHn^2>$LE-?RDuI)f! z4j!C8tWt>Ej@;J!!|V}jl;*4M(0;%0$Np9|f5VI+%%19RuzwEngWBFTY8bS^pURJN zuUhWlSKx1e>bHtde0p72dynf)kF0Ua7w}2#`9!zlc9iua+_a@>W|xkwJUL=gdM z#4AoM!{ydGx_-czlksbnP&KH;C;=5sBMm7U(S+SfQI6youU^S(TKI$+a{p#{=_3|3yXCdn4zxAIf_C;phZBpJY zQ*M*i?K0gxc5{z~hd91xY8OEIc0xM<^{eSj!9qVqp9-^v+Jq4b9j#9e@+DLBNkKmK zDE-Lh{6zim=KK-*_~yLF7`o3%9XF17SuuU1#`h&k+^`Au;e(E_j~p~HGHK9J_T)iR z?5XB&B3ZD2ftc?a-|NdzbaG#ic!-##Kc{<4;H-l(fhBF2K$yW)JGhHtEqW7%Ajp^^G%mroq7k8;n$^bt9Ve{981 zy7%EHQDm1E{A8}rPdY99?BNOj>}Y%Czb-o6g`nE6RCPyZmN@(mO3sYE4?bKBC}scB;8E&n9Sf9ad$T+ zdhNKE_6g`J#b-vYXKe?9cKmPo%#H`}S#3uuO>mLfJM!&QRd}gH0vjM_-NuC z0pIxz`Tqre1Ohr|>aegDz5!c)UoT>lzdzQt&v~3>^-;rYUOOLJx)8BH)SAap5{Xo8 z^?2zh@4eG`X8ZRp;9l$Zp7o!+w^y$|+N-Paa{m1xqIoONLV(kOyR43qipws;KRNmN9FU2R1=eq9hpk=jR;FH_7V+$vHkxQaC z`p;j_^Md}X_}Kfn{sR!;?brF(w(s#c*^3n8`py5JjB9eL*Ur_yZz=By3ueahF)|gkG zroC?WLZV~26uQUlsI4A#%~k6^OQ+*4|D`{pK`0ZC|NQ?}L5u*kwcGmtE+G&4Pn;us zM*TPc2iBo=eN-Oa4tI2x3e3qLEVvCvVxh05xFp3hxfph$mi-ueg6k$Wl*0 z@WK5HToAx z;-XnZf$nkgjHPvb-A!ebzT8`i|9f4)01ku!T&pA))3M{mu~9b43JxxdOp#~X!nRWK z3qr2cVh#}z=g1Cf;cB|ltM}Mj;gS5ow}AW^z&SuruDixr+Lc^?nB~c>1HQ0*xq?D< zS8h-y&6uUiOaZIbt1a50FobK(y-LmHI9X_?;7!INVrL<7N-0WLZ1@89PHXy|SFR@B zIms{AlrGHro6vOtf^sc7!_negFZ2B({%W~Y?8%wSD8>ryF)@ukm8K|>O5FdtBv9>D z{=COCgLV8Y@|OX>1A_Oesq0@{x8KKZkB`dE0Y*@SQVoWtjZw8%2OCELVSsqRYt45| z?FWQ3@o30$h-gWe%DAZpNqX2Ahm+`1{_>-ijM{L)?8z6%5`6K+;0%k34V2~cur+}4 z#+=RR;Y>%{BxDl}8TA7v*51~_H$6c98Q=vVSYOYL zg|+v&{s*AaojcdK=Rp4MUbH&&_v!6l$HUY`ei+bS|LQEfu+WJb#Q&nYf(A<;rNFY* z+$FUSS=r3ZM7rpGujqtmYQ%k_$5w$o9V?iS!v)t?^i#lZL<$75r=H|NX5CFJ? z5ssvlo4GXv;v;@Xfmwkx)pEkAAzdahFJn5#QF`wRk!}_y^+;Zs87d!ThEheO1M*sB zzOLjig#J#I->Xo-#{$dQGr!SSRJvXoeZ^)ePZIm2(Jzoj(|>;N;jaPQ1oP$7)u3{ZyGSW@&o-CWDRzaNkMLHWj^o7-G>nk_%HOE!oj|*FqPG;_>tdA< zD~wF_1$vDN-8p%D4#Xzmhth1)S^Gl}a7Jx5%?;@w0IRgo-UX zodw2WUe)XU=k4aV!Sj?J)PAmi1cLq;=s~sLUu@qWM=n~jWclJ{;}$Mnt|DxIj37sA@ai%4t`>i0OUO3>w*kR>C)`c^TcD$_Zx{F(Y5(5V^BoH0%y&mE z$EWP_1vJ>=wMsX+H?To)xLX~@02BT5iJS)lrz}#Fmg+@|Y3t24fmQJjoyv(R$MQj}>E0&$%*p4nbT23Fcq>IFNCL4PRwQcGgmhZrc zCM;szh7QM>Sq?7w%*%y&is{7D%mSS0^)Ry!4~@;zyP4M5*x}vS1iW6D*NO=uW}D&O zA+td^u~;Zp97&DDBAuLYw9M~B|M2}qyrUaCCH8-J8XoW2c$@#LogvjCMgf3U_q=X~Xz-HTR-+Cz?$8lNoaq#PZC|Nmf?^}guMvc>D=|i9g}+B5bnTuXfVjSG$cYpsNJ2560pV z$wK#D>xT{?TunmcX|;EFc-T$-Ci(T~v>AOG;4C1(!(&2kU%ui0 zwE48z*XRDFe&|(CYyXuQOPAIyAc8z1phJD6NBpa&9DIO)w_na1)mFfamPB+(*I!E_nG#5Yo0EW z*p5T4pJER%d&Gx9`op>`}xelVa1A#X51Sp&eQ!AE4(sB6^OHbvPnW1*pR6qJY=FP!x8S-i!kAdZ9r26~3{`NKFE|^avKU!MOedtL8 zemY=&UTaR(o(ac|GONV>N|XPbxh=oZeoKBGa3v7TkDJIp4FvROaBknTT#i_lHl^IH zzn3DKQii{mqI%8-gFU)gb}#2z;oK*}sjS~{%!)xdjBSx3fMB~>D%dV`o*1n*tw+m$ zNpXBo5pAni->Umt>Z|kwkLP+8ps)h@*R0>afO{(antuPXL(Ve?tS~K_{;SNX=b1{) z|4w7-9q0{egXC1fiPYqmb6cx&{-5})_K2+sxFPp7TJ-}SKB~90$opoJUksc91o&7* zejRWPpwh45WA!2N5flLX1CPak>p2bb-s<60rE1R7Rc7cslW-49w~5drqRJz@&ZfK5 zEJN5+wscy2DTE&%k^bP7zlm}M{ny^g9yd@7sD7vR3p-qD z5R~ga@{a-S_urfMG20i=y>|T!O}bbyvsbBTx6YFk1#pQ}ESccyHSJMhl#8hz`J&Z6 zu14FXi((fTnf$Lrm)p3+&x1PY=2TDS_P1?2)0Poa^Oz+qR>`Q%dP{v zNC&5OGKxNkX%*#G=GmcWF2$N69~Ba>KB|fX1z#ssZ}aN6n{xM8H%P{Vv=i_?5Y(^o z;h1(5@Owa|XP@`v4P!s`&hE`thdWRAKa}>0Yh%uJ(JU2JiH;_t|w^Ibde?%Q9~#bM(tHXQxcQ zEIaL#-^iC`;Z8PxUzQm=5eU94qdR5#%X0Kixmt>D@+bPYLrMXjn&6AQlWvY8|85g|cF!$mMe$FShd%@?CowB$4^I~=jd)a+HwDSIr zC~#oBr;NXC<-B7h-?g&-HWRIKrWzTp}(gL5PAP zAcvxR5ake2B&&dc2w6mBQ6VZI;z3kYL=;xRKPp00P&`)Ag%#!ce$~~LNd|BUyI;ZU zuI}`7zj{^ms_MO0@7)LEA2(7<$Q+!89yStl4>9*a#;ErQ>-ej~^&o72srDD6kdffPJTQXPC2={CbIKBx44bLOg@tcGF z%CP_cE}CKgVGlD7v!G!dV4lwy+r!v5>@U9g#MzfZBizJj$Noto-xJosjaZm7@ltnF zOzcUL^DnCZ5t8^cx734Q70+Yv(@qA`?r^cgw0?_7#w)x*~%Yk$% zR-V|WF`kytj|pu7X=&h}G;}q0CNz!QN3@3sn@Npnc*ePiIdYfY;?|g;?eygk{Cs zqJIu}Av!-Fhx}x~?Esrk(WPB<B`~^FpO$yUqXbZkhOg0=n6w1Obvi)OSC@Hk<$1`p{d(w2?+C<*d=W zG-D?<-=*n0v35cDXGa;$fT>0qO}~pTW_YT$@hSlhQcE}?peujG^$dWW&Y4Hn`d+N^ zx141w*TKrzvN4XlBg%aQ@2cVZFTzd%Fc2Wi-S07{vI5uZ0X+RDJC>p^=3CF57mxEA zv2fDFSg=%+e+gIPd&og-N-Sr?EEk);^;EB=*I&M@&*h<*a_95A{u`2c#WMlb| z>nK*c!|cS$r}R*ZWIoWXkKAj}y=^zvo$ohbU14Ox9k+F%?~1(zEBM|5-1tv>3+7O7 z?cD_FR^f4|cvsN3&z2~CS0euc;46TnZ~jY&GX%H{z|()S4_Pw3jvkQ{FIvwRFb?4l zo!2rX<`1>aLQc|}%>*5=nV_6Co2_QeQH$482>M$f09++)WK}_~b-WL*PTiHXz_^z15)5bhB=bwmEJ`SG9G98|EP`v)$NiP>_^o<_GioHZcp_gYC@)EwUP= zw01XU`Av(#khqYr+Gcd^v{Br!G4>TCb8@7(?$ zZR8+yIRoK5mE8Da6Nm-lj{z|L7%sOND9SE@*IeM>w_1jxJdFGbz^Yn!or3@1O}Ksq zz*DSz&i-l5>HBl>vd#ZT!2G|Q-L4!G=KuE)53EDriQP8;?<36rGfAfPt}+`x<&`LY z+^!)R*XaN|UC6GXZo9RNC(1FQIcm?Y(SPjsmz?MQGwS>PsL{kR`GG3?{oC;_S?}C# z;!9i~0myOZKiEywEr*c6mvf_u6C?v}muzc)zh&(YsTHjKwG^Yq+P@);IE_^3;$iLY z3x^m?mRKAsP{U>t%VrL^<%@H1O9iCJ2ckVpe>IBlUy=VC;0b`l_j%;E0*1uIF+MLp z7Df|gka3=+q)ff+ETl>0@E?dsqTKOfDx8w>sR$R49Jl52BeZ9sAyAZ}4+TEiTchi- zg~+c4d?~*Z;g>e&8+)+YbRU&_;FJaNvwFnm~o>f zAQtmfzG$EROnlIJq zd!BfnBQ75Z#3?=7HldhyrVP*oc~{A{#0TbB;&RJrPjUTU#4nuLs*Jl_+KWT5_ocqqlV>J0akw| z=u-JwwBKw){yo440NHOi$-cpLLCm_grlpN7onA|kIs*chu(FUnLX_5CEg4n9>jidZ zGz8;)cwSSKc|;3fy?;P_zv%U7KWPy$l`*&;2e7}d?Ql7jTX8Mz>F&4X1a?&_Y)WI)S0M2Q617bG!DV^S9?5N3HBVEn)^CJOs zc%}m&0gwaIZr7CCyjogN3&0x&0#wAc3=(&9l|ln)x^fISx>k9F7d1Br(UT0bIc*8` zo7-$3dAg1Alq$o8}u_ud9bd8|Cw zp^qh$T?mVSD{hmdjC>*9Uxs(duKgtPTL5nWc>QpB>JH>T0vrVJv~9Pjp8-3?*}93t zB)i=x(}WzO&iAu(Vq1`urE;bBa-sCrR}xnRLFC_#^H}MUqWB(p#sYe5IBk6y_gw%35q0L7c1nkM?V!hsxZo zuk!8E$!^`f3n8`_vkF&_+@7Hx!S0U{`ZiStwZzC62N4P)zUiLJ)C_l~k&&mvjuzeD z3xCsB!HV1iPytlyn_*;n04jh6{P10YC#l<~(e4f-)1Q%+?oTuPey@?navmT9^I>G} z8l(<~Ig(LIodZ0*d%?ey-qX{3EDtx$96DrnzYE%hP`eEk2|qh z3{{ni?JI4r9z$86qs!3R!V(F;FoP<)E;7oENtPvA*yIyJ&23cs2ej<4l$u3pH#&_K zs=d?*x4?2LSWz#xF^ z4?VYIjtVgB_8CeN^=yyx(R$3zxnk5UrA_mDjT=9n$9Jz)uh5BAs@JVoNu|0VZarop zi`l~=Wfeu&hg_klIhIaDo*Ds~>@vMh$Qd+@1v*WN4<h4`-R#Zmmm(n&|`d&&9pWaIV?R?=us0n6P(gtTyoj`Zvd4nuH-^ z_ag|qn&SctfNa5+6K(~W2;SQTHUQiXpg*h^eEDIRy~8yd?Ey;wR}0p1Ku6qd8SZFy z;?MSLqqcRmakL%U6?fZ)yP8D}S}=m}deC6u4jpM7bzH~s`5>PrETp6Z{VR7-9z?7) z4`~LCO%7b!yCE=g@F>*Cdysb_KyF@5z76ho#QpBL-xh0Q`AiXh%kT5&yOVs{1wyCJ zunP*1{;*I&kS&;IKm>l6X`4KBGH*o3MZFvEMD4elB3}e32FQA!fcyi1vLm9thp-FO zJFj(H4uC4>Aug-Q0Ly6QeN2@If{;NL;6s%kX3B#wTk$}|dmVza$3YDUdn9P-v#`lO ziFQQfwsJUINGN=mEOZGSWFA z%5@Yt`z;AFd`RxVUPnLjjdn(YaeHH0*$U)j#4J|OVnPhr#h0#)yW(^DJo3Np;fRikW% zUH;R^d*8)+7$EDl7xLEw#_kv8u73IV>eaRdpkS__X}ff~TR*Pu(s^ypb}GlzmPUwn zO-PI%j0IB@*bSMEQ0f=lm+sAR>E1;o-k*RFi3!UH49Zx#yD{pC7wvxCE?N^b_-jzK zOi`x~sTzoOBfS+ z2(ky){2PMYl>v7!ArJ_{Rt)=fEtlE$5ftMcdk^0rd>w<>`W80y~)c zvfZ?mBDW~lZs43~7p&}rL-`(lbB+RdxwyV_KJG06#Oi6!L+@H$Tj*pl6;Z0~?-aft z{mwAtWA&;xE8x*z#qm+ zFN{m;85wLh*WsbF^b;~(r^r#6v#!j1n-R4@s6MThr1&GPIhXYCg62H_Bz`p=f{i6L!yWP6@ z-NNg^MiAruG9mTvV2$?=h@hj*tcy|BvAcC#i-`c{1L6LlZ8Zm z2ooXagT(JLA;>heVdhot8%!v*bbmk((PWnA^U^#w#=>by@OaQfN2Fs28sftm^#l`I z!cxc&d)ETJz1EA3QZu1F=n9j)#>r+o1Df?<5;R*ZpqWL6mZA?;uhLUh&@IoImtq7% zjo8)dT%x;ygF1}Sye!0-apG&e7`)-+(S|qUuO_6bc_Z`6NuI}(!WuhTIi*SL7q&s) z844$DWQR&m5t_!w3z4c51^vrFTiHGqA^&&4I)J4Ae&mk;V(ks;(jSHe#ZyM3BMc~p z>BGm|{-GTp027EMY@UkXSs9p}dTApyh@~_FSHl@08jjWk`y6oeP?W2Yj`Afq8`!P; zpZ1WpmQZLWF=ZBbmn93lhkY2u`&s0_1{?#({yk_9;)nq50`RoKwy$59D%z2FTl837 zJm0#gTdv;YCtk1Ay)|~!l>VhtM=4V-jR3brw-*KwRtrmpAlmhZMB=~-mcx_ z+j-t1j>3wA=o{Jx$Edd1j|5#9lDp&WrRLXoUam?s^anH zl)%AgE45gUXC4EQB%M_ZzHq&ea2&)0(r#)c4MctU_X_)5mj)veY>z`3Ra0 zwoM_iK1{7s8|&z+bqt+_4e1iLAESPUbtLBv;@d{ND`_75I#JpXOJozPCzISy-XIy< z$W_Ys+I858-V#haUMKM#5_P@N-!GS_KWYbfm(9rvu=AqGhO8i8f!+P}EBW=p7NalqUZNTOGhKfb2)) zV~^qiv;*+8=T|}Rvf{e+GRdx&xpxS?V%@ILgLj3PI>^o$KkCjquAg>8Q+v6lOq`P6 zcM9(;gE6|oSw6h()_qN_pNyL@rgZv+|15eZ?{w-zah9*4GR->8rdu%&UB;FmRJOK? zd_b(zONMn?=s|#q=0>tN6rvOyN zNQWrv9pT0YCf!&MCcJ{a=WPt3Jj~?MHxp+^t*!3BT>V87^k+k=;J1!$e=}-4M|`i5 z7SxS(##X|NE;N4*`jnBu(%Ed1?98X`mkE0Xf9XW?>ii`_U7HEsc+OuIlaL3I$J^?S z;@=|y{w2IykH3d%bE&?Uxcs?@+(WQV`w$Bq{~p5j;vJo7{tn{FWzCFHY&27o=Mc3g z>jBI17Sy+su=ipfxiNXH_J`zaw0xEa=#*Yi zVbEt2Lv;_rJc6%#{+0wb1xtqmy0(U>owQG=`Ub_SP3;at6?mD_oAUjPj}Z0-(Kb*n zqD90@1G0;DBm+8-XNcYyp6ea3!A&!}(;`T~eBRC;>b(^PD9NOQi@7JblHJK97wl|; zGucdbw)c1Qg&|AULtWzBp{{W(G%2n@s6m{^U*u!{NxlXM)YCbk6MdS5BZ)aAH=ZTT zBXI$sIT4HOBrKjaGCDDV_(_JEK~hPCB&zWUnHb{hC8V+f(JvD}iR$~CA>Rhj9w5hM z{%mhtw|-6ZN4ZX_+hGPzf<}wa*-9~G)6wiFDRU7I5B`rJ?eOVE2>GSKLo-h;#COZ_ z&WLqGQ658nBVY@l_IJsrhzlEIKU4R+-Lc7!e|7<%D#iht0G#;#KB9aD|8((2oSTQr z(crC)(o_i38)zX04+2RIQk~YsoFpF5uP7y?6;QCw6MIdHPz&;nw|M|KU(^P9P zUPrW^avoIGO5jzrU+`s)>z|+0mBF|k4v=^)!o4*BsW*`4x_DWO=p}489YoTUM#Il& zgHCJ0p=CvD@txgxXA!vF#rxTxxcDdQs_gFV^DEVBB z-$l0nol`H0=f+KJK5pW)spH1y!zu2VZsW(JzK)W2spZ+a@JC4ORaxS;O4^EYZ%26wf%nrW_ovAJ1fU0^<<3Mt zAJ7@V)1hin9`^si7Gesr{t7c_^aytO=bU56U;^lcM(U5;dZc;A}*`qO=pb?fhLMOS?h#GJ&|06 zs2=#!6`0?F4E!~ADk>D^twcGB?0egg-vc-dkn@3*ET`f)2%9zlPx}vx@`nB;&h}4h zPMz%XM(fuyZm?dm|$Z`;0#YS3gG_5rEy`wBAmq(x(I#B5w$KOMSJCk}}Drcl5hg$nQefIO*Dn2EIBS z!h|$PqcH<0v%lL1-;nj)aTf!>!Tu3|o#^*5e#xTmA5(hU71PF#x3|BpU~%v+AE#8< z@2$W)c)Q|BzW3Ey?^QO5a!CGqf%n={wmCFTyh^FE-*0>oeSbsb+XK1)Y1!2=9lX7TlS`+RLb8lSLGRM3lg5ovrrZHB6e`HRU3|m1?u=E3_`w^dexleyK5LrjrdTMjZ z$M_(NGG=fs2=2AkQxMe-uF@TD)9us=4`zwmMP2Rkqg;L)|9>L?D(U#*BHLN2$NfS>&UfUsq(m$kJ4bK$13vwy!gmzQyDTGGOz|39SOheC7?K$dr zi5f#3gW2@~kK-EVcLgvgK0>fU!a%4rgf)Fc^Q#d(gSCW71N>g71}x;^4ThvE6-9P` zM>%D`OFisSS^`D_WP8|z{Of={0G@iD6zz9Jx4L|`((d;qGh^GskkVTwS;L04Oe~%b z6$Eb_{5EI5$WFDhv_ZLgmMu_8xmsWyyDq@Ase(PNvOK-Vn;bRiOX5AtLDQxzGnoiXRi4zv{3yJGKNI}uMIM@C~7t^_^+6scbmGmL% z2${K@Z9lDI=WP!yUu}%=yFMeqVHyb}z$UqQupQX*L$g@aQ_y?ZSJ83$9^@YetO7`S z^LPa>INnWFTv)*=-!W?DgD z%~Wl_H~X7txr&hwQ9}s>c)7Sc!cM&FyAQ!{gEDyJ?Ue^Vdv$78~z;lNW- zUr~-FcvqpgrQALao^=6p0P=hDkY5CdjgxeN?{T79Z>zT7e**6>vfqCU?>P%lZ;HO( zfqW3)jjT_|Mo}(@z&C}X9l2b5rwZ?obovzeLx8UV@;m(5Z*l#5bgKD|?RmuGZ)Mer zIaJYxDoQtAH)Zzs3cib$>-WA_bwRq-Dwp*=drHO;MXI*HQ;zS*@x5_?DjRUU8DOV# z?a{IE5Cb;04viW!9(yc{r;o<$rF2Yfd&*TTLD|W-%ktDu(C-A^DfvrBFICdN_b8bF zJDuyF?03I2WZW&KSVb$Vn6A9fV{a)D`#U9gXY6<8Uix=1(oUQ_YVsWl^bi)RMfP{L z4512oKES7N_E`=8Nz z;rj%-;kp=LCmOqN;k@skh>>d*R*zrt37<%1_WS4JU5WNRzAmc3_3B#hpB^*MKi~UN z=XkwS`GGo>3j4j)c!#8meD80y-n%U}9_e}LBCJCGjkjVxZNInhhbX?Ie|0N^aeXzw zPIPR{dgpxj+C5+uyGALqq=N|>!cmJiMEZ+%x(4r*_`ihw+kl+_-p`Tf0px!LjM*W^ zL8+&e-{X6>dyIlnB^T_Je9h+!H)2p;#g0Vdrv%z;Eo2(e-Q@@(%-E0?7FgIf^w3pc#Ot&@Tc{ z?X);sH*whATCDTOO8?MyJxrQBanh(8Vfs>22#n*@8o{3uC+B*Ia96P>$Z2Yvp;w=# z@n@)cnugBM;iswd3}vS&I|DJl@^2IuYJM}(TR;=>aG4q~9C{ZGvX21OHW70@39W)) z*oiV`AE(ZrDLl9^Ec_Gw^s>g?D5A9S9c7$E>nQB)e&j0`^ukt%P>%Rdc@U!xfjE66 z=CgUKn}K;g!G+#e!xY)X{CzkVs9-SzOpqc2?fL~>BK!tY74rK5?58OG`Xhf0U@U;A z-L_nB|G~QSv;K%fc`W@#O)i}%X^Qp z@V}382;=5pL5JOT`S`tpyP0wj*Ixo8pZNI|UFnJYQVt-` zl27p2Uw#3~)J1uM!PgOwP{`jE86w_a@UJKzx*YjG07e4j`T#+gJt+61rgjGK3*~mOEPOZ7mI|Z9Mkj8*+Z| zGxDbaxyPdQJr4Ol1MUIvwEZ1X--&kIE$e2jLv>7j*TJ(VAF#kSM*zj?7K%j{lX0J> zfipB@^$)NY^p66I$pmZFzEAl8Woht=W>i_uG0E33dDV_ks2V<_o-Zi-oYGHd!N-(+ z0>uL~L(gl@=QQ?z6k%B`KXjbWEaCRS4k)nD(>M>li)aygIp@Gd;4E%LkVN=*tevS^ z0M3F9_ErypxJ0SGO4OTiJUUKgAm0oy1R(pz{fivR99%B|@YLrYzfUhC`F`uU^VTaL zfI+UoAQz8{v5K{~I*8LESGvViz?oy%iCg&!gi8E}rtN_TPjZ}->E9FqRKKJCqtyJ7 z(r+nyLvy~RvF|AR7Q{mF{|r;d0H;HHbPgxzkZyI5D3=ggJ&aRB1s(kVj`E2{$hQXc z1V}nui~KmiWB^Y^+XNltde^#%L%Gc-3S#JB(}Pf7*s3BH?7+ZGPTBjm9gkHgKPDguCHMiOfMQUo-m&rmR9w677?9sg;Xe1_tP@xMS>3}^If$TdMJFRHPpkgN zRo}1q|4_ z(R^o9-K7=6Ob@?9Kw*Mdk$wbv4?`VB)e*4f@#{X-Mf?y87l02B7xb?NU5h~1uVBw} z9Qn9kQMUk_{`#HBuK{ca@U*M!*(;-EXzs$nOVCvgdinTGp+<0e1h9^_86y{jS}OX@jHmIrT@V!d9{U zs`4D%$F)GDoM~{ymkZ9~$95!iZ%)-M_MU>?_;W}46&#-0S_efbxn9s?z{zO8zZv=I zfTsbn-ycKXI0bzkfTzMkqCJhb?eDFdwGLy?>Gx6mWw#HPZ075ghlw?oU}ldwJ;oAr zdGlA2_!|kIrY+A<47zIFiC6nyN!o87GhG zK_j2RKFq6!-`AkC`&d&y(&&@AbB)g4)7Vaor>+04r>(*sGHc3zA9u6T3XN=n7>bLr z)37_l#Z8UIqyuTJS&Evg(Wc-BftYZ?64o(5>0uVsF?Ho5Gdgm6C=BsFr0UUvu5&>z zbZ;vyNB(iZT7cx=D}OeW=Wtzkfq510r&9XB=cj)Z1J0vF`2nS$%o%tFEv9+)XO*%s z;=7U4Q8`#kPV&BumbGrzQvTCQ2p~^zqgq@NZBH|q)lZOwfID+jCXgZ4Jo8Gy-w7^ zIQluEU3lRTTnjOf-@_&WG7|VkZV=@>0{kPq07$^&E0su*0?w`6TuH|?iJ3daa z%uUDLz};Gvk(S(iWC}lIA=C;M(L{L_1u)$WTn_lGCBg|45}+FPFv8Jv-+%zK6x)KQ(-y zTzyux6f({Qh?%mA-MLgvSf;*7tvYZi9~hi7Bp)C;YG5x*r^69!;SMl}&;f2s<`PWe zbD2H;2jbBLw`a30l-55`#ait zF!w&97UZl`;H0M(!cC0+>0jg zD>7cZf6{s0f8T%e{+7yIFt{diomyspXWx0g^RNHrJFS#*zNM?$et#i1XtGl=@&f@w z0g_H*k)H{W?MR+wd$PAp4fuew#3G9$Zs7hpMh?xVjUS-B=A%^wuELIN&h-jm6dsSs zPCVv0=>U@y^Qlc6`V=8U1QL-6g1)=)P1)amK>ioNNdU)->uFwj)v27tb*!D7#H;SQ zR|^bBIoW^s7pm;{ufh8zeWxN;@W1!!%C7)fuYbn9xqu6=?`plD zi!AHm;`+k$8go8PQjK2)kH4+ZkzJ!`*T9@Y>MyJ8?+?SfWqr*={(is$K<)28 zi0fECI#GIF;QQR(La2VI$KM1vz@x)uigp<+W;Aq&$Yi_xc=!LG@7pRayZw~e-{Xdy zlHTSTU5N)I0c3g8kZ%gOpg&hxaM_IH`=^NVtik&S*!MpF#;tq{_yHi_-w5sVbv#q@ z)jaE|*mfje8*N5tGHopuW<4L7D!$`4yfymNyFM_Kd4Tx<`JHm)9|0VB`#*h0bg~Nj zy<_p-A{+nT@O$+`L(u{9y-wsuzUHc#UtbV^ij&K22_Z%4NM3(*>RNr+p_HbzsxDHA>~xJAbu0`E%T6Wb0B)xx{J?O=cX@E&BLZ7&$t z!dMHvaUlsWsA&gPHlD+vwv%|ucJKi1N&42e{~f&)JZs3i#lrNmStL9|Gy|pTPC-w_ z8=aTkg8W|qGXWCMlgK-KUL_3R>3dsGB=zUk&02>w_By*h%#Qo@8a#g7=u)LVzv8~{ z3i(grL#06_J?dCbNhRF?|8|^N?t+s=EWeZWX4IGBqzL0f+iTgu!hjJ>hwEi{EyO(& z_ihZv2mETc1V45{v49T6G-EK`PivPNpW@dM@u>-8?XaeRh&4@*Fuhw+5nKZM@CySW zTKx#`z|^j(aBZx^)p0@!7i93YmEe?*Pf z2I3uXhKz0LOlybhM0F_Q2lk>x;DrMKBLE4cu_SS%Ow`*F)Q6m>zl;1{z$XAcULw(@ zO*~2y|GDd!`cPQPMZ#6&G}Q5&tSdF;0hkBC`cFmdHwf>%i7Xy2NDa6QfbX7mPN?ME z9)}ysDKGUioo_y*nt|99&J+;~&si9cXAX}MA#rpJh`7cKCc*pdlFC1hl+7OcK&>kc)}R4bjnoQJWEchk3dZ> zeBEJwGfUu6RSS>YpjYV#C<4g-TaNs4z&Zd=QcgDaU|qRN!J=~O@6}(-x6T)hXCEvB zcvP+$UUQMZw5B=TCr*UDP3BSkjP|2Wj_OzcsPoyuY+cy^TW;5Jl6r!;KP16w;&Ezk zs@T05JVo69M}nUa$HyeNpJ<G#v5?4*$r#`x^L@Z+ zT6B+~dtqF39$bO^vw%$iN%!oKS7{CC2H+{%_FE_8I$1Ys9rn$vC*8-|bVrbZn=hJg z;#uDKt(j#RJxb1UKOplp_~hn&TUWMWo1tq3OyfU8$#`#4H?gcLK{PDQ>fkZA!9=* z^g@Vj4z=4DGB$;LFNWBbP}dhi#^zA)r4Tp(m*3oNTb#q#zdt=p)s}od+YGkm8f=0z zC=cL0)}fiHPR$)~WpbYs?jF!HGP6D0$iYy6F{7QsFpxvAopvx6K@J-Jc2E%_N4tWB z!f}>G?UH$-{!XA?ifsCiiT5fi0FMD={iP&$m1_V~06a}?C;H2*yt@6yeuXk%)+N^8 zv0U^uUq*@&(MvhL-9|A zyo~9IPlr5@hti)2#jgxSDng#6q4Z^;_=iG~g(1)UP&Q(cYUvMzvL6b;^adfSmV~mG zV$X_6XOk77td&-FE?FJQdfdv+Bx^%iPubbZP=oa$72C=}Ltl_pev(2-(E{q>l~!FSm${A?5{b`vi1GnQ23UmuMzq>;Ra<@R`l1w+zh=b1c14)^GJushlt^E zq?_^1h}+}ZLEPCc1pdi(K0=Hv2ZA$XF$AH@QW5j10r9#0267SHCIfB<$acLQ`NM!y0G=YY zU$@qo=Wo}$>o=dPU7q^1>*(*~c!B^TR#Zx;)bCa;CHjLxrQXuI*e))nSQkzn9&9z+ z>{LF2^0oo|QBH*tEKOTaRAwUkTfj>_#{+S6fY$1_K;!#~|6bxh8E`OmEMP1lEoR3V zbK;EIaps&j`cRxldz!>ww=C#r_1r2+W78BYSUhDiY^)qM-up$GNX;gif>o~CyWwEHGvZN><`Vo0O z8FDy)tgoKPmjWgMcq-dF&pIFbP@JucS_kbmQI8kh{sxSiJcWubZCDkL5~xYL_a|M zb3aY;TydBHLk-L))tq1Rv7rr=n29iqur!C%8a0B-x@>UK%fNPZ`|Bfjg$Dbop3u%i+#mTpw6czf74JjIOl-##$32e5ybcH}o-`uLC-p4W&02>O_3h`f z-N^EbetYo^NdDgPb>3~vZQSbd;(slh-{^_cCcuG2W+hur*E3Sd9w64Kr*-@U7rY8i zFlC-|7!7s}^$P3hv>R05y)-w?h6~24TI3*4TV=Qs;#B^ zll=Y@ls?AKt0-+flrQplrG3cflVAe}3D%tiGA-pM7&a_|Klfe{?HJOparg)msyL@CIP37?mw)DT>?2#$A&RI*CY{V3=evHQ)N4ZO-xz;S@2 z`^^!r@>jqb08izgi+-cI#aX;9dhEBQp7TE0?gvi80&m>J2^Zhyxy@wL{7acHaw+u1 zen-6Dl6RtCm7Bk)3rtdO?jzP|Cr-57JRz>GvF?puU~*1B&!rr2j`%ByUq}-59O`>3 z)a8wkQ}-zT^2(|{Jhw?b~&bvw4xEl6kB_o~05s!*d-s<0O%@rnTZjb8a+?Uj-T9%Xagafcy-= zEPxz$o1SLu`)w&9-pjNBzS+ zH&Bnx^eJjPryqM<4eD|*dU?D}x{bD%h9`*jA|ZDZZ6e0glC`2dk<94)_b23&8+w%n z0NGz|NB$AOGXS0pdw*%8&*s@wsC(L8-*uAgFN49SWPvZXEpy&hpSI7Rx7St|Ss(lt zc3**C$7i{@rS#bNM=L*{U~s4=O|1(AD(5|Afv#*avHAEr;`yG0^|stA-fq7q)BBSj z=eRE3djf23z?>bJVs(-58NVmR{Lw%CsNemo-_)i$lD{UfK1@Vpjc9-Pgk;Dm(@eg$ z%D{Z$2J0LT&)7jY4ffNiVz?Sl)5us%!MJO-nZ$vOpH1Lcd!+6mUe#&F5wC;68{31# z@F057bXZhl&O%|Ft05FenB-a&TK0_HJ{m>a$MwkH0+%VTB4^O%by0Jc%Zfqm$oXiX@8_r2WEEh*?i=aV1FU+A=(;Plvi&V5u;Jptx zW!vLGLSu{xfW`occRunR0DtN$_`cLP)*J6`6Qy#iKI9Jp8ub?VuD0>MM0~+fc00F_ zU65+7X5SL!j5&nfOQ5>5Fnt}K75TiURGY5i7EdLW0?z?WqWtfEvN;;nN zf7nnDMR*~s`0nG?RQ9}}M^RR^fBXsgnSgr$vff(E#-0*f$LDVtqs5UxOtma8?iHY!!yc%8sE6Ky)nep{FwttZ}Z2jO}+fT!60YyY(7)a+90 z$-+BYSFgJ=91YwEAWuU9)16VfCpV{qL{G+cA(9CrufF(t#Rfs=HNZ2rKYxRJlFt9T z{yc5!*ol+JO})eJ()(Pp4_maT+9>cTY!9xBm>eRJzLkQ;W7wxG&g;6y+s>|32Uv+h3BKMd|pz z>n~%*jN|K^QRD3n^Rl~khl#u_@EMD5$Bu8SYvJ?1>n}C-m4%UuqCm$~>aORM>co z#Wy7$vcEiydy=m8?Js}2RJzLkQiJLgWuE84{iWnJK}VxG@VDn*y^+5ba6LfsojZ}A z16ciUF`togwtA1_J+A21zc?z8$vkS@{-Z&T8jlj|H0(!%&tD!ydlc58#qe&hr0kaVSp@vEJs)5F9&RJih1NEm!to% zT7@Wg&RF0i<<8xh`{Hz`m;s+ql^vcDG!cck6@?gX7otQK;zN78)iy%cTE-K+9lt5c zu>?5A*2kV&<@le~#}$KX%JIX6C=RqL-V%5fv^W>9TX0X(_kV`hfFXlw5PQ&jfu;aH zuwCG_9XJ%&NU)Z$CkA8yBwp>2F9u9+|6kVo;48b;;Dtp*;9;^WKz0ZAiPN3C0=)Sy z(UeWzDOU5n<9s#$FT3X9EBrZ8^Cde(Im&^foX@<6{Fi_u09lSRf5c?B73`K{?B46$ z4~GmJ5-mr#l3cx>`vENzuCY3Dcc7~o+>?Id%|N_>_S!%cBUkfVy|58)z*r9~5IxW0 zz7_8Z{09KH*#0*k_ay%Rv;H@D(9jzE&v-A;%g}D$6L=Y|ftQ7;qCA8AtAIBEl0Wl5 zoy9e4W6MRi3O*;-#r98YPJJ#_9vy8x7g=ROV0no@v+c*U0e!K}WIUi5U^J@sU8=0| zCBU^rrsk^fXe&MLO=069Q(tVxy%2^q7)^yEWX^JvJ;tkH;sLekJ`mSe19*z{ zr(HvEJ2UB$>E5l|CDIT-jQ$cKWJy1(tCU*DT=Vw%{Gfii>`xHUg zK8Bb?h&o&g3@C}=Z5s2l$R4}?K|fe}S?Lt=j`q;217!V&k#7R%d8y-sO-9(}O&E3k z_|hH|Z-GO~DO0#chsH%(H6RgJ0|%&cjWR1P4e@f}8Yhq!abg1_jk#^%k%2`wU6wansQkN0aD^V<>H%5 z%?qY*i-I0wOJSe3nhjT0$1w!AOQbFcwcTNceK`tu@7oHNwa8Y>@}pd_<=^#Rlz;Po zSAHo%+!V*MjBFeErYsW}i&V5%w6{JT&&B&b+`H6z+xcJOT}vFWJ&v_EI@q{(WUVdS zD{Q=#PUqs?1NSZ!@9O^&?^?^UPvY3+Mn4czxehQIAn|@2 z`7MCg0X)U7XGHBqkH;<1Zw^=^?BcpoBjNEjtwN5$iZt zTx3}nIP$6INjS+4Wze9m>w~GYqgeiV6I@Lgyr#qxhnk$>j=<6ZA=x#Wj<83Tdfu9` z#5GBFWV0GSI&Z)q8UF?Sm#u%8K(okK;7yANZB4F(Q3^%0#Ho^0`vm<9yI|g8Gt*PZ z=XLceEdi4LRmgt@I1S)w{|!REf9$xJH_F$^^ReQ3>d|Z1Ox*?ZMNS%P@j1%?MGoP1 zMO=Jc*7p1DYdVFv)kvvp49qa$^csIiLPTJg zsn9USQ`lqN6q&^uS@v$uli146e$&_qN8&-f#cbV}qr=eJk7Kp&KklAM0+9goXRKiQ zO0GBKyqAn^JrRqxG%xawS~-VXM* zn6LFvo!BywYIJdw7;yYxjCDlxMsR`v`(2&EN}vVIcbU^~GDM+!jC-WW&=FnQY7*7^ zMLVk~ing=QkoR`;DsceW&Q>D-CSX5+C;v#ho!S29t(&zD{k{`_uYWtKEzp2bVx=%Z zTq{nwyC~UB7Yc7b&x(7ktb0v`ZQ@?1GAkYxTpa8bQ^6y4L+;^uS4&+FLfdt_3|>&| z%3VZU3kltL{_0&uB6~Eue&6NwyF+8Uw8SH<#a?E7#MoZu$MI|Cf%#k@(v20R|DfqV zY8R~Dr??eG7A#Hso~L?ylFdZ8Z{gKDOVuK3D*Q^6aK94zSQdeE1yrr-#{4bOXaRd{ zgb4#Y!z(r&W_?!0R(&q&b$a(`y-NP^Bksw1tuOx=*6ULFM=b%*MK_A*Z|p9=f!FP{ z;B7H=>tEsN@uG>HYF0)4R6d>Y@o= z>qj=l4{?e=PJ&O6Semc)^mxqUJgS1`->In!zzXeN^;?wY$BFA0p^NBwndWaKa!j>* z)psgr{;kS3I1?W+S{&Dme{1Zx=Erfl;s2XO^S=>RK{6iD^aY@L*>{58!-}ELup6`F zTZ?f|w(nTJb^nIn=UdA!nQzq=$Sq6&(e{$yN3ld3QN_vilA5|TO0KoEh6z1RU8^a5 z^ZevmOC#GPxnAPr+M=?pYT`bv#haP|Z-j4ZejNV~KBPDk`UhdVN%{d*KWH(8lB0rN zb9+VYbtS#t!oAw`8rnO`=Qr4Pc_Ce_k0p!u+xwWlzhrvV7W{4F^qNhBb7)K>8&S^b zb)TC0W0YQ>MCrAcy7o|d{QUI#ghm!hdfmtAHBUt_h{QEoi$$8TSYwMcKaNk@^ty{8 zLQeWRRbS8PWskQsi=9%9p6X_T5VlrVFFG#hxEnN*a$ZSCZ=bq!tUX>fyY~0nU8nCa zb-b)CfIPf*=HQf~Ckb~cB)iq0{kE`eHrt{RuA!>KiPbuh# z3OGm;PpB;ptHxI13w`8XGj%49(beF7;=7la_vk4$r*(k? z@GAVbrv9RtYGkA8-k@rG?P^TMYJ^WtL)H{E{IcPHjkMXQCTvpKKU8xeYxN#we}|Vd z4MC|r+6-sI&nc^>5%`PeQz>g=3SY>4KI(xBOKZXsN3W=vurwo&tJ)yaQG@QwayG|j zD+`E5;3?953m14YH%^4>{zQ4gRJq9_sqzLnghI1)_=ULe+-zs{@>Enw|V^^R^5kGI$IOn zSk`}>RsZp#{%5NRb5!;>)jXu}`bWH)?Hc6&EO)!T@1FI0mO7`3-?b%*^rol!0wvXhqiFG zMpP*vc{{zSCO~(UL*RP?xFlMbAo$>wUgd7UJpft1uOR;p;8Ork#}Y+9Hmd8+H_Gk# z#*+H#U8KZ8O4n<4|6Mdi#D0L9-(mj|`<`uOq>8z=TH9V8*OfDgEj8Z?ZNwv*Z@CtJ z1YWV5BQ(43?fBsK_*TvoGtE&*`M(r83hQvpJ6wO$T*AonEZ^J$KZYgrM(aoU+>Jp8 z7yjA}P6>Q)(1PtrL3KR>CqW{mxCC7X42bGCG6sTI0y+aEU6&!h7Vt8Fr=GUI;~{pu z1M6n3L&2PSj;FD7t_R?#>y+^%pa zi<6pbcj~@5lr&EKj5sPN>6Z8@(YMg>M(Wr{0((iv4U}!92v>W>r$pOAyKbb$ChEDC zeMZ;;(&?h_yI0$B6kxmH6et>un!~PpFoNWerlwsT)1DkGG@%}~2RX->CAUcau zgn`5po3T|18}=O744>%G%`i5@2V5Pkeb>EI13R(1LU7}O;|qj$#ya10m|7=naP+Dl z+cPQ5lf0rmlnjdQZ`^~tJ{Wo!fNT%9BL5`dbpTIe8j1cjHCLQ#H3ciXufF?jV%x)| z?4PMsWm{^Gja#4rgCcB&sK!0w%07({wfT!nsZ46Fn-77!vFtd7a!To*yxh*-dgH zZtRRXt}P-T5zkHO<5E83te6YzFdPOEJKE1<1d0r#yYaXa2M^n|&k5KQU|BKWhP?nE z6YU2=ms6Mu(GLinPiPMhUf3QxU7b2YL92--cEvdS*h<~N>+yUq$lnWi0AMFN;8OK)vGqH+ zbZT+WD+U)JytDt$K7#q{-9%ZE?50`pNaCgehr^`sR35NDTofydvBu<8E?9>vqA z!i8Ds7|WxP0>AI8;Xb`8IY{I90H9RZ_(X<+hucJ$gZx&&Hh}EEfvaIR1SkRU6lyB^ z?S_Wu@4sv6$Iqw0s$Ej>VukYjH#I%Bg1${xa*z0#)^VnA3j3BgJt0nr@WP*1Ax=&C zEORwG>Qr_nH>bU+vsZkkca*cp)5Uwc^9kZvNy1GM8o&eu5i7fB4Lzndk7VXF=A9P4 zuicY9(-x51+$rYBdr66VoT=SQIwf_p`{y&N-7UGZgLZ(vcCX|+ba=m&XC5~mtoeHM z&I+n+Cv-KTj}y9*@cjk}ym!vf;)$0y4Q$%M%EjXhyAxcCs5{5?6wy5lozbnDWDKDc|T`w+<$?x~8c<4dY_T1TwYQgM1(oEopUn%@T=WkXtXqobK0 z@C8}EM$~jQ&BU6QrL&Yy@%{uOWHdDrbnH@uE~P1RtF4W$0UqSU%^W@>$wbT0SUd~1 z^Wj-|w8GPV#{bTGIeX=J+o|{k7Q2Rr4fTgR`aMQb(1B+>%xxA;av?60Yc#xq40gn) z&LYE~B+l#FdK!4bmh%Zd(o}jx3%!@pe`xe!9=C{YqkQLjnzdKGI#bZO=nwEAXVbY1 z`IiB&0VJJ=4aXj5z@A=$&LKsd>q%#O*Q#xV0mjN{z5u?!K=U@|91jTkUv2%Nz&b7w z)Xx*%!{(XFhVK?dgz>YDR6{ochw{Ryo4Na$dR*NhR{eLFabS2VM;9U-!WWU^w z{6WAWfE;g9uEn?v=mg+th#l{t`~v$;_t$sbE!Fuo!jVc^hIRyf--n_(#fc93AX8RF znsY}Zx^FfkjpNrb$9Ed(7Fx$zKf~BM#-3rlo@VSR)@dCxo?))lEE#()v0ZT8&&yqvE9g}PI!Jzb3VHW+ zUL^pK^lFcMF<>x&C+YudTY>0j)=hrjUdI}@*R%gc=@ngxHYn4UYiCtbrn%07?XG73 zfW2>|xfWQeUb|fNJ)(w|s}VYwrRd3OA%biSL<|@IqpE8$=h2+Ithcu$kEEqodoH82 z11C!ny%y`v5IF^3bDXL36v-EK*pBiIv&SSomZQ^jM9j{^&pR?- z1NtQCnWP)V%$El4orJqrvNTU?vz69`KX_-8qzne#L*rC9EpTy&$3H`!^Xg3|^D38Kpl$F_FH-<$7E>ylJv@h1Nj$*T%IPX#2ONpzu z_i^GW_E!?$;NVSxzM*X-ZcxI+_~OJDNO%sVS^Rt75;&6Kkq8cJu-i={acWO2S3~*$ z1~-Q4d=GdvZaTUcen*1Y+vzYEO;F(uK&j{;+Ltmax;{7>)Rk_yE(X}?oOr7j$w#wu zVhl(omyQ~5i6B-pL-{B}nlLI=HXd8MMEPSmzP}8x5+KLZHOOxOtod@Dg_@MJanBwr zVMpXc%X<@>r86?PA4=fQU2Ux9mOltBc%wEzwTo2IQItD!eU!h9LZ16$ycK8Jued+P znYg|Wz*FqJ%(_|YkXm2<5<{OTlfi!bYS>BP;N{!ukDx^EceERJXr=FH-tAlg-cQ?N zZKTe_M)*>AW5F$@s*@Cv_Ve_>S(c0M)3^%P*8zCi+*9yHNl)u$twTZm z%4N@2V)pt}U~~9NC#i7$c_qg3qZxx~U;##79?!$+Z;`jI=t>tr3T;}YxD>|d&~I5R_YEp_BAcU)rho+`EqS~L^}}1kn(LqG@duL9KTGl)NV@hxHl0Vk`YZF^&pl`I0r@Z$JN`SR+3Zc+N& zrs%gS?lo}AfZ^mBN6L1GvCZMXpVJ7R#UAAXws8=!J-`#(@yrhvvEUEC5I8HkUM617 z_D#lO9!`<^$U2r`$GIA~ft^Ww!9(2ufwx(}N6UV^#ljJO_Mi9TaFwN|ete{%1`g+{!EHwzfqjFsst6fxpIjf2;m~q?gde|DImfrkzPI#Y07X zYVlutJ!8|$0p!{EKUaFGIXC>n-%PVzWtdCOO(?GGT6M;CYU#DAX9+*-PK26aqTGWo z`6Jz2jXb;D=SnxL&#l~oZo!15;)0zwu$BOvKXPbBE@?OH=RE;?`^G zIxVhFQ#WXF8#VQA&9hOLz)9%*%>osMa*1b;CHfT|IY08~i z_w|~#PIIr)n5NvJ^}I{d?$q>E8h;6QYs=@~gM4z~9!R&SWk~=X-Xt2G7df;VaoKYjDNZ9s74~ zSl0E#Jg>vgwvFH7(SlyG%A5P;Or#e8t^nBW^%&C60S*Ir7}H+xcY|B_^{Kby48DYO z)GIrqy_yDcYHRqr6i%w1uhf{6YBKh`+tX~?7al+p zo$;>u*~2)u&*S?+4W8hAnosE4E#IQW^cDi~1OF%g@0T(i&Re4oEjPKIak2+z z(7p?iDh#_Ye~c4&8d(8(r!`sLfb=VXMu3f{(zza~9`F``ht+99&QRD*;LFSmyG(yZ zeE%2MlVThQ!xb2x`oPb0M}X7^?o!O?7IC*)M30N;ei1z@qTM3eL3RWe_L7ktn3@0j~X_5ijke_^A3XZllGLW2giljk(|KJM!OWzF5FMNs_>EpV9UzoWU2 z@qBPq4?(<fDiHn&w=MEkudi~ z&3{?k+B=XC(8UxkJAWqXHid4d7!1HF(+0|SUhW{=(;zhQFTm-QZfwHh;09eyF#Sx& zV*4e|6>x^IIeJ+3%yd6TT+^L@Cq}9MBGDE)UL@)w=0f*+fOrzIGaAAtg>!@mg;NFH z6wmwPc)S65R`}27@i2APu<`hdbZZ_jw%)H*Hjl?uNG}7d1lV+Y6zR_a-vfAfG)2(s z^w*YIMLaXwdT#SqR+}@*X^zK#k>vSr$CO?MFR(cD@qpOF>tb%`{f?bKPQE*s^lf?$ z+`+r}0k*Q^pPKuG77Z71x+k9%IAY!hvF0Ap+*;7#P~tv>?5FQBjeV$f`9O2Oud$Dj z&k#c;kbR-WxtT4cyvcI^`ZHB3=uT6#dm{mHY$3D{8Au0V&DaI&BZhNryri|jPpDM1 z_k^nE@$`129{@ZIu=(u6NFN7`vEQ~)-9@+Cqe+c*(VAE64eE13kTm#@?O&?1ha2ETpo##eRVjK*kslU2;Z2eKt zQ$5`;c8ZpX-Jy+Q2Oxb1%$IG@M>b>4StWCG19`EhjWd$M5U7tLX*; zmvm?*ei(Qnmtue-JE2+O+YT_ErgI@6!ferQC(s@(`NGUg#ZU07|9l(@QwI+lhmK@e z<50+Ye=Xi+^M$V<&Hau31F=mv0~UCsiGW!E9_(@GY;vSc*7@0gx!;^oPKz(BrQc&S zk<`+D=5-rxzzd?kI6pBGj~ntojfl?-f7Ig<+jw7v^m@R903KGK7UR+K7tZ8c8ZG{=-M`M2ziaka61MK5smGL`m7@wd zrg)A*ru%yaj)mEss_jX2J(rriH`VogYU>YTvxMC0gqG@v*AlU01G70XClgg>?KPkJJIc!{N^ae)cAui6`?*T#lU^eOmloGp@kjH5flo)8YKR zBKn+|cUE*-+|Ax?I%%$TNh?}SzSfzz6gx`#4NiA0k8gHHtZ{--1B3Pi2{5cfR4oX< zsrM1(9dfrbXO+_cYxWem$=QCHQ*de5I+GVWT~|A^Yq)9&=bJGoMbV3Fu4@z$jXa0D zV7|sdnDxBqFSb7|Gc)YcXw7e2k7eQpLh4A7P-8hjr2VZ@sN)7BahcKn24m1N z18KID4-a<|+J++~3u8cg@EZ=RQ{%azg#u3vc)u-A`3-5u71$#N*nGy$FI6ds`5(_3N3KY}1!v|cP|lO29NHb~8s=Ej4#RyUGzp$)l5;d%A?kMm z>Bu{2AKGDi+Sa2 z>#&3s=Qt0aQBI5hX{29b+Xx2k{L~$<^g(70O$S360Xb9aqppOHUF0KI>IoOw?CiYW zncy1mxr^-~1D+#=$6QfId0KmpIFGrYv-F{>iR zMTn9r6SXrdf!uolHbA@MWEk!)#wk0osEUFv5$qk=#3C$z4hQ3pzZ!`Hi zYY06b%PcomS8Z`@0bgZ#npRn$RnF@F-O_s)3fpix6i3!!m4&ZFC|+#U7j+qF<^6zRS&Yw{jb~)g4;p z_9A%)xzU}l)a?o7JDqpB+yCyeQ2o4&H9Fi_xUs5ob4TY~pDjHVJei8x;^Ap959{_(C8Wdb$Ek{t-$2egRSE5M_A7Mo} zcD-oFS=Tg;G16+JHv{em*z@!qOR!$P)+6oPvdsKrp1D9^-_9K`_r2i{@LdU z@Hv>Chffjhue*$BJ;IZ9<0zkKOiqjz4;r~l8|671Xbpf&S#Pj$w1!RCbi~PSp z6uyi8%g`Io?c4;3NGD^|*aMyqOcl+et96p2{7B^-^=+B$*R{_ffOSZ9YXyDn1MS%3 z;v)&FvD+yJdO*Vt>PVb}fo!)v?BU|Agz$#M}R? z_giQ9!XK%qcVpOl9ZR8q0f++F_0At`NI~3JzbM8RTi>aP4*ntnEN9;|P}s3IDNO0@ zkc_#KGMBr=^znlTYOM0q;9WLdK92OufWrWrem+6^6yO&C4=vlnLTgjxx=hS#f3cXh zp1yt5F~kkqi79UI9aWsilz7RgTFZLe!9r`9aR+NBDOIZyd)%BDT9s(roCxNFuYC6s zTtZylru3FuNm3Qjj|=;=K+3f8N_fW_8wk z3zq#quCHl#8CGG70z!47-cSC2dcR%qI_tf;H#XP%&N?RD7o$qs0e1hoWRfcF!2N&Q zzid@wtDi~I-J;(0VekJG>3R>99tGI%R}QJtx5)ory&p`)$f@v|n{+<(zdC|T@Cik* z2CF=U%bW4>AI>`+!sng3jiR1w{`=>43V&|RJ>t11!=9UdrzTB`R;7yoHvV#u*B$WR z;xFuYZfS+00qh*;#D{RaVTO+d8{{1sbvz_M|HbsU>%D*VVP8awTy~TUf`d zWo@xM>S{S=u{`u@*}Yh1SIan*wT4_RM=qAPBuyT~f87xb_b6=64+SgE zV59@@0Fi7ytm3dLVEyp-7_1k!2z*N`oBbBFLb?~A8err5O{5*Q9w`mLLq|*hWX#V3 z?`CG$W%XahcfVOB<-mCZxen^=*rj<@HlwxB(KdkeViW001&ZUsu>L$Xu|CztIvQe@ ziS;DaH<|q2LSEhcn)&p{+-0VOWf}e_AZyTx`2`C+8iK%1SR|U_b}#Zsm&xHyq{~t_QPVQ57tQ@Kn_e;z;M%HXq;L? zIsvA+9Vy4DTZvwzwo?YO;d~FZ7(ZM}lt?+wYN+Qa8fvQ9 zP?XKWqvE8R`$ao#1^%-vJim_g2Y_Dyb~|;t*&_`BOabsvZLN=%{BUMFHBP8Czi<3& z?KHijU&(A7QIF$oe-_8v79B9#XWn--@_QO@w$iI;r7nx*IQV<|o@PqVq8ZMopg9ts zqvhp8R5G6L(h;wUonasKsJ?fIo({@puLa=whec?nRI^>+>lpA5LQTg&!sS`zk=g*d z11x;0laQVXm=EBg=77Lc!=W?rWqyfE$l|MxKPA5ZH{;2`iprU#)nm)6W|dTOYdowx zKN8lNDdzgK>FNZn4>YlO3RuM8ktQ7@`7|(08xc)=;5=mqmNuzMsxx(fUdXU0MCCb_ zu(x2+*K(o^r4_PQF;#2_f}&3GUC%VT+|q*rANx>Go6r0b>7z-i^iP02t~)AK=_ll$ zb^Y__6~;6?e>PuPkDQE2aP0Xg-E}aZ=cN?knDzd_w=~NYi;$iOm;&JMN1&xhUkuo} zOW@b$yDyqHv*Mz;CFNz)U}`sV*!e`-%0^0)J+1=20q-Gwp^>COXjZsG)N>7#VHVD3;I1E<=Q!>uVqsI%S^T-}V% zHb{2@bOYG+X^nFj2I0PCeacI#v#Tm9tNSh-J+NQrE_of{EqiW~d5)h?+Rl~{DIpO@ z${I>P3CsZ9=zQ(Mt?bRL~R&y!=dD9~XNG{NE=Q7)gX zIAr)ffIA(6eO1+mu7?y(xb`5$h~MGV6KyS#qcTpf*09hIVu<8op$sw|`-{M>Bu#;0 z84ilrPUu4f##z{jta-$0N7TWt=YFIQ0zLrP<6Z1+9;qu}2!Mwk);XeQJ0EzdwXQbt zP&o}p3sqK0{3ff-w2E@b(}!1pDtPu8xfPdyw(H4zGC%NQJ9~;kF9bsKF)1i`XS5S~ zB&GnSB-4&KmR0_d$P=ily#-eb9bppS!ufd|KN@uaD@J5GKS_`G-p9DbR{(?DRpvC} zz*94ziD2TSJ59!liU!#@btV_vKSdRp7`CXs<}nLDsCUS$xg>2y`Z2)M0Gkf>BmFku z&*%>ay`xY64bLV&#ZY| zlp}b1bHDz1zaq8AeFuOQ{@^!4pnR%L{lI%^Io|7)wsJ8z){EA2FT*qJ@oELq8vvUC zcKu#Q`Xs=vU!*l|{NedU3rgmqq7CFl#bhx4VKNxo*?z1`P*WUUzl6{dI)xf64iY6V z$ws51AxNZ(`5hmaDF_@~O-LU!R1W0T5m?P6>@KUg*xqU_; z{Q%%$fL;D^wVKrN4$$?7%S=?=Wt{{0hw=}HQ+@Q&M!H!sw;=WqGm1GtF$WB_)7YCp z&N*E}=I;j{Fnj!Cy0iy1%cE&Di$X1j$~1vO_RImQJUWgfP&2g7X=-FoHrod*!C(;l zkLrt3)dV=<-bSELL;U+mMp7_-Bps$0un!NiSUo=`$b#_zchC{cLF@%MbQ%me@dxN( z;KQIn3PXNLx=m5$LKGY67_ZzJrKOcsuCFn3St7lpL#wk5*x+{_DtmoF_88)BzBGPXH4gqYs zJA(8nfZcCwJaIgn@qDY_6KjF0)GLmB{yQplD z*^2wfMYK4=Y{m1G6ge8}p(G^%u~Uwho<&QxdlyE%%6p)7_q&w7hp9jIFwW#s-r+fi zsq-BeTbtB52^t}Vyng4=6ijCnPMz)NpuW$E`sUu%JZ|+wdN^Pdz{Y=V4^=9~earJs zdCBL@ud41hr2l9TUoAP`T!LN%_d>vYBavR`Ma8hB;4~etoW%O@xuK)7%2aumq~bl| z{q=Zup@pxPk$wm80lFKL zYrU8wvNvFi-p;P2aJ-NLzvmr@-w3fBhL+6iJ9CsR1L;7*QceD7VH@|rJWeLGH@piUZ zmR7SAC^tdnR1KgF(`D+)0m{5?1&2UmKE`;{3bQhEMLL{LtL#hofm?_r8CMdUZiwq*np%2k>B*`)uRoVXNQm z-PlYoeBA6;Q9ff_$!w{jDtk!P$kNI=W!4!mAe&|I*8@eK8WBB>$Spk#UkHat&S4UD zh_pRS?kCceJajn+@nFfGfHRPyXhi2!n$gwQLxHQ!MCN8V!o909u~03f8U7SCMRrF7 zd^r)S%j;+h(+v+S_*1)UJy`D)0yi*%6pvAC6txEY4u33kA=l!Nq&zIp={Pw;IZ1H2 zL{3q*5`NMS{I}$gC^&&bVWhhK0)N3eF^-l4H|voeb2t3(0PJz=o{b)92jFu64;^!a z+b*P??!oRyC-kx^zLwLh}wgF*u%SHfA+=^Z+TARt|!!E45K6KA@0B z(H>Y zc&T_$_)Ep_mx@1Y2e1Ttn0-NMHtB$QXR1j(Kd5Nre<(0P&f{8g@S)%`^S_amfV`AQ zKayTF;PPS}mqd&$lx?OdYFjmpwe#hS_ARH`H-hF872hgqTn*P~gH-Qxr0pIO7c~5N zQ`8G_Xq=n6WW{}3Myi~qr+rI$-;9H`H7xPdekIA@5!d&`^%iwq&~~D#LQ%kDV4!os zyoo~el_qCuSTe>aapEpjOI6Y`(^#rE(--LE)(0Azp14T85~Bv+yV(|lQ3jo3jxwFb z;S{OQ2?S&5`kU0%Kpfp>Iu5$iHrczr4Qgr^S6k;dvM1n1tC*qYZPCx>-qSo^E=GC{U>(33UzK^k z8PYSjueH{9jVHz4Fxu8b?;Gp;dXi;coqP@VF#Cu~ zuPS+1$##egfrVvY*09#b3eIpaqQ*KUX&|IcSCCHNN^x^olrw8nb2$r;?hhCRu*ZWE zq~`#x0PxUq9y2q;FNw>|J6`UxDs%D4hXa(lcorCzlLQ>g!wk;OW@7LfM*2uUW6CY| z&Gb5a!@ONMKgp+DTP1o3_{|}TOD>{=`B(VcSnT#Xl>Vq5mHXgLHSdUeoJ9EsTjh{9 zLsu720I=&ZyIPm7!~IGC4=1g8?QD9Rq3eZx7O%&gvhooX<%>!yE5?*@*GQPCA0T{c zp4T)}Ctu~2o@A|Vp*>bp^%k19n%0q9sCPB(a0^XX4c)3ntjXZ9&`Dz!EBZ}}cZEW) zW$I!qm)&NDfsE4%MZfO}W{9fUOs-+{CPiMM7}vm081C3jiW_&=b6u+M30}ZgmaUXj zqAOnBNsUCL44FFWAVY`H!fYUJFHzYT%}d&=Rq_DV zpZBH7%2+xTyj3jM%0_57BFeKB?th)g{06ZLz5#>6uWtsJ_ zGb7i^X8N0pbIMTQ{>_E0Ax}XxQA2w1OJ{ybzKWMwG~5Y#3yoe)ebQ=*#_AQ`R?jJ_ z+7~k2Xn4J1k%>+S(A}!1&}}$-JGv>)m@_zfQje5^q|M_PIJA$=I|KEQ6DnOi+l6<|q6fd}750uQ`>P~WQ35vA1& zDk_C|XM;G^bf`H@i0ejX-sGCs3(V_;9cH_|uS<=ncPd!RB-MxGhN!25bC@YsMruDFt)Zzy-Tp{;2tuSqU=8hwgtQwKBFw9Lk`xvJnVJuLo-@(Vpu5FfUTWY3 zL2nvMPsFMu5}t{f)`fLYq4meb626f!D$R{HVqnF}RL=Qv3^wRu4DU*>Cv+E~UD)4= z`WkfR$eo1Dmea7*!7oLq`=>?Uz)g*nck_L)(FCO2-%L+Cws|CdyGNP?;Gx*kbV{8z0p zOpeFNjnhgh1@Dv9W^DhneKX{bU(nQA1vSep)kgn z4$HY6REd>mf$4}*W7%Xa%@L!;I=-ONZI~NJ!GgLcxLi2Ba=Y(_0MSP;dKl7sP51aS zFz7Mm6pi{8BDVtsdwX3T!IVa|9W+Ks*iP9a_&uM&6Zg_$a1{l~5lJZo)>i>vB%D*9 zrkQ)GZ#3+&bbKP6s+*_ zUdrV*?v+zF%0-c`0M6PD*2^w8y!S;iH7YT}73Fg10bdl3wj35YcIyH}xuSY};3=CkcRY+SE2=5H=iIDEI0daafOM>tzp5agE9UPO( zDK_(TsI<+59H%?<^-SL;y>K&;bfz$}jyJt@^@5171Ofmbe*GXn56p!%#@%)TCxmh3 z$w>N@DMpl}ifJA$9Wp;DdnOxF!E&5oZHd5CH} z+ApHb#E}UPWWK7z!AUa#|DuT~t3`=Ql-z<6)l9a>VbLArda3$!BdG78u2koAC>rme zpukz4G~23%&=Mxn*WN z`2!J~H@MVaB>1wdFg}ORiq@LMo*>OE2;i$>aG2c}maH?eA773Qv*a524RV_Dj#m`r zh`yUhzbeqWeup@2qcgka@&S$O2)|>=rJ-Lsgy$704lZY>V_AEeh|+e{GiZRUx~b% zk*){q16cS{m+yeB0zj=7_zF%G^X|S2L~P^Bj!#&A9CG)G@`y1o99>v(b8NLhq&3dk zyBGE!rq3^#je|^a1Sw5DseH%2QpibVrD8_+5HmWYe`StuFJLH3T}@+eq5jqME&nal zxtjKnjz_)`0i1nHYu2UxMPT$aE$HX zM7YdO#JJU-W_+%=zg0q!L8jBO*kZd%&G26l5rkaXg&9&-Lo}#BjN!uc=o}s~)}#4g zz@|r&Xp-r~m;YwZ~N4G1@m#D`ClbP``ly zmJRjz-H-!;&(JFJcw_O-{hfl29RsK#SV4BeQYR8OF2Fr1!NQKT!8UbIYM;DbrqmI6 zj+)2*27acFpznq-%&sBsWyCkcn_;BT6z5dr<*L2tTzQmwKfw`%5%VIeX*VcHPa`8D z;<;hi3j`BGEY-1JM#*hNi*hVwu$I5o1*wikdO#0@Of;K*Dd=MsQjaT=vfQPJi#5}zB@iXbD__*kIBg6V#R-3AYRZSFuPB^lp5<8%!Ej!~3WKIRG z^e>$a;Q=O0W2?T>AhpIK^SXhUQIvTNn%8RcdZ+jzv=Rn1PqLlbN}5qiUtlX~axFS= zjbi|f&|1;%zE*JPk!b5X0 znR$+?0oDJmsv2qTKCGMG$JrfCjK#GQ?TprAGcF25JMJQ%u;dq2Qew6APlP1lSmB=Z zIvuj#H?0xkVx@w73!j$avv6a=YFhX$0L0u;C6sLK6)R)cnP=yz{9?GMLVq-_UC-l;IMs& zKeSU(X{9}6&MBEYpt2I<;z@b8{FO{j$_I!U-68IdDCXT!Wb#I+bLw$MJ=IfM)(LQ`$7_Y%+`=cHyA5Z|W<^^>v(r*C{?iTH~+=|Uc#iiDo-|YK@q2hO9 zyl&CoicFm|G~O|Kk8GNOJS`mT92R%09Y@WX?t7o~g?14vU7X#NOza^=Mq>24M02VY z^7XQlsh`X7E^JLOS_!wERCa0n2&qc=q^3HS7Car{@X-vWq(czoz|JIJR8X8w(N;Ck zE)GW=RxoB$JjAiDo4iXbnj?Z#K{A8X$KDMtEAz+!-fXXSaM4*`zH3jED& zJhPwFmWc6;zfg$Bv^Ic$CrO_x?0v&E{{JLU)mf1~2H8%$Mv~frSCFX&KoFGDM z5DbID-9E|*wVl!qHoSLGHbA}}=YlfP-;0w(y$c@4`Ri6Chao)~Py(>B%;;~px8*8! z%Uf5n^<2z5+H*LP987?;&#phxac9dbaeT|$<#9nip{edg&yZ%zI(QZeFx@|q$p`P_ad75 zl}>Vj!kE(wDy<<(Uy{~zI1(>UiYU)Xyeq^DgTVJ^^(Q@&8(@V$#n;q@BP+`)D$A-D zjxROUOZleH9_ExDMDMmjq4oR;cvh~JSBdoHfU5v@efd|{;=ZLEk<-VYyoc)uD7i%- zktDqFQV9lk6zs$$VsjYJYOvnhh-YS5?;SQ?l|IA$S7GnH2zlTB)%Tiv#CupRksin1 z3L1_t1I5FOQw1K2pK7MN)kxn3xEEm0mw!k4W5AaH9!~CDX2xgRhngHVU;Y6PRkN^X zGP|^|g@0*IN%gFp%98Tw*+a^!%OQj-{*+wA`_JQK9f?~{x=YW}6!<&Ph{L6UCFN51 zfB}28fXpH>WRk-)?k&uDez(EJaa`6i5t7JR*y_i4%Ejg{5~YJ23AT-3#_5id*QAMh z7d+iu@6kw40Zaqf?Rx{#w*vlB`*O_$Ui$6uFHoES$?zWb8HLzA1qX1Xz%3P&RKXdx-LX|2;e+`U2p!|QMf-_z0KzeY90t`=8ZK@S|g9c$ZOJajrE>2 zct%S)-Wv9ve@Dl7k4e32Wlp##e=C6x-_GWC3nASO&<|kK>olY*0ha@KXju<4Gwd?A z1-;_ER%^~ZM6d_!s_>$UUDpxkdZMo*#(LskM=-wMMWi}8jYcn~&eKG%p~i2-58d$J zNDrkuRFzy@xXoL34%RKBAl3jaj;3>ISEvd~T`|h)AOSxZA3Vnlg_)v$CsFQ^R^Dl( z<9FeFGJsvbn~`1z*aqOC|C3@qu6tJWOEVLfk=DF?#m;8AUwP?*(U`kR`8Gyso%%Z4 zpprUuOxShU26&m<%P!bJTi4OT4YWfYT}G1oh2E2sT_Kj7dYWi;G)|hHM>D_Ve438k& zv#ox`m-L)Z`-J#7Q^;rYSFmgpTfwiuu^A!vL9-4fStHSUTmx{gQA`iLxRrt zWSo!5u)&w)(BwF);)QJm9)r)K-&jQ52I&ESp#YoCi;_V zMLE|rO0CnTZO}-aws3<+>$Dg%jjI!wdWK3%vAG$kMyq}(P)=22b?8soYuekaZVlgU z6L>`sYTAkN?Zdm*ScN%?^e+HucT@S4G^BF?$`fM!SZ~R9t!hg3#m#)z^zy2aSQeI6 zkLI$OF_mRds7tN&?@(9zNv*#m?CM+T=j~gsNgGKu*85-vVDzf1owV^(RWG3RM4cT! z)jc)N$K_bOMQ-F;4j|q~i1Igj4WXYX)XB%%hFnpPI+W3_#}i0@0XPk?>9t>jM>-!+ z3gBVqE>RzC%9-opMyo!Tg{_NGFPJZtmdpvSSA&0>w%bn{{1xVPk$Jt!yv{SP@FLH@ zE-|mp-F`PJyo25>7p{_P$j$PoRr0llbl9qXmnY@IC*UX#K6TU)_sgU1MY;{03}lP| zJOy;fbJ^vN8lqP^lEFwZ$2@&%2t#(!H{DnC*#`S^RAUaQX8CSUauo&bgPJtGV?CVy#5_|E}(4{ zPBiUOM{n1nqNA^hY8@RPl^-35az}5HH_((ioaQF2#d^=6RZzut!l78crU!8DPar?C zfL792r3(DLPwVKnccA#@;H}t!YJ){mC;BZld{8}(P)Dhr4hcOjl`Hk@F-3kWyA}Nh z`Klk~)<4McKVm76eM%1e2!EjL7x@%*cfd)Z<_@C&RxA*%azr~OITt!G&2e(w6oZQ( z*VUB%jn0HEc!F~iac!5OwtIlUGgibE{x0+(>iChWOK1fCw@Nkl9varjO-Qe8eLb7b=wiKDI#+zChB5~6$}(~*1v8w8DW zatf*qq}nVzF2@F1^+&xzR_1+3zW{g(VAr4Q1wRgm1Mo2TaZ!JJoVEVi6xD_GdwzJ& zn2J8-RSTeW1OK4zBXozG)Vt%%YkTuLZHJqWzY~%tg%%{Igsx21LNlaC<-Gh>-#aGl za-2uqs;)+U>qz;R19fNdEc;Q12Jh(D&|oy1^Ig2oH(M?&${=dOy83HVf}BUMCS(D0 z5w8T}Im0SpXo#qHE$UNe&Cfp~o&Ey!+yQpI$Gqr~ZUejk;9+lx=&wtDIJ4h2T6#zO z!s;E~Z>uX8o?l*SuIxmY#$d>Gxyoj?&aNu0gu2lAGiFqkR{vGMfg?&6h-G4GUDgKg zhAdK-b%>eKE@DRThr~1InAi7l!zYT#*p4Yk?iRW(CV4_AFF7WZARUn>-we*hy&8HC zE+u1GY{w||RB#9;k&aMK_E7`eZFi!L(~4x(BO8~>^W9)}TI1JA%BPeM2x>Z(?CCT^ z*3=B8yAeqvCg8*vJ%V~ztZAeW0r?)5^`S9}^-G9MG$W|$-5r<8t(mt?j_+cvE7e?d zk+%(Xwe@xL!Agm>MQ#pKIQNq)_51qE{h*Esu>v=LfYC!bG^dHr5YKEwGZgk)zt z=1e}PK&o?`IC70l`aGe}vzUc(?s&Y=Y#*hYd?Z>~j8h+AQ*Pa=H?a2&wHzgyp(JcC~CT@9NuO*bB#ULjzsE}g@(u`U|U{LoJn)}M=0Y!#rjc$?`8D~JGMvFws7!)E27a4DVfhZJ(prk%jC+J*;{{%d z_lb2xTOgta>5YJ=02W@Zf%NT$&H&8zPUbId&*2^2&Iytaj*4D}K99cVA z51RIxVjP54TM7h;)74zHn>tL(f}T_yqzY+Xm&>E4v5;GVQf->k_l6Q^RG^<1Z=N~B z<-A)dyy* zHr`@qrNT)9pG$!!pM{hMkbV-d6JSl7bRW`x=Xovc$IgnUKGWvSFRLsqDxFgT6Rg6X z@Y3Mvtph8wr0-dxl0m(SPluxhH=%SiijNm{gg-vyn4!TcS7?7T{r5$B2;e+`U9Ry+ zPX+w7a#fbjE-k6D3zeIlqoF`Uq@`*iPN+8c%Xx9Q;sKtY4>`SE94Rze;HeSsT4T*0 z$}8}N1Q-ag`{P=qp8&iC;Nh-Wf=}Kz?M!~z{1TVJVR}O0^T%vcZ}krvrd)k>S~Yem zDqvoPEl1ar>Q^pu(zTeF(WlgmvcGb9QL*jhRypA|*>|fPd7HcndcB_C=&f?>ZL)T& zoO7FeAC+p=$+V3xgOcZ1!d~X3WF3p#$l7R^z-Aco#jdC2h#H0J3DG|A5IBfVOHszk z0q5K(4d)HA%`A2c(>61fnFL)p_9Rnyw~AqP2^yTwpN!)i zR}z};_>O|?b76Iw3Cm&rR~V`#V2?8rrG=Nq-?(ZAgq@@fL0Ls;C=u;2;nik2LMhU7 z0apO*c39X)l~&HqEFSOhw~)TnM(qL;I3g{`8uuqP!?dLEH*WL_-@B)%|X-O z!VhB0g<<`LVwf6C`(xt>x|LTGWd@%e=d*G2MPX=hY^K0h@b%{Q%}2T~U=YB@SKtX% zD#E?;q`;HSmvVfy(EDyWCl)I6<4leDOE*C8U@!X$a%)ElHXI_A1)QVo3pEmZTs$^Q zl%pQ+m|&5~n@FDod+kLv*sv;DXMZMRc zPWJj}JJP!VdjK~6zd~AW#Qt4`sBhgfBIfwF>o#QMz_Qt;+`6t-u2ojbq*i`{tV9>t z%f4pPi;!N7)l?X{$^f|O$8ya96P#91+q%dR?a9EQj5u%tYj)lyoWwq-qyRc%!|5d! z9Vv_O`)eqDn9%-wg5zPXDF0@XLa72AP?_eKe0!pV02;Hk;Iy#8mLk|NK0@kay z%0q6GkI2%OS`p3MPJ@tQYHcXFM@!wJVQxC@h+M8E(P%YFncoqP_~ic2lHe|oLIl_u z7YPP^TS)LJ0%Jfe-lJ_H`eqVD&h=UwETpFuQZ`j9N$lV&RD1gd!XaYNfEuvs+eG3L zXij{$yx<*FK0$_mLL4Uub4O7>D6w5T%@cftxOC0sbKw{=m?>*PIYxVy=#LP;2QS3I z#vbhvtdIuNSc4Z}KI`EjapegY(sqgQ;yI}_3p}uk1Ue1G3qK}9`Kubq@B;)_@skwV z20w9L;p|{^#D<^432X2G%jw$pPDjLD4yr~`l!$tQKKw<-UmhU* ztP9_2qG57%l-%RcHaVDi^`U!(oVttR?k-1s9nn2dx(Yo@e47c%tnGp(KyWWf&@vQw zJ^}xps^znbSZ9|HR!|D&2A?9UvhEJ(!QdN&#a`@7#$@PzNK4EN@tJ?E_8@h)r-7|h zzZZkkIGdW+8&CrpMkewFQ=GXC{vst=Ana(|!1e>Co3I(iF(`|R8HssNFWO|OENmmnt%M#XbPhdDU@z?K=}Cg9kapo`Ypf$t5uR@9 ztvJ%!XarZsKBkTwU;FsoBw?A%H)?j!*i6zoDU&XvDcFWb-};z9r-Aby>lAevVFMs- z*sN&@j(asDLO%$?G8H`v5wBO_0K6RmwTe))MD)j#=x2kiJkMJmDGAUMVE4xvNLKm_8>s2gs+kW`$UEpgWuOs=H3$LwdV;98N&G2(SQP@ z9>BxaU1A_>gfbhlXy@uqXWc@9uQGy^G00r%(U}# z0w1sMlp!Xm#^`jCCU*_Fl8w-LkjK?17_`>YWM{~bUUiyU4{8_4OO|nkyl5Bgk+NBb z)M`StDYWwVC>fLhw-WII<`kny3hSjpO*Vg( zLHVx&B)T>K547LJ-PJ_9G=s~$3Wp8p;^^YY-y7k4nQQj*hS|l?t2l4ni?pMkaE{A^ zDIOdS*Sq7nt-&V<M(Bf(HkD z4dCHX%YJX(`mw{!av!@Fe6SGRH!p!rAR+N=< zu4*LT)aBDyX?fNB%F@vl71i8R4Ae9aP*XAUc)S^{6L)jDmgbA>7T65<)d~ULx3EFnOoFa!tNjpW^SQ+NZsc?M0?c|H*$>Y zaN_Bt$1udVT0t~qctYFpgLw}?8u2il^blpsiE#&3Lo6;SSxHr?wx7hjOs3Zm`D%by5#DTnrDGK_ zHW0nN8t2W9u5{i&JZp)2qTVI$PU3x!1_V%*D#r>pxayl5lwMR+^;I(uJHq?!$)zc;?uye|6)X>tU* z*8qDwPe-}{FbH6V*O!@5(6TQSna$URuyr^3dH>QGC2)9DR9aFwZ5Bk24RE&v7NLPm zGp}*xRpl1{NAGS;?+(U!J~^af_e6~rIA4?YvubH@B2A^)T&mO!qV_Z->a6!=z2CeaQ-t&sz@-2i zuPcyV54aD&gRQ4^Ha(ugGuA6sd7!^jJ-uQiU}Cxag#>NZ)S=(!P^W@En~&({o(Qh(0z_Hj4Y+L|iL~uLOb{j%j$Y#z2O? z0JL^SZ^~1O_}&zr1O=KTD1y1*yb&2@sM{#;9QvTy{yz`tK7dI8yWZ7EF9ECs@UVTq zpr^C(OSAs0-hB2z_3Fu$&}^+I^SPnfem2FbVh2?5B#6UnR??8lQ)tg&ypmm0WpDq! zz1ewQ)N+7DPmpZ*!K#{$NQT1c_5gJfpAYdc)Ut!p7pg*D)Du}|Xt+nzQ#;zs7bYQ{ z0~i9Z>p2zaO2Cx>9;&T%_3(E@ea+0UOYNW46MOB`2Ub?hL0wP6CHPka}cx|g` zgxR3O&DwgKYP%?f7LAWUZQJL|u?2ney%+G>cC~7YvxfZ;)0?#&r{u`RYALTR9){Wu zM{V&Sv$mnlqMj#F{<&5;*)fk44d?){>p24HDS+7k9*zeDzjwBJK6$o!o>w)~RQUZ` zcz=Hu5wGSWFbOp4x#%l0;3T|0p;ZGXfr0DberE}p=7PIOqNfa&^V$u_%qT@&1MN{E zE?#(#JAmwnDFU0QHxi|zyppJxw(u<;>cWWuy1YmkAmfoV(A2)ty`tVHP@e*;-ZmZo zf;_w4=Ss)N{;b~Pq5D`Wt%6l+&k9bUQ6SJz-*`5UH(3e_igo-}QJ%{_Y^JxHkX{FP z0$`Wt4WvH=d;#ELb&_a@v-K-2Ib8l>jVB9udB$+po{doWJHV}P=N~Ys?Qdu4TkBEw z3oqwo*HQM!zFu}EFFV&C;zP)XVV&#Z=FWwuwd`EQ_lbHI|3lby@H$+I^!0#^0K1;M zkv;%;55U8>|7N|Fd)9gif~&&`Y33f!Qbe5KFwcvTa*#=IMiar^4}x1st|z>*G#5gH z0kUxe;RMI^Z#cm{%n2@55Zq=i-I=7$;N-?1iTVx#vEgwRv27Fe9r;gBlN`AW>3M+5 z0d{?VUE-3i$NdTb4|UHiGvl+#wGM{OzfJR25iI;MlnBe}Kn|C=jK?&3!z7QH@p3AMyTN^UacEmow`4e_qZLXDKHi+MCMyEHByQ zZj;QCSufu%@U|51Zdsl!|3!IzJxh7aF4|O}7kPoEbi1&*Ks8o*e#EWxH> zRQx!UHxdFw(@q2ulkU(^jl~bm(C~o3&sMx^0&*0VQYQ6!Zs_t#zyP80a6)PaU*eb z$nOG=KKR?K41YVoIyADB11zzT;cu_7KqF%ZnD#1TFR*U3gW5VuChj;->HuefE6`QO z5++@D2}wc?KlQl)+jZ~pB@<8lz%7J$q8&IRbnt}`w8{BOE39lvAg@nR;Pg0=P9a@c zXLT-)+!_H>zIoc6#L<_K4_8|w3WJNQz(i7>4V_!uOR z%=<9$4~Qu62O{jbH}s^S=gUqs^V2sVeFvZ(VAJ#4NPh|V8NkCARzGXB^t{Z>u#0b@ zpz|~6dHQ_e`moqsmb`A7v%Zcs@F_CtTcVvJk>3*e6!CmZ21!fg6o>?1&!L9&$T!HS zBLp6h50Oa3?-9?Na6M39J8Za}t1@vn$6l~6J;kb}9lZovO*Ds34=~aig|%@cocen3 zWD#nfvgU_Ro9jId=^DVb0DD|nhxC1bV{iR&T$#)LHVns<4(r7sBQdZvV294E8XTAM zMOcJ+?aY^7jqFD%g;LZ^C~7(30H`A|)N6?DXPs?Ley~%fMn^}uf9VBZ2~~n;dPbBp z_?f^vFMR>hlL40l?DkPU$65ezC4h$?w}|r2`p22$jrk=mzlO;LIUCF+K(+(Y8Q6tt z4KHWrVl1_ued6B1NIhgIW^}1}{Z`xz-2n_YvL}@If&?Spop4->$HJJwku{db`>;E` zOOYFR=FO%fQ4?qm*(?)fIZ zr%4Q$M=yqoHmp4}v4p;WX1ZIsx+*KUD7_*YWPk`0F>V-&`0j>b>Fx~QZ z-tjll&SqxVCEeAa$XVa**2Wb4BedE4cH8CWJTNL#O?Bf z@;2GGT~3$YBt_j53qz1&B}8}g4g|`}X}SVtw39El$%L=oQt7HC%0O(>r9lD+uQpM< z2NuD~@HZnw(*<&jbW`2f4VW05WaQ~-Dgof7r@)$&VfW`#feCtUC~#7eP-*oMfE-egCp zRG9*kHwUOWg6KY3P2uVXuzqW+dtn*JbVKcoHqoImh*o)%C?I3rJ_w=xqI|`8x6Su0 zK>8-YDu7+SZAd>47 zS-K5N8;_cb5x$)xRw;(+N|a-hsnoz%HgAiCv=2t`ZuBPD%yI>zD!PLExaFmPRg~BF z)gQ~7i@b9#?|BfTl}#(Dsul&WKWphkdmejDyuTLjvggvxNIwH;2z&o~NPh-cZk^+_ z??r((jxW=NImcGtg+t1xmtF>Joh1K&{DGV#3FdV)mqonEPEn~2k~{2P!g3g!n~5@a z3e3-o_Tqb!3NIzO;4a2`X|7_ zbTMBHe*4eo4N-gOVTJ8CWVI=qduiv173HP|+}gQm4S@B9FS8 z*IUU31zuKhC>Eyqp&0IA9?alr0ct|gu2FOd^lCM-kPf4Ha;z(xEplKp$dL@DkZUq~ zWN2{HDKL$Rb6DKiy_^9Ufnhxt@Qe)a36n5x?yQTWrr;B9mUlx~_$IsP9yxB4Twq?y>@=(olJ1csH_6_6jEqaEy%aKcR7-@o%4}(fiQlU{Ab@!mEqH*z=TT zwmL#RkI=|RDT_9~qeeTHnQ;&f+fY^B7e7_7A_pc%!U6)WDcdMLjT4>lFOE0Bk(hA-x^& z6o7|cE&I!}t;Z9-TyFkOj6W^#TxRvEItW@eaMzzJ9`z zh~sl0I%|oHhKf7_L_^Kh0Yqp03V{XofH|T+;!`Mgjz`jM1U$$difPHjaxY-{5@5L( zupA96!#b2?VGRSRG7K#l$s!>W3y>$gBk(-<+vag`64Et*`v5kcz2AYq0E`FlFx`?X z?mKp7zxP>obp^G}cy1cEOxL_voz{ahH@U)7)Qk=&X7r%Q3~?UuAY1Sg8Spdd^%DvF zOdeG2o4B8e-%dyUOroUJJaB(7ldmNMZX~^ylfd;*gwzckK3V2)-z)IB607^7{Z4~= zobL4esCcXxA#jY1VbOY&2D5L?#ezCKgx^ja?(zX;y)KTsoH^m>alkdC*Afyn&-75s zSwU#4i*qHvlSw}))sEnL7b0hf8ApLZGz~; zIQ2x0AbsM^{lri3SNB742~tc;@^L58ogye-AxffZZM?NG}3h2jF3eHLjd( zeKkC6eFZzm;brBQn7b5^L0w|<37Aak$fttrw_=l^#Kq>9skQ4!T5wEo@>{zyNVn$&on6P@5tyfj+YyBghkDsp?~^>_2Z zT*%eanuYG7*kNBuF#0A#sWDa{uYg`7(+g~lFeuZF{9uC@L~tqQ|u?w*X-QBp9y?pZKOFWxD~6r@{XF9pCLQlS^h-q zs1wb{J1u;h(XN^9EIpq0S$6_9fp9E2nADY|?`qQP7UH{(bf?S#Zw{xQ9?2b$#vzmxI4skl%lfVWCE>i}8&Du$f6z-AYxB_MhMQiv%;H?Juu={Bp()EDH05-q< zBGPXH_G$u8|A(_H0gR%^+FjLMJv~=ulF5Be4st=l6$m#WKnN%pxkOY9M}#Pl2tmO! zDk7o?Dzc~u(M3g95qE+0K+USFc!I|&>!M_pRdihemv!;}zgN|rnZ%&K+xog+b%w52 z_p4X$$op!QtfCJ5ZU0J^peQuIFdYS-<% z(r(}RQGBnI|I{pMm_4DcY3!KskN{=1=+}5Q>#Rl3m-m62;g8^L@tw#WlLw`M`}CVE+9{1oND(>1h8C1&1Tu2%>f2{x zvgv~nlnhmj8R!NN+k&SCV>myLU&}Z=6`iZ+n-JeD(XV0VMI>nZPC!tCB7~2t`rV3p zqkgar`-!K4zYX{+z}Zg}eh(XTz?{CSKG)fPPi19yaoZlXZ|_{M{ZX-P{6*^^>F(#t zdl@P1zb#&KYeyq>iZKw5j|&l4@o}-eV8{u_Yr18X8>l-H0pgS$|(X z7B(A6!C|=t$^FO(oO;+dB%z@y@?!Hj0eugKlCsM+7-O*fZ{r9knrdLPe~v!cypV>8 zrhTpIr3v#!*e+iS%D)xxFu)lf6z}nAq&w^3!<$AkI~U_g+{?6C*}T6qpm{d*6SeUm z7!S-}c0Se*k$e(>rvtJ9PCidOYiWIu?wk*eLz2a~}dH~awv@{{@MH>0WcC!Pm`CCO-@{f`F zC;>hQFdX2}RSkSHptD_zst?2pKy0+ey4m&T)ywc`&8(ih43CGG;q78S(>?=n56fJM zPk++x4zqYI9J0$gs{KyU(Skg}RAIP&5Bzz+O8|$C%WijR?;?FtI$-9|NKWstXf(4) zM8q_@S(1`>n06eL^o7gSGhqjSoQLs+JPf4bdzF7!3$*9o7kDLL48X~M3h;u?Wm;PMF)}TGwMM=^Zc@$N}prjZ+7>ea5D(rkPnYHIr27Dx-8sL=s&R1O8RHRQ@ zZsMRjXO-00fWnsnavFTd_A>^~hAN=kDA&^|R>LrUJ>yBhDt@%vA9;lB!aV`}CBW+d zhYpIjascUHrUU+mNgPGZFdG!~0VK<2*vGUTl8Wv|UmZI@qGB0>vdMlJ*3Oc-Lt@@e z$8g}|0TTfZ9p?kT2yjw5#>{V+HFJK1h-P*f^}{!q?1)FW_9kcu@EYm zA(}9#Q@m-fDf9VaUc%0d%p)yK(btN+oqBTm#jAK{UtRW#`o+`X%YCtS7>@oCb;cx* zVr};KlA$T(@Flsx%K<|GPI+sA&j&bqV2(V&9{-aYh)b)viVSyC0h0h(r%|VWlxyEn zN#WZJj@h8E&8;$M%;lr63q98dyIba^*^)}V&Mx&nhDAA5Hb_`k<=v0G9r_LdKMwdB z;P9K;22)Ey;J_>j9*CC8>ZrSOZO~x4~lt1Q%We zzsd#Cd5=fdmF-|7L3S%MPN64`9jCJHng&~9nae!T;m~_2@LK^J08V`#`Im{c|D^S4 z>m}N=ed8(yno4XS`uwsdZ|B;xAaA%8Gz@as;3{>cnSNsCh8c&YlvisOik_p$D{Pm4 z^+*@O4+OLy0QS{oe}VhZsw&h%cdbp&fae$LF|GFZ=E2`Xwf&EqfL{yP1aR8p3*g@X zgsJj4{`@NWYp35h89UGI9oxfM=Jvo}rCjDdAV*)@#pAFTwKj>@@W|dI;_nh-lemj% zU!oQYVbW6d7u|YCw|F;bpL(n;h(^J-n5o`07<_sDqG#{dJwf3X@9Sbed7K5i=NH}K zk)BSu5QPU`5Irye^QzwXB5fZqMsDH!0nW*3sP;GmvjYRdY@Bros34)eEmgluQBG%F zUkiK};7Nc}A8)t<+WUZi0qFXr{y9HNE{FbUA6n38TQaMu0Vd9fAa}rkrOG}i6wAj4 z>hX*zQ_mqq!|(l&w#Vpcrh3Zqd&C>8FB}fRho2iki4Z6uCMGi(o)cZ&O&*^YazmjN zL092#(BtyNi&zA8!(P)9lV*$v4T0`HynpC1;Jag7y3ZRD!E(QR=Yu0k13M~@o&-Gr z1NYoTxp9U*0>S1g1KcqDV?8{v+K0)`GK|wbm?ji1&J6OTX>MLK#e~QztwxOLi)VP0 zm_+I1YB9VFM}yfz#3}H|!ILUPd4D{^Wnv_4-+Ti^&s11Zr}Pkk?w2GC^e1>m!yh>F zxNsM-6a7Oe*q4!>3 ziAKWt79XV6v5JsA?(yvk+2b@#M*z}PKo-EszZdXPfc`9dG;mh)9LPl8ehZ;>jF zZSjh}waD9Dp+<+-bTun(jNz z25Ro9+Tf~=HFww?l$>-W*}sDhE2BEVLEuf zemK}4!RTH3cRthu4=`i(z{L=u=W~xpXPE+-l40i}&^-3!NJ`6At;EY51 z(`=->;&&IL85@Ecoz?=kyEX}0n@!h6cSiQh+mZj1fYvCw?tjwM-b8woeLn2iAO{enF z{0E?rHxrgDm{qrE#KJipxWz&&7nOihu=9G%Mezt@4HNO`3uBAuiP^dcG0EVE5c07i zn$?E$J2?f&Qddi+qOVsVQXf^oX94B{ocg#1_!_{C|5_#g;o%R})0v+q)HT5_1tAXL zcUY-2ycDh5r8K-?B1=Q)L9QJJrTuxCm4WR-7P&w6$LTm@4f>o}WVO9TC^p7Z5Dt@S zl&-ccMelyllB`~7-D0`+FQktF?2C6L&!Kfh(utdlA+%j2YBySm^52Bjcc%p--<=Qq z8o)|`Grv3ld?(-)09{**8{~7{cd9;QBI@>h6u;Y^zq8tBZRblL;cPp6ILXHiFde2P z>{g*|gcpM{bf)flwv~)$kINIUSa5OiQYhWRN}ME(#Md0Nb%_QWP~Pi}Ac<#~BSkl` zE?pl|MNy zG7gJ@mjMO>oPItM_-Oz~Zzq~ha>^%FI3$~NBlsg~ux$*^1*k`lcUYpEZD~&#yp4|n z)7IQg<-Y;ncJ>eV0Y47-8sOyrN+_Vc3#k5dl_Wj+hkujCO9$mO5OX&Ka|P)<2d*9gZ_KH+IOJ8$SOc^8pFc8`53|n>dW+D`q027 z2o`%A6aYh>ex(UK)Kec|;&3X6!{&5wg7A=OC2_MzA0h6*zX^EY)ZW4&D5R5lhEG1d zzU-8=IM~l+rzNG$)-N%`2(ai*%hSVRycn2aAs!HpxzeEClFhSgg%@Z3vqd1?mF7w_ zpv+Q`mg#|{U3jxskdYxASPQr$c!&i>YEQ&xa`^*EEQzHv91iugRRyl4 zlWB`YKWm{XpdT^JYjs2>faeo+RdiSF(knjFE<=Ey0;mBv?Xvb$k2VMC&f|gFrH1rd zrDCg$0oboyT6+NDe-Uvcl~#Osc`w)dd(vp=PBN~fenMBchsu9DzU{0Jt-#*|ybEyh zAM5pM|3v!vKdJuU?60Hx1t}JxXV?NL$sA`%j`&B&WuwR#r;qT26&d#^;Q`T6qUacq z5ZOmm0iOe?2RL+W0sb1``CK*c8HMWEUJoh|a@J9fa2K2%jp@^%N-ee98C__a^4^`h8k!Z&luK zVr0Ijb)-Mig8=q*(shJ*#7M-mRxBi^%5|j8{%%XucV7hlKHyV;Gtd6vIa4!}0$LD2 zSC7_??P}-W{`fpxKSxJgycvcO#{7E3<|h|OQ7lA zPphGgoc9*tUL*W`vJt;lWZWjeX=2!;hT%bJG#fywn}aYZFM*O~Kbw@ks@|495b^6) zi!xjQXaG3<o?pBf{SAHI9Fm}LT;ijp-g)lM-0{qV#ZyQ3a96u>6=>YO#kKB4Ci8VS*$k1 z!;FjQFGVA@9%HwB!5~u^?>P;XzI>aZCEKZ3N>u)HU3x`Mc=UzH=FdJ7w6)_!e1+ z<^hWS!qk9_-$X^I0Dd}PD!`$?0r(m~UMG7}n(re0hw2@6d{pgFnE8j&idm~a!-J^y zdxdr%1YJqyC8+iaSAp>m!+v+TYX-#Haw@|i0FkW7CPU|ZsMpT`$*AlN46Vcua)?;v zRWVr6t))f!O9}8{fRO-aT+9M~HQ+`7U4@yCgD?N6ICw&L%gYTuyn_ywlJ~ zPqbDW`${QlTmvSP`q)N;Vl$wfPDcmw>}Bb1vp+IKS{!Y_KZGcATIoYl>!Bi3FC!>r zLmVB8dB!;id_X-7!uE8gBHh&vRpmZ`@;Lo1COx2a0~7i;iU3tkJ`(1RA z36KA|a?1j)S1KrXs&dX!#e|nQ@(~a)Q{W*T1wFztjz<^tTjpvn>(#VUbuQ~^QO!LP z%28#ihhpBHgC5GZaH=V&>t(P{pgDj&bA?@gl&h9r;EK&q{WS+qr~H+`X8{%f=-QF? z^W`r*dHJ0Vy&a)m<@V%W>{9$%vrc{aYI0KJznLemhv!*%nw^OD6{3pP4qBZbaJn>w zWhA?m5v&TX8s(x4lhk^CgHGE2A24WFJVLbBj#TA)40MNya9l3|{{Zk2z!{JK1s=-8 z`RKE&yOcV7QGj-&HzDO0Gi92C;QPaMvp_QTbR)`wh&h75v`A32|iLrgqc?EKsCZAYH^ zU*N8+faU`@`G1>-b26m6;ujb8uh8WIUA-|~ zmcO}5mA@I^cGl&4fjLpt?CHP1A`o8fFb z_#~`yAa>-FAu(KKQtUu9cteX}!*OWhl74l_ zqb*4%lL643$2nQcIJu}(TlZpTdOx}Mj>SPA+LmUc$2iJX2b!;t zWvt+K6d-t3-Ng8R80yB6FV&7$^jG9W+J7qWMSzO|4*jQpc=^?C%j zFE^mY0vx&uftLZ^w)^AH&?PGtEBH-Vx}kA-pLz_J3EYV?m><0wMOhyx+RB0Q^J1QGhdkQt|>?DPSmou2Fwc?eEB2?0?$+nA2hW zbc)kw^C#fi6+=%7hOoidOqP0s(akLP78baT^>9rUChsA7I34L_1TmGanc8ge&Ri@XP`reTni={SO?!<3+~;Dk z$CuSrJ4w~Yc9hey-~9>rtAGOlr#|jqVQHTteH1{KBj0x9nD#%_w%c|FOQY&z@sh?y zI3-)GL~o&*YAFzRX?w)byLHb!dcxg$`aRfMRb=pRsi%8Fuk^myXI)74Z<;m~gCPNI zmc8?8v+1qv_EUPB@b1^ryU(Wg#*@&yU}mE>19SyF{)D)6ESmOJJ6YA+9^~oBdEWy5 zG2ov7r@#MjsY^5R13$5Up>bGm*GN)h9ah#DKn-95R@gma7uOoIaj+WT1AKviWg>v0 z%kud609tEj8!tJ6k(McnjvC}qW79Sl_)@^-0Edo01OE`P;cu!R4>+oxLB51>Q;8h-{{)gEyzSV~N2qkM<}Y3aJ#74-RCh7o57Z!BVk#Ta9Z zIB$Ghh1&<|+i;46Xn8QPkHoLtdXh24i`P~N!cvH~Xwtg_UOnLz%*WR;AO%3?VwOf9 zJj}=h7ar&;AO&lkrpni=ATqCv1U?;bF2E_@THwD2EPYa~b1l2nlgbA>XqYq7ifucP zZqXmsrC#q0c|XQCgQz{?L8zmkgjH}eR)ZxY9-`i(%;@WFq{uFa>|h!VtW28gc?_J+ zLKa~cWjC2i(8h=y5@M{yV3Y;<9){mymD_v50E~4C56|))qJ)pmsJ?ylM;ZZ z1Hu5OT!p~<0y>}9%Kp=MQCY)6It2_hRy0^B!CBgR94D94U?CaRXj=9lB7r%J({bDE ze4FW;c7It7d;?%3z{z(A{HbiA-*h@Bw)5@p3x9$1%Wc*mG?Jtq&nbbJHzNv%j!wzps&s(x^ zc5kqdYuR-=j9S*R26?|S>iz&(yR2o=ucGk@Yrv@O5jXp_CwO*Z4kXAi@Q4~y>gKu0 zoG+9NrK5w%*_gk{ha}=w#+M}O9&ZK|V1wDc zE6YmzP8?8LJ~6+v+r;$Jw23jL{@`?2$_qcf7jRn{(!`1Q!`-^vg9fE0Kq)#GX#~*o zJyLCme&PI~^K%&b0y^VB{xIW@!@V>dN-Mzn6G=EPml(rnP1&r&=Asn5OQl{4&lQ!T z)C@v>)T(rqT1dJ}J-B;IeYpEe;Q_QVPzpA;ni_D9YWKrvPp6-K16*_uXdZym&yIXz zYN<#&apU=H&6NL52&VMDo zT}$-f+DG~Jz+MF4^t;!A$CN<77C=|kt~ewX@Ff0;OfzxYu2dy}*e^*+AjctnrQ zOHUe-?B?l%jXcxi>&UM6^%E&`*<-iK{p|I z?r?0d$$SsmmO+f0Bt-Q{fVE^9$Jvw*f(7L$`5cLpv&k&JMTZpTX3kEh5ZrRFel3UU z6A96K>4=)p*L6Gd+{@ileTNxLO6I^rr2BkWAMuqub~to~d;`45^HZc5^m4!k=>P%$ z$+(4(fJ9#*PcimeKfe(2<>p4!kN2T}5P$R#I_@#TO9NUq!0AT|fnNf+89-Off;+1#Le;TRX^k>l@+CVfFHl z>>xV%dpOf88d$uXzrhE+2_~;7R2(Ss7uRY1acp7a#}~$1I4-jadU&#pg1A(8?!bXH zqNQgZ*A3qP-+C`-QKHAdi$`oyVrJsd00N{1@NYy>CJyC8u<{EzOyzzPwSuW*EPsLX zVS0@LXBlG=#i9@7fEae$7$3;KWAKRwRx<=!6hEs&1vJ^?ANAqzB|ies?TLLkz#0GZ zfHwike5#)I+^6b|hR=BjNI{;~XiT;1yYzaDL6sv3FdZBfgIQsvtN+MB7tas3VWKLDQrobvq#IO_#DhwW!BdcWh$8IvFvLPRVI zYkvS6X$aQ^>?&{JJ*|uQyYN}A_@zS}N0w*?I6S=%ypd4$POTR!IvSD3G)0DX74U}v zdjL-V9NioG5`eV;x|YA9>f0FJv47g1(Dp_3Pl{?rG!mak{gJPQz!xSwA1W7w8+4g^ zL8Ts3sSp|VyeEztMUBPx>qUWLQDhW*WG@XDALJS=3n4p-O_dlC|6mQW=E%>p}k2f5&3( zhp6!2+)uGT?FmM&P*2m`gIhS?4C$74rA;;`TGXBfm`Me;t9G*@nCYO_rBR!LzaOh^8N`U1HoNlpQ+u0 zU2a;;R8Za*l%HX>F_CLp@X!*1hzy$o2xBcjJa@&yW`*{^NC}fLPmGUzDAIo7uki8( zYf>+~+N=5M8pfBQIpO*3ZpJ3E#ik2fLd7+zT+N`_nV0Sa{s+Kg0H<6p1OE&#*|vL| zZ|ggBUe-^QZKJ&K;B4p}bvE<|_1J(%5G8yW^AXR+9_|xnWb#XlRV)^xkXoT0J&raG zL*N)~IG+75e?q(~0~%Hv*_hb%WNaLa8kZWjTFbSH{)(~){pSFm3z!dZ=wAZ-DnRG> z9$nL~&TG-F_Q8GXUMzdN#4FHMgEo7MROBO4VWcaUfks3Hg}%rNMaLfGQET_>zXLb= z1vEFnq2m0o6YKg1(m&%Ky&fqgLJ*$$QRToX?{|U10xu zB47#$M!eQ+Ifdl%Bm|~5=?s&Pgt~oSJqbNx6e=@MZrZ@2yJkXO(qELP=49Ci0V}B2 z490I>r+3i>uG|qGXRU?PGYE>yCKxV@Wx%KMF~9Vc{E8 z{?qzL>U$6HF8~SUk$L`e-~$K14gx^eyt*6Z^MoD$L?&>n{b;R3t*2JYXXk#Q#;)Bj z=#LlIH_e-}ux@E1f}71zWq|W}8Pai%efky^9j2~HHRzZ{4e%95|K9oF#JZ*>iy&ag zIcolg9WmKa^L~EB%3_a-~|UnX8-C-&Jg7l12t# zlX0z{vO-_MwHx7aFNt5pjTvyrZcOB{zLETN_#B;))x(?W8=X>Rjm{r7(72GNT*YHP zW9CQ9_<xzf`?6p&Pfl5&{Cun03G{WqYx#~r*?QKMUmM zi0)YQJ~kl$$pXusf{~Tao`e4hVVZ_`0TC#<$FR!z}-Q4{>#qLZGMXD6;a>O}$ zko&yw!kFgO;j_){>*4jn&xeRf@t=vPTks0Rs(q=ns6w8+UiJ3@10(y#vB1v+%mCQq z&A0^kO@Lb4-}>Z)8)ScWZoO>z9(BQbc`CekG`VU5AJf8HA%f*CJZ&?FlXK#^9uqyG zog2bTFch+QKX^nJ?Bd`zgSahPWUl29?)1ms;}tV@Dx_Cv1H=(l?3mWPLDAg?nw<6J zTi{uPa7GVs=80Lrn*l}_eBn>$MH$n9yq>*`W`Za0p7IWNXTY~J`S<`28Zd8|(kP=4 zTzr1a6rO1LvD+~+yrr=oUP2KQMsUB+?~MTn3o_n~)EDp607{={gqhk9!Q4E{oV zC$R}7U=v=qr~g5SocyYs9Pgl|q3upp-hC*G-1np6KL`FFz&8M=yd@aTd@#R z;k5fP;NJqYArU$r?QLpqr2l{G^7v_}gzea?J|b)35vF~GT39a0m}1l*=h{tn`SH!p z`PTo>`A(Rmt%3%$%1G8~c(cmqu`cp?^MB4~^0dj?rVbgk+4+Qr{c6h68N4l4#^LQ+?~3yORw0ehj*b@&(Fa0HOdH+SZ|Oq z3fny(xvWX&3bj zrXp&5qY>IIJcj!z(fw(W{gg<38WxJ#kBiXLLOdnJ6GH5ST=W9cd8Ib|y)AZlnHg77 z>^fd?$oVa`k-!!`491(dNwsK zeBqB_9XM#kHR4jG84r^ zT!lmqKcLDxZ$yM2z6$v5fIkA9@_qpPGr+e1y4KlxevTZ*DW~(?{A2#&2!}>j=;Qbqs5fm8ogo;|aDb(wnJy@x z4L+@|w%^(9Um0ot2Y|l__z>XKhks;1O92!E=-M($jfdw?tWqE9boU)m`-iUg2ko(@ z3X5kWW;UD+&s;cVaoxzd4Oq9@V0<7C>u*tymFh9=Aj8qJ$nWOMzgeg1^6b)GMY+Xd zr=Gb>$BYBXotWdB?Mtptn9oH_4vs&^S^+rU$^gfk>Y3+C%uNupGiRXy#Hy@>%!OvW z3k_`eS28{ZadmsU@yC3y!Z+)vFOP7M^DZ+};B0N1s;3r|-{Dtw1Ai0nFMv}|38Mm9 zHlP$hS7*MZQ#EO4|7<-)_!;=jBrS_eULFp#iFXhV^4!$QB{ocjr4gMTw@T- zNbur&sR~vympeBe2ISYU@YOUQQavD^liZ)5Z=BC2vjTEdtm@z^)?No5m^R=omFE#v z4_i^bX88u|AMh^#aib&ka3}EnfbRfw<-M-@i!WX68|{Cpwb=W~hkmvmBwv-&mcQO} zFrT&M!-*C>4l?<8SEYu@#Dhq9Q8x#9Qdw5*fQNYbgaIe?^7H|R^m1#!3RXUHz$1Ej z!T_(flJ_wR5q%5$qM)Y)Vs3p@c6u2?U%p}4f$8F*69E^wW=!tyQztx{x`DUHr>inU zEad|${db(}ug&!XxCWw1m}& z`zg0;|0b_Yr?5PJHYAIEFpr4=_;E;|PVFgZYT#x8DY$uNaZI7#VZLW&U1GfWz z9`GT+>EBbw;LI1W8$j25J3igLcdwu@1^CI!7IV~Bli_ICVC1V>Nyd~o; zCR6XJM}L{{Zia8B1L8KGl%F+iz+$xL_tdhF3N-59S-E$BM>`7X<=xbp2r)P)yPP5n z{?}!<8&~&1+>z35J|9y8Pu7?_Tnq@RMfyjGC<*M0zoRnP|4Y zve}ZLnGqcnvXqoFx@xvP%y^LT)AXlwb}!^Zq-@jPXva9bDr})f+i5o%>c4G|YPX|k z4`=`785huc0!9IxcKZ}~-YM|k1EA~BUe#{K&W_`4yWMUlqRwA_%6HMe3DG_iUl%_n zu;#?|mVCU+eoWxaiQDkN%l8dk8G*E@ape3-7&!* z%=B+fJ|Mj)G8?KWp1IhN1e4v+<8+flL$f>L@Wr7rVsW09gm?{kz92gPa{L7$>~-kn z78ps0y<$OjaEeEGT`nOf)b^oR0G|huVfw6Ty=Kbxop#F1a$2N*(zb_4RO>~4ACtpG zrEXJ%eN1Fr;@GyUMs1`6vuha~v{%Ka(r^r6B|G983ypvSo+(ow9^dZ-k&vq}zs zutI=8c!m33(Qh(pQLz%=C@QmY(@DjSwc;F4*2VUw*byQJnJ8(_AjIzeL&|){32Mw*-I8Js$1AJ zc^>V77oS?cpdRO(p~ll2NNb3?=US#cBq*k67UXPxPXPRy0YM_inJj27=cf@DgDd>D zJwJ|*$lq&#*8=JQ4n13dZwGW`7hgSd(VUBCE|TZTjwc&pQ{ONon;L%*CTJgsRYFV6 z=CNiko{TLUMQKlmgVR{V_>DvF8sd)g1led;oaM!Mh}Fw=D8S*2K*05k@C^wi&}jix z^*;@Do-mDnqVwVM41vng`i?3`ctT{K-5Yo{U_8K?FBSp61#lODt}U;wlFyUrpC0Y7 zZ$-_+YK*LYr2ObK*_+m?PW3QEh#qYlBI{@$!$d?t2Fnq~V<5JZX%K+t=&*kX=)+j9 z84_^fs!G$sF~*~e>%f&RlPvT~0H{6@b+eN;$&u z`1M0)%d&Vjf;JT>+cb#%s4r64HrZrj-hWS(?=a|g^gq7=?x_iA0f1A!9>A*s69IJ9 zJg3^xu?vweqi)Gvl+O;QnN&Lz;*#!wBMdT=ZQ>g^Tf`Ba80;Owd5!_0fEn-d7;sg&jXxtIVA71 z%eB3eUkK;#&a;PIu5F@J+o6jyywf;$8zBS@LGGE*%eV6#aC)RZoqSKjyZ<%ciZ1GN zV%-A7M3!^z9$j4Eoo|yuw6@uFv?9-5Hu3v`e*s_Q<8rd`Ii~|3 zruuD%aC4A#SIAA_F||V+LW1vYVp=*x(1VUM_rICvIQu8l-k}!1)O#7{-R|aW`k+^t z`xWMSofytIVpnMHfvnyS*9yu;G80lGnz_*C%NfEpzti5E3oz#of`c26z<2Gd`2*Gd ztteOL@*luEryYK6`CGa!KTT2XQR6bc zenHiD^g2?7cv+~%W;TXgfqciVgqaHRM!Y05MXeYieXu}#QSIU2wWA$xGKABpy%Ox+ zKV-UURZXk-SkV`p8sVezfcFFp2RQXs@TN;U9qDNRx*R^rdCHejx67jHZ9a7O>lW56 zs)P#R{D!$;uzD3iA1<>eb%NaMbYrESc%7cP5=_WZ@LJ|o@E0c%e>5_`gF2};8HdDp zkaRr}L9>9;)oRn-0(zYFZBm$PyO4gurV&5mo#%a3pQkm-(8}Z#jlwO1jAU)2i@wul ze^)yr^4$dB`GD>Kr@hVsJ{QnQF6s(7gGi&{}Zb69Z#~ zkQoazN-ytk)f!89ynC>Ppfi@oI|ISMg{YvrD~sf-)U2Cq;VZn}mqK}{6po_D2?{cX zb9nqjYxz{QXDet6B2Cx3z&{536X4MQaFt*C5$Ue&E8FRxT)&{MVF~%PYg~wNdkP>E zcA!|>nf5v8&w>6xu2BS$KQtbQfLdd%T(nz0Q*_Lm7NO&2;9CGY0ZzZVz0%a)NBUy` zU7hKXFQaa2qxw};Jx{J%027{>iU%SNTbk?(z8F119qzdOe@Yx7AwO~uB z-nGwFc?X;s={GgNrvuIfIQ9Or*R9P#y0gCu)i0$d6uWCW7ev<9rXXvbCiOTyero8|UHy-p? zzZtKep?c))%;=>kY5A8bf3NA0_8uI=wXsN#2iO-szH^mCoq0o*J1T#t$88gEU~IMf zIcv4QzqX6-|L#|PzrCk?KwodOzk3wl2-`#!*K#f9EXbJwPW_E>yR~Y(@5+D8l!X^8 zMD+Fv$}5(v$5PdL+r(1sV{0kCTlI8h+@{uMe|Jw8-~IU4ez(0&*5S+cb;R!X=S1qI3iwpO zSpcX1ECzlhpzHBBYQm%rbwipZ2$)DGG4nRdvHuR&UUk!YQGq@1I3C}5U3lC+U6}z# zz-Peoj6FSQwMP|wdyuzNuWe|zHlz;$oObK%7f_AM2whH{Zi6DxXHfZWw!a&m5&7i4_oZ-Z^gF;*za}&e*v%$;H)27n^*e)=}!Q3J#YKz zaN-);|5Q6_$DP>{wSLScrlfv0@u<>>oYYH6GnVGsncwp2?|2VwIqVZE?lr3KGjeF6 zgKiJ>Gl9&6w~Ze7c=IuPT%X%sX6+Bx`L$U{F9JApW?t&nRv^9F{+%;EoTo#l^L#9d z&Y80>Tmr2|C8Ppvie)O4ER##S#{-t>4{qZLcP97z(Ve-?4b!$EI2*u&n5IH7O+dd? zbndtFr+k(s7}{q@e*tj%=?UN)@ZO=zc~XB^R5!nF=Hdt))!KICodTmKeBTf5w-2^) z_J?Zi{N~k0_CXH=e;)7>z^Rw-fLk+RgAJf7Xzw!)+4{`#Wz_A}sCrqrc=4o$C5vX) zl`d|Yxo{4oXCuiRV^RG~$pzrgcDi9<`!6Ue>5|1GX4_VzE%5IK1G5&MB=0++aOcrJ z6zdHQmK#HPe!jb#fl!FaBDqIA@7>c?kvCjarHt`Zr;Q0zi`X3Q>Ia)MbAaGrBcyV~ zaa)85b02kNj@_agWc-2Wn71BeKkjKS-y5%>|+{Gm9;NwWiP%)qcgMLb}o^14}ZGXstw z>Oz}-xVnfzLEyNYbbAIM^u}qtisX#)s{OB`zt`-Dz393_T&qNS48Wm(#Z;emI{oHF zMR()V9qDiRc&+>%jVnoid)NIC28`M|95S9>oZ20=*JKpIjyHTl<(rRK%#OVKF?|0y zz*_)kJZ*qyoez-y2Y{|$;-`<>{ey=p!f?|S8t&3|h;A=ZXl2ifEOrlbYrVQr>lv}S z8%+t%TNv!!s1hmmtJZAO)oV_Ke>xTTEWliVGp}p`{y1RIzwCPG;GbGWf}RVl?D~au zG#8JW*#zJ1n2(RBZn;c$%V9J(?+`ERT3I&yx+IVv3N&OAoS=HS%kA;x!sI3^!H9QZ z%jEHd;P(^1fIKPImx;{^0?Y;6ICPTz6$fm7o&|FZh#90G_ z!hzA~-ixlgLu_PM>A|b@&{cZj)%vAZ>7J|gh1#5KUSKB1C-ck%2=^Z{f(S_ekWu)6 z;YMUV>`h>wLLqwj7Q^#f?9k-C(IwoOJ~tip7V`nHAKb=_u}m7(Y$W$BA-HJ26jioR z(<;8R%YRIyASB%FFK5aGiU{-Ne2OXVnH`~8Y+fGrW7KM zMp)=nOzX5?%jbhg51Kw>UoGFOa1j zA@HSu%K=XQbZ&Jk(gQlltttQ64NDd_jaay>a#<7lObB+ERBbq^dXQxsE?6XW!-`{a zLyz+WNaQ*m^DiFqYW~qKKYiPNcMLe27tl>Ig= z!(VYY3*e*i_X1KKb0OSK*egPQuOl^v#MKYuO)tL`&+bs{m)>(Ut(shuhV>`7nM?gb zU2W`aiL{~&PCdT_{58Ov0Q>4Pp2wV~hI$PP8p?v(H_*0-#|c{!4gix4hlg(F>igmP z$oGqYmjLJ?*O{m2i8+SjvZn*(>_Fg9$sq{ zP#*GXv%kMI>iehHdbF#NzAozf!4)p;X1wp}`%CN$;2---WDsK}!m~X7hx>Hh>tXPv z&a)8n@RX#iM2|ZrAR8%cs&XAg9?p390eJic&_e_`<;tn^YZXX$W&b#{5fj;*Y8hC& zl`U6-zM*n%7){cX9pXWRA;;lCH@I2AfDmpl%}^IafQ)glDzV6h(8>v1BW2ouVg}R% zvhmMUy|@&8E0K3tz0&d%xpoWE>jC!FWj~hl1aj|#@XZP2P9IfZKoN`fu3<}kw=L?s z6d&nRr2q8`-?Yk#lT{7C;eKiSkqAzcmlIXdSzTnzUolSYmm ztEQA4q9-EhKs619mCnT7Vt@ZJe5;rJv3sA=wKtJ|2jHwnPDY>Ec~*Cd59j>dd4|I` zN|eS2{D&==)JVoRl&W8>@Ezhx?P?>ICoykgLIR>=2IB+V#5~ZKC$LyJ0F9#$gx#t> z2P}xtR}Fj)pdR4#qkUhR+6_owX8XNz{D)AE=QV&6m_5&C1=g}_mD7(=YsmFSth~pq zkyGqiUE2l8e>ohQco4S$-g=ENR=_k!k4FdN+&<|4U|?NfyvwBXY|z*0QS|Lc-cJ2} z3Y;y(ybo~b>j8WKpvB&YMcbj+^i|c@&8KtwX7+bZ2N6|Q!Vo!@Fz=PL4t}O<`$1e^ z&f~|0k|l{TFo)~mNq2SU@viE)IE(-jK7{2G{)%naIE*96bwxxE!Kdim3R;}``?m`q z1VMTqpmTeFfp>IuUN7a#s9SYZdoPv&2o}rOAyo}16E#AlGomOnwR;4)*f;(NHbURU z@buFY)1ztn!3Y;Y&&M!=2xMbl6@Eo`MMGph84r9mU>?A!=e58$0*?Q8mHd~7c!Vz` zez0oEeA4G%%lE_L9wx~P<$dBBn3wJle>S8gSRcfiLKtP7>mZa5Y(fzZgb#uCAm$Xf zLV?E%3S3WzW_KH)7}TE1>-qUm9R!z*&_*)fmVl!7C};`WW#Eml2Lt2*9C{B8@@ch5 ze`oK@3vD|ks_+G%<0EhmJ3jkIm18?kwlLC0ZjL{C!W+RNX zBtDfV`aF47E{hkS++<1>wTME*_+aaVG_QIee}a)rM7?W%Jv6qW1sPzb4f!x~P6O@ozBqc_iB0C+7%E%03p?^WNosJiYzU*V6Jk znfq*G<@YnsR_4DOtak=Xn1v^a<${kfeX+V9dPl+-i|{Gj9n0%d;H;%r{@`Gy6-WzV zQlkENK;X=Owqn;KhCo$Dx$AsSBkVPY!yYKXAA&6q{()@^S50M5KX`|q!j=8IK5ZRuq1992aU9Tl}NnG3I((xU-91IT%9E=_KkSNc&c1<&Y4 zt3LS|J+l?N@`c34Oy-#a?Kly3KrU*~0xYz6r`onQS<%}FI>L6Qw*!9&@F>8ccQ5cy z0LK7yO@3R=3)WuswEt=QV_Fowg~f}MvuzbYWKqL>Ig>@bZct%9hf+C4((NS*ulSCD zZ{PvgU4b8`462z^?<`2yn{#An+Fe`v7$PQhDchUEYc4%Cd z#IIxDL+ny?f@S=d1%&ShcBNL_-z2L#FPxW!zT@6C6wxZr+iLk=vS424NrjV93x3Ub z{0QGF<{p_3IfZ9j~!Rn6A(tdcXnTQaIT; z9}Dbs&r!x-md;h0nc)R%?ME?jgzt6@g6Aku{FDspFd0xlm4r?rJuJ1xC9 z!hfv*{vbeG8fmAgz%K>d1)!_hmX}rA{+i^=sN06?qS|R0G>qMmXBP+D;+%NNj-kI?q9=QzheS! z-VTz39nTsrU|I5{p1WJmiu&;|tvH*Hj&WPvES8*_0RI<5vIgsxIiu>VlCy(nl=6hB zm6J*)2T#i>jvsEth-t2FVklG>vX;QL?s?)|5trLt_j?lK7F8}TX$megtyqykW{0$J z!p*xf^^p^O7-eG9JJk@>`%aoqX&DfZn z0el(Y8h|~{%x%D*0PF$Kt2Ov~?yus3u%5Wt>RvN_qo5U^M0#^T= z@DjLu8ff%8!zeTPEQxm?Yz*VxK+GtWGOaApE!iakez-3h=qjNVkXRaceM{l*3CtyH zY$g9GI0-3&sSPCgm-7*NiFr2@B^F%L&!fc$E-ltFww{Amcpok-7x#LZg(u91_gma=8 z9r4bS)Eg6V&iqLGX-cfA2IoOL*EPD?F-Rtf8A{gVG~B*i7CXrIBvJvApGF&5QWVJ_2#bS9{gP?`6 zymSLkza2bL=oakHufa#?Wv>lf(95I^{IcK3HzoF)&`}l^;kQ`-51EMfK^ht^fXY|0 zC(Up6O@vS1nd9aZ%nr>6=dug>T@-6%o`ZM~vqtgoIq^I=Jvhn5`%lam-+w~JY!eHY zhj*KielB9FSlRGF6%cy*SxzD(E{@CdjRJp4-Sum7E@ogeJC0>?@OLWFkLA!go=Wt= zUsmf-t1rO0K!2DmeQIF3^{LIvUCcO-kK+TelkA4#T)=Cv-zPkmU*I0hbN$1T7s9(t zu6cgyl(dnVx3g>?Ka-8i`we1oFnEwkFk?YUvjMN->0p(gV8&dSDCFpunR6f&%M=yx zq;m-CySu;>a%cPcd3(mB`RBys!9l=Ke@0AyK#e~=#_|sYWcWarAK#;^zv{nj=$8&Z z^bPPnSK>?x;Pl_W0rxjUo(G^S+4eJ6aT0%X!IhD9w!OD1#YXIV>t-&%WCh(moFC&b z`Dt~gyj7*j<-64?@ql_{$#>>vcrN||*7>*cO4-wb>v&Z=ew_6Q&(E#ROUq5p>yG~S zf{y+-37)!o8b#LWW^CM$y{z(0R`3Q3zQd~i!YcQ(f_GW)+&;VEcB+UE`YVn>Qh3RJ zp7vJ~o4?J&-QeQ#80(jg>N>!#!nrmqb}=_1`ZLtJ2E59|i)`>7auQ_1X3E2hGU7gG zVORJWW`5+yal9g38x<~~8j;i8dQ7T;v4~Ms5Z1v%CxY$_e z$~DHDIbyE05Ny;0C{kNr!x)RskKsF@68gPCRs}DO1#slrN6fcsU1?UbtGlNk&-89* zzFFA+4+fmh;@lNJ`Ojp-4N>jA5AEp4wLS-)c@^Th0NUFdc*51-i2-!I-BYbchwP_( z8FgD4#s79{Z#tWtyGXY9TK=UH&+Jf-aw(-*p%UBG<3;rtPtN4N5L@}Je57pagyU>r zRQvWxeu>)m8C`3E=P)?=2^NQ|sgeK4M?S#|9_I#?fMLwU$*NGE*{~7w{!7lu-A6GqBCR_nxi3NHF;4S+ylE@!1}ri zi1X3IdU80JCBjy3(FgD?Gryq&Bx?Uw@`_!6DdN#C+x;BY3sF`Pw;I|Gxoiwypf>rt zVQPwg&;)lMlQGKYnhW_{=&f#G)~C$>BezUCU*F2%7OjAmReio$E7Htl(cS99Gu;m{ zPd(fj4+fz1VM~m_-1xn`+A8h#N4q-h{|WH#0om6?+J74G`G89Rbj3flNf$ z$7tEXqGZRJC-%Cbu}PhJjhD<Ne+EncVER)cN?BR=#|$o-%1v|fyIr(-`u?KGjQr9;J#dH zm=dS<9WlH<48BFGT7L^~0=!Ni4zC-ik2*e{yU{f~@P_PzOft?KCGuqgfc^ zpKQNOT8^_DKn1`VCuae_0MG=WYr`+pQ)9F|wY{Dk_O(NOHOZQqM~8$*MbRg`imEBT zLp6D{{@I50kYVv*sF=m^+hn~g^Ia0W90jB*fVmJ=K>}^c&gGSS5@}@>@hX%&zP;pl z)iPR@_b|%QxxCi3|9g2ibXnf`tKvex#gJtc5 zDDL>v84w-0^-QA@V?RhQTE?jI&0F!)@?DB|PJjEgXiM0{nl(!Q2RKQFCUB6V`0UgVWv#Pn$ zwxYGVl^`gz#jD9ID=|*Coj-4wN3q5?zqpRRV#nb;IMuMCfDDfn_scK@JQ=ylg z(%OM2F937f$CydSLziIDigdM9yKLGis=WJA4u|ji2)KDY&W`|2c`pRM3h)~MT_^Uh zlFzO0s;5ju-D;!D3%!TgXjz=f`~YSjr!rrv$6%RI_k3FCkHa3QfJWCHe8TN~+#Ni& zz&gg_zhwF`7JCQ&nU>cRhaOVeUNKcREbs{Aa43sV6H3;AJt1Bz&$z4b=(u(W)|{*p%M z1Y?{Yi|UWZQE;pdI$Fou{RH*l%#)MZUS2iLf~x$8?#jQkAgfbkz={I>iNDih8*W z;GAQfEH3D71Ou&!3I-aUXtyi=t#f`#@jvw&p*sq2>i^fq|6Cb09%TKKrA@~Gpu0tp z9}y2y3sZ7aeJN$yV;J-nBIVxAT;+wsa)RXL*Uo;gL;bIs~VKW_%U7H~JfspqGGe*icP zpzD|HthaYv&(hRd9(t&P<4yvf+4CC~*U8r~)LPDQdN~e|4ZSec7-md{-yP$D!&vP@ zYK^7(#pX3E15?fQ%yT3AH>@t*Qoo&w5C6^`&qUH-OK*!ZE7Hbxb+Vw$4Y zS`%3xsok=XE(S!?I~?h;0JZNCo3(_SJT#qW3Y-5w_ns^uFDRUg~H6UwZeQl->v{Ljg&xbUm#jrHY8=v+VxA z_NR0$!n^;q9uNI8T{Y13Z)%y(cg8*gVA<;j323CV_O z<5%KQUCYVFp}dbTwBlrd&X?K)bT)!P%)!q5S_)vI)MgPd56)pAv4Y@_=D+4zMW=R4 zME*?iITMji12}a4e0ho17-l|`lYy=zRMNA z(!LInDPvKo6L=HKmakEy%~PRi`IX2o$tiuCl>|#+M~$Q16-Lgr#@p;hBX|Q;0PV#G zCyuc&5}`<&Igv=4IE`tKwE4D*v?4%C3QVC}@|9lDzk_SFINvje@DyXJJB9ko2>x5fs|Zt9^BhG_#kz=o z^_jry0Sf^RJ+JlkY0HqVes-1oht4^fs>e?82~~G*Vb`k#{XAOFzY@1_ttlJ*yvdkL zYNQKo(_BSIOP!+QF!HESue8G3VABp54{+Lh4e%|1#{hI4{)eI`{R{Q9|7qlr6WQ+- z7Dv0x8<$|NTC#ZbgwrOEm^xzYsq$#+S$Ge`X;!7Y-+%)w z>LVwNfx`!N9~c<)64S26QNz8gthaS5b3vT%s)+Z8=o~81<8OsFb3Fk+GmOgM_+GhBhBKxQ_}$wncbWX2-9i?y7u z=xIe>VY^jmAHNsre*&C#{k8k}!l-tIyw91;Plz+R)3$vR&%BG<+xAV|-(B?ZXqnix z8?;qy znWYX_j{{vkE><%4<;gl+fZBWdTb+pwTFr4h3&KmB7_^J<8Zp)7z-0gnv%&CQhqfNh zd$3Gc8F}FRWvocX89f3*=>sjFOL*Mw9FLSRwTpkX)&;7bx1#=>eJ1T+pF#RLyG~K9 zC)vMF8P#e3`ZV^hL*)L|{%+yzk?+#}br{m;0G#&wwfon#9rmy7`P=)~r^RHyACY`K zH!*kEGu@r<*{l%m&y1+-ltwo`_yan@JfC9b+k|Y0l^X)e&pfh`t)mb z-Xb#ZjqFop_tIJ}RP{BZ(>}8eWjX|K#`DScnNMS%IW>Bpxk!Dl=8nj^O8d+UkiHb) z)YGrsXVyfG$HHRN(@*!APm7{}9&{xaCgmpklFGEFiQ(VK^gEerXyK^bk-m!PeWp$4 ze$Z5*szQ4Sbp9LZ69A{&C-5#g1%5qjJK|ISs>Y#Om)hxY_L+F0JuQyt+Us&>IRg4~ z-Cgkpl+5Dwp)MKdS7p}Lr0Qwfomlhi{I8^ZkX{0C^52bj2LQ`$KhVFBKQf1l^pD#! z7v?~{#4QPc`jqYwLtVq=%o)B|<-Z?!&9n2bNQK`(r2iY0e*@mF0p!{K$A2bMQW zzgK8$;Q}_vyadIsa1|I2F*v~7rFJD+(2}-x0vF{8f0*d|6}b+;HoOV|DCz(yKiS-s}a8&uo@uv_Z8wDRzQ{ju(0}1-rt75 z%K2!$#ABQ7moPXqv=7e&E>_bpd9G}vn-$Wa+@)9_*AnaF8UFHiq)f2(Iwet0dR50& zoIIS3N;dq@(zqAkNlCd>*0o`*I4{$k>_XBwp<6cL;A*cq5O`&4n#h@_u1>f!)zUBh2TQF zeE^?Wx3G0z4brZZ5dPz@`BDqtRu0;Td1_Z5o^4qif@Uv#w%IG=_&yYL5mYO{)zaFfW-h&k9CN@ z1(>mu_lKZuXR3Krf*;v_U_H6)cDL({=KX%<0GvK&Aoo|e`NA|Dp~Yy>snCwf#m<7zY$7$>%JUun zKxn+KK>Rkqa)8M9Da2m{d>G(-J6^sP<8abb_^5^;zD*09*5ERc-***)1A9I#INy)PXo*X zh;e)|;;R6U0$Aw(5wCBdr?*~)Jt|uCqwsaKJ*YEAV-<}aZmpu(=_>mORqqwp(K)Q{ zAg5%;K3b-xMnb2I+w zRp(5fF=aArrAW`H?WAIuHnl>djX!Q}XuM={d;!w^0ivEyR*s+DQjP~5M+@@Ls9A8h zrYB`4rX{%&3*bQ(g8UXDzfAP#%%rqLS6oPtuf3DkQv+xc^~ChxpCbK37@a4(-%@)F zIzt;SoJ!4?>~;7|FSa~bmolCp^5aAsl3e5%WsVN1@ulEhoX%+v9!=+UcqjOIvUF}e z9-VySgI&HiV5Sb9YtBtBaZEHPh0)b`H>ax+Gz9IsXX~Ipk*4cH$T+{>U2(p?%GHA8R2LSYekRd4;co z4kJw-3YGr^>!23-Gr5=-A1k5d$65*5{cI)AzXo{;{)}wnvGq_9(qjRF?vq^)Ek8cp zs8%e9wuUc={vgRUG;GCYFNo~69DLBOU(i%yzhmnnR=OgOjsK&?mygr{hTD3k6FT@EoKFQ~GIaNG;xO+9QefQM- z_TD~POu$UVWmp6EK}UKuEjHKas^WS-tVxGkNY24KsE=X6!p`t9H*& z=Dnnb(97jc+B-zuVC_|{(%_FcAJaF8ZFr3HwI2Km*j#xN@s9zY0fc<@U&Ma{9BSkK zgjhsCf55s6jm^C(hvdfPIKdnk$+#F+_u(uxf(gS+U}DFuvLSTZ2eMA56%e~?)^j?7 z|2dkDbMWru>9CF(&koDmDqXOS=d-Sjd|v!V`M~f2Ycc=E5ZYW!Wc4`D$9OCxcRapb zl~RyS1K6QWyOrT`hrIy&3u0FtTSo_MywiX~tpBb>d=cOlfZ)@8h}QwSv{7H-_(k2Z zTAPYmYnS6jt-+Gk(=8q(@||OR||U%I@|l~L8sxsn$&31vmbeh_2Qkd zlwqvLdJ!PXS#$zA#KzxWYOM4siIt=$IR7euBWNSJ3-N~m>j9#_e}?!0fKvuLqFyV#OtgD;$P|?vh)_VTqe^P ztPY)Uh2rfO6C9_Vrp97B-O(qaui7)Bmriuq)j1i*6fN$-u_%t;LHBVQ1m_a5OuUcK zgYY55ddBP6As|l>t5-z8VNo)xZG;V+53^A|G4C!#d?jElK=9!M#D4(%24F$4{c<0R z54(@U2eGi+!xxq}V`1q+qrfD+i&!hm7#fLk8;19s*zsjrnHrCBd&fi;tD_=|ta2A7 zpMkc)h9r!aIAjUcP8l}ICZXIgr-0?|2}}=}4eGBy9X{c#fBI@P?4Sw*>JSfXe{(c&@zATa#`?dW-Gn zG(EMYp0{?_Qmg!|UMCjjU4&bV6JZyy6sIbt&%CG{r6?LdiWJ>WMq#eimQdVm>_#(j zv(e!)9SN|2lR%Rk)^f2(iIGz@{>tM>vxlL^=Q*AGL4#O-7*E086CfTS=qz3Bk$NJ% zZ2+fFIlR!KOFSCergLWLrDcN7$)NLBPUjy~`Vw^J;N&g*^LP_rvmbN<0=RdY!J1AH zc1%*V-n0ijTW2n0OEz;lmxBhw&a@8kX91f5g3ffGF1?NPGTXn0uqPzw415#LPm7%w zRhHqDhu~*qQGd{xG)f0MuY}#nWN;HMH{>K0BV1o40#xaUHrn)LJ{^(+f`|_T6afT1 zSy$@Pg-9>5_pw{kv)|U^^ZMo0TRMGW74FbgR+hs{L}bwr(9>=dRKeOpc-6-3!gPzE zb}D+t0L3HwwOAJAp{9ZJsSbGt?RPID{sCY+z^03imAukUq(5)te8S|ZcY`jUo%`=^D4-8(5(U5HfeJgo;*;gylCF>m z{`ngGZP|E(z$5T7d*0_EeIei^@Y*g2=4+ZXvf1o*qm92FxQc9KZy^2|pb;S2qp}J0 z0x0Na_v8GQ<4uum7n+p^?SLD8;ckQ5FI9e*mnhKPFCa^>fiz!RuS%79^h(G9K6aWO zQ=Nf{!;PW-988?W@^zRKSj%9WL^m*Ra5M44*?ipmm-0PUFqEFjb?LQRc)M8u+64XU z5Pt_io(Z*^yAfXxcptz*!1l|xe^<+L1Z;b<`@{4@&E;u!gFC%qTv=rmj)g$szD<4& zx2ADj`b~Z-x`u!3=NG2`K#CQ)WP~wtlRP$JT*255;}XVp92YrO9j5|5BO}_4Tr1=2 zWI!IE9l&RuSxTV(7%k7paZugp2$MfYG)jQQ_n(IEB<~khImFuSd)AVRliI!yp);1inClXc){HNoR zo6}GS8bjRnr|QA6eAVZ0dT@id&|`n9ok&mA3KLCU!6qO4f;s7ad;0DTM-Dv{Y-V<& zi{WhuNB7|{b0^Ll(R+w{vt^O51hu?L#zcmZ1rPR@l@_I_@XwZ88mslS!Q|seiq+F=q7s)7B(ftNa#-B$| z$84z(*D74mkylcxtFCtRZ0tg&K@T8P)liuYL3*0gdU}2)sNYD>M(WJey52`T@K~6b z#2Po_GrY#SVe;Aux3bZ6y2bMZI^X8`%|sau}1c2 z1VY6(It~GKl^WmWe5?h3a&0C&f%tQP7XhN558vsQ-bQ-6eUGQ!o)7JRkt$mDbH*ax z*D>5Tko$2w$_H+|LZmw&35|hqT$9Z)6O`f9YfUx{+c+J;=ea-k04C+Jh+hh*0f_qa zyx^5i1Iz%h@YV!gU%`v{r=UlCzGa^WD&E>geGVzI9W!Gny4AFXqObUfw1O^CoFMp7 zG9q&1?}i+stW`$@N19SCoem966l^1FiieWY$ny*n-OwBW>rO?~$m^&?MMN-vfTj#X zJ1UM(HV`^JUQNijfyfKUAnU23il@@)xR;bMSFRxg*hBT4mZQvx`IjH)14;^vAB;cE zL8H=F(8x6SL+n6RrRxfc#^dXWE;||9-9u58Y$W_jg+Uz?H+%OIJMuQOKEN(4K-F0}(>c)aD||9E|EM?GZPuXZE;GvFXVv>SOd{NVxW-sk-9`wssUXmVS&n;}J_ z)u4NQjy{BwI@&@CmvK3C0toam6LqWu?6Ox#la%WTjb?9o2z<)vszIJ5_PeEsKL*$e z5b}br0luIBc>orcZsg;E@m0%lq1Kk~hKI=u&Evl14!#B#UydlGMqNp*4;V*hDLmQu zzC#`xGp^s*RE+Iu&13ruRC)ntB-qeBT*<)DjSJ}*x;wL>n`Zw`-HPK++7Dg}|D*|j zQ7WhYMkD{C>Yr5oovKIBwaYMc2R$SbwZU4zyY_zaP^=dFvvIa3)8*kR`Gw3x@(vlx zCU%E80eTsn{bchc0SQ(Je98G$2R;aS{bj@(0lxzTzs`EmD~$w92Cz`zZkt~vHor>R z;+N=cH8?6_G3j2;vTIs#EMidV{;#jwEj(rZsIliL#jkM!eG@%h3 zOaF{UHd37bRdKvz2RJs~J&5)Saco^P$Ewf+VBfeWt)Nd+a<Oxv1qh)oAmpx-EWL(sKJ+XjE3H zq*lF;S|7Lw%2F$N>Pem|S^*n*^R*w8vF*^@(pxa^6HQvCcd9)=KGa2=%c(6F2Hz2s%yPHzLFXCZzw;8uXmckM&On*hEoobTzj zU-PKEmi^jBD=o13zM-x8{!Fam_=;se`9%Jakp1LFYJIFDKVpfG`9YW7P|n7Xon*K@ za)O#6XUZ9Vtf)rO`1WSJCjmBg&4*!IGt#79&kfX>=1_ed9#lLJE2>{k&@!TgdxQlt z^Z

|KY!e98}AjAK{6cR}y$#@@EmJx*K6t9_tXpUfiTu+OyPPo+C z#~?90AHp~Tp)7C}bP@1I0CRyT-R}O=5%T2kin?Bdf4brICI$hlUbwOG&r3S~nTMP1 z_~$3_&0O4^L2~I|86=a%Yd+Wpzt2={!!fbqPk%?~>x9muKf`_q^{l4i!46_=BEeCS z1;V22BZLmtyTDz8*&lnB^a+}DC5d!`Ur&$J;(wj6qa?f}2Kyu1g$Q-HE;btmL>Z)B zH*#TEcQVw83AC*Y^Qn*z-hKUBtw1+WxeV==Xr+SQ(UCN2L>r7)2Rv@)JobktKo`SE z+riRXy`55>4F&R?&uLt|Eg}(%6lPL)+_i_1Ckhs(Fn`ejAqt|HBr82cqAAI7s7_>c zia}L2TpGX=?=!?gGsQv}pJ3KCO0`3W(Y_3JT9NqRm{tlX2RQv2?TT=Jz#(Vcd(!o( z#D19?5;ZqF4Y1pPyPRaL#5RPd8iLjCBmTIS4`_pc*E}g)u)jpn!9)@fyF=T^BlyM(n5V_X;iD(-=B`_ds)1hKi zDd(=v*nAeBNei6`)EI?l*C7c!XCf@tlNaOa;(0m3*8u)?RN*^bFqRE!2{$CVMK7@8<^g_Xm_De<27Q2csDp)m3^P+ ztDmH3gi}$wfg`Nj%%KB?;7rwch2K)7?c#R_!jAy{3~=d}d!3=}!2SPT&MulMyU22a ziTy5e<{PqvzsDX4dJZV|p?GUhq(YCcYJ$SY8kWGP4B_5@YJiK+aD*oT-11y;NR{g; z=+&wufs9yX*hLm8WNv2K_jUo@2Y26!*M4-Cx;GI%L>6bRSYg(hCMx{4B5im4@&dwp z0sjEF_#G1e4@Wt_ukdpHTK)n*XMWm(N>3{){TCNe5%w_cP^c6ufwu^w)|axu&=m3U zxY$(|bpjrOM1yUj^%mA@OlgtNk851Dw$Ln@P5aR-@ji~m$qN73z|F<~I)r}@xD(*w z{}{sW12(>^@IU3c3Fg;wq}-f|1}vN1PGGO$ODzAguoupbCb4P}DAYKp?YctisK5eN(VpNp%D|HTO32Kcq~ zZ{1j;HQJ_Hqm@)NM^A<;fjji(Y_iO>`Cu5gYEcB6TNZ(^#V(2>k|=`1gkK2z=d#G+ z=P3O50k`An9~qgz|9_=_i}|lp3QZ@Zcf+|(`6Dei{dEX80Pb#){&NWb8xa3emH+AZ z*PT~3v%oI6RFxG>@Uhs7`w!PvM9M+N4zvIQqVtU^!L~5sJ}WrU&LuvvGbb1tzN|7a zcu-8;7nDo|C3`{#r9CK%wrdKnr>v7vL#klP58tRGK27DTYE&X$^ANraa3#Ryi{B#r zDB$3eDqkm+M|bL_o3G>fd!c0S9S(cHNwr5Kg1v_#Pv=Yah9NAKpUINBeUhoYGAn|) zVI)-3qzgs4y`a@kSNLnA6Zp4BxHq60;NoA4@Qr}xa`{%}<7DlM;tMwy&85=>54g~+ zY9il(&Be~}N?qF<>4_Ya!!2hH9S>LK!dy(U=$Z-b>2lj(ZEg(dhS`_+R}0FMeVE zLThy5jgLv-ogJdup`T6dzkvS-yhXgiAGkc4ocQZo#J}}BU2A*H)XD&E{3n%*Y(*Ev zee$DOir#x$#5;R3)2h=9Z6Lsncgu15w2LoUSW{M3b0Mr#&%%~jSy`2>%^$x_DcN_exxvocLF_h(D>HSKEa9+X3Pb@t-c<3u>op zc6*JtmtTPYXFL3lIK=p09HBQXEe((faPbz;b8vrBdHiOJ0?HO`I^a#*-whDQ@$t)tEl!Qc%Sp9LSJ|AO1k(a-G`sUvX6}_{@G8^3(u9YD z&QJ5)r+DybZa>B0+TeWNMksycqMNtj>L5Nx(IGTGQGO)|R|5J1TH-qz_u~QLI32#b zPl2y%V_sNNEs*#YTxr@@nf@!yRN&jl3wCh3k^6UWvC>70{kb)fydq8-T}X zhd$3E{4U^qfZIO*@mWLrH}1FoL$%B9JZ;vT#j}@8FN3~v`LspTX3nf#P(Bm3;}=d_ zR990rTAceXfawhg5^Yc;r?IzV7ht{E5Zes3va#5f>%hl(FgcsT?;`Fb4dc-Z6dfl` zP~+u!K*X4cx#Ogm*5RzgI#$K`kY@orHcQcQ_*-iJv)8#gS<)K%D*Fw$JibmoIwjr` z`*CyT*J!7FZOMYU3u-T30N)L#ey_MxM!2Y?Oqf_0Quk}$M$aAzgqxzQr+@NmMH$7Xt+@nRfw+we|@kB*686vU0{B790OWVt%J9u6rzs=f%4Q?u) zr^(0Hg!XU~o8NelFG4gq+Q^G{@T^82*uyjSa^D_q?B(BZsJte@5_co-wu5Ij^02tq z#r;Tmzec`bBu5(g0P&YhqIeIF?-i)zG;)0hHyU|zb7~Fx7p%*EcE%TM(0=wh`GTeI zXYW9x+}h8AUoiiEHvbFOb3Ze`VCH_tzhL%$=9SOe$S1Ghi;YmuRRGE1Mm}o?&uD~X_8y+Km&cpSC09myQ;I6eq(*Luki>7e{3|3&a(;7B z?pF`|my-z=8`U22VzsOj^9wjymadWJ|2O~*v$NMM- zjG@1?i{E3Fe}w6GBjAw=&Y1Ag@~uR_m*}%$DhSe&@JQa(?xjQJErh!;&4W#n(MAUk zj3n837=TpgGE zOJ2{*-jrv(o}J#5kD+c9pcOD6MolcbX6Tvv6n=rZvLEo~f9%!EbUQ#R zyXoD>%@b8IH%0Wd~vG|8D3F83*`dwjD z%CE%k4C^*5S3fN7sUCedqQgW*^7|2rMj!$yln28ZTf@Ew!^W1d4)^mWjp}fA%tra) zoU7F4F`CL@SOYF-OQHHsS$jAu)YF8UhBWy+<~%PI&vpB^RNqMP=qbvcrh2OJ4mEdU z!M&6QshEneh_HW9ot3QJwm}rtBb!2j)B9k5ZogZWJl`?2qvVjKlv6 zZ=|8HJu8`hHPcrzR?p0TiuXQ&8E*Cl;~y}1Hi_vEGIlE}hYl7<;>O)fzm?HIJJv2_ zWE%|2eQz-KD$`*$Gzk(}u#b!SgxTF84@7ck(h)z~$2=j!Z}4=O&9YWYJ+{<;W}B#5 zk#AxGhu{sIU5EPRxsmG|IsGAKT7oh60>KD1Z{zG%4)gkWg}xAt?~THM*iSK?ryq>z z-^GkWG5sH$f6w8;@aw2kHV32n!<=nHmGr%fT@yQGW8WfwXvTAv{;G8b_00CnVfDng zg@(RPTl!5}$$y_)BnIqaM;Rx zDj8B~s{`bP0Jy1(f4D`1e(TT4ZLsZFKq9DMrk;**Uck`5jp)~N|5|Q+7I}i`Re0#r z2jT2B+o$?()X6iaXKCdq*PQdpE&t((hrtP`!)`3}o|%<}%l z@;_#^`HsV8YrQ@mY56@B75?_-f5Chw{AR1=z1PCBf16J^JVET|72W+^$d}v)Yq|gC zC;Vcw<^8?o-)yycAF1en(@~jtt^wj)>e)m*u)ppN7@4_YVVR*EruU;XJ=daVs8F8% zHepS~2;?PsLD4pgya7*ahRCfa*I%@UM!u(k<;3$fVOOD9iD6d^e;M)|&Ruq)AWkPe zL%*A{?f5^}FCxiJE`FAJZlZc6{9P2~dc?E*xZcn3d5m07?x5UjSTXk6VEm)WNCC^G zA27TyUj#|*w!FeiDlDTDhYkmxm38c97y6Qo9*{Mo$(iLz6(vSLgLmZR5r~4K>S1#O zb{=JwW2M-w((UrQQZk+lhQUh5c%XfS&I&9g>pS+!pr6kpWad zYnLgiUplJaMniwc`8IYC9z5Pr@*{`X^J5+XlZwcO(QGU=alU7MhmO z8+_R=UU`?E)_^T}{jZci51SsUS4yJcZwzAt)!+Bp9|~(EU;;Z}r2DoT{9!|H!KdLr zWB1h1!L1!7w}xLg^w$g>ZivyX`A~xBb*XPF)&EHKm<`5;=ahfzu?AS>_QYUt8s>=w z?`6Tju&^7BZWo41U}~GDcj7c?*sBeG44Sz3XN93JH{kV&hSwMU(8(9N(A7 z?;g_G-Rishb^ShULgBk@y8f^Z5A-H-32y{Dbp9fTUkTU*5;acHEQ@=5=u~J2>RZ9| z<;(_yuGWoI0}_-1O%NUkhwtI~UEI&@tBGecMldi%h<dxz*}b6O5Vl5qHv#3s{= z)WD-WSI^er&FXS`1rI&zxooGW@(P>xMk8IwbGXlnb~n19@9@Tk>SyZRJJ2L6!0sU5 z8TQkr9XFn{oub=mTQW@b@IkMIj>AXWe1|bh+8UoPOV=xL-!bmX(Vw$1X4-H23-m&d zrGG0v7nSFzElQrJSg`Ixf3P!-+F&nmN*G-X)t01qbZLsBuytkIQZj_F!e9#AP+u2pdrdHnW~>hSuLh2Ddy%~U) zA4x6rQvvhMfEdXp?Fv{TBuMf_j&oAfE>d9%=CBRwm?xk{H)EJ zP@O!$Ta1Md1w_fjc<7jKX}!PrC-KRl02WDoa9WGz3ihil*dj$Kl;0?a6%qa>;kyY2 zRp6oYm=MqwV^N3%V_by+#j}iUXL_<2#rjrKeKpl#mOqJRcmq$sdbZx>_vF?L?_W** zaWlBi)*rRn{nlsAu!7z-e*Hi|2OSRtydWzO0nvU6ctOKeK^+CKD(FRd+!)kBuN#A2 zknfdns2ddJ+rxh9bB0k3BRayc=1o};+4&I?Jy?Vc2ew}l1Ii4Ylhqy$UD zmIm9xumxMhXRzytF%A;3{}74?&=UHQvub_|V|P%vj51)t>lhOR@a1#v&s1|IXa&e_+BGk zZ0$}km-0I(8^*6S*jf@9MaOxDlL6+R+~5#%hZ`JVK9C6RCcHblnE#!y>4@-#K4!$i zI9svk6C}B7QXn-y1wP>Pq<@n1+_e03lO|;pXJ%yt(lgS0FlYP?&(6&11_;N~b4JE9 za}LqG;@p01+R>bX0fjI-UsPNiFG?!RK~rQDB$wuvtfB3ew%trC@`wladujVKI!6hC zfnExC%qhg`^g1cSke^xY-(fvr*SlA+x4+l?s*%+0>+R?7+osCw^9t#Y+X3Ez{sC?J zTh+Y>`v$SWeW^Xf3n$BM2HS&<`GW$fLXLX$IpY;}<&1R7c^5@v|I?OGe=b!s7 zod++~3yS933)|LqTGXcyx7@pc?exgaNu71S&m0qYyH2hLekw;0PoXK8ygvuNjLgCq@3`s+`S{)_+D z^vBd-5x?*J>2qc+sa;anAM2nKu{*6`BVZ#AYN^6HT-$Znrd)5{4R!d;TsVn<8DmrT8N46nkREJG!x*y> z@xpW?)`NAi@hG;y+MxmMjn=L|g#6)zs~yag!P6f8ta?h}zYDm|FR~0`n;z2aH-9`G9ox)+}hC8W~u^;E?P}h6onKX^{^f zhRU|j5E@{&5}2dR#sUz+@~C=R;k_C-SmxR^A3XIJWM5*J;Ak`vU7c5tU}pVQp}ee;KG+)Aq)Zs z#T3|Qo9%J}C~q+xwsovdB~dfqz%+&Bi(zzE&meH-m6YBOtLARSnfk2CR}*lqaO%y3 zg)yxTuoB?P6YoU$W5BlnajbCkxzFCa#`#KJ77kf21MN0lC$GP*-;BOc-hWBeXtnSt`$gV-Sj2Z{EqjWIexP zbH8LmzGBb9V?<6aO+|A_x_If8w0k;CtM4dr{J^U$I6I!ay@ zdOCBBA^KRTvkNW7p;%3a9N7!1JVsxb*k7zexE`<);PRQU`*a8HkCz8ICB5gM&k~J9 zJsBw!1V_NE)_P(6=`rNL7|C095DTU`va!Vu3JnTb#pQ%|aJdCV`l7<`Akuc_4_y4x z7X6>`Q++!MS9|ePI6)rF!Q&RBS*%!3dk*1u08IdwZ)sgjO92!C#4-4vieHOAYR$J! z6xu##ANG`VY~DX?mP=_QxjTe>Zht-idER#4(1`XS6Reet8k!4N9sPxcU$Dh6 zPwd~c-G8X=LDM3*=U(9b0LbWc8> zaxnOhlR9F4#mRRfEo}|LHv={S+?@2ZDSgUg6Rh-p?t_9IQSe!S}y-3ErAaiKqw$s}QH9Nwa-x%7~RJ~2pYYM-r zixT*qgYZ1Sg#Z`7p28Dh7}0uTd&=mG#w`jP zj}bx2-dOY<=SXF14X-PFb|EddKRk%=PXO)W1U@f&J(?HyzZ#zj)8;J^woOu|E`;H| zbcxP`L_1fYa}JZX_5>nB97LwRq41a_(hvoSV^SW~mf-%97I-|g)6lNM{VlI5zS?)s zY51#u%{&ldy0##TLN-)*;H?19zG z+e422bnEgE=6;1`w~{h(9S*<1&~{o+E?9>qZKJt{Xd|=WXF|&E7s7&amxP{l{Vpf} zNW(3M-ynQFU<1I#L(D_&#{Hv?y`<5fpD6#o#6wzgL}@gVX|mz2-GO?xQOrKh%B4kA zx7&GRypNRvBLHBLQoaNoWG>`>>)%v(9YmTgUdfk01|CoeaPca*H=s?%{XA!!H29TM zA8O;=?*x-;*Df(1hIsM@XYs92VuUp2xb0Am0y{lv?&aligJyC$vq zOGwZNr!=<{h(@J>K2H(&eF~tb<D8-2vya9RDjiQ144751|999kv>n%A9Q?t%|DcG)A z+*FIdtMFV39J&g;aXgFge!vlcJH9#dGU$r{ehU!Cql1-v)XZvil@D8-20Qj6TGKI6 zKLj7P%7pQl3$dKVRix&%DmN`xIee)yi2@is^?1xks6)v67WKSI?b!1KBc?*^CYI4P)DyHuV0dgm zEh=UVO1dDN37c5ZTjCdB{~$=Piw2)dUCcA|E?(I)3Z0SvQYdr5m2O9RHw*?~L>r`o z&{=K~4(rfMj;GV7wnJ{YiRN<}3xu;^ClC99_YiFS#WsC@yUjVdo4PTY;~qkz8x-26S=-O%2|{iN5{NGhB-e+j-5{dKF1*2#P>Q#o5lwY|t$ zX9%j4!#NQQAaP+w4MHzse78XuKSD?x4WB6ds_GN?)gXK+pdR4z(JKhQ5BM4&j-3%z z4m-0>+>W(54Ic^o$JEpqRiH#{KygCGW6en({6Syu<8ISbIo#trZ?+LxlUPg{)FipL8>ywK1)(tOpOV^b+J( zv^=|XY>Fgd*%HmnV2sfH80~gLM>qo8-SjNcFFy(DfD>%8nIoS~pE~rpGBM7Zj4#>z zc&i~JuwPFaOu5kg5M~;p)K;=bsBb&Xlg*z05!!t_4L(Bc?X=i^9)(je*)&Gv^AFHM zhD8gxM$d(gZ2DEixDvj57|VoLBoCCb;iQ;aDSEmNsX;48&(pb8;J*wWP3GbS@n3tR zLjKoD?~4C=_bbPyN>wBSfy5p{tpsD_*%GH676oLXm=O;N5aUf z{T3Eh@WEvN+4|@5gY&IqD@p&FMCAt%0l^3GBO*SSj(BVcuZR!k>#+BN>k|J(`V#+Q zz0M!;dpye^H~{~qxsbTwmW0m**NL{|TLT3x1lezQ z^ieP27qbn9F_I<=jkcA-8BQL$06%t5g|vGywkhE=k@z0%7t*UH_X@R~P{mrQ@pHsVJ@tk>b`XjCRfSvDGGv;E&&9lEFUA`xk-x2mb ziEHn{2?3bHL%aQscd0k>KP5p((cQ|E|0T#S3b6y)7qQ8G7F<8c%G#TxVdEG77SevS z3(H4;1MT(Z8vLcALnHF%(&1BtzXSXTaO-cu)iJFzU;seU;pa8-+Mk@b{q6cI+F#H? zbmL;uTMv(AayoO?F*)^H$+iC&MLwFBnnmt|{8GyOMj!+MR7kc;s;cIw;>;KnFEaG5GJc&rvu>T z_f~`(0iOZHv9q7b=S*k(?0njM8U8Esn^?sG_wHi%n{P0(3kHqlrCgrp%X2-Emzi%c zF%8~D-{x7ndFI<(-_4)&YA>7RHjJiC^-nA_I*#$5NyjAj>5TDcfsedoWW8#@3;COd z{+3Yy|H}Rx9b>%+N<7anGW?lCp~LNo>?Ec?5TISn+^8PwA5QXxNe&i|&Ot4Xk>n2R ziMJvtMhnpHRlwk$#~*}brlC(_=jbEhzbQ#Cu+l(OQM2CH@#UEq9FE1L1tX7CEQQ73 z&$WQwD6CX~0egV!+QS+iY=xQU+QXpN3;PJ<5yI~v^bQuxie!rd+(luPEfd3dY(}Se=-Uu4(dX!*D+e;2d3P)8LN^0 z*Atw2pXWnXKZVU;PcF|%ieduV6H;;j}(I&@jy66Ie%V zeB=weM^!XRHaNowO`H0&%HL84Z-L;=2;U2M0N}jHpGNpCfLq>&-&F0oo1qG)9=9$^ zu>k=8Zu4swsPcqDR=xK#a?B?6-rQrhD6~hZ_Pkw*Y3IZ-1J2AI;~NV%<4PJWjusK# zC&Dyajh`#Ai9;({51C}c5rucf^@;KQ2!tmB&IY)63;Sp@alg95h9%!bu;Hk~CrB&qq#*W>UdH|F z0O$C{{!wdsjTXBWGiv9_WSzus!fw&|Np1vK+E;Fk&p50DIy$?p|8Q2S)C$T?=0dGrh(Z!?X{1Cq{B2_S)Bvlsh1COe zjp&WFs$+^iyMT|o?*7M2L;D-xd4MP{p+EaF!tVl3sz*EJjBe-DN#$;ddR&_$uY=bm z>gQ~PF9oat2zm%T*P9XkBj9fUal|}|e`h*&U*y9Ur`0E{pT(t#hO5NcQrZU75DyC% zfEPmSa2rY1?Eb6GWvfhkwK;c{S*-E?>F{EjVQ0?GC{{zw-w9vtu-zziDTa0s;FkB7cy<^t?>U91+rG4Bx7Ku*TTs!d!r78>o(X&~ zfTy$uL<|d5N*D^f(Mf;m4T<#Y5xx;{7r>?0!wA0sco!g!otol{j%MrjP5z0~wqHRn zwT0J!u1XG3;Emo9k%8qyQ7 z&M^tQdTp?HRU&xear9M?5Ot?xaqjT{hQvH%F2YLzs{n33Hz2$ja4$d{A2{n(C*zBq zzapQ_TW<@8L)2_+ZD1joziPGVU1eskHV3XU+iAS&Z10rp=>w;=Q(4?1<{3Gfb{XW& zvvlOI3MQxZGZpvk9J{S2)-ayRc@)oN1!r7+nBatCCTWH#58cMDu@!Q|;m`BQr=A=g-h} zn2;EQwGmvpQGyW)`ciqA3h(%D6ZLl-!pi_1ZcgC+&hIeS1YCHl!n^+`s@@jgdwmOR zQ68VDdB)Ps>*RBZ=D^xTu!DCWZsqbkx81& z--SOiX_NUam3Qx$^0L$T2OfvraPJ$yc$kOt|fGc0c}azno2#e~gyVELg#km$T3cRGM~xtmW(o{(y?j*%BL(eq=d|$oTJ3|BN@<|4bRh zW8?=|A#XTm%d;iV$a$vxH%b1BbC!%#{R?D!9--Dz%+$Qthw{=k%h@(Wtw>g|oZYR` zQE%_W4Wd7113x3AnH&0!E5>bSNiJ;}IN@l6+3g(pw;=E)93oK_FsQehI+gfQQ zjXq9_|3YkE#30+e?RR($qqvcvZ-*KCEwEMg2Y9v~X@vBc5!X}c2<#n<TrfHtmj*WR;s66 z^&IO0f1Q6IFB2iXE$nKR>2-m{@~$rF9%9W3%^r3)iFWM_^E+fdSzHvhLRL_IpA03Q zNM>stvQ@BNWh+U1aXm)Y&;c4J zzgg7lOfp)1(P;{Vg@5D5Jo577l+Xq7wxLNA?k4_w$iUl#ZASmyB%BgjU}ZX=MCBGS zG+yG}6(y!y{Z^1ORMZ`UeripzrdqHdU@9}HE@^S7 z5^wkyR|oaU{tK`~JBbX(yGbIqtH>~Y_+b3I+1vhMFS|&eYz^15tEa&5sd#g-8%ndS zkbWJ3!vp^Yg7=d{nKP@W_?zESp;TRefLPBE|KA9xos^Sd<>LMic|L}Ux)dqn{}6$mbgZ6Q}YLiFv#dW875i)Y)k3Sb>r^N?){GzO9kQg9g}mK1P^>^@wM7#zlyugM zrK7t$qaVd8(F}~PJ3;jsB5fDIPgVhj2bhtpBth85ypz#)D7~4GLD4Ho>V3kxQ(Asz zkqIrDVKCKjH_M*u=@osDM&5$;Sl{3ryaXlv#dK0|aCA}XG*%YP!$xRindb-MdxjL{ zmtIfctGeI{x}fkJw%C|lo?h9ZLwe)CRk7Ilw_uh*E*z?rdOv~>3-_Z;K|iV7W7tp$vO-RREMpIj$x(c z4+VzNXs}Nw-f)?e7EfRu_PdP7XSeRSpy8ACBym+%MF(B5wlqrw@ck2{Pg9a%lX z8VM{$g@%r$(P3F*#EYxM-zB8Oczwbknlv#qW-Rsls|Q2|>eN4ooCOC8cxAGF)?jqc z?bBFwqtG9tLDQVvd!}`m6!A6`P-1Z z%$Da_52H*snXB0m+Vv=X8H#5h)Mku99%8-^29SIeN z3X%_5ZD{Ok%VWUS2x*^MNy7x%y;eWd5}wE+ovjP6p+#5I-rgCIrU;uJ14bJN{!7xM z1-`TNsa>OVTCy+2>d#BwCbTz)_cgbzz8d~obg2AoC;mrd!~O{!1au5oaLm~W#VVe53_&-_w553K|iS-uozbXH%XMw)M;diI^Z)wUFC?(UZ zZ|H@+&Y+j}pZbYe`h#Vqba{tNTu=4Ami{|f+;$r8XARO0Vw_mnLO$ru5bmykNc%R& zxad@R6)HD$ooNNVnC?VYXrG`ra+Y3gjWP4>j`S%?CkRK0y=i97(!4r)iGC%?s>`e6 zLrut&*BMj%bNR@uxut{Y*)XG=F*=NNS2Nm`yv)F-n@z~~lp?dx7q)c2fi}kcE0#Xd zU_vJvrXD;Nb{dkrsc5o`@JO11_oMMm3cgJuDPcX96+K1~L!XdnD{|m;Stn}sJl^Xx-)GifDpXThc}53Jikb)L z(o+6NOpmsaCs1Bki5V}?8S-2v&uI@s%w>~Vc!Ul;N)5@Fzo*(KkhRdOhW0Xg>m3R( zf(!9<7PQ(zh&7#O29sF2KV8UMh>6utP6PFTsPE4gX30H+2lAOfS;$2nz6?Z1)`TY={ z0GI}F=_~9nUxNGP0C9ADMzz20{8c_|aoXRKzHp0Nw`6{e#6V0pSBVAZ0tbXccS*sL zqu7kn8n9ESziyVi0y_!1F@P27g|<{78$j=YaaKj25`s775$~q(KM1@kob>5k@aqCt z0&wxyx4^bOpb#LADo6fxsU!d7e5x%xTg~Tu&$jG86#jBu0{GX{&-pQ-r1G$m^~{ww z!{j+rp3gjnS@I_H3AAAYRPP z3O>i=B429+O~OXZjzELk7D}Gk#WeCTBbNsPfknm8?Pm6atk+s}Hc;xkp6S;y>sm(Z zLih8C!Joo=5xiG$9pvv=ndb@dCPvKbn%@gP!1KIj2G7*r=9mS1!jde#gvG<_xP6#a zmF4vsu4jG8x^9Lvfj7fQaya)e)K0}v@gd4*vSqvujv9NhB?bnnc;N|xNp~hGhFn4~ zp>>SCM+mFIIiqm?RG^2AUa$$KL){%pD`q8HiXv}W!1ljFEYSeuqkMX)a(xlyRpk`U zM+n<@Lv{_|mg@@$zYRD95XaU5s-Ju8ZFO}%ZNBW?@XK6LSlj(j zeulYxT<%kse)8ss`fKSrYL*$b}_sIk3x!NA3 zmh8WV9ijLGVX(UI2u(UFyaeg+>wFJ7sK684_?%S3%oxf4H+-#?Z88dJ;CGOq5M7)9 zCZm&l*kN{(Fa)@rp#$>8TlzNTpNq0-9y{y3)H1x6}9(XU#gm@!Nnsmf0r9-qMc)CLj z>lH>vyl3%lv?t6l_;@d~t9vgkqVy708W^>V>fD@EFGSS5ImsT_ic!78+l~{pe?lh> zb1_y(_7USgS;H>G^f3Tu6J8!+e-Qc`7W9R$fc$jS@|jsy02U$wrU{!H)QISxIjtoK zJ5ZHJ@m5tH51{;eBYZaCDu7cS#G6x zME`+@e&pZaVcU9^evHD{;#t~zbSOmPJS5ViPVIl)151?BD522htber-W75c!s&D+?L#ln z&$T9zK4!UZGl>q1oy#&r+qsEgLu)VPadt$9B;q%OwiV)s+)aklz%%z)yLG9+RPDHa2_~kUX3<=$@z0? z%OIIB7Q4|4q#SxZ|A8N4q@H(`=kCYA1Px}@pIFtOS^g8W|C2Q53EJUFx)xqJN}i-` zpP)rg(urEfK9p?dRS(06TP7z9`_Ip5ART@WS(khZuCZZnN5|k^*jtJc+nQu%Elx8+ zb9JL|YEcc%4;9AzrYA0>#znb)ho(yOVi$0Xev|b)1MTWavyWZMVtS6XsXcgG1 zKg%&X=xeD}Vz$MUY@(!!rcE8K=sEfiicaG3cNbA@I__ry-0|7HPZ-)F+`HqYEzc@? zHkX&6)A$8*mVyA+6K!@D?Fl{8OtIKLou{xwqBniW2Q`ei*7LlX1=D-4g2dWLNI$#3amR&5W%;s`v%=X> zq;38sBNS`C!}F_2(sLxPTC=X0o{R#_gzh9}z9)dx(>%*TJJ$%aRGQX_!mRjH9=6-b zaMdJ5mwm`Hl9Px2Ai{tGfb$yPR_@Vy<39A5qQ7g;!udp7c#azXiE=5wa7pdrnwbm# zKibX&zN+GS{CDQgUEcn(ylf=!5(tDX3Hz!9Py`o10oN))2ts8mTea01sagdU+$dTX z>VoLc7OGZJD@H}drHa-Ct0HbxtpasvZR_&?&Rs|hP(pvd|IO#zJNM?kJ2Pj_%$zxM z7J@Y{oO1fChU)3_=gwx50d=dz5jSZyp`Uu^BJZ5Ji5c%^=W2<~nu-w;2L3gUpR{DL zIww+W7HGcc9)Wuru15Vyet9pZEFs@|2kD1_$AGXMwV&TtxNoiJxX<#psgfbHTCWk+w{oGJo0$GiEMCy;~bb-0V~sXT{lwM0?O^3i4aew?lcx z%cQ>oPFc5SJhCoWpKrVfd;{pCZ$Ez=y1MrJn(6i;!F{jCr!mQa#nU z61>28_pY$>@{h^bv+UfRc8`BLG2+0>_Mdo3G~)?bHc=Mru)Fi0t6?4bqXVy<*(G1K zW3SkG#2v!-ktmvF4X|>q5gp8IKEh$EgPV;qmE^r@JFnQtSc;I;%jiqC+~pMQcC0_y z?yrP(qd?cude3$W>^wl?%xI~&LlmtP?lr>smt+0K&b>mETq>+wHxXIxJ^H6pbR*G< z+{;9q7p`#3_Ab6+tunKH#;p1w@mdNK{aD((Z+EytE5+!6dzbe4l!KDPn~W(Dke%(+BFyUSQ?A>r=6kZC97<`%JOA&Gisk;bnh z-VJ{*<3%wTGGRq>Vay9$?W?B<8GW4VbxcnL(TZ!8pKOEQ?^gY;Sg88sf{ej^Z$ z2TwoY8c%TFdj0M7zuAjsEu4wX%R(<6QnMka-j6hHbcbU9&n{=rQs&E$gVCcCFUG);gT~{Xg7?_kY-rV>E_*-EYL2yRAzi zp0nz)-ub4eclnL)@!Eg(vG0BRSN^j}yO7-8>Y&G01C4bQRdN!>L?5U#cBt#^4^+n; zD*k~occ|PCRC0&fWbRUDi?I)iybWgi2h7BS<}u!R_TN5%sgPa51Q64mAhNr zAd%6*ixh})Y%gL}Ag-#_H?Vx>GDO z;fjiwePqWnRlrK2GqVm1a4gTA7{5Z~y0y_^76$6(sm?ev!&;FoA}d~0h%C-krE09K zv~z}ZJS~2zd74tDj7?3RmP1rOa_hUe58qd3N0Mdu9TKj4?6E`DA^%ci@3HchiX1y; z$uWr&i>0dMwTOko)%3W;aY;FOwaWa)D!4$fK^Aexxkp;#a*rGr^K)mPjzKDJWkjx- zB*>7JS8g8~#}XqyqVI~wEm7%9RC(4dMxNnVKO1`$JBdV6>+BX}4}og+X4Sc9G^6WW zfNFC}9_`uFJ92|0dP^Q701o1E=;PY3M;j7qYx|7+Tx&zG(xXN(o4>DVofu;9c99pG zq8fR-#|2JdtEW_6Eo9%wdGQ|dR6M=G0^KAeO#9^m!=NjKJhJRgk>zFv9!hPDvMUF7 z`n^!&QoVA|&AD9UpVWcfk<8$t(+khkHeo)guyU@#V^%8t%}!=yK`MiI+KT7KZeS5ACgT#>!dlCt3ovvA!-nvJ{OJC+z#liZ z(=S847I9y`5d+dd$oG8qM$8z>y}NCx*Hz!|^Zj{qcfiN*)eCj+(qi*~IZHxE6G zU@_7#Rc{1^Ljnq!ANlIi7jV&aJn9{F?7K?*O%4A{b^M!3r}LT0A26#QH2WEM;XAvl z6uHOdh}~-Vr>f&lm3|ASe<)Q~|4w$-iw7;^oecH$gWURvPI8J*XveGLsVL-ZyW_#5i`}MoNMZOpF{i}dgE%JRP;}~oAm2Y1CRPxMt zd9EaeA5JCX_E-iKyg})??KpDF@mOt-Zng*ceZ{w2?+I$z4`2@r3cC{$J4wg$VqgXB-kTZJYt<;^pp|B+-T>63F^}aC!abHX z>>X;mm`P%+U*5BP{A{KyVgGoC^k+cq!RG!km-G$5-GDyc@8tLE>o&yJxzkn8IHyAD7GICV9X4xa_t`#vhl~COOz>5?XUw4WCdZ zyL5g^R2O2`(n&^S0}A*oVtA zBwZrYy<3lXcdJK%%U768XhuEQzbufwJT1fs`7?&_9BvYf?+JQU4yY>QOUocEim8{yO}b1-BCC7(esNxrH)>bYTA>-zWo)0Ydrd-K5_Dz5w)bYcIe3-FNm) z2kGN{zO7X~e2;|ztJQk>+k!|ClRxLn!tby=$#~6jDze6TOXl^-PdqNunchr>cflN2!~ z*ckCw3xh%VAc*D`?e!}y`Vq?&>W#4sNPzbqI38dnP=ov2zIuwEMoS!}&V<+BDpP#n zEvdtgxoOlV;%0YjEKM_C#no_mi@;Z@r8=?ii7efj&%WdNetR`O+&rE>Li%~&Lm+Ig z=tlG#fC@k#;~!t@T^9y=q23Lrbg-UXzwdrq)y?OD=gpnBXcj*6{26Y6@qI1T77d30 ze32*eJ{qlZ8mwmyp6a7JRlD5!?^NkdWqqowM%=pG%G4HrPl)Ph_jd1tJj>2E%Pkg3 zr8QQqwO%h-G}Zoue|3jqkuLr~&Ov9C=RMi_iHdfjUX^NE){nr;5+5&TQQvT#zqAo^ zI_|Fp!v1y>&+Y{J9;m`_EhT8|Hz)e%mu0MWnD^K4`6zV!f>J&VG1xd!0;D+zF3M zbCYcUxO6uW)Yy0)C;x?RVvRI!lkL|?_ZE!4<4g?jq4{czOvVV6Ky!4#|3D5DNF-P* zX~s%yH)f~MeW=7lBIel!b}Z~0xBDc0lD|fi`q98iP&kpLX_I2f?xdxs{R@S9TFOP% zaQ8RtBSrYh1jgd_tU4%i$KpuoALKK>FIL*^r_LBY9secT`59tTZD@X* zwOs4$lzQg{-npj5lMY^br?1}A$Gge4`1jrXLyuX#KTuUEa%T!NGzFHVy3k?KbD$leXJ7SneLem;l-a-R!8edM>uO0CVPrbF(?ow3R|7Hitz}`$&E~0YzJhM1Dn{1EX-Cu=7L#I1p94`7; z9ej5yzsY(}n!o!^8@}7ByERKlwe;n?<};2J<5?+5!x`QLqie5eiPxm)+QehZl@9kR_Uj2>dwDQ|H7PI#9=-g%aHKGZv3 z>Hp&*|8l*5Im#b|FEBq)(H-hhI(gX+MPH_Se{FxRYQIqJFz3M2kzI0_9OMpGgT|?B zI^uTE9~w=#dAa!ySj}%c6|WM`U@SsAF!Av-_Zf{%JH!WL_F(=DoWf!wE8{6De zQ}vW8GaCi^^XagNjo0|~cAsidZ_;Cc=|EWT8%f^>Yy$Lgpn8|yaH;p+e*4SJ1>qEi zdE3m{?0U=(d?R`FUni;m#J%c&K9h*rp^_h{k{#+WV})IwOnfb({}ib&t+Fqgu@LRZ zT4tznFC&nQoxIF0S!%~{PLmMHgsrzUHBa`9mVVVh34NLH%QXw;W9)8$5NcFQl%20F zdWqv=9cI-3GN6;q&HGVDuQQDv`W^@lIr;$d2HamAeZqwCp2^M{=NRKjA|e$c2nZXd zDafahZ-&p-lD-GH9|*_4RrlM*wyCMT?6CHW_xXVwIdt)wSJ!@+sLKq)oXZ(&99hO9 zBg0^sUFFx)czRErjC|5rpeGRKKZNuIAl%;yudVX;J&_iU|4v|w)Ms3j%qFw!y^|&o zrAFt;x$R%Vf5?j>C#TeJ9K8psJ(Hs4pC&O-H%>pfRfr>e3^!iumv1$BhkWu_Y%fo* zvW@A0E}yn%)%KWA^3DPH;7P!_0wMCF|hpf_8tVSBE50K$u_E7b)Xt z?l%YPl>MxO_nqJHg_vut5Q~?K*b3Y#L~juW!9@*>5c`<^v}kM(%CU+(YJF4~8%eje z>+JENV?4$4eeQ=g?+xr-jtLD7XHtrJKbY&z%foj=fPltd1@(WnnI3<0ex7k3@Bk3j z{|}^}0$TfrYef(H$am&X8mQ0qAfL0yC*)IxKNd4iDX@*nK$uU>uUz98p6}mIx%a6^ zF8$e%@2)2=Zl?Mh{QSrB?XX^lf0Qut@%mB-g!$`t$MU>2-_;6#$WdBLEV~9GOZ^4- zy&%60+&U;WI2@Vi!1fyx z?wMMtevfy4^e0yP<*6sH@VIS>Yy6P=IY3yR4Lo}a2-`EfdgbwEmAjn9M*GF#H;iBV z-`&7Bvcbdcq;~^fw)k#hWXd>X%ii$}@D4L?e#3$VHPf}2i2dPdGp8;*%+q_v9mS95 z!>5d#J5M{CUm-3ry;bs0mNQoW*11y|TafY)pGrE1Mux{!qQK19MW&qEImy;qAZH%81{xWGXCGNa~n zQp`Nw(_Z~`hxr40TWAr zSg&2IiOB!MIVZcu$KzMvC&XjNKS4XdU?9ZfNu*~0^MSVD@#;3T0j>%t5NO zTob`jh1D6cAaCj;d%IuX&D5jLdtw-$lU6V18xdg7I7509(Dc|+@2#-^v})_BZZp2u z5xQ2>*dvj}(e|=pMZtsi@&d7<;AwMtfo|;eQR8w{Ty+qZZf-%kTeMQ;M2QK>o(>^5 zoPG#WI`w$*cUN^^3ZL}655 zb6c25B%Ek5E}2BM27Mc=T<-$tjihB-;T_y=bL1KF9S^znclmg%eWjV7xR~_Sz)e6% zH(!vByoy{2&_~g0K0f#7=VHsg>1J;{Pg$^F<{AF@gENNVbEbK->0Zrq`q@Em?OMHH z?tJ*^!g}d&Q_KsP7e=Xb-(rTybAn<`&eI- z&VP-45Fo_!T+){UzXJ3TuE+O>=dHoM$X*C*P0!)T*Ln`1MYQo3Xl+=y_y0~md(`}f zAML?;lek_!B2f8x!&efxo{R1e_gTgoY;@A`61NCRd;xnx6+{kir-+T z<*8`TXnSjPvX9f39nzXnYKV+hMQ4uDNOP3yRJ$=`K~_w-7Q%hKd8rrlPe^1!w`kN4 zWmS(64smNdI{rqg3xw=%j!=E{vi@^{!pjhl@36R8Ch!1yJ&Ua1_??gc-QYDFJlbL@ zBe4~`C?GiY;{RIVbq^UH7KsP_*QSki}`BuosE+%~*@N*y>kMz5jaNl|z z^&j$Cz!0Qr55p&}CiRKN#a2>|GK?`wXWSU%yM=FtbhVT8KY)J&E%W`3`!?t6MTsNd zDdd|ZUxL?J3k_qYmn#|H?c=lV^}X?VGS7nJ!1&bkYN<8@_%3e7jxfXHqHxlL&ghG+N{TOH(|{qx@V9Ky4f`L_w5Uj803 z@74Ir-s9t~k#B`__sZK`<96=vYVrLaPq2;O@jTSe4*BC&bTV@8{P~OKEgUmv-lByq zotxe3Tq%svWq7%wU+AML$oDJq$a+t3oLn!Bc5k5T0)+KCA`&r1^St#sp;f+P(D;~- zc=P*to@E?=R`Bh*d;NM|%(p|j{4MF{fVY8gyj$}oexiVv0DZJx4+WoVzV!0tkFCnH zXI|OE()*IBiq*)aZ?eQ{Yn$m^3cd5i;wA=+d!1LzUs_eGtd(3kud;q=qMFb@Ks!1Vs_U#mTm1Dm!kuDwf-Wu*cCgLY{}A4KbS| zcG8RLiaLZA2K^XPW4vX?Q#exdO z=0ti<=QB-Sx7|d7NW}2j=hT@4(uJ1D$tx-^8jz_^Otzj!(b3IImnBj$kr)u|m|H`W z>)F1GsF3ei$l5*;BFp+D2QA?)p#s)wTW&VxX__;5#YFvYrKvibAYkCHrL{WuxCm^6a8@Li7zO%i&+Cw2f?uv5Ssh%10fV&6HHWX_ZyC%hqoe=~Dbtw96d+qv1UV zMFy=E#gRn!Xn9UqVWemfM5BcTUkA(F5%LWa{@Of8Jt^d7Y5qy5tpuI%h>26@3xdB z5pws0J>sPQ{OXj&Q)bTcUf!GY-djH9nKkVth!}W)pU#-MVBx-(RNo(&r!!$Ln9-IB zdZqRTDD4X1ITM|lM)^O!D}X!v%XV0uokvI?xUJp;w+_m~vySD|J*VokRHD6D!Lo15PVOuf$+ zcFNf3=%AyPvOf_;7dkp3LF@volyla(?e298K0@a$l0=EJ+AewVbVw>HJl{|rxtLn@W%ev4> zFcw(RSj^0ygHR&^zv4)9hwc8&t^ifc;UZoVD=sR@+$JK8G>|t#->B8CGEB{LnN}#8 zw*K%FmxPH$!GoU#*E;W-LnN8t!3{c*fvJh2%oq>;JIDKOx%^D}bNnCCyT(kC=%sT2 z%shLVC#X^8*<8!j5$2!ugVz-{wraRV*(PCuQo_`Bf;~_cInn5Gkqu@%9V<>1e%WB{RI`-qT(;8%9m8~MHPJsac4w|`H>$XBooP3RCKdd{-aLw<(ZBui$;F! z^l2uu>m5uA4okG8(tM<4%~X0!bgZUPXAC;Bx|0_6(%-z+>G-dZwy;z8N$CO!`-{y> zNG1L7#^L{`qfZ>i$~odhnsKvfXUv|Wy|)R23*iR2ECR6nx1 zxrwmx(bnqj%#CLd7-=Shqv>V8y&FGlZts7RF8qkNB0xA#y-C{s7}*b?j~;LM?fu1@ z{?&Wf;xr@Grg>^Adl-k&<_DM*y!{S8Qrg}G)N(TOUOZ;Xe9f=#7B9(N+EM)F{^cv* zZT!q#9+h;rDs9K=YL7VIs#{{UhwD7TqmuDBIJ*=)ozGBzcH9TAXSRv9_C-#Pj3s5R8KaaD=Ke+ancB zp|w||2y)|e!{bFJV#kk9@_#@+M$=ZGewv_*kWc-P z^yk1=KuAB&tV|jOf6X__0eyt)_5JOKjBnezWA@zn{)Pwg)$xc|e>^wNg#1@w?53?M z;UXis`DQdxo?C+2CL(Som%J2AT0ch3q3UG4oIHxo(;gvd2^&U3ji%Rw@sDzba)A3u zzYM$%g!SqE3AP8oI6xoyft}Xc?fdQz)7n-a&9_ZB8^1PKtp5Rv6in7{m)>QNcP_(+ zG!=YH85hUOqb@z$N=JxDgYJAL4^7J~6=d!o9yzSci4G}0tYoAdpJJ|#$~vt(R37c5 zV=*b*Uvw8q{LnbEeNQJJ`F<}jp;u17H6bVdM;sDQWchO;=Mg|d5^W@@*G<3Gk^2aw z=%MQ|X=L9F+CRX9#>HPqe+GOFg!t-uLez+UitG~5M<^c(uinEJr?d8rFEoi8!i7s9 zIvK-OA&MfkWys0e*EnFO<7HNl%W)|DajZ<*|BfbA`@cuo+8a&J;?fcQv?!h#Npds> z0)hc$f6&K>5z*@?S^#QQ*&jJ`VfJZ|Cga{BbV$>7L8Nz+PkT;p%(y9U;^j z_7Y>yo^ob`=2zL@-23M(M>}6;G;sY9uc>uMi1kOTu+|-+$>V-$ZNw7MdPp$D^|QOY zC~IDlsTbv67!l1EW!IOa^`eYFC)si_u8faX&VYe+E|xyXj{sKOP*hKuigZkh7h*Fp zUhNK(I8bGo6Jyd*$$Mk%9*))Gvw0H6VmVgHGco7sSa*g?>#@!=j`+Psuo#eX$j;b5p@4nT;SWMuIWw*fG!-eY&Y=mZO zQ=(6$8YXegH%9ibKP4umHq{%41DEweZIos^^^ib_-p}mf)?n8?9M5Bp2@lx`HBL5^ zi2cXw{?YV~Pp|WKHOmz*A$=XN83^gM-)?-<0p|hwcsXyGcinxTfAwx!oVM<}y<1pv z5O3>z<;B~&&iYR3b;H%t+97Zmxk@)vZ+pUvvd2p@|3$g8RWo5WU_2PFVTsUg%CKpr z_r+eo+_G%m_wk^ict{1vLgB8i3k*H^qa#1T+>K1av-9iV6>g%--Av&~Fszx3z zhs(*ZQLlP7cF?pxPnKE^3u-q~7KPw}+GO9xm_rej~*^S=QkGn|U4?F~f<$Rpxo5|ytGCP17<_*<-_Poga6C%-8Q^t5_ZPko6%gBr`%eLV zw9dzS*y42QzQ^~fZr{sa?}8+1Ph(^!Y(oFFPM6!A)LJKcJE2fi|BI|H7h0){tmuUn zNSdVxCqRrNPhylYvLE?)Iq~ymzT|AuOMokYmgQc}{Te_Y2P*gKHk8}nib~Y}lyZ0V z%01?Gyv#a@+nrL%eW^8OnH9a%N-VQbGR4fcvo81;`ceWN)Zo}2l>aNr73zQeFWXh` z?D&mJ9(5o4_&Mu~=5}34`X=CBpe25u<^DxL9|ywEz5B;cn7`ksvp`Ojhn)VmJIS?< zdAlP$6cvD?Tdhi&bDNdC(=zX{NT?yo$1?Wl#pC!#~{bSB>6j4;%YQ4H|i+^?Q(s{wVEuPZecRROR3tAp>)_($`2{X5bR1Fry@PB^T{SM&#Glbw79e+qj|z2%Sf8(wB=h*^XRN*=<4AF!D4 z?47Ca_MLwFtOYOOKKFy9p9cO6g!udRr;hOj_um2f82ao|@A}~y|LWbeIH~16Kh**q zljb1>YG4U8Z$2x!(|{2u&D`f4vfPTUutqMo+OM!K7seXIVYw<|i^pa3NjdUS+5Sm6 zJTX#>;1{dz%$(lp?ht0ok#PI`z&o8pTSfUq8VKl}IGZv^ylpmDEg z?|O{XUd-{M>o;p2k;O%Qdv?0+b2=`!R9KU!@s-Ik<8}D*Iua$mei*hcQIeXr$hGSS1P1L=|c+ffB z=v;<+S`yzS5lyxQvYkmnAFG3WtNz`b?-8Vr1jYivA-A^D1K%sZ@qvZ5pQjv2wj+&! zE&_M0^oIR-PQKn{3O{Ik?$>uUd4&C==V!{;!2KUu0b%=3{F`kY$Nhi8>#UhmQDJLFEL+@5#jqGP#ar`YS{-=_ zF3Z`mjd-Vcy|8h9QE%X57#75D}|_=)|};{v}t z&E5YcZF{DT&@yvztmQl)@fxDTz1x^sOST%SkwZD4NGl?+r_SC(G${kRBocHX0(Haq zxl}zUY~7cBIY(25P_DD{Yh|3y{d6EK=fq(VV-ENKq2Gk#d$5SO&zXf9Mj1-g)Nm}m zAH9}p3i8{)x58sH=|2Il0bzb`kp375=a=x>f*ymlL2X~Ag(L!w` z6F?@0(TPO@Jg|Rc|L)hf>RT`WM*Z{<(!+sK{xkhZZ2xY#zP6l>bp1vieaxhz4ZN}$ ze{l2(oFfiGD$bcdEke?N=nC^p|FM0gI|cz2*i+Si!M(HR|NbxaH~c@_)xXv6a=)(> zySR8#;=leoVXo++@Ps>lxrePd<0mucX>X{3V20Ug7L?fzdE-Xse{g32WtQN8qD*$+BAfN;r8x?jqwq=|8&^dCps6lsVZd={Cwb99hnsF2ut0ly|U?8z_%L zXD1KojaGE27KjQ*A>F}NZZy&Yz3G7n!@vU-;S%nb=0aJmUX&KU|5#z6)L)I1Zy~FJ zlDe^9s>L>asc-fhAAetgmvA20gdI#N3*)R_9Q()q_4Zof#d9b}NUsK*hi`OpP>W7W zO(%6hJ~Q}c*iOqx{|2}l2c(%dA0{TGifa9*IzQEc}D4c}Pa` zbLa-ScI-=Ge?>f~9Py~>e3XcPoJf3>NZTm0s6?roj3(WD2kZ4~@!{#r&h&U$)cr~G z=3mOl-({9aBTuRFzss^OrTkjvW1nNmn!-+a>_R=J7>R$XLZ>U-E~LwPTq^69>A~?~ z8QCbaOQrttMp;I@kjLaPx6AmQ3`~QT$U)~z>j@dzEVGR=`jo2JEK8n}@;O{}S0Vwn zdS!Exz7!CD%VqLY=u3McVF5l)o@bqE|41BxD)eA$HfA&V=J@zeEJV)ZlGB`7(c@C1 zb0>?bDSnSQMcD92@HppMe40P9l9JupS8Mwu7B9 zjss=^`Uv&iZVmQpf}ifWoD=xP{ytGQ_h&u)d){KU$aUIV%w_&%tMKkN`j_e6O%Lx} z@16U3XWYp|A-h*0CCDzd%<6rqHQOVoW8PB5+tiE7*lE_t4(@!zovh zLHb@PB3Fpim7@0*BJz8aZT=3jr%0{v=wpLfdB16GL@I)pr{XJ7aH!;;X@L)QJJ;^0 z>Y`EEQJs=B=zRdt0oPe;=Kr)(5IjZQf^ji|89_a%%O4| zO$oo6tTU%$(_S4TDj; z0M`*sL4IksxnAFHb6J^Wj6;C1UKMY<#&964Pk3G2FlXA_`88+Eoi%078P#*=pHXvG z%|eu1YshlWg6TMqtoAC~nQkFbUTf7;CIqvy^&bJ4ux7R~X*02k?XK#VoOW6nLoIH#O$ z(Nj*)3;m-pwc^52m+O~nbfkIyzK8TbfP!fAc%a|Cg8N?r`Z&aK5hH zNnv5dV$wCRBa#JpHG-*tV@EqoMxGR9kBCyU1V6PXA9&9QHLTUeWMqwC-?^Px663$6 z;eV6xA^N{~5B09epOh`&_c8CS9wiMWW|yrZdTun9S}LM={tk&CtK2?l%mR~&Dr77dLXHg73Pd5}t*q%r6EF9nU^Mh&63;v7t4EVP@5jeC$ z+UT<3`$1V%S@{7GW|l=;A?#$lJjJ8kqdnr2!IVDgihVqMMV&+bclR06=oZfy)j)8_ z)^-gNElvbB-4@5xJX6G6l z7$Sj6+J_*H>ulVM;c^iIDa0d8)3xw`2yD(rW`}$(oxA`CmPxb3H1ThIqs+_T+|w}T zYj)QHDRGG}VTfr$Qq4R_o`#88FvZr5&@3VRs}Dhgm~wbWW7cnze@Ww_5-C=Xk_GUFtdKTw@h>9!cD`i5zUIa+8QhSZ8R_>mXESxeRAKtE4`S z0Uqk|n(;87^b+6~K#2d{v%myc2n!2x^2A85_=ZvG*ZV8#6Y@QWWHQDP zz|lb1Zu;$c+_#>$+tzM-(dW!L_*`E&PLpf{>wHyvsphpSsn>nN_zVUxj%3-1M;uqS zvt8^4Fll0{)mBP)G0+ylNOAPiuqi12R?0RysM7TX8RG%qc_3`J%@b+!!i*6G^l|i4 zOTBBzPkA?-cCT9DCC@t0xcr|xkYB%p>4Pme5%wgs&gJYq(U{wY?cnanCq4B@ILk;ySRx*M6!uNJ8_N3tQ7q&6NRc6 zuQpwl;&x+=_R=pK{Sd zN{E{-vO&xNH8Er_pvF$TC6e2OtrEW%nfpYS-+-D<8Z~#KDKIux^lOcln|a$qi)B6} zR3Z8WC3+wV(bGjWV0q#_qMr3#ef+GZ-l1Il=3>?W!1F+epLQh~qYrQ>ppS6;+5YfS z@4oe^sjcgO(Tm@@Uu9}{Q`Vd84}uDVHz)w0GrkGu&f;d9OiHOXw!5fLwu@Dru%VwM|UARWwm z!Yo1@C!d64!DQGWepIv|LJSufeMA-ywT9$1$pS3Q*mJS4K*NtI=936@ACcB`vh)qJ zSmbRr3-v~>mi=iVUwcHnHz?Lng+5PnyO8%QS*ZJm4sg&UR52w$giA1!OW0sFOYy*7 z?zd=t51&4oX!p9Hkq>E?F^&XI148UnER^%eJp&=r;FBlA?cw1ob$h%pM3h5 z!1gu@0|!YP+9t)fHIapNcM1%?FA&3Yi%cWtN14h44{R2C(Ey1;|ZPSX6^u6!| zJ^PA)GS4@3I0$R&x30n;w>#wl$6o<7lJ)euB&VP zavJTM$KU>>#{gr2u$=P-q>cI9|99n_7EJ36qmc?Pnnn2<#j_q$m#aCu5_Ex{bFMRu zm3BF*FGW^TmB^lE0(bU`;PEcoMtLgFVs6n}3dCA^SjfXY@-XUp`{i$OKdaEQVX^y!kh`QLC=30kpO2pn z)IAVY8dsIGh5}vy!g2qUii~jva2BAC)_C$Bwm1!)aG7`aUychtciQ$LztymEy5DNUefqO-)iFjfjY20N)ACSi9QZALU6HI8XJW~%` zUEn{4sZp|*Rg7WbFGX}j zp;IW^`OgjG>QyC z*?I=$I>bQoEo=1RP?yL%>Vk4Ll4t93J^25lTnO%G&e8?@&?`%0kbkzz9-1&Fk)8_t z1PJ@(8qyB}PXqc0@e*FWhb>N<4{CjW`l6Y$rXA-oNyki?=dnj03LPV?63*y(XZEKm zzEc%`s^m_U{8ZUHiJgg$w#u&+ZGCcz>gX6D$QJ0d;H z4nWu97VWh$U(d|iqi9d8AnQdb52A)Ko4r=oEM$)+iMX2#yrXHTPdBwy&3Z7?NS_JJ z1p>NJljlc_v$?-`<5KT;t>sJWgLPNi=*Zu$)`|&ZM?v0s5x{TeM`=x&QNbbi2`Uv}Dc=aB(IBhtn`BJlco_dT_ z<7eYp>m41|p#o^9uhioArm=%QlZ(_AZw>Sy^cC0YJHQ(DdI%<|z5vODLgpp&$=(*%L#_JKr7Ux=Fj8DprZlRq@;Qj+q4?24I5k>XR z$d2&KcVds`_PK%d8sI)495;3OUgQ2PKp(Bk={;<5+I>*t=J(34!*0%;vuKKr-|7qB zsEhV+IfS|le$!B$N{E7FYT% zM(*-mjLhA|fW5`pqO`J7vE5DU>9RE+ky%Fe!?0FBF;B)+MdSiq8*lG$vyiU~c?Jv8 zj2;n;?BRaAK>ary%8F97t>8h_ry7N?@#mwI;xiPssvJf&^FnzoQ@n(hGGykeyzTseKRf>ys_P16U#4=eX8gp;LmOw@f}KVAYV8%AH}dXK7Mki1 z@9jbjGZA35$1y`p&ujJQq~xN7Mx=Am73L*Z6{8qhZi4#k7{9%YUd{7yWpCy>pdJX@ z>mkw~0-po=`0$yf-gUzt{i}D=;y0($F6}XCE#nyaE6xML8qGFD(!N<(Q5N*JT`hV+I%(9o3-O5*HAa*pdcgV7N))}5 z*RYoqyHaE>!;2|Tj*#L33_yB6i1dibsvQjdY>9jV53qkk;ZJ+3Lk3>nUfZl2CnHU)$(UO*}X`Rx|w_NM+_g8@w|Y9^@CAx5W00Bq2Fm?UTd$mgF!3a=0ZVhhD_N zHo+$(f!IhBMFgTaJs^t3(8MB0Vt!^e1aW~Th=(DF4jVN={DM~W2%>915Z$6yN)yC@ zJdx>d_Ot#V${!Nx{-~K-7mC7*MC7Xd5rxaA90-FZ*F?eol{Q#_C|)wvU%6>b6eA#t zTSB7fWmgdf!P10*cVtZ(S~+g}Sf4Id(cZNlg&4-Oq(26}0z$gz+dpGW0;T}^2;*Gs zZ{PFlgWUJ@>B0|&h4;z}eb19C0;iPzo%UB5qqly6S3|Fp{z)1aiE=w9w-7O*7)UD& zDC?i;<;aSZmD^5t!l?nyr&m}xq9Ac{%Es&qP$%n+LA@FsO~>m_$0KA1oi1PABk%*$ zCs7ejeL&nLB3T3n)6-zrDdGL{bV3G{+IcBBmPTHF@~Q(JT&7tzrJh9 zBO7G$2I+T!9YDyh?IitAptV1RgR8d=@~M~IqsB$3|LFwgE<^1JIlOiia};d^dDap= z-8CA6a@7xPF4r}r?*TRdAsufg{WV|@@^KWFZ+~?B+(E6=v>R=2`4aH`NWj-3?nd^l zrQQg_AMh`=FL=|=Gs;+kK4uyPJ<%QN?Qv?)Oz#R}$ef^lC=0Q2OKPv9CccDyTI~U? zo(Zk~O`ERpU(8m#?*AK8_L8UTfqoc7dMZTvMbHk$_KALboke}a_WCX9XMrt1h?lQP z7Y)uB6@WetgqQ9A9bWc~yjw81^e}RscfQ}hDfjWRQW-kVH+bPJjsY`ctH)&~c4P{6 zu%^_w86c7oOSnYc4RCV|xETs=JSU|5*V}exXk4msbF9yuRf=kG^Spocku&ZhtoojkIC|;O&YAtvp{qr_5 zWGz`EEfQ_b84=d#kXBn+In^^c1GPCsA3)9Um)I_8-{s};ICVsyyHp&iyds)g z(egoc!-oIQ23!vjM*VgkKeV}>!}0k%o(0Fj+ZA~FI=TV8F>;3t2Ig##--j*oD>?+( zHZTGR>1qk-8-Y6jeXMTf*E}fKp3A_S4rX1Z%jH=Bdo1ri{Y86&$MP2V9Pe?DTka+c zaa@&JhU%jFk(T4C3YEH8xxYZ&KBZ(j6+yKI$wN|(l+lMp2A!MagS70?%&$%|@`$jW zqtgu*>6o2Ed+6EpIqf{Hf?p)L@}H1veN8Cb*^SZ7b{tfMC0&NLPwHqemiZ%US@#3K zowA2Ex6?$@X97O~LcBGReiQf@(8uOamwMMPdM@*>txt6awVvIBx3LH|(OBAI-e$jG ziY?~(!n@SIKs@A^oXbqSyxpt=3f(D;>&zN;2+pLh#&cwjhp+;-&_mff9?BB$(*?## zH2cnA%+C2pSbxVmO;XmG4vI+lkTzopL_C`G-?zZnAWR!_;QH%C){~zg>q3tfKXX@` za+brJm*JlTod)=L zyL9uu@i*_^mwWHEeLOp8WwjPVlNTs;Lc(-+D;n+Vo$oBsXm6L_S+dbS+Pk^N^e(UYm)9-t?j!$=M*r?!|9|Uv$ggkWp&mLH zBNjMMoCGgxb)X;gv^NMb9uGu$H0#M;(>@K^;P@zRKO%WLP@G8PI`=tV^Y+l|y3LqNE5vO6^t)ccpO8jYNr$$g6IoDB1CCqfHt_r`;g4&Sqdw@NQ3rewW23_Dd}vJ=36r5ovm%MsQA1RY{OB^aeo*)&*~C)9Jj|iGVfM1 z-I2Hpl=UKv3c74*UdfGhY88iQBv+^0!ecHrfe-2vLu37qXkQe#dH2-Z@Em6bI zS9wcR`h14ul162IXAZtdr7u+0PP54SqT9~;IjRgGhejO;Gzn|tI&Bz^`FAcW!47hj#Der4R{MHo z{h5u_=ScCI5V(BWB-KqqUSgUz3Hv*NxR|;^Kar0FE{u~W;Eyczt{I;YyH&>fIj@_V z2U#rJiSyJ+=HYf%tI+kv0SEtO<6}RL=f%?TlL0ICv~+XrgcWzq-2hnlj>HO5|D%Tqma@L80Lo-zcr)2UADl;w;py5T?LbIr z^vZPSY^B?omQNXi7-W{_bazXg1dLTeR%RJZ?CzCR&se?4pLez%-aPN5 zMv(>v1L3^=3(~g$_W=3``RMmc_C4>EItL?Hnl@KY*ezxri5J?9>N_bKO@df>oa~p< zs$fX%WFve_dPCkn&n#jwUJ# z@8apgE0MSl)`6aneV$wCxGp;*2$3~*kuRL(<9FZ@&G=nUx(V01&}380T~ z{nmQ_+xsV{g$K<~jy&SXJqT{&qGK<0MPFT+FTt0t7f<@S{+K}|m@ccWVMwTa!On@s z%glc|7>F2lFH3gB`(VNlhot(xCFnSV~A&mt0-A;-ugOrBRW4pap)niIqN zZa>?{VSS{A#|2mp?X=R_y>*|A6mUJ2$VzJqUB&yZXSMDU@{6fThVqb=SrCmA0?hZi}`Sa6;)9;Cz+Dx$@pKxR@`Z& z)~SN^Y`vteQiWHmBHHc-A*V6#4kuJVk&sugl+(s8T`yL#Wa=9$;WvMyeW8$}d$vnD ze^teHuN|uDBX#!2D)y18{8+gkDfy-IJGkQjjnVngh=b8~O_Mwj1_LR#^G93RP+a!Ja zE2#x$moTfW)@GUL6E&LhT@$w<@(XF^s1vMJ(tSucr&DlkHZ>QKtqxRw zn?S`C2G1f;r`I--RI(tENf)MGGt)mvmE?MDle0?{9LZ3Bb|w}K^~E-0dQQx$Q=Lq^ zq_Q|(k}1xnE9_pWQh}wNk^Q+(=QECKrt=#}{}$K`gmhkVG`beR5r94pG#=0TFUI3N zl&(kPwf<Yiu2HgRVWyi4y)3hv#RzE`Ut&Ana2$bJ zdL_y}XKyYTl0RYY6z3zLNUpL7!uxY8bG;S&m0kWXJ^6|SFyk$Mi;qH%g;+Txl z9tiPUM|wPPGN6xeJl>z2as7WY9tQ|!p}GPeXUip_&9cj-UZDW#?G4R()g{C-;^ehSU!2i4U?PwG_kc;+YL) z<%JU7u`6N`>D1X)42x`^7kx|#?cKT`a_NXh;1yDxMSyVGB=n;FSeOC6YBVnM@#c1To00e$oc^f>m1H+O|UKeY`F9^MXW`^-aJD7^)9fAlfe;9)Mk z3wsESxvcumG8TimaoO=LF;_rm%gSF$`2!ZJ1iN->?I@Uw?!nvu{?i|khsP(KFV&Cb zZIXBhT1Dhov`P4Ccr5qvH+p7#c@4Ub}Cm)HY0*buSbcVVgNGI#E zBz7x+5e6=@WQPT{3!5?hp_tZ{g^x9*Bz~cYl(BSmHM->hM57vMFlfdZzoG$@XOqGu;v`${VCt@Ztw-Nh1jKXnF~eHH1CfI^2E~ z;gt@sqe=TplNeqfNR|hBYq0_t;i228zshf~t+Ye9KbANyV{`yI0l_|ncR8B-*7_-J zqvxr!=lN|BbZu34zP4u@1|)$>koYfQY+$x7mAH9z3t6RFLdGZuLV7!u^v{5+0DWBjjZbff-M8;?ZpD93 zZ+m*-`uI|3U0ke>7kcLlgmNumB3W z7VOb=71u)1RjfqC0yb1sWG&GZ78Tv-x{ABHe!nwwC+`6w3HtvA&b>4Dy}UbT=FH5Q zGp8_P62F=AlkIiP!3=59krj)0^ z0yqLd>CQ|!zioI#-o?YX%Zpyzwf50VU1h_J*^{7vEo8iZ5VGAhy12b+h}%8?F_=s3_PEcVTW-KH~~A;>8||+XCcSqR5^|LEFI!9#QKEIe*s!1 zgXcltGJ)U6cv_a7dAdC@O-z>8go~gm(!n>8x z1IE`dQX80P+@`}=a0zGG)Jy-ePymMPGiDua4iYLPAsfMFUB?$dxV22y9qrt7tL(QX z^oP=eeFOQA0sjD~ar__TLnlYu{A+Cb>r&B6?dun*Zg~8{8VW=Vd*O{-SPFN)4ZD$T z_6l9wgQo6m!AD#JcE};PTk!C`ktQoV0nP&5wT!=o!1Op*Xp;4wf;yFZ^s*HBYXCO@ zRK4#&{sBPd3$nh7PitSj#daI23F6-w^j%u^L`VB*V8lVc`)z7#F`E`I#vLyX)`s=>)N_mI$#zlRrYE_N9HYGh~ z-j>9gl1>)aSGl;&6IbiDBw~w}xHw8J;i*Tuvxh5i6h97~T6C{5hkvh@Wc+BqKS{Msy>?j)cEzGFJEQDjylNd$~X6B>V1RC{& z_+K`Z@+@P~_(e!X9Qa*D0hr9}OcBSvw%+)vx%}m>%?ODgtRd1AA z)l6B{om{&URc&VAI-yX21u);2tn*m4pQ1SvEsF(g0T+BKYS6Ss*1LFWZ2vhE`9*+p z0jl1a$w}HZC_nVvuc~)j@2}*S%Gx7DI86XsIc)HYC~_cJrnuVsBu&66i*P}wY3pQt zwbP@T5=u58e+S?>fST7%I|FvafQ0}`2lKzO)$@}xyMy|dGqGhMOOl?2XVEGv`lNkh zb$>zRXhC)xbU}79VEROnm+{i4iF`M4#W4`AhCed96PAZGXPD_&OR=T4F2vp#jFitg z3p{E-YcYxt&CZ>qpU8{NZu*HSlgwPB55jcnw#ALH+@I*KG5p||OmW92Nf@zV zuy0t(aM*Go#74OJ*jZpl@QsRxWWR4me`bpw)wHB(Q7spc4^VVE0QnJs-(o(9Bi0w& zW(6YFy)X_oizvn=%?hr>>}`@;Vpe#bW(7Qxsp7A)-Ycn|-m}ffzX{k5Q1#B6>eoI( z`B&6?ENOTvcQaT7TG*ZN<4UvG7N)%i5jJ>Ma0jy{5rs^ z05zVvRzmj%a3p||@#a!-SN`lp0hc-72#AZ1 zK2mfHMh5Xu%RCw$4CX&owou~++1^cP$84|BK0^L;Kr2Ae!|abN?R%7eD|#3Q?R{A6 z%o22Q4d{Ye4nE47AU7%lqWz%E&%#Fw|1*`z5de&w5v{fkoih53j zl2u%{w=z;6r|P}Iv+DW(Z=4H>KOMR3VJ$BCgfqDxYMEe1^F864!CJ;S^?@jR+O#RutjT9hLwkD{wy~ zG&+a+5CXRV?k4WHGtBvZK(cl-B9k94ypsEzgak8j5S+NCd zL~Ib^hSpmpI8Drf6J+;E(4u?}0(uzcZRwClWg2N#_OnEh4R*c#fE8FH@@Bog8q}6% zrrBA(%!oe;j?PwCnU7iiN3G0TEh~yCfwl`9AsM7-7}F8Kmi>+KjXE!(=>q#umxrOm zM0f!MD`Z~BT!EYK97LhEb=w!PXoG5-w=?)a@mrZ@TXd*rC4D%vW9!IpB2dziS5 z6W3I6y;nXu@ zX*Rpc7=8!z%YtvhRkoae{14lK5$<5jUq@gTdeRCOgs-tc6aEaJ%h^Y|_`WmBS`qt; zMmwNq-e4bjH66QBAcHs}^f5wpOV zyTg4^?BX0~c9$Z|Nf?1rh9{)(5S?4Id@EDW0qfcFqNIb!nXz?xH1d-G(*cSOezyg- z9VlN0prqyrwf__k<1QOIBv+?-f}E=f3qiqRoc1BbEm{Ib13rZ9a6jxwJvKwQR6dyJ-e3m{68B=xDa1NS zUIn6QR*vrW;E^9~zm;?eb7{5;@3mbluCU9nQW4t6bP9N8qUlREviS^bqI}p5`mJ2J zxYm!*hxnj;>2ip`U;}a;;~8dD&oyv9LAI0q^b*s6-qi*0>J)6iWcH<<5XO)hYr<=v z!=hpTRZixAY=0KWQxGv7fMI`gmN7(sj~h!3o^AHyl`Pw-Bs#wrJ9oUPiCF?m^J~*e zgv`x0=U~=?ZSHHb|29zHcwO){^7=f;bO5UVE=K+qzyknE4FNe%6_5G(`6{zR=e79- z^RaKAyr4!{?}|GXre^(Nd!x>p^_6TR2IJfIcMLlTFyK9koW5`&5bm#c@hAB#+e)I< zBR|qFnrzx#($hK(LqFB>6t^1;ntchy+<~DhPl-fLr%Mua%dv?bVx7oS`#8yFQm{W{ zn|9DjbAo0NoAy4r$$C$4N@l*VpIHbMWYbCVn?dDh^Lqvb<>}C;yw+gH@e#7^FVc<2 zIDe5Mw(?>U<$Xt6P}Edwc~iFUUbLG!S16eu)rJE`0~8-aaqCV*dC&7pMf)l~p?&lH zzU?Pt=#cNf;pFvi-1+b-OR??F%;DVviN3yYbS#|JoCou9)K#RWZL*%tsDt8bzDE9k zfKIhBIv9(5HQ+1&rGO`QI?wY*BObc?2#dHHm9Xj_$tA^ z(H^k~$40rr1UVVwJ{elsGliWgdaE}+ya6;roZeJE2DW&KECYwWCeD{3_lJ(Tk9a|( z(zj)MwW1x=xanCJ)y4wK0czZAK>lsOrvOSSZdJ?QWIv0CahKx57&jElimcw-Vp=_J zuc4S$DXlY=C=yTu%)ePGaTTeiH^dM~!UmA@Qy?1y5&H%#*Of^tG>Fn4TH%TGl z_=(f{l;bx;XzF|D7%PnS{-<|NbS>vk>--tUZszdf*n=jlJsdV{sG&@yf0OMz<*XQ8 zE=B%&z(WAl&byFz7hsG5D81+TB`tsX=iAxow_JR$ed9~erEF%*;`O%E8H>^@*2*<9&Ppzv>C{Ffx&#}d1eNQIytucW~g`2=Xmsa zI_NVcjz&|^W_{>%gN&ikE=S>z{|p_WJfWSVX^+B1C-lIHPCE-a4S-I2x6x_E`?CEO zqg{#x#+tSk`ELN<1JwLeb9b^{b{W7eG`bLL7yFF@-pCcqgM@Fh0Cd1k0%RJlx5b6&Kn_ zexgDf1?ext6xb+31UAY&HRy**em`uBHH--3DYI=pY-y40wia!o=0nwPU*g&SQM-A< zpyj>)KOwDEd?N8&*$~rLptwX&qx>AeONVVgc=kj|80gHPwxJKymt|`7-WypMTNfuF zKNV03Q0@ElMpvsx`5W(?z4@-C;s@F{Zm}-zOP5|`u2i8q(+_m!ha=hT_MaIXH|Ids z-pBHTm+Jx69_L33U`^p2BWa~OWji&aE(eUGxcdJ3aWrS&fwXP(|1VMy?b^Is;<E$MDQczlQE*M{0jLnuxx_d!M%WKzct4 z&s4kqdV1fNek8%KKYD-S&i=x~AMaNCwl`1b+M_5x2~hZ}@uu$W8+YD%NkLwA*i7rp zv{LVV+Bw*Bct1V>`3Zm%01E#V$lnAoJUNK6L+`+Q+d@WR3(>-Fl_6y>K*U&%IJ3|Z zc2TD{Ev=x)LFjp(nRp=!xxo{_%3}?2U2A|D?BZr~a6Zu3KN8Y|BxYOPvGYpynGraR zX8|$>Cbnjsr+ah{Hzw&{GHW(Fi9`EIVAs?t+qV_CD80b$zl&-E07C!@-wDW90+jrx zV|>2|-x7y!YQ;kg(#uQ$1sB_4|G>19!Gu`zdDylsxWqQVnMgMHMlwGS$5e`@^QACN zR109!SF-+FQ8(3J=DF~_1vm+y_^xk}PdhKFoH``~H_b9nu3Abkv|i zfRU_Hzs0^yXHEKr97 z1q=2x3iVEg<6@r|>KXKf0$N>>5pC#R?=%dey+b}9LLQF7XT#KApXGGX3nD!e0M2C$ zPHz0-8odmv^`T2)iR!buIyO{=5~A36`fzLtU!YJiLAW*qG_i)7?f`cnh{$h18y~^V zb0a;>?)W8V2JIsPZUk=Dr(XzD=qNK^XBp%=sS{i$&2`3`P>Srd)x!50_pcgs!Vx zP+vWAZtaXY^(tH@`9gewoDN3-N!oi9wBVQ!4hXwKZ57_jAacpTh6L$7tWv27buj@ z1UoSX5CSVp8o9gRI~MKiOR;Pl5#^;WZUUs-5UxQNH{y>5BUy`q|*j#bX#yt_v<|6`JgtQ^?)9W06eGikp0X>5MO1vWH z$Mh1%AU_2#9YFI(il&)!kzWjG1W?-Zti801d|oB^OT_z-wa`4xDn6J)4vT_Ulw1a=t$y9a>XiNNkM;{kT5aiYZT z8p1AHgWn@#q~4vddw?-~rd9k;;~0MR4*jnDp9SmQ6_`LnXW zI<{`*7s!5*>g@PQE&C_=-qH(VeCK({uK?TtQ0@9Bx=)q~N?+{0?u164L2 zgTx$ya|?3w7M7y9!?)NMpkaq<#_%2H1?*iD(Mn0U1T9dLBd+Pg%;>7;l5x=mjN!9v zZH}mK_Jy%?p(^Ch1~dW`ewQPEE#MYFd+=-g_4vh|p|`O6+wgQ-Sf;?UVKZFL8^dq4 z8g8+Ul32pi14Lq)b`N2RK{A{-FnolR%_N??QGc~>?s5_2nx`Q?AwbbjZ#)|WQ2lp! z^rNO?_|U32MI77O6`1{@s>Z8OL-?#Y2~rVK@o4ty)rfayQ$>-gMp$+}h+=@ia9kj(Bwb>4DvCYkPSIro7wLR)8JAAcmUWKAohtu#((y|=|7RF?F zqD#EAC9(bSP~;~9rT`RP(~+MKSOlP?@H*J|U42-19iY0h8LA=htYpDCP%F#U+>u-C zD=>UVYR1U>tSi{P)<{X%+la6+A3jHfU7#Uu7C0_^rezxvzZTS6(RJ=6nD+st0EOQ% z$e#>213>9e_&xEf@rxV2t?Y?5JpHY3X-9ZA0?%Q<^D(QDJ!%b;c)mb*cF|xm1U$PD zo{fy*GcDVcc&?6Jxm- zSb=MdyVxI%v}+9Wk47)a5M1Cf1bdj#M=}I=F__qx44+eKvL(JPz$05cLDZGYqgr3U z2!O)(c;rt9R0Aj-3g1tD9lolYphfj@8`kbh-brA6V;j~5!1`tA7`f!G{ccLkxp*?BWF0_*|wIN8-I5_^5skE`{wDU<5$n-GKZJfV%;d(snEr_f?)< zt0=@>@|%{6Qv3LK#V@rTK{T=pbUNF+nw{@9(mFl*W&2j|B;sj1<9S=2Mc~B7E%Lcf z+r2eobSsVp&f5Pl;!~DU7=IXH(qTkV(EU5M27;lvh+NejcpL#eU~+yA9-tBjH#5yA zxf8U(35 zh>~LQ$b+&zYf%S9_bn^I@9)RQeSv3t0du``;KQts)PY4)w0Q5$zal1gszAOTa5g~o z)3#l?GGJpLtIGXUC+%owj{dmR;)?Xny1R_&5? zC2U;);{j?u_#N^$0R9Z1v~9brr*;szSk+<82W>~?71wbro{Pb{q28DahRWk|F%`rg zmr`3>}93PRwZ{*#F2p@OK6RrESd#ap#fh%sdg}A0&O8QKa91=niH!jsBtX z)Et99{?t6sM(8aO*?##eVse@@k*^0_2vF@uZT3f$R{<#PKM#n9ahKu_o#*Bk#5j+h zg-RJ$hUntK;^QGD!MZtUlilfAJF>~<&)Sek58BRr1qm1M$c3C=z)`Ik1}=lh09KDc z^d~A>ks$GNR>tr<7Wqkl(*g1Lor!WSfYPDxoAMjrH&TYW1%f9O@nS3*KrsDTyAM!% ziiv1yPqIF6G$heNJa)#ZEK-YyR}1Q^;u~y&YWtoZ~2Ol1Km8ZPI`K#Q0g@bvmH9)q5pt=C7 zMeGz^jCHLsQTAIi>YXiW4kq{Ns1^X^0OIjG3gyuNO3Lqvnt!}MZMy}oS|;-CUFYNR z8@*4EZXPT>6_{SZuqoeUN1nCQH`x=Pwara-=V$FqY|vNp$lW}BEkbxwq_*4#x%m(U zcU{A=QA2>}A+$}8U>r}xxP>fPR|cR5 z`S11m_IP02MU6!CEqawk;tt;FT^`!O-FImmRvFkT41$v)7)&u)Jsd*uWW?EZdkyv(m|D5|NNUs*V}a(eaLF$)&d zE+`aNXqz;#8{nhRuYF)Y&a~%^9KKRmrwlWW!)d+?{x9m$1KzF9H~MC~+LI_h6IZ`2 zexLRd%5@%pwcN7@mDPvtOnd4_(ShTu_kn#s)3%{f7m7+18^^MIQaM4YNRjm#d|iw$ z&AT2vAYd|p`WeZ5dlmc++yK2=0HwK4Efe>>)8sxv6mTi_>UU@VgX@d8wWC-DXV%O` z7?QdLwKJ;ge?E?S-1kMxOP~E!2aA^S^Is>cm!I_{^YdR{P)(fsFH%8%L0oO{{e^Sk zOTTNgacyLy!J3UfIU9{I=%Lx%s0W($qc`eCvwpF@QO|AG9q5xb>oc|pU4o|n!9zQt@0-TQxZz|yD>>l|H^cT9W+4u+`54c!B2Vl5W)@5y z9PDHVL$IfZeOwrB83Q(q@k5a-dC8Nydozn9!^;?4n)1NY9Pxoh2S-GX)vwkgn{gah zx_6&94(8z{`fXUj!2-4H7-vi(4pOVlUdhp6u*WbzgPRYN%=%rJ z>)o&WkAR$q6qw&PaHiT7a>S)LV~2r$n19QW>4nS{ z+Q4+Y{7Q~BW1{gI)6Yfyjz%cyi*-`bhO>j7PO_ckMtsXyU<>m<%R_yG*YZ$FFb{r8 z3lbvJjs+ zD9qh}qfZ=48$#DW>qAZmf;LK7ANv< za$ur;tp8opL~LiX1@-`Nj$_ewhhBE{GAcu8&*`jTiOwNStQF$K*;v1 zSsH#3w%QNE{k+NM2UZ~XbXV^|COOygB^{gWB$=!-%g)hSNT(4uxM{H5a54EZu{xXw2awn8ALxkxRZrmWq~yRP6m5hM|ZngS;<{3 z-Ab}~@LiU?gP9+(e4z4yX_lA+%mf%bj5hpJI;T6GBI!QEpE5qFZ*nAwTa_>9FS9K= zcY0D_DszxNW5U>p&IFU4Q4V{XbUmP-WPZin7j=Ksf$^`);P6>@p*Sz?AUc7s#X&SQ ziGSi`{#(adDTvCu%y@z6f9LQd?_90YK?c`-iE&z^}E@`#vk7SQKe&pMY_F7sI8~aYU9^!-yiOS@&(!&1U^V zrmc1;#`hp2f3ubJvgLcpGGB(pwZ$Fd1bAK%ia<@|hbWasH@y2Nk=s9pKNP97MB>{D zJQP3G`8L?11C9VFd{05X0q_R^B_$6&*nX+5!}t=PK>Xjd+ZbzNSAh#8Y_9~iW1ID( zv=4x0z$(SgAfnrs8)KImoecOxWzZLf6Ca>P;*BzlS9}5PcF8SifJP&c(o>*RsS<^O zHCCK(R`ip2?*=|f{?n-`s`Uem04Tg~LVg2aGeD$wmWtbMPk&WDj9pi^XP#H{+SmoP z^U4>_KW9d5RW)$0`;qj~H!xfhYPpE_7_Fs7*v>E$!Iv$2U)S!0BL9({eBmJcn~CWL zq8i`lm<}+(wA#iP%q}6CU3??E^D~d8f$b{@jBwImTsH!v41+q{)h`CMZ(zazv!rV% zDWk^qk%cd{;IkZ2APUiZ?^ERCLbRb5y6Z^z?CHlZWgLNOL@*msDFdSorc(L}W`y+s zLK$-C(33A9c*YT_bf9eK^4nwWoNxzp&j9lQs-1TupYp zoGV1>7uSTiiC#7Nc-nIO^75b-*V?L@nKf|t^NX(#Up}+8X8xpe>V7`xVB6^Se)r^> z1(owEC#YX1l5YgDgXXivb(JjKC2yA+;$e=s-Y%a_6@@%;?Ry(kn?HcdG45yg!e<}@ zg$Eh@eYoseu5EF1V9Dm^^Nd6z45M0z5cxdj+GeEdr9S_1ALnzx#Z7`qw-Fl1lhge! zf6WIAst0_hyM1r>1@H7h!X4BTeF1~lFvCjB)m;Sh@Eb-CE9kTC_PKZYOy3baF}=nB z<7p-iarCG!c_^4NA|{wKfP}()u188!#XZCy*S!bu=^=@1f@BBB7!PxJyu~cFoSD#R zxrpg?Rw4VnQAiFPJtQp)tJ9SX=M>QCadn`H)G}1k*-Frln#ZmfWor+j{D?C_(j5$-x*yq>mU&{+mNio1mx|$yKLomTd#4p zl|N#N+Z{~Ymdo2~@-|I8J6>FWFRp#Xwcs_V^J(EnGXgjt%qNkdbJGeOCAb*iu?X=tBs? zEGL4^n*~)&J;i4s!o_K2XOx^Cu5!C$j?ag^C*~>&kB7GaB1UHzzZx#A{<(2%8qACJ zOt}2)tNZmKfg^lFoI%ic7#)QdM5m8YXpA!k2NMw#$7eW4aibTW=z+7!=9kP1PlV-R zXD8R3P&T?`)M-OXjtIk1Ha3DdSu=OBpbvY8f3WC)vXYX%;q#6kOL$MRsa?=I`qHho*D+9ZBbKX{g_0uhH3BeYd=w zCJJ}5HDG=|wx3AQUd9pT(Y(kSVCZM~$C>ac&;?QLf?y*!9KsKQMLVb<*-QY(j+nY| zI(vpQ*_;xoES~P1hJ#XAhM9>tCE(1nZ~@yqDq>m=yl4;6@lAa|Pu>-(!A8F{ zZWrUw-!g4bGf|HiTjt;ZzO5f&CK#5RYj!ph)B0xhH~K|NXbfQV;rnnon+nb5qcC1-ZMSQWhcKEt4&!oTLQxBi3qF|*DyCphO47k@W{_l2vOb&7e0l?Nxy+H1tj zmGCfwJEa6-+_MnkHei0stSA-+XB_A^Vy|abCd<@GF5Ea#(&;MDpPK(4NB%j$7Jw(W z6t_=MR`b8Q|KeO)SyfpF=G2JXgwYtuAWCA|{}AQR;+>pqkPhP0=#JAj9Em_l8$Q(F zao8jH(&J>kivJShf5`r9CdzXFs$Lzj!)cqk5g7_%)OdS6?=biivbbAq|9`T^v>N9EG)I!|``BeoEQpf`;Grk-r8AUHIHh&5(eC(va~nF8ww;)swYOFUNs2SxX!@4pG<%>dQz^wsw2`^UBW&#L+FBE|ms z?NaR5v?(6_-|y|8P>&OkKOHa~;B6CljrX}4kLrGiegJdBQzLa@7Re2}Xzaf-ZOVAK zafM18wOv=13MF^&4dnclnm}vz>eGVvsrE{E06x9}eE_tc;TX{zi2O*vu>eXc?!^A* z9Oa&z{^AbocLlfAGw#UaUdh*6*zz{9&YC-Fn?3hUQfeHv$(Z}BF;XJ)FioVnq`h`5 zFEFub9RyW*TmjifqXsSJb-G?CCFa|xDi0yXggj$ke4zd_@3_e)9&~52@3(8}>ASn_ zQxG^ZfLLh$1Siq3f~ig#xBQyc3tNH&NbQ1Dmqri2Rj9X;*Sv%Lmw=xD3ctAxJ}qT^ zRO#zO0YbASA>WJMYZ~hgS{de0FHtShi^e&rqbBhi? zysO}VqIbWnfi8~@G6z4!jN!U4QSNW#@xL=Zg(me0@W|N(EE~SAX{Be#eq9NC6y9GW z?>-3M{s4vdA6~Jvp(u|5P`YeCy)yAI?y|RI{Yo@B=B$O42N#{}+fZ|B&#qn|B21H9 z>k1NSg#Z)0yHy)P+x(epILm^go!;C@b-CU-)Wzl4w{fEH9M=`6Om1R08V*?raOm{{ zqYx+L+G{o`eHbDA8gKakeN6M{ANZ^Julqwde+3)~Q0+8%L85jV%GCf$hthv&?ht)o zB-d2QK|QvndT!Ok%6SLhrtjNg^*)1Rb1)KC4;*yy zba=FabF+Lm91L$}{^#i!8i9w8lw!`4u!O zF2^%U`^!nx=jxu_|KNJB#6Rqd-2Uw%*|+x1ku~+BCXH|Rj4~cG3O^FfbtKM&Q@HUJ z_F3k|*m_z2W;!BC{7YONFzMvBkfQoJV8b{?AQ7lFdF{0w?V#F=$hsEt0a#d3xu0z)F2zufg=Cj?P z&%kjp9T|t+ZoXukQ)=Bk;6gifeei z;bdmMVkB=e^l-3K$}2|jOGs{NKViK;WYb#Ux~7-eD{CnE3NM4VRsILQ5Otxs6v2*d zNSX|+FR<3dcCs7RnI1Sj+5a)iP3C_V~=rlak*YgYH@+xM|NtM${okcX*FU=FNKdw4we&xKH8Qve_G-*QR zVyUT>TKXV^{#n1_0d0?6#|L8ANAzR_ed(f=<-nCp2$sBE4F(5>88-U?rsKZqNkpBnpJ++d~)`EUY=>bySli+y(V*!fJ2Hxvv z(@~xapmZ>Q{o+GhwzY%rQPh%O5bOEs$^{g$2!pp>J^yoKey-U5G_eQeHZEVBbET6~ z;2!&4IA>rrFn7y=Zv>p~x<_GYw@)|D$b|A!r*J1~xnuY!80*q$xA$$rsj1GJ-+@2L z3n_JeDynq{^Z}@L`XTDqMxlHRfYPD(!`+A1PA7`#X>{C_C8w$+D3vf`BXkV`DFB7voQExK5g;GW z+JoN@za@U-Yvz%_CMmlcG-ngL*g$j`5cOgImj&k#VQ!QINq8m&cEjj=Thl5$yjG&V z6{5z7M~uAL41Nrt@cQBjSL=atUjU{4UYwnSozq9IUM9ZRo|#R~G+50J$Z7e{n{6b?G9*w1J zPw?^3aI;Cf9rCLMvY(rP=WKz2roD~)F2Lsi)gE^m37YeCR7(I*dhwH`;;#4>QHZ;A z>u`H`Gp}e94ihQZ?U9L8{}lqk4P1MJ_pV+2CV(kMe+vB7lg_KP(sN}$eg(c+?SqHDW@=MVx2XUx9nem#c9M1- zOVTR5zi-BO6&?H&`Tqj!=VS9@ALK^^CIToax;@ywXI01cJ%WTqsIby|Y40&;IALA^ zujKjIf_Bzk1p^bMm|+O(V;k(;44!?S&DkO>e+!e(kFey1B;7A3mRxK-I$_%p0_3w{ z9HNWfygv+_=?fLm9&oUPCWD=Xw-U^z=gIb41-uj=@E=TNJ5c_>!xdlako-ceYWW2o zT0ILT{~|kgeD(ZU4YPq>SlbI_Fu9{{^6HWQLaZJ$ke?4&0PyzR{13=ey7MsTe&70x z0dFxDKAgZ`M0(;tC<0cg)t;nX45w4+*tkU)xC-(pk|g$VQ7%dU_p-fSL_Jh{wIp!O zdJ+5uz)QbDdr|$y%qPyc3iS$UHzM=`^oYFom*HLezyG4&?)~vEzk^;}>b-Y&+=9S2!P@v#voq~m<*tFD1LZw2l$A6`w!dopW}z~y9Cf&M{D@kpDBPVUvf~D%4lCC;36$iSnl&4*1${(4Qmbl8_sU=?7N36+Tj*07DBR zZ}#d_z9lxFZAN|v;B$a#=d_pbIp9bDrKVTp{G06Q(TIn*lza1Va|h<5{DOUDBb8NE zhrKz&-;SBz01bP@a@Tk3D|EI}U(Qxw9&fd4*oz$D8oOe9I~|+g_jnN>@+o$@g8@2+ ztGju-SixqNum$pKACG*>y6$4mPB#5xVslA1>Lbnu;|GfPaLDTX0Ps_HGUGMQXRxDa zsi@&Ea#jF^kW3uyI($0)p-dHxavX0!`$jp|$WX3++H_KzrmOZeI+262l51u$5rG zu3$zwAw3yWKSe7UNcGLWK;qei`j^rJr0^@y*9MFNC_L$TJ<8_*DD{7OskkS5{>wxG zm-6dYitK%d(8r4BpX0`D<-tY72l<#Vv6y^Cz04py7n&@;S7s zrX7JTh7U_+0qlcx1QG==PwE0*YP?XktM;nY7x@fROD*!t0k;FZcD3F?eh+}ZCfl{) zv1Q^Hs=CU%`n_VWU4QV}baeerAPM!2?h>i>3M{S_Gg%UqH$^j zD#Z@8iS6U_cJU(-n|z`kf;mOH?QMbXU=Xc;s^bzyZ(+4P-5AZQ5%y({TjT2yxP`IN zBu~AS8%Zn~9?Ce`o#dHEG92SIEs^a%1Z}JMw#8`UiviaG6kXpl&e9%6`6&P;6=z}p z{YJ!V|MxsQBb-A1l};hk;OxECE?8&U>rL0gx=@MD*;;k|Mx*GY*0rvq^y+f}@wF1DA080J$N_uE8_oW;0Auf>t z%SE<>^x}owNL|aevo#En-kRjCpD27T3%)gi55Lq{XXdRpan;2&wB8J&Mb?|6)|tNb z@Jas}9XCe0z@*YjGb8>yMC;|gPMDRQyzVZ~$uu1^mxsDo&^k2xCtHa48#TKAQO~sX~kLTcfFP|gYYXjO>(a#Ua=e~j1 z%K+66{;jrlBFfVMlnzEeTRr+2e|Y?i@Qxa{4?3Z`j^YM4!tRE2I?kp`|9vk#sOX3# z;zqX9#Av*SYahedxC?yU2HlYBb8RQ@HY4vY0Vke$ z41d_T%xe$esoLXv`C|ZE-<9^Dn+`6m*FF<)Skne;SeJUzm0Y3}_Jw4=ktc zSXQE*AM}ga$v`W%c=arPJ2uYFMg9iBUjT~k-b4N?D!(J?PK~!s9-pu1uFj*oNxy>b z_TNUGR7Xa%H2ok3_)m;AqA$cPU0g3kfiS(4i&^J7l@S{Kq5jz}WpF(5R z_Zd#8q8OXO7tFk8%+t(HLHB+$Z>>3*#wpF_PcUq+qv-xSy`Db_bNFQ4Z{5V88(mLh z^&SS<{s~5&em>TWN{M;)wX(mbpnb}{#<&#uKLJ()RDZvR{BFSZujRPyNPjPY_EwwJ zxBLwblgjRZBLE`+qX1bjv23-!Gqm^6=Mjny2b27CqnisYZ+PJj!}Yp>RWO1<;(}YR zvU`Mv zp&Jgk1VE|V$YtU_+L3orh`WsUNqeEahsR%1lPD1#0!aRcjwqY>jWUkp6j8XFiQ9N_ zEfLq##kK!!SixKE@AY+N@_O^Ub>^h?W@Me2zTV6i*H^$w z);PT{oJyMZ@#X}xpYM$OEpxk7+y~7}tHe4l65yYh@eigCwi4OR*hSf6oN4xaYY8*H z;raj~!S8h(sh$US{}C(Q%y-i5R}Ab5;995g7THhQju<~zgZ!<4X8~$FpZquIF9YfU zlxDxSRNR~2k#|vuyWH&cQ+wv6*$ZdRoL4zt^8W2w0%lb=oKRcaP$vIyqBJLMVt}Dd~N5yWI8XKOW1>E@FDYV{-9|-WX^oh3_N7+fz!pD zm(h4tIe%g7Zf3!DD-dw~L0At$gDPLoa}oN^AJtO==sch0_{~r#0Dlz-w~)XSv%DU*OiEB5SfAAZ=i~s@x z<%cYc#e6Fo%*zr#>i>E@4&>IAPy6 z6OJIL&F=gPkCx;3($y%u3xBRQ`{Lg?1NaP2x?;@COky4p}H%3#^K zk*_(KZ(yuDf&*fK(6kF^C;J*>r@@Deg(_%^VEWV- zKZZYMe7BCO27eIaXX=n|1S|ol_3hM~ecB@^XL|g&YPa@~!3bfI;!Of-R!?na z&-0CpH8bBvF~Yy%+E$*Q9Zu&?Z`*-?6a>rUNhvVu^$nuXaJmEAFSu>MuukDotE=hj zf-yAJSc26|aN45Rn*Z$0lOM+T9?~lvjPfvmmkvwL>R}+2O1#sBuc6R+F^)Nsm}3c2 zyi30SGQ6wQd)X%Bw*lS-sP??&(~y?X5^a-zsqxyL_LS$ubb>#r)~nMfHMOpiv*}TC z*4zutZfus55P&bWn;*P@j<_UAJj*p4Ulxq3lLzJ4UCksr*|mCfkMNXkusU>Hzp%Q0 zi}B0hgw>mh8t;~PHKNW+e!U6#w*Wf;YJ3%c1V06U(*cx{&1K@=GEUw_0hgAaq zhxp0aLeD*35l_f6kDFUnT@M}^n~P5^v6Hxke~5~IX|MHZ7ea39q#KqPlhTKuS#ONG6mzU6(n`(4JBzDL>yQvY_ ze80qJHR`JN_n#rJ{T;e707WOH2hthk0sy7#FC;#to?V2(tIosgvJT9{KZjpU)udXn z{v1Jgv9j;M)S{?HZ()_0K+5;t|gW+V<|JpIf4HP(cAC$$lKT7tzlK<8MVgJ7^%U5zj} zY1cz+W?NI!lUT}Xr877bPMUofFiUkr+ALtb5Q~LQLI%(xGX&d?9~cH4(ZDfq|44o| zPB)(6dV#Tov7W^0Xr=3AJ2e4+HGjOC?`m67-Ud+f;^%l)@wTfO8zem(%HA$snm=o9 z?esz~Ue4DzVa=X|{f+nDw4Jf{s`K6{c;@xvZ*|@qlZ$)xctS@dC_U)mk9R6LPz&;{ zfPVuN{{KNfVHfK9w8UTWZ|zwZ;^YSV)uTiV?3rRjlT!~mx?IY&r*vEd2Qd&`>Vo=5 zh;9v0w?4~^p`H#4s-OLktbYaSrqboeUkkVcpz8k=^6vvwzo~nB>hEcxmp9Q4c#_!h z{*Cb=@DUJVBM?ev+S~l7%lJ`Wm_z=;O73ByFU{zsJmD>J5=>msU|4t;v;i4cMu5{P zgv$Vqu*$Qk-pzlNc;x>hHb4EQ_K5KWSyx%Jfb`I$S&3-2#)swmSI517TW3rA5b$>Z zwIkU>+6yf0A1Lpye{e{9$oTii33|oLajAGjzQ1gDY&=|y{1t$k0W@FX{b(~yyBp>E z0F(}O9(i+z=I6MI^(<^t4dTIyQz74iZ~d}ecH&MuvdiW>$;)n8MdGwb1)qkB%{6c- zgWI792Rn?4ZIJk+eHz239QjiK(*g1L+>&8wbMUM^_^dsAe5A%~u;OIk0%Z;06546! z0*k9z=qi>=)eq54q(-m)+fgsIUM7A9{a?TUfU5r}JstR z%T2$c{?b`GmJ$9hzLB$LZf)d&W{UJMYnQzoU4o8@?6eW-Vb+;G>mptoIWtnzOR_{4 z(1B>SrmaH619H_r!0xVTv-olf%p5_mhtkJnf2{@{3g5493~CdH`?TW$ivA}dzZ2h4 zbf@@ZN&mAe>t`R-A~=2o>^B07$>R&@C~M7LJ<30i)gymdqP7g>YXGX<*DeldH=+C| z043#5ZU1w7s7!_3;k)cpciKbXa=G`Zy&(GmE7DWXCilvxG89E> zdR(?+WKXRA58V~fjzf70K-K?UJlhHQ5!qd$eW@ z9X505Xsp9e$oe*;ZrP$HU-pH2$P6kj~{<5TVBe+bI_rJXC?H`nl zNl}d_a3Cw6QL`7$pHp3>qFO+ZxI_vK*Fl~b*1oaVv8(i*>}uV)O3%AmH?Pv|tMz1U zSP>!va_fvCESSafdNi>U;m~bL-ihWU`!o~Qvx4_O30v+wtdjtzw zu`fSxAintpGvd+hSvb9VR?U3T1SDdIqBkAnMcXb(IMVyZUdy$qIo3EVmZn2yCMJg* z5}puUpKdu0+&L0cN2@FO%sLVxhD?A9yd?-5uRi<~NYW{~8}@Y@C0?shXT|5WApZs6 zM*z_!l3DscOH2G`R7(d?+JBsjhjEt*j~{La-l7@g3NvC6JW30r4{CuL!kx_4zp;B> zr8`&axeuD1AA-k%^Pus5q2c_5=$f4ecJ3Dj+YhXV8^_Z|uP1sp7xA?u*Vfo9@w*K5 z9_-a`E%HwTJ_IQIX0=AO%K+B`C{6M9HOoDHckfTzZq6O@Jg*)25jmqrTj754pQJ#s zPX8wux^L{2Am+%`dhls8@{DObZ5BOaKCXXi=6zpfae(EI8ozP|pFaH2WF z<<1^G^11E?^dI!de|6&rz39JsLBJp{tO!(#?;!SQLBE8qW-9EEA|d$18iB?L8p&Y& zvmG}>Pt+59ea&(!m}r}sW{#NyFT;6xy|YJ}!(rB_yJM|!Cfo#@ldO}>vA(0BWI?0^ z2bl@{I|f3U0lzh82!cw%U=q3+A2H)C9bPmo(%yjOa0Ks*KsZ*UKdxg4)q5@0@~rHK zRaEp=_q_-UvFsNP?KWZK-_HjtX?M0WegXBIuf^F9QmA3(8|L%Oy0}r7CeIdY!$(A z9E2+D4Y8+`wSHdWu^n|(cnp3-*RsEc?+<{YCq*cQct)x0VTp(0uhr*m_dhNZ*#q$; zUkJj}I@X=ZvvCRxUn(=IwFAHC83iMj3qJk^bNQV-J(LU=o~S`OjwwW>stT|Et5B~} z@8uhje-*GDpz41vV&LpS`QHFa7jKmHSL?d>r){_7gVujR^}O0e)uN}yEN-Ba%5gBdth_6&CfEZif8+Xn!_MM z|B!+|_7&G6am^9e?&6yLp`f|tCK=jW3C^X=IthNY&Cm$An@cf@L3DX`h9GiSY~LkUt)95Pl) z=R4?vv#`I+g2(V=Vz`cmE(q~m6iK6olGi%$Y)fYvTv!4Q3ACLpDVM)FiKLzar`7;Og)-%R*!%?cVeqIM=h`RSzD5^SkFaXGZ%vw z8o}DPcGq|G^6h$OZEDvCLgn{|pq1YhrwKwiAHQ7svaE0Ex3azIWs{Mg0hj|&^sxZ> zB>*+=sQZERp=`>9-`Fm-#B5B1#c77tds^iDaNM4Kve>70ll%0m80$yxE`7zL6TB-z z50GXAxwdFwl6DS&61IN_$ng)X2V!;2U06R`1m6*}h8lk{<^aD{;k|G0cmHSa8;MiU z>IG1qq15QTZ#CYh_CcK^T>It+Tl)?`iSXOs4^9X06KIPc!llW3U)tW-xSM^7u8m5! zw6Oq6^uAB_TUR>pKH^KrbFlcW7VmwR;e86fE2Eb7D9TR&)cf|gE9%hu$ThQ@s2PaE zs=X@dWiNjF|A=11zCPP~PsR7Kb>ipRvH9@ZQR+&H76M&*@7s#^WqZ$lj!yRH2RhJ> z;(fv%6u;l-y|45?F*)VW;a9$&T}(&cC;Z9aea+tc*5Z9?J@~ozUGvwa0{er{HwPW( z0zdEA5e^(Bo#g*FMkn`Qkfc2fcoabM9F2n~kbeQN|GwlvJb!j}Dcomr!EaW0?``@& ze(ye$47IQK-n1WL<8X0flJ+g^BEJVHd@sPW<$(RypZ4ObCY#c&vVAYZ`&55EwdKIL ze!X|w+6#ETeLp_1z3QC=|yYarkC{vmk2x!w0%W`I1e9Ket?D@^f2gGq~FP>@=s(3@rzB#KY$5WdpMRZ>OyGzUR~-QQ`(?`X%$`AOKDB6Q^-#ID8UK&j$Q*)DZ>XM5

CCNI2rn|$wXyjQKO37E0YdeW~g1gQ2nXN09?;`u?(4-RY?AvcLh%Tmjy z;TWbE$1oa>20M#WJkX!_{>AuhCAV6G{6@eg0KFf{_!jvP1oT@yxyQDC%j75CwK?Z1 zk^7}{A>phGfuk0mQ%HQpnE8up7Szt4S5168ti0A3!piH|6;OBGYya7V_l+W>QF!v5 z!+Y3K-WkRsFd4Da$g&1{e$eM-aESu(hK`&t&!bcP=XL1#lLH+e4mmSfIqCQ#h6sLX z4o*n-z-={Z+W3xa_X^;o=TP1cK=Ixu>NpIM!dw2@^8(X{Jq!Y%YPb#iubVB7pCGLS~g!O;e7iQj< z&?&tT_H*Rd#}{y|&Q2y21~dEyPr9Dv-N?G>wvA91WEc_R3r)W>o;K(IAbxMNBK2c7E{GLQmyY@ch1;krVQT zypAN#NX;#>Jz9aQnrC}hDcVthDu5aXJCW~&GtvqGr3UXj(AX^7MLfi%$Q2XZ^#b*1qJUtzG7#+rb*UCCA;DAiv3F7~cIe1~qntJ^#Dy~ewGV29qKzpM8W z&&(bAgQj+uUc&oZ#ZKf7HvD#kHe&Ye@PAy)O+7D=;0vbZ@>8I;Sm%y`2D{Jc&btKq zxv4+!z^9<9Tq_yFMR!0l2ArEYm{R<<`5Eu^4{q+}=0*C`q)~Tp$^1C< zg`fG8ip}CWwKFESy(=3L=&i=kfnQ;PQIGoET=&(6CNgo_o>_fEoWh{~u z#;fmQd^kzC*x zetyUs5Aw%i*U{d&LOaLDjLP}7^J`#rGNP&qhG7^QH|k;_tU-(o>gPe9vp#!RkIz|T z4-0W7iG{`8pY4#XTflX=hE;P z#QU%wI1~tCo*~uH6zfW6W?QG4qtN*{bfeVxxopp3UrHN)b`|nXfYkuip8thh`Z1KB z15i4YoUy!P>j?S5m(C~;)&ca5!nlAyZO$vt3EYuk;SZyezQ;P#sX~9=pLIjHLS$>-n8}W`(?GmLp#Zgh+|cOLMxi|V$))03iXU(1`)AZtE!ejLv$O}y zEDUWweA60fU`1sr{X(|?O1x{f_v|_3-vn$2sQPTVE~I^e@}^G9#JAd`R~_FEb@8>K zL(YrDG4JT}185v*DK_I=s+^y0B+*J+ZJf@#5bLOErC-YWl?P(=yBzr&0Z#(d_}Kcj zrG1L>cK}MGGxn{gco=u7>e%?Gs+|Yl;``!Iei3n#OXyN9Sjt}S>SK23uVIBr+o8Mf z>R3(W)&H(;A+Nm$B;D0)%-yiTox(<0iDu{3EbVTFXQsOPS2OD#+QdA;jC$T#?*o;4 zlzQP{`!$A{9kC1q_-R##JmH^S`vlv@igq6ImjSK-sQ&vi@=pL19d(eORzmVyI9UC$ zweVD9FNKi+=FN%xM1GtZusWFu7H5epffNsjjXDu(n8O5g!6AG(G10U!c!%lJ&<~AY z$$IC9V&k9!`Fg;G0P0s@Wd0HP+W_|fC=K)MgZ>|D-vM7mvBkYJyR+@q^iFOF>4h3V zPzX{Cf)J1G+M9eGtMb91L_zB(MfaHM1K48cK{Kz--$ z4EnuMvRjRJ2UYU`yG_eSuk2wMMnfJ03l#YY3;UFH{)EvztYkHbcZXK#r`gD$_(t^rt)+8Ja-}w{VeM<4)1d5YdP}2zUNa; z0Qfw>>vR2XU0H?aZOU8Duwrt1jZIi_CFZ}|$Z(ue!&x-+sF6V``H!s6A-v07=N7{9 zWGP@dz^xDe?yV@d=4Z~Z8kyPdO6w96|3Rb%=lzSro8OhMRB~M6EjFTAK60va zlxeNbHO?4c?mkIuDurA`ytmeQ@9GxseFga)fZYIR9nt)e(MlJJa@-0@l6oH;L!PXp1X{T7&#%zzYC(-Q0ovH-Of1 zsJ7aNAo5bBiw}*JoHrPTq46u%Vz1%B2jby+t^|)yR7fHsOI_RCgdLk>;SlY(6|`zZU9fLr03b&WdH2=yj4Fr`X7nG zn?=_(wTG)0!8=|5s;cSbWwV;pZ4g#lK63QL%c0uUeg(T(Th7P|b|OaHvX{f_$?wK| zb+6iYpE_@^>f5Kj>D{dr@6p=r)Xv+bjoqoyT^iY~mG04&8++BXeLT?V>@M_d2sKSJ zhhDge1QD=#6Vab0`X-`2O@f<9?9=Gn*pJn|AF93$ywstdWUeFDd#dkU6&?X8dz8dJ z1YdyJ2;-W@)c2VAD%0*Gu`rp}pdooDiMU{{c_OT znfB6s*y4ADBkT0>K^iyLyc(vs`s8bkTJpDR&05l-)Xt2fap#%mCOh1c4=Q z*rl)9IxVmQ{q6QK)Y}hH9aAG<8Lz|6C79vs$<*)wEQH}bN7sWi9g>2{Fxt3NpXIa6D32p@(4gY5nZjYB1qeXQyo9U3Jxj5u`XF=|i#y!w(oy(Y zDn97wJYUG2bcS@{hFvA(F3D)68}9KT|l`DjT?zSGY(aruVOzl4>kLg z2dDnR){9v?lPj`%Xbg!VUTZA3{Rx%^mK)(|h!?h=fM2#N1ijZ`e7Ni6kI36`(aOO1 z=5cgSBKF^a=p;Fgv<5kTR!&~(PIcnm5GT(Ej$O0-kK>3-p!pDjuScn;-Za8yl~%&Z z$h!!1K{Zk2LkHB7gQ_k5j~!6UM1Bpvio$47xI*Ob!z28kDX!yM z(k<^=?B&L1L|7&OBn~%5eL*%@!!AV5&oT5x9J`D z-6d!{UWlJxB|pg*g&E?y1CL@4sCZX`m}Bt&t5kuayYY#w>_WVVb_=xNe9iPm(BRvI z3=ZB&wKs{Shh8J_F!v;h^;-|a?cD4*nt3IPVg9g(p=UWSf|9?Z7{!Z2uQ6AsXFP)kG^an`5w|f zeAoT4ksCVBvYhN7>_gK35fXlc+Q12KCcZuv z(cRw`zw;>R^9nKlNz7`q%7=ePc`Gqrbjwk9QnQHtLd+jY@CPzvQWQSlKurA1BP4bg z#f&cM4_u7dMA*}$Pdzc8b^kdI|9md9eE%lJlcFCbgYb&*M@ZBfI9d)OJbRP|Xa4fv zw@}|*WIQiK@aioj4?}v&9w5b!kl>?a$iu{Z$oHNKhn%s0}cBq1C8KEpH|`V=PvI2+K|eryszCxmV%3AbB(M*T%K zeQY7Z4m?GTjdbq$DfN6a&c(Lhbdp@hOH>;SeR@)aVX4;=&=QU1&h@|{kK~UQ6LOxq zOEr5NPY|=vm}8fcp2iwtmLT}FUX4(wSO5{Exzf0ku%kq)@_tR|C{G{%4By;f4V@fz zZDg0Yc-kfY5iJ34E=KKgG-x5iA#RpxU=~cl8;yQm0H_$HP-&#)2V@3UG_+Wb|KcF2u zgbl@4Bl?_W_A&ujTUeh0v%3lKA+DFNi`hBYWeAO8ee%rwC>q;QFXpuvGn^Io8EzJv z03VGT5uaU5qkV&t%yyuUgY`C212>;yXW4O*TWmkcz+fP?ETN zLcMSwZNw(RX$^8r930iA%5~*Kj-C;Tvv9TC)XA8BYFSB z;=&8v<$BY!LJ0M5hdJXxJKpoWuafO*mbixAN3^%!jW;bZ?WOKj&3)=xs_cVJ?YUTu zefA`3_w*uin%>duq;?E-ip}dxyQ@Ag>?7L8CWA+^JJTK-^ZFX7=WRF+N%O~g1P`Uh z!n1+T+(3P)u($BR-Xhi?8^-n-7!BI1P&)PL%)=TDbH8CRW`)7mPj=7P9x*hodw56I zJ#%1oW_W~f1i@MMbvmN%a2_Mg;Rq(eIOQ{Ll4WvZ?PA(M=%sWby@}G-aN0yB`r`eN zhh0sRBH$ciA;Yr%f~KVrjlj7ZZ=uqdCGme1@anGXw;}%^U@gGK|39|t%F8If3*f1> z-jsOQ;!@}A_nPqEVuc!D#*^HT}ifhTSK<2e=w!F{<+9^0u%t8bdnsrsa`IBE-S^kbO;|P zYJI`0ox`9(8x~cP{3s?28-%P9|7kekRK1$#>yZ)s6=fi*$?A7G|#VBf6kN|l&=K1 zbmrFgE%t(rjd=MUS;LrsC zV;V&nft9m2ezdT&tY;(M@8Uc9LyzK5i&i24u3TZ}$GVb$@~$@Yt#H&P>_aD2ltYgQ z(zeRts;Y{t{PVCiZ+fJhFs>uYqn;$lKtQ^KwN6cj@QqGA7ULa-PGf`V(aPC?s{k&( zNJg~c19SrLw6wqM|KIzadOa$vbsDz}6MJ znYD+lCA(R04{N`hMeboayBXWV0=rq%9@b4fnI!Uqce9v1jO}KgJ?u5AeC~nm-Q5dF z%s&=@Cxq|1$abIE)Vo2~XYSdj~?oEt5+#DWK#Y}kL_Wg!Ip zN~_x~R^**l4*W-v2l$f*t!UcyUMuDvi>386j zcP=1o6+&!DvGskX94xS%8Ady@LNgCq8)LnBkcIT7<9&I-uEwpz8qU9-1;XsES?aAk z@;gJ&_x5mElmj1L0Xxd-W=C3+yiuOP-WbG{f!&Du3be_%PbU7aJcYVTdfou~2#F`K zheiG<;5fjg=XROU`2Z~X?-KE?Zi=Lz({(B61U^A(W)CPYtr%K030AP^(iP+-Dvp%r z@gwCE#y(Zq>`kEq%?Z?_4}=6Q5{{D-An0Ho%WHr|q%S{sx`~i>bO3flMNpC8 z`f?`1MsSbHm|cd^L6GMUhO4R>&eYP=X&>tD@(u4JzZdW;z#S)DvZ9qifH44`5*OY6o8Tm~VMH!Re3h2YB)KAF;H5TVY>XSU8Bhrx6kT)}F2z+`9M)v*6HG2bFMES$r4!+BkS)0VokB!0G|-Gy%Z zvZIxDfMEa^KlRA(02~4Eq&fOMv%8&o9;t0*N7~kW2VZ4;oDZHdtgLLZAdVyCYp8fZ z>+5}aOBIDz)x$V=JYn<`_*(=t#_nNBVmyx*`O4iaT#RRo_S;l34?yeN3P+5?4o}oGyZxHI77jrq7c9$-EdN21=t9%~<53=3Oq-V56`=tlMeWtI_mb^^;AjhO_!aiunZ zhOz$UIqC&E6prG}OK3SA?is-Ya`r>VEFzG5Cn46*csP=-#IZ3rFf;-`k?3|w?K!fa zuF7fF$NLBJYXI*8+RjaE7X`U7|xl_UFSk#l!{&%IyO1|2*7B1suh#6igoI=~DwlHRGT4n$LUT_R8!;$rm=J$1m-0 zYJX-&?y%w5)@ynZ=~+tyfiqv=A+NRB3&)6{b(BA}*>;<8^;eE$h#!53!W4s}03luM zuiXUiig2jV2eMz`dUif1_90r7nh2DR1{UM+D-Ls4*jM7Q4)_Ti&%o^+`Xq+q31cTyz65f{U}3R!IOEc1 z(rFl#(@B*Y;!TDon87du!hToGfm|7hH~w>i!;mqU^SIf&bdnPdwJL^6~*0&-GyVPIRq?39(vI z$|FRZOE1RB?`2vI{AtIqC{9x)AeNUk{6-hvzR*C4mqTbT_NvO2kSQm1gzhJx55UFC z6y$3FivT>m;&Jfe?Dxe(T#Cz9i0r2hUK-vwGrZtfZC2Uf$rX?yyQ~yM#mPtLGxRWp z#eMg~V8UG#{fL1GHm0+(hNI{XpSjYP=r4vcltp6U5rUc-aqI@4XQdB9EuaRp#8GpM z$~E&{0z>+A(o;*u)+mbW=Wvw3bGQe(4Gn`FIzc;Ke)mV@X{Tt#0J!6&H}V$(b~t`A z7H@CWPQe*YDX)MV&f#-f@{W9Bd5SMr4Wu3SfOf+8kt%QbQ?wYaT?r-t=L|GUC@C_o zU52_bOu@F7(NwK{K=s=Ajz0p`<^yCL4mFl@7t8GdUfIBBh_;S6&KV1fobivky6tiK z_kXo$&uRI0(VogG&et?E!kmY1#lrvScer;+5sl9KL!F!FpMJ<+02m2y@i~68psJP@Q!Dh3;3}pT;P<8DWGAn z#6vya;qpbhk^dTS7~s@Hd*M)+qIH3un$xeJI(|1@`mU~!^!>+rRnIv^y*7|X?GlXAfqb)5I7HU78g+2{>m}rO0zL(}>t3I( zpg%w`l6g>jiRa0(I?Q(b!y5g*TIw>}c9MBLTdHhiVM0o0($;5biA!mLd>jz%S%z z8b-|oM6xyPM5bCXQ6^JkH7tlKgx9&f@>nP-qg%*?XxzgL%aa{Y{RPet_(cWj7hnac zcLw-=s;%M23EKHw=WaHR=YCYdrXb)@lDixzjl(3KHvoUmQmOE`XxmZV;ouawYg;~b z3iQA=DSA1eYz>0X87_Z6wp(*Q_Cmf0Z~?%j`|Ujp2yCB2~rf6Z&aMubXCq&>lR~X=MQyksQ$MgoNDBlUe-Fl+sAr==^+(NKI zQ$H8piBwW7>%ARyg65D&|3%(kfc-wet#?P{hX9KIDeLX(-~Cy=D@q-r8D0a^_^L^Q z$9h5XSalAMb%HA22Vv%b^9kEbqJ-C_j*#tGjXJvH{Z-^Y0_*{}s_~BKj@gfPFd;2#jky~#v+xwU88Iy2))3<5AqY^3Y~2`Hk6_Q{^{qSdg@Azo zw;tzzV=2Q>cICvy&idlwulUsKiiq7Q6>wVChZEeL4yn}>spROK4$l9_ZNB&Tex{D+}3VSt= z`%96p1k3`s_=pGHUW>BJ54m@jURIoP+z+Y&7DT`pM@fSs^3$baC-yh)KUxe0z zA;YbaXo!q7nB-*=Etos1A2U={NHwI2XJ5zmc=}l)RMCk%$K?G~s0hLK9`X zYEfTz9eV`%&43pGZoAZTqLlz36~I%`jwRyWdY-9v=qC2G?YgoCKB@+kT{dSb*6AbU zEq0g-seXiziZj~#4CO&I$vdC+TL31`PqQ_gTpEbnfv_lk=r3XY)=R-}Bt#gd9lVi5 zF*||p^dtTH->A(Nl1J#}zhmc$R`WIeB}2~jBk2JOMRO>d>qtq|v9WK>2p3?Ra!rzDgwv>?bp5yq< zBJmdJ8~1_UB%lc3;+@-RT!r#=0G@7l{3W&}N3Clue-iY>55Zg1NH9is%ROReu~Td> zcZ%n~%?7{267OZ<_px60GI}4IdM~r@W9^kCF=x@R!(oGe41=Tj=c2G{d_F47fKPvF zJ`+Xjqf8@D?XoV)em=@Z=pG;yGPn$NE79SOo_miTOcJ$lwHH)Wr{YAbz0v?-E>FAA zE|>0pLEi2QIs-WK6y5Zuq4Yz!Eq^(#98|QfCM&u1} z(UYkU!F1pS3NbEq)5jyL>yZp@Of4_OgbcCPyPLQ}1G2pZidawRyF`!%uW!jDS??97 zlS>~Pk$)5L9>A^lZ^)+>Vov&M3I8{xEqRw)?~1CaZoM~P2#6{5dwF{WH$Jt#%9MNJ z;KnN04XlbzFXyr;9p3zHk{p)}RuD#aLd4HKTs5i5KEU_x{UF2|1c`PY_}amUyb%Rq zR(n7a$>sWllMwR`ngt!SbLnKYJArYdM=6{l+j$k*;I{LA0j>0_ zo8~iTALF(Ys{sY*sqySTZH@yz#Ra096g8pcEBD0Pq#b!p|v~^4ge>eG;f_S;*~A&h%8YUelydl-1z>{%AJ5`0B*m<4!}7Qpc25-!On8Me7NHu_Xo~TDBn79 zkJ_TYrk-i;K`}#e|HGokic4!|A&`1o|KG&93*q4=CWME>#TB7Wxg@cNxW3aQR>Y&8 z;!(D^o_CmXDtLsx&2ryi2gp%&&M`LWC{vHI;87NSjQs$g{s|pvX9Tgh5E?~^dL9id zjLuyYjRW-EtoI%^1@gO`jq&H~ix1JToGwqttNY@Ed*j3F<8v&ZKVOg1!{|({r*({L z`KGfjP)W%#&SE)+7iFe;d$sQk^5Pqy91N@9RGj|t7&HA5Ugl;MI!0Yl7>v)V! z6uo30W&UIACHlul-ed4ad6ac+@vXoy)>}E4&;?->(x?wgV~IRqi&qQBrjxS0hPuz7 z)bJ+YB+Lv%&l3onR2U78QHRcVJ`ZK2G=1*9J;8$w z_ZD^?NO0N}~Be{tn)&c8O@MxDNXysD?3hg`l=5H&W0+_)s7Q&0H5 zOzLQ$E!E09iAmHv%IME5?I=q+0wI(#38&S#rJ-qQT&g9jQZh&4zZUf_c51g5`QLau z1~<2_5AqiRN&q|^bNmw2JLme&zbZjT&akERd9|2jg|bjn7o60%DSX@0zZA{N%6CNZ zqOY32*u>2Wy)7=H{HLQ006Ap}7gl~E%6_p&I-)5bC#F!(C)EEv*b4THscts)J)ZE7 zJTZ{qoBkB&=6cmxf%dyR*`Ij4cEHDepz&WrDm*K*N&b5)7YE{d^nZ^0_i?JNCO9?h z%ZFAjjtL{dcOg(GyN&AEECdbS6>>2JQW~$6_-z2*dOAq@9QkhmM*vNH)HZlwK>1-u zKIqa-TkDWu76payE%-H03I_KPqHIY@f&M4h))~;r!>#Tr`TmmgoBQ{5BtLmp52l1$NAQ2g7QY|eMT!juc>~;IKjmLYift+L9rc@!n?r(nm=px z8?(5gi^~-s0TVBFh@Yt9_KUoYIxKkOZ!P6xwA{$?=IU7fd~l!}qEzb~Zc)$U?4s37 zeT=aUjKYi0JRD=i(JC;*qagNG$xxLCZsL;c_>IhiZ`uej$<{NrjtzQ>nH!n8nwgI= zdp*KW`{S6Jc_V*qqeIs6Hq>4#+ZlVo$?L*7c;>cKGzyn9T^Fuy-A;$*{?JGsyQJTw>B^7o ztY>XnZ(}K_bLz7i?{@j(yx+7RG_4L+s@fqo)rQC@LXUaeOuENJ=gfiz zg$N;=g{>CcpMxf2)sZcZwUpXg*)JQ=z9u{%PlrRt3E<*^w|x-GLjgRU4iB+CmWuB+ z%@e2Y7pHwnS>>dv$so#(mz7tR*38Yntb8gK^77KkeC`gqb9aSmo$lohq4Ux5OYVhr zBWrgr^WDdokZSlvK1ErP?1zR}Z)(1oq`j5Y?wKT=!d~{ZB=%$y-H_zpnB?1>6paZ+ zSwpmR9#mQ*?KBH&^bA~#ADTERqSCmO%$In1w*|d&z2`c#|6vCQ_}VGcNxESE#^$z480IUP>Q(MTUB z;13(=CE~h-GmDKh#=Y|4{+*^gnhZZcMuKfwTcr^swjkRGgjbeP7YZi~8K>b^AFicTD$)96r^z z>BAfNQwt75bhb^8ctg*7))=$aJ)JGa;`#$$So6S=4a7YkYO8tJV+@sx$s1HVLFjlj zjY}cZ!KQH)%?>&OS7>vgQjg%j#DjYnIJV&kl?e?xygq{E;hXpeWwGp!6&E(oZ_gqB z2H;(Q+aLRp{}J#TfTz~}b;ZLLmxtRJhb|WX&>wv5;aa_aU(WqRO<0bKoq#Jsr83H6^V89KQ2&mB}lJ>IkMX(!~AeyDzDBCq{RP+4e?z3*hGq+uC zmCtlNt#6}U#h9E$+uXJE`H=NL53TjeZIu#o>az>)cK01WAaqA z&cIe4zsgg?GN=9Hn)jEJk)H#Y2XNcJ4*8D(UjTTT{q+)YKYOpdi$aUb+4i5y)5Lrw zF##lOC-=gS7kofnhnqO7(3Q1lGyBLKb4BduhW(k5a?l_LjB~y;$YBWje>C!bFvi-$ zEoM&vB+j{ThY2=|H=ob%zZ0>G3a5+sM9%|)_r*O5y9PeTVG8*d1=n^dT?aEv&YoeK zr*OcD0P93QhY@oLG4jl@WH{#f7&08&sYG%i{~q*FZ;|+_zUX9nz7x+}eEn5=Zft{| zM^{d-th%z&A?V`LxfNAVg4@B#`8S)Mu+x6Cla7O&>zsNWLLJ=s&>Rn)C_pU0oex9E zcLcPi-&4$oEnkg3>U0Iz;b2FuD7c0w`_Mao=SEN*p=ki4 zB{!)npXS#R?Vo&>S2{r*5dQ+X%x$uLSE1e^r;-mM{~X{IfZM(wkhdp7ha14t!;T(g zTl0Olhn9-(HTB1-_^y^MMI-CTJMy6NRvF~mf4`X8pMumm`xxu0tZAR3!O`X?*2H}l z`O1v@+RWH*u`6cG8Fqy>$KD^4U7wP$)3QFcgcD-kp;x0rVK@)TJ0!Vv+|?So77B~! zVL++r#G@5)WSj?$D5Axw8IVWMAj7b^v9)Z*H6|fAy3^@@v^ykP)I9IMg=a2){^~d} z@3`rd_>m$_DPzcTbTH0^4j-4L~-4Y1A2Vym8K|PY4|g!$UgL|JN)_<7WNzq zedJg7`TbDZd4a(iN=NLehne*NA9rSi-VWzy9<|g+dzghDV?HBoD~tan-KbBoqfK}* zLNRX}^LrCuRenV$Z$FG;KHl%+-h=Ru^j2q6or@#$SE%-d%E$R=2-S3KctTut#QjMog$+3{(rBd@pfAAfhrP(hT?XG~0G^h< zEXVbxEO~eSwdof6{tWmDr$<_KhD$LxnJt|G$=vv|nyTinw3=VVj#V5EG|j-GNBLch#eXpUdfIhUY=|9+M-`e^&cJP>O&i8b`-UDUm#RLa& zmyt46o9~%$y@y@riL3Q!KiCtG+w4Df*q64Cc4vO3{o9tGMo;xjQ>U^G9xbH3@45Ib zPaupT1VVnF0PlM)M}aH1NV=QLpJ#z->WTxt5qJRaFF`nV3qbE9h?ss}_dzm+ry8*w ztkE>6gY|*s+~qLx+^c$SBf7;m4Ba7+9s>Cm1(&JnrOan07%?{J6b`sFPq;tLr~89G zGuSa1+<7Ms^05Zc z&npfM;j1U<|D7;8C=1d|=zYl8QYFssbehyWPmDl*8eks4rO)=Ii1CN=MgUI-Ly|7< zd2fj*yO$DYo*379{%W2lT>5i)l18$SpN%(?#YEiZ>=)J-_t1Np|2}q>;Q6-@WpNt! zUzb4t1##iVRR8AGDA>aXUP#q9rRugybt~6*1$S0A2KJEiSUlYbXX9`Or`!YkRB{%$ zeEbIF&JRhvg(f%eO9vr;G2k+Q+ulvce+)PX;OTVx(&MAgU_81l2amD=%G6?(`$pcn zJ0>Sg*^G8-H<{k0rhc1g-EHbR`Nli&M=u;={NxQ?Vf^aRfAIttc*$~a=Uctmw|J-D z?A89~5d<>_D40|9qgVUEYajBa8R0aIql8asI3tLpFLEmOL%!1=_ZX5w*Ky*-0(%S9 z2GSEmy`JkTDQTcs9nxw;AIQS>5H&FVg4o7(wzBQ%mS#@GJ|GM;q)LjI2LA(7f!9YR zUau-^rlY%&e;n`}z{Tt1gLUOYl=lF5a{1e~fl+?p~LbH`ic(emJnK8sa5f5y%Z`@d^b3r*Q@R^*`A^) z&HP#w^0k2L0B(EMA^#fSLjX^w<7cbSgr99{nP6tONoIBdm|0~^=IjmM&v z#4=#RQ%NSOeMM`4hsPxz3a2*rYZ>yh0doP)K1#bD`F{Z1{%pNpIdV!pY{%I}CC%_V90s;r!CyXbk@*vvm(IH!!&HIW|^l*sivOveP<^0=&))U%=f5=ng z8d-01T66!oa+bb$=JwxT<+p2F)1$Lq!h++EjW{*ay&)Y;_s#&_6G+<`NZJu-mb*M5 z>sybyx%0WpM;>ZXU)j1dKcBn(uh!RLzPF@X^TX+aa7VnT)H?MWSKcfaS%Ca)fRz9j zpC2QC6hJPQ9(}A6n!`a3T`D(9ah63m5u5i~K6`3;f!A|JdJs3^Qb=REpHOMMfl~yZpF0 z8iMc$3dKzkYWX_?;m9Ot)))3%yh}%EPdfA_g zfrpUu(51hvc;@2kuhQS@Hu|#|p*!JHcSgCR3VLetJI2XxRgm5nOj{L9S{XEyTBn|! zrk_lAm*SaQPq$rXD#zaP7whSeU2TT-YB+tZiR_d*r+(W}mtv<9hmrSGL@O}>w|*ta z&jHK_@Z`=zZLKRyTg&5G@F&fv1hIAckso_@10(AKOfn>oerm z#EfO%gsb0%>kkwE10=M8*pEPBV8MHjX~Gi`wj$Y{M7RY1C7r#O7BCVYCE4%}Ziji8 zm7NXmb#i8V-IZA0;^;Z#(vIXM@XCC48!2`le(XMM_6C7NUuNWJ*=ngoPU8kicgl=r ze;HZGp9dHUaPd`-{I>vBDe=|8*=Mx1-Q6JYo&Jj({cOKK zYL_3!Rfe|TKjC{n`_3N+=3bl1o(gD@+N*)_&j*+hPxCu?Gk`YE+G9XR@__0tYab1{ zd<^{uUmW4CRSHKFNC*K`s$QRMwqI1>Tz zbh`Po@l58=k)=}28sxm4)Y133WO*HVA5yU`nGh;}5g1e9ogP=ge)p5#{F6`k^Zx4( zo6afIEE=SJ~6IUJc*EC2&Qme#Y&Q z{U>Y(Wpkd8&B+nX;nOj1PNnj-Y|V{X!6jL|H94$1wr>QA$D%crqFPGH7TF%NrukgD z2l5jDB>?w);3?#{0$e-LalLO6KhPu%arl*!=}jn;%1WcYP>=pxDn#cAP3StQE8R~N z>P2v{6UHt>`8}(P)*cdL45HX!Tw6RQDOQcS5NaUpBg13t4tAOzZz5)1f|~EMX?&a= z8}NEO@Ztj(DyG%TqtjmSssyWiny<20D+#UU%F0hW=30HUWjyrD6GA(LH>9vZ;-Tj( zIW8YH73FEq%w9>$d{73HQ&g47?Mc%k&`{)+e1_!5jh zG$OcUkmF7={;jdQ;dAUj}kT*KFdGP=l9P$ z$}*0zxyn1)m(VCZk;ZDlTf&34hDRcr02deeH1cMqwm#bYHq7h-PnX*=wIi9+KFbVS zoz=xOzYDj%509Y@iBVU7E0=WYgVK}C|9X^R@_UGA_9*a<&7A?`< zB!+bdQQ@5zdhl>=4#f)(d}WT*UV$?s)$bjWeDNE8%DWOWt_+ZUOguCwIC26xU_X#0F~{kMjmA>R5U)BO)@B=Tx9RePAwE7>Y7%o9J|NC?9_o`qjvQli1& zkeHA*P))O@dBZ%tK<3M6FcNi-$KEqL0tzjbZ=TohnHQZAHCIwp=uJtV+d+3BhseG| z{sf@o6;1TXrax#YZ=w7ffTsbDUCZyUOZs#E6^_#+eQs|ZcR=uev*yg0QCb7v=*~Vg zxmNu_{U0T@>c{r~#M*F|x{Zx*U~mD%G8-7%#)RM^y|=OU+>CTV zE;p1vn|4hMqhx2mbCl^nGw+^&U%&dZ0PGtge`ea}0pBw@esk!1Ir&NRNsbrx4Hg|4 zG;hrDMZ$%5B1{6Q_Hi~loUI+n*1pTOk^=2O2ZyrRLpkih94pE@AV{ws7}+NX!%iG_ z59=G&D;)C4-eH|$Au*fXgLO-VY8`tvhwX~${ZJHBlith5ywD5QCeze&^-_|f4l#>q zDI7I;o+frF9Z73og))kiVvk)4<&GHbGS2n9ML>!=@scG(C!lH#fegl5>#5gA4H_9> zP~g-279u{U_?XSC@4=uFI54)`!?8=K zT>iZ+JzKMs?2c?OnJ(-AL_P?$2Q;JqK<@Qn{B0kqURn`YH38>z9FiAHewlm(Eg_A}%h0~TkCpNmsi6GN3bI?B})isA`Os!DW9Ou-_5i=Y@To4wXk6(p%o0 z(pz2!<-P6$X~J^_<`y*5+aWw5Qcs0+-fPCeT_6r)G3&s+Duis7HJe0mJ970bBWGiQ zwH~O5;;7&v;7>TOg74!z0;cm|UOpn&zNgB)>8(VA8|M(6LWYsPw2u}rg8cuu^SC>? zKRratLut3fThD7G-nsy9qmZ8oSO9SGwh{T406PIZ4LG_)+>d-I@1oG+Qsm(6;oMg9 zhBNLYJqYQh*p+`RH#1A+?IN+K87pQ`^e_9Sz<;QKsVbIe2TyQqpNjYi;+%3ncc<4M#?R+L#MzI=sKO~c$I6o` z&W6%uBN{%Dc*(rBd4B1XW-Aw&)C7r zTRpncXg%hI?&j9+k^%x4G#2(9aat5i-#e+{s!vUXUIJs$Y9}lI>F(C{?kt%*V8~T5 z-SUBRKNo_fWAJcaY<*z7ngf}4ZxG1<5Q(KItEiR(FK_3mAQ3f(M)CjSB=Q7z`Z^fx zFWfKtsc3$)KZ8o-=K}r?aOvYb%TjJa*_}UKe(u!sqrk@i1Z;EtlSUNw!g*f%z9#T+ zm2#p-67_piwK$*6O)q;w*c5{x(P5}hgHs=6fuz^ncovU*7T_#^+mGC?VKmC60G?*= zlKr>6mEEQI5SMDFAM5{g{sTE<6c=9$GlqOXim=~;6J<>6bH%its@&bPJB_%mhXw~& z@KBJUb)4VB`n5YFVvH^(@D&jtVh{|*ygnCLP8C&XXq0%~h5GVyArr!wqsae{mlrm- zt0(e90pkHYO>^d_-yJ#?4_jOoIqh2er|sfhfreH89Z^^36NFtAixnJ1KLn4SaC(}d zZ0L#AYE=*OJ~Yelr^6?-p+VejSt>ll-qyof(StY9p*m>Jg(Esk5d5{Evg?VKim1B* zOhNbt+%b@c2wI-O>-o8C_Zw)B%iny1JXwS?!0AsGH6Wmzi}F|iPqUB7{@n5VsmEof z*82s)$4!xDDUypDSWzYv&*TGBRhqS#O9`_;jXD%-CbjXZ|j-DN3o z3k2VY_fF1wbh7?-$>1F&?86dcM@f4arX*^yR*2e$kQ4UO3SlGBOBYu;s>P zVtXsN&xPtJy69b+0eI)1u?`!1*;2|B(ZGu zLXC>(CQ7@)l!|^&=s062>mQs?Xg|*y;@7!@RiwIqOS~!9H`C1vQzMiPDE9)mbo0mr zTlpE!{|n&B^}o^DZ`YB@E5zq3o$=Co-fBWekd#V3Z8m9bS4pX-MlVchu!+U}0)dCDI z#e7NW4DT||(d_Y%#i?_pH>B>NqM*?^{_Kln$D*k=y zd17rF{Vn5RNWGJ+{8QkOui=BZ;rz&J`F;%+|7J@ z81jgQ42LUNHE=~l0k~fWVth2>>2h;(IaUiwc27kFiyc#`=>^+v)rG!@q4C~+m1+lZD7R~so>9Cw$^{ndwQLAARc10!^w8V1cf zEH$`4-!v} zXn&Ew0AgV*ixz%X!*O@{h7QR00Bre6;^id1TnzbHv&$z9u| zuJtLa(Yh$TAJ~Ot_+$5#_Dv;HaE*RKEW>-sP?rX^>~N zyE$5E4{#D~Q_j@J``ryKyw*+UzY=oZGY!8fB{Wd042CJoP`(A=_QTnWyvjdN{`Q}3 z^n*i>E%=O)RdXsQkE$uJM%&7jY{0-oalh2X= z9za*fHk*$9+7{>TBEr3YdP;qn(mAuI3G0J?lP6;b1eWrsDtO7)q23D4@+sW-*!U+& z&I|dIStxA3sx_*?{c6uf)qDn_ayHY@Gj!r+TCrbEUPFA36SjshaaA`{aMzg#N%=Sl zZ&Z6Iiz+(Oo>bSm*zvZ#ZF=6;>HZg|v#ry^9w^i9E;CINce~rlGV}du$+Tj}w9xz0 zCT^cb)yw};mi(;htH%k5%EVROK8-z4mbp$f*Q?U&3hoV&|0@8Tvz7^0BjKpK zQurT9cPoMKP7YGNw_pqcN&s%ZsJFsjBcL~cr^vG;e;52x-kpC{3Y~M-ih`TPqo(ok zC-gGBdbTnf!q@zMQxN6gr1iZ8iCqEriBtaU3*vLyzCrs+-=>iU?GYkwe-~HS((-4o z${Vhn1YXxZ(xP{1$>eDQrv!x#qX_3of?<3}6DO#Bl(iLIXg9NmW}_3-Xckq1$<4F_ zSP~$4K-k<^n4|es)9yk$Xx@#NM<1ifFHa4y&NK#oz_KVyLG*=mM(vlTCcHAW@WrW| zsoYvlWoAn`v)V;kG_5w-`fs~j)xJ5o=Z(pBZ8DvoZ0eakY%PQtYw-GH>xtyt`sAL^ zCfn66K3AoR@ijQZ(aJCUv+0Dhpp%!%EA{YMcLhj0RWo>oR@K&O`ESgHP=z5@vS zOm8D$UBk}`^Z1ARz?!_S(P(%_M_|pmZv-Z#YUJE#KyJ)x#2y- z5Oy|@8JQ8$NA`H_ev^74tZ`byBBwtuH$b9KMpt- zz|&GkK5?*>z3g_!u5m|e{#&j`)2ilFOdeS_yJqf5G26r!z`%-6<#f!|ohpPVb%T_v zCW=D6ER={se{sDYj{-{F3}{0Q>d-q0anpP;VLf$o8DU9DJXEM{j=||_5(z>@oWVm| zdVcH>9j+40K|SDEq5F9_=H{#3|0wB)Z-?WXz3CCIMh1HMjcDVsZT*6>!Q2us9RY72 z)L=|Mx3)2{VPI#HOK7Z zNLeU#z($F-r@b$oU8%~iXn#l1*kuG7CF$B|PdY;ld(Z-!3m?=abRzfQ%2P;{^)0%i zdB0kX{CvRm0JpwRApZ{F6{o(M6Nlzc=BvuGD@P5z5PMIEEU>d^#5u083#nK4L!N!Y zxRWWjR&wvm;d);^08v^vGjMhW!GT6A*jeC&(se)gq{ll1b~~}K+Kh(K0CI&Y_;0Ww zaByS_xez`*Pt<<;m5`cFgU3Z%X<)Klp*x%TgZ{`516&Aj+jS}OHGtEdueIt2G>p5J z8v_;6b{+Y)`8J}guS~*0PqCE4bMLIZKtBOU$()#R1u$GkYh)_XW&IlQ?qcVeeHZM; z0DS=Ncq>PK4qz#Or=icw^<#14AJ>s!*$VObq1NN=+WgUuO9%`>3-F*JxR=teQZlgJbY`yftu#$#>3cH2CSj&muKg5--QmUUz*R zwo>}noq)TG7cTcHN4Xln)2?kx17pVL(0mk z`&E=*!5vUU)RL)u->gAXVNq2QWl_QCAyG6EtHm&_GvD2|hwjV}ER)FcRAM{*kNUgw z#M*yEEB^#|?{04A1*_n59B>4{)7l2v@0~7NCd%%m?vU*N$lFfKM_^o@Sv$*}uu$$n z@2t6%lkz#1sw!JJu5$=%N=4O`5Ts0=tXwu{N_iFkp4@D-E}v5EtHtm-Sx1bvud~;C z@3%>veV(}PL&48y5Opt0-p9OqS=c`IB3Y#d?@=RGsgd`n#jDhad(;?_-)yf^x6yl4 zQ{4g5qYkTmTsAtUB=gn}#438z}&_bHAaU!;qPfa8n zC*micng~HV&DE1=PJ%(lo0b7*jh;;=3kzZpMZ#Zu9AUn=J52A*Cb#8^BMBd38p=q2 zhs3e??6sBV6P1Ai)>s+$;*@TrEXV>~eQcv?{ak7NRLPE1meeQuyggq`G&d!(e?mh^ zqh&uC=I=&OH$7c&ZfnG-u|b%}GG>ISP=kZ{q7R~BEew%j)OJP+BjH@+&&T*|OweAE z153+9Ow@@uu8ra=XCfRYq~KU85fBNh%S5_BC-BZ4Mh0>d&A#}bZuov2Z0@6w=a2)| zJBIG%zAh3luh@xm{j2Ic9mu$8WGmM zos_gkM3b;GtE>GjwNS-RDEwrkYsol@4pTJddy5ZSTW^}*+-fifdGuo5klmyFFNh}=5 z#=&$TW@gX~92WCgZHye}+wYO<&}u9^KO=A43z-MNna}hxrdGFdfU8j_y7%&|KQB57OR^!#Lh_q1;cwPGYt4LWQaA^PfCAhq07gj>mTAui6%OFq;#F!NO-igxh%@O2>e%qfv@wE$&nC}>BLkYP&(yzYP}lkKcCBLSw4021lM##g|mXw8YsW6i2*VCE%^L2nvo>PB}+rF`7vb1 zY0AOs0vh{%mG(hZH0`Ofm#VDSs)X2(H*valj*uE6djXmVN8cn()H9SQ+jj)@4vHsG z0j+~f29O7E`MEp(rYj>*ZtVxTHJ{qDr<&^B)I*&v24lB^2+nQ=RgP4rz(-$rBz*6q zQV4Z#w1a=hFl-<+gvrf}2;*Jy49+&1`Mbg-S>FcK#jWqx$o~gmJkZo1tg$Fk$wj#v zfTz~=cADCB>+tYWk^i&y%&w~9+rXyQ)RGF$o6Hag8GUzaUH52}yS2a`&8w`LnPRuw zU+JMS3pBP&vv1burLL?Qr+Czak%e5b>A*F5i~+m|uAS<&6NI-0?ZNRlL9ANs^!GbjPyOwpVBy44%T< zd=lH%vY(|igdYb_J4~=PjXuoJgqP!Jm~Tf4#aTx?an|A4%>sLvUxb=Hj3aU!e0X;= zV-K?h5t~9F98lhynL?AFt2Vb(Cqe#Ei9?Tlm7blIfln(9$hXM9@x=VL($X<$vMWx+ z5!2@_SQc+Cif6aPYkMnwhbo~<{Y|B@qY?w)iE3+owfSKs`v4^%8(zjQCUZz9IzWrz zd~_iu$96an@j%B0B4@S=@xyWKQs6P6qpRsY1PaCxFK5cJ6uH|`r7$G>rRbq%`Q;4c zZv@l<+v0Es9v=GTzu!iMPeT2S2BniwWjQN80FGN2LEY6z6xDs_!gcC%={ylkyO=;Fs`nrml9KQYs{ex|W*rnzBefcoIT;v7Af z7Gf9P+gE7yG}!$!t%qk~--<{u@VUHdrseako%sh7(3w+#82 zfW-iw4rWXIPW$+eblLcooG(h8`SJFi^7B8NCxyTa#JOGlNqb)<+tu#xizDzE0(m`; zvRuS7018FO+aon2X~HKn&3!Wyz<-ke&CFd;Gr_jr@7?)>-npI}ZL~K)E8^I@Gd1betN+=hhj(P){vaove3eYbhAr&Z3>QnG!$B zW6g4zj>r!LTnli=?Ox=60Fc!ZKf9dsF*mM`_^}q3d(L3}n8F=eUpT3zboQiaGUO{3 z^g1x@&^D_hv&D6ybYdVU%@JbKEE1FlMiK|a1>#Ajc!J|IJi%ltkBo7Y7O&hl3p3}9 zHRjS9aC^}M0KndM`@&9qL;WDixd2dkOi3V5Z&2&Oh+U_33T$BE!LuM!+x_0uz~HPKoQ>>eqx{Yln2kZ*deA{_8dfxz4_*u6E@!!GSn3B4qyOD`59Cq8#ohfQ3S~hUhxwRn<*M0WkQA$q1l0 zQD}&rQ$8KjzgD@Eosdbb@+7q`8P-`l0i^CvZ6j4OVUsPg`$Hw(iv?tel0J~?hXs>21DQ@r> z)L0vKA|S3c`0VkxL=4N>vQT`^Q!?cZnzXJ4i~n2InQvFC>XiteLQ2$8YEVX0*~_5z z(H7V=76HPH_biWuoauYOws!0Te<#?xzaeB8t)_PoxteBklqe4zc)XY&4IMMHwC}Wx{1@;zALTdm)%!b*T()h1%+AF zC_rv}%IdXIqid#&nuuS}mzX?(hfzptih z)aY_YhfK5!X4dFtxVH6G>#+-~Dr#&anr4Hg#qa+M^^B5KR!pw8-{o*1!3p%-C*R{C zYdy!Qb=j|4m)*K`SxBr)t+*2lwc zR6Kx~z*oOklenYC_hyZLyxKiXr-u~w z3W@$jRKJ_DEL{#qd_nxVBK^A4JS8z`9j=Ui1zbNh=n9%amn%;#)#L7x!&G}nW*6!g zF-U6RZ|GwE5=P=>s<{tpN&B@c-SKq1QND<}e*(JN*5$6%6aUaI*YauMzlpzr<7fc{ zhH|-A>4_`!8T|e?#EpPXnOZBAKmUN9c(2Z%FY@3`&t1!<(|=YmUp(v%-E)Y(n&_IY z@lUe41QR45?$uF!b^^OOp>V48vpd9=R7Z|g=|R2D&~G$6E2`O_O9)$*fIEw-^)=P{ z_wnqHB}6>4sM@WwFRT17*BJFR{;#U^gV?5rvXpzLuwx27A9|{akw|-$dEW5^-$idK zFV|$gQiF)^UHzfB){L54YBK9<6w34d95y6e$}l<_sv&3B@_b$2sZQBZ4XTlIau2EC z(p`k`XAtW2qX!eoHBk1B&^vfoXe@b`16vI%FY~Zx-Szggwec7+2fEwn=cs)>G5_@t z?suk_#?6ZF8NDiTM4&jF3We(Yw0fHHEah9<1)vVn(-XB=L6COOx}W6s$+u2$kESLD6U=09(xBvwq<)EdLPjcr3#R!p($YU=nMtkYlkB9dyXD-(oSSG~V(Uj~ zL1O-9Y|W;(&21#rOwpk`t*5e-bK@gXKmOelj}0x(AFoIC2)ZWZk9W%~t}>ygo+AXLBms=*{-xhX8U$}7^)_Qd@Zmohk!2~B=#5m=_WehETk_6Tmf+Qmq(G_ z0oVuN$G8Jxzki^`esBGV)8Ll-{VDbrODlG0W%=Y06Dw*E@xfWQx%=T4=nC$G|6XEU zF1F5Rt*{s=tC{rnR4&&|RG{o_=F&DncSHlNZbG*#j1Yd^f(RxG`*Rsm0Ke*!;h$Gk zz&4H;9u@A+UQ`2{fuS=u6wtYJkYfVlz(CH#hy?Ay{g(J>2MoxIAaMW%T7Xaa=>0Ms z1^?e|I9{+v0TVILs%pbv(SEs`n*8VtM|u?CYJh_m{P>z%T8Dh4&4=pnj%dHeo%Iy$ zGnGeya4;k8H1kH`CnvhX3T613f+$l(%0Con4!PvCHn8l6IfXY2B&QV!jvyrSK7gXN zH6c_9<_1`hC4n|!EYD9nWw~XOM-E&r(~D>?7#bjrqjadKPy91NZukfyf}D-?T0ru% zP2-{AIq0GR&e<%+L)#Ao9zE_Vfp=Ih!>P{Je=Qk#YPsQPOX0xLleTJX4+E}03E^>_ zafh+OAa%z5#JWtg&L^mK8Ec(?6u&&ftlUbGTO^*iRpdHaKgC(+b}JyS`BVE@C>eha z_8az4Y=zi$SolJ~L_XV=WOI3UQj*i60U zo}RP3z2#mVmEN$J52=Qlw||;(a9V)&rRnAFs|_05a@kp1FM}%l9aFJAetn&P|8;Q< z*I`Zg*LAFJYMTK2WSa5OG+p(axK985Iw(A7=_(Z981@d@mUhz86*%tpz~-NJD>SCW zGkJh~p1zXEZrucds_NIvGd&=}Jn-?NU(aT?)x2QkxM?`vF#TD2wnt@oEX^BZDUq@2 zIMy}0PciLer2APfW(KqI|1L&#UYTs@Q(@ii;tq%O@TC+%yozN%OwHo3JIrt`B9POo zH1m$>#@*9PDA_VKO!fMyzF1r!ynedz)O0AqemE67{sH3MJY9Qky79vFZn~F_(~H%J z9At_2T;6T%<#C<}Q~;+Nk59)--<#^)JC#w-Kc{=PP1ir2%C=9{@11UJnvQz`&zn>A z?Nb>XOSnH$65m%MM(~-baeJo*1Ns|N8Mu6P)BOaIb>f%?(+p#~5>85xBeRK{uq0Qy zJ8HJGMUpor^Qbv61XZSjZphdEB(AHBxSg}R%$u97(Dh2c|QQdq%!L$Pw- zNz}8@tZABGkJI9HrWNC6oUUvCG2mOci<#^$folxEAO0Ky8g_yQX}DXcJreME;|m5o z0UZ)r9gn0zfH)|U7?*3txf4xB5JCqO^=u;5UM}W;?0G@w%*O(@80p6WF9YoTKuvi8 zHgkaP0Di=rec&{95(VyM)^n>*!;d(59k6zW{*d@{BBKhXGA)-$TN~gy5a3)@;xxb4 zK$-^fv5x#Mc9R~~Hd5$pCfHxFv2Y2JU~TRellcPax9fXgJBj;Zy8iX_ctloBZY>+K z%cI7tv2Vt@Y1+}Ty5iO$xBg?S+pEJOKS1fBv8{g`i~l-0mL3_)4vp==idc7zeLUSb zIGtN1!!57E{yDa7{aAa;nQim!RzT}ZUPDxgqf$(ZHY*cglu!&ZKQ}SlyoaGY% z>wUd;tGY!a_1YFp9~sP2C=zh3+q?U z^es%TXJZA01B0XQ$bPxBLH7Aeq5vQF;6JSC6U!v8M;LGG$l0rwi(jS`^JFE4{&ICqgP<~&BW8XnEU{ss^ur0{$G8ecegy>#RB?QD3!2mW0 z89~flAv0`*cFDn_z*QzwOztNI#{@Ax$GzO7hjbItj{tT6obeg|3T&4ET><>)5EJ;s zOPf#T&(+y-yWRf-pIMC0MlKyNDETh7LMC-`U+b){uvl=tt#LESqkI2^x1LgG&{S?= zDtFCu_YFlf^vMj2jD0h%qVAP5^;>50IFSsYs|;6~K2+zC(;%k>=9oo-T1U8Easm4^ zKb%>*Z>Ij~OnKkT{J4Iv%~UtcM3Zpl+%bFVKS7l3&@ln^(@`lhUWA4lS4ZrPrk@0P z#-CIzg)eWJRjQUPht)2z$)Y{ip`9Fl=T4+uJtNWqTy1<)`4QMKOKE4MVst2ZP4Cg5FwGY)#xN2DtO*8=!4mx*#`+E?plv(pTFUpZ4fppbSNQ#G-T zK!1hvAo+e{?G_Kn)(){86M(NPA1Tt&8`2>wY(;YoI1`7239cxxLa6H}Ks$@7Pfk$w zPlyznP^+3iHcn8e`qTvFiwXXn6By`{ZeW*~-%QYUPLSW5pdXqbe>1@l=|Z^;+cg1e zI&6NR@buUOdEEpihdA4h(%z89=LwPgEv%!a2if!dR8)mm>?C`sMXIm~;s3 ze-z-*FBk6cN>>3|+UNhN4V0G8N`cO^Woj?g+V3lSRq&5<@qz_NuK_#)aPWsOkUkCw zY_W#J1Rr&YFW(TX zzroycgR=F8Hk5oZ1MI!8XQb?(5w~lGvi=5R(+wtpn1aMO#=^&S1sZA}fn`ikNO-SN zddY(!9xlL84|0MWYFj~wffP~*j!O!x*SHs!exjze*V*eI+QV7@wj=!k;1hte{ykTs zNnap8@oUjeciPv+_By3sDIpCPmST=+Km+*^dgYc5HkE#wl@7vs2DyY|XI_Zvp%$J6nwRb)!zEn`>>moqF4!?%=n? zb1m07fj9Hz>pZ(RVPHP7eAMJAl}$*og>#Ls?l-jm(5iFGVW2o_j9t5oW6BTe4+=S^ z?38~{cqM-YUpjS{61CXGg81b^KG3h#)-ZtN6%tmLbv}eq0t?-+rMd!+G)4x__?dmI z6z_sL&(d0W3q&Yps71uUo**Y~2&kUCqVCoN|rP zRF(t!M$0lw^e&8LmSUzd_S{U@<1@kf=&fHN?C}_n`O+O#qycT=FMb}&SXnw z6(73+-N^B(?HH_lgy+#7~(d|8dLtwb-@u7j7~YFE6`5gIfu0LV8JJ9=y8bGZx{lfj1rlu7Vk3~6RC z1driki660XQ+Nvez&7q`P}Z>h3TaSoG_A`KVqHF$_ge;CE2Gk`TKH!`Y?8x)@V5a_ z(Nt5xq#Y(tm8U5~m7EgnsFVJ5L4D~)H@MD;2 z$Ze=G&86hSVe+I^_#fkDm-{|f$-OSu8W+98rFWDIGjKn-%Y&}83Z~0rT&Al#E7aTQ z5kHNkTK6i=?s|clpN!!Z%WJw5$k&r`Ctln!tY>0=4~=CuJ!9tUxd9q8n?DlLn$;rKS9J4>S2$A z;shtrZI-xQ(D!QNCE#dp)PtZ;Lo*}zDO!}+GeM5$Q8AN)Ne__Jl9U)cZwYz)LNRX3 z-W2@g8t@M{BmFJl2Y|D$RqcpK3jmt{{P@V$yXk9Rt(!OFXO|*XM!WLNZ1w^SW1DsXgIUUYVrEbD}^#SLx*m8|oxkb3wrj!({+-nH{PA zyc~T=?nV1623#|>p?&0t+^4hQBC|c-KTNaM!%mpY<#|wMclzla9;V9k4g% z9ZleE_>cq4066Q@$L~OY_FedT0`TLwt?$=2RrIHI6Q?@6pVw{s-`A&cqb5To)9JpZ z?T+gRPpKZsJ(4u`xxIXeWzJzY!yQ4pY11rWbN?xmM>jp)G(ygEoVa^^hh&4 z)5%Xvo92|_(u#Vg-Ytw>#Xw+BD0BFxg9R+aiORp$ZC}2Sh$d*Wm$a z3Bi7m3(LZQH{WA^GKaL@JqM&^_>MWSo9(n?4hi37rQV){IX46qP4t*@Phi-d^e*pJ zXge#w!Fj(z`=Elo&p!l?#f9*E29;vQ)mTkX?!gy{7SIbrEIcw0Fq5Gz6QP&O9tD23 zTD#=PJ+n#va+G}8Y?3n{SEH-5z@FeCzplq;XX9(Uz_f@Ag1%A!vbq5G74JdDgCbo| zr=YEMNPwq-M&QB0BKXf|2xjL%WP%7b^^-kIuf!^U5$DL$)=)p;UW+yoIwnvNo=ZZx z5h(R#L?_071i?7%10oNYH~h!TgS6rP`w7Al(AQz-kmZhyfWOd1Bn}gK9fh|;Ysmac zpvnuDuQmu186BjJSK(G!_NzJWA@IW+=Dv9Svaigq`IgDS)`8S8p`GHw@i-^kMsYU) zV0dCTOU_SfpPG|0RZQ7C?D_v*6CQpa(pvy;0-X7;?ZoeZ9squ1rV9L|?j>;*FKar# zeHwcHRQQR#zBe*>PCeOA#*Lx!YV!iH(#y+)t;&nARe{eU;jIW~KI@>E<(2CYFlH_( zqEQTZ_jjZ8C%p2Vm8vUhdbyf{XZ?-D`_zr#)XEV(MIpDysW-_hbsWle~8?8YbPCY zkLA(k7`3!v=Rs=)M8wUYD;kxIk0OJ6?+RK2sxZjF_Ni_jxkz!NkC>~BCO||B|L1xR zRA9W%%q0aI=EB0OCzxmqC`<~BDWWis^y)rW0)90%&m{?uRLL}PLzM?>O%*sdORLC| zD)?f!uZo2NVZ&6vnM*vM&n4PtbBVqW(OY4aL?0aPSvNdIaX&WP{p9dmCC;F1S&qToBwJ58#uN=2z$cU1+b`|c`o7v8UKt}^48N%3~qw}2=mbW4?M zLzTY1iak`t9>%+BgTY6kY{8h{*EXr^Ys1ccX_#9Kq&p~<9n_HfGv(o#z4Ubr;|s*f zqeOWfVmq~pPS&2MFghzV<7g1-5-B83IXh73x`nvXLs`m1S7+{F;24A&WFD6&C4gRu z>WLbPuVuMB2t=qGVW=PGdflEi1VW|W{&Zj4U~+)KN-YE$CggYdbdCmqo&#>;n@haO zq}Yv@T1Wi@VtiioVH4dl8|j+>djZb6F#Ds3v>fmVfFEPx#CkBsFRs?jW~Z}W6Zk^o z96!UjwDqomo<1jUA*7xxrCTi4&jT1kP=SJ}1*Ed0Es-wn_@M%2-G@LpYcK0=(X_(% z6v$lAw?a^ia~P&n(yI9+9gAiMh;R*AS5ueFBa!9vh`DSY(dS|kn{co1r(a+IxWB9- zU+_WoWmPgp@_AbW^J2@oHx_SWlywET$K;k?J4~4izQo0buk{nTHVs90x&f0=s%K$OUe>=rxPo zx;N?xx+0tphE)v1QY!&Eh(Ci{>VSe4X^!?PAZd+3qSNXh7UQ7o<0ie5I;7tMRP1UR z2W7j#qX9e#;K!GCyofPhoIDOT*!bZ`!&g|(HI9Rm5WADtdV7Dc0@*_iSALjb@2-_U zAlBtS;&KPIa<7QoyCSz=*hW!MT4xsjj;n->qYwZk53_h5rB<*1-dl?iE zun6Lx@g&l!HDsY?fIGMkMjT#;`(U>Wklvn;T`C0W#5KyQYxJ9|lv|)7N9J6E&;bDi z5}11phH6)j-$m2kx+YkAjeJF`Tj#@?C8*Jq#Wj9Pmeizq;n@b1i9EN)<#hoOQj`!= zPgG+cqTYoyWI+uuy16y%<@v<7bv`j)#s6=cPf|SGmNy;>dVIyze?s8+pXXw5>%U=j zOV={!`U&P7oo=h92t`8t*=`y-ABbI>U+0p1uI%uxAHHepYRC$_UahOie-W>Jc0Pe` zoC8>NcVledRwH{Dp4fr~nJV>jN#XNzN$@HBx~PWT7&Mm7Cwh<-dfuwhpYVHihNYi9 zskmNN$X+Gy6D10Mom=jvCv8$(?b|0hV0rgdDvzI5v>M`Ip=nQs-`p;BF>BOg2yr zvTSIUXSxHXtR#~RPmf>(dw>yOVV@=F>k~}L!ysX6}*Of!^@>)Me3XFWM zFs)6fZKOypPHRUxBvVgEvy?Z(I>vSIwAVXfh{&EIz1Tz5_G(u`^STum zdfVs-xu&$mGDVF1xI)&3d6QWRui-|q9v$1$v>pxJ3!h4Wa)7fhtwnkR;8g%WMt-r_ zx*oUvCR!PsD(&^CvZbBC8P=ml+*2@LTP|V3^|fVe*y!rH*JSuE8^e_tc9LyE>z&8t z;`&RA73r3R*op$)Ff;a9mV=6NU|8m%VIvva#U^~p=%>Sc2Zv#+wyxZ8EtR%&g^V~j z)VFqr_1vp-JcttP5Eym6sL8tO_Yrn3cf>msQcMq&fiI>Lz!reZVZbU#DZ~+bTC``| zPn+y9FGac>a1FrW!!JSle!yJ29czkDUe_ya{jipeaWbJkx}tJ=Bbv{b^6#-ub1Z$0 zENxzhrLPi#v2+D`{|2@@B@re!K48z#&*ThMj)VV3NH(e(0NI6EE?8t!m4CopXg7_A ztf=24>f_(nG(LJG&Er)J$JH4h*YD7!D&%hj@WY8aw$0Y(vu@(F(;gq;2L)gM)b%^% ze${A_S4Xb31d)|1UoMMrf&`a-}z01o}I2kEZ>Cjk7|mLvMD?`tRb zUt7EXlFen-bE{j@4`-MU_Wo=K7lD@(7$H7Me&9y4U9IzPA|osk+y6j#tff&F^7aun zUZ}(iOpcQa*i(C1=Z@0Bg`@|V3ZeH9&**~%B=YNc^EsFDR1F25qtPs-o7%p&@$fH=cySU@<(RfZ2m8?E|6!3e_0 zf}DFY)RYJ7LuiP_gX16XN|t@x_t7YfYT2h_rpIYU>JZO_4YhEv`?%{*3D+8HTHV6@HFzjGtki&j-L!` z+Vd;N2n+kQ zfE#Lx{NQykX{YqQ>nH@>Ijkc%No)(Ac^B=>(9Tv{r}IUcqpW{YF*`3(Qsqf%1#L?# zOV3pp%%nAhfm3kpE5%+A?N^U>iP`o47t&e-Y>WZUd@Vw{FW@`?KjzwTEPk>5UsyMB z^4sIM<}`ZCXJ|Lj#N{;(QvtCps@C&3G!H^gg0E07$WrZMOkK{6V`eNq2-|>>7@qx( zoR|Ckd5J6#vSgmT=I?P?G8|FyMG9!OB=t}V;mqhI_z~nR`nR|m;MsP88(y^Qk9s+B z&;v*Z55Uhiz^VVBgV3h{+yvmq(e7gYUiQ_=^Vo08kGHp^v+ViXr_nJOA|#HV42N48 z8G2|=j*RT1no=@>>!vdJY;MXrz>5N2zwv@eh4Lc54l5Dp@R1;BF5dwLg66Rf%L>u!iAE@ zDR9i=Dz@v+V&&-%%oAP?*~N$|nx_t>+1eym#28^_lCW_B9LiLP?EWGs)e?47xp>N##xR)XCl`Gc*s- zLFf%*x&|?fyJH;HzADDwfG@=S+k|~&8qzldHUXUVOZ^gK5|9tz$1k>gXJppNCFA~9jUl)j7Yr9+UHpm2bdvtFm<--> zP56Xc-g+HpONS1lAC4{}2}%#W5KNoi2KyAfb8r!X#xyKg{WReZJb~#Qfqq?k=vK!-J+9JwoSD2I<#NRZlsTqJ^=U;;LP)xrK%MD3OaNEew4kt*t#w}o!{ou;H}N) zxuuOS7B*{*!v1(3C(@+m?@5ql^k-iV` zFu=wu>Du!`(l+E@su%Ti;=VQFSr&h&x~6Kx#POrcx%_6-_{ki_s2os1D~DQsV_qcE z?4?+mTY;^igWUn@nD-PoCKP@GQN9~cSX`{*bx|(;>!xzOA5W$B$ae>u#H{t5E!^cSf2luUU za5(K2`}6DETpN-l{wvlK{*=Xn9HR^*PZ8{S*0J^t(O#wBH1Pq4v=2yEA%8W%DgT5^ zRjCa3kG}rDmtXMr{?sm#b}R)~y#x1ZENvIx+lhA%6Gf1U4#7Sba2~*!Co7QN0(cL= zk4-i`l6*vb-~QLe%fi#(1E59(N4#|*q==jyF1*nR|3rd%VHtI$4#g1T>oMeKd0NnDVDCc6{+?u(at4@o94mgNRIy9{H& zMaex&iKa&7*zLGN7am((IvBM`<^nr~=N4M`zsDvd*a z3V6Ye z2oN9U2@L#)mU7~@8&@zz6;-}c6`+#_VM4+`sa?MYl-pTH+aG~nUBGz&r+&X@XwppN zmjeEm`W~n5c%^cVCFuBF$y%G+KHgf;g1|QhM?Y22Fs5 z7-BcXKHdxj?UOFFv$&oj{7xjGH zuHT`DMEi^XY&s8X$%nD}#Ttk3F~RvT9J`r>U=-exlg8^7q8;UK3MEAGBASUf zN_UES)}s7QJ?}vJDS-Efrh0Z*X-FH8e-*$F#SnONUps!4cw5uC{`+NC=HxCC^{jB} z*+33K+m|+wuZ73t6yY#A+j1}vhHx)YmG^VGm}C5?blj_`mZ+nxC45M?$TNXHLsOJ% zeC|>Fdlexm6L$i_=*GVf#sa&nb_0RM&HNu)PQni^C+zX%B=NE3M83-Z?lNM$1K7EY zw5I->5MG$VU!1W_(UvQ69k>K4`PULiJeB_}0cr^a!k?EA&o4kYaivF>kZwBr?-HUP zUqYg|!*GXT7f|6~ZaMz>RlFIisBZfBk6!~c^I?47>M{Q&EcE3W@N-=Vt~G^DeFog!+Xly{3Z3YU}lQ(!JDE3LDC(mvIkiGF-FZIi3r^ zqLX<2K2UHt8a@#HU3si&{>DuSOLLL0132TlGwz)WxCi(6G1$gS{9m1VTyJZ+-`FZg z2(%#0*0U?BDqR&5DqT_ure9KsUYY1hm=JHxxGHRh6L`(V@sTK>|Hr2Cy^L~XBHs?+ zl<#xg`w`F+_xKTS$BjFkKd9ZODIb`AR{7Qw6fnNhD-A*cQ`VDIS4w;`@QrM+Pf);k zj=S(9_OU2m=f|4L$Ky=ap&Y9L4nE1_O!mS3iS~N!;KQ@^vC?HJwA88aTKj#{Pfhz{ z6zL2=A;78cwntQ{JM#Sj{D^;F^uy`anWLwv?**eKPO%(0mlb1u=?emCww_Vpu1u)t zU1>^ismpwBe}d5)2n@R9VkMDUPWdu0+IghYM*eN-F3}Euyw$XBT$Dkj%_v`evvNPV zO_gR(4NGs^A^`(0I`z1d&^}`sX zCju$~PP>ilX-KX=6ln(T@k6)iG^anr+nUZ{`}>XSmOYLwthxz%u}qaVcG_HEkj(?4RR8MDjQ_)dLS9I^WKS*F~Fr3yAxsbO}*op=qE<76!Fgg2r6@|fBe*gocF+vB)nlAz;941NP zMuD8C`62=7`MwOg(+kh_mjRP60~X&-G076-mR?B9p;jLP@YgQEUxP&cRk zhrTeRO~`KsIPHHN_p~n!DSSGw} z0RX3+HzNH8pv2~@Z@66G)1tRPXMB6=b;DZ0?C;WyV{Ebf10%=SO3k`l_yfXeU`)ED z-_X=us2cWo;NJsU8ULUOiT)%rJDnyOZBtmbo~0-2Nq)Fs@T;xB3h|pUIyjV@Y$SQ0 zo3GJqnnl+T4G(HY3V#&7Bg4bNX!PhogBc#IK5gPI%fmyD_&Sup{NMB@w^FK+baUdnj%cJTI|hH+VCF`yTid|q5rp(-(3|6P{e zmeXm58dl@H8G10)6&HzwgLmU!jY+ zeFk5US5X*D(^QLkBv=|EkOy85$Br;hBR;Yk6BS44x1v2t{?oK>ZLLzIfykc=aK`6t zJyhvDhX@4^<}uFLHaPj{okf`kKCq9 zU6Ah&;73b7oBgkim$H`aUR6G+;=1yFlPYVbTjfFc;u{Hq*ek-RQmW^-W10JCXvTjj}@6=$%x6zZZT1~rKV;enewMy?s5L=Ii z#0oX_NssZ22iP9>);|e)&MJ5O-(M*PGd)2`1Cq|r&N9JU>jU>54_fC5GkufZ+8 zBYh{@S^uMH-StPQl!AOZz&_}{XT<*Nv}@yRom|10L4xyW>`*?XOMyQh1v;0Z6(4e5K1u}Wq3dpF}94!>l1oGR@^{-b8^-S#)-wGg3^ z6}+pZV7>x>Ttx@*M#7LC+BH$Kn52T0pG}g)&gB9+k#Kv0vEl-!s4I z{dImOt--tR12iAc{98}j-*n=SI_={W#JQejkLOyw)n`=uoCc-4Sv+;ajYTceQY&!A zZ~>iae?R_2)4DPU=?ei90nYa~AiW!K2*8iGzZU&I(e_Jg-E4Lm)^a}O=GlIpS{HI{ zCq4jAGT4D0)a3H>&l}8_^`{7y@*8EW<+b|5Am7yj7{~e8BycKvsuK{4iO0=^dp{72 zh1ld=JNnvmh;_5sX=zJ7R)G5rXrUP!FXxy;XxZTuiq=PLRcMEgZvjBWQ zBp@`Cc9^-pWpIahglR{aAJM-;1b+vZ_e-=hjAHgP??I$ohL5&VT7#JqXSBjOp2aw0 zMGieLI%qSV@jcDE=qbiFT3wX+EK59n7x^G~8Un(|3${=kWbPrTcctRq)#xseSGXUC zYY11Kho=N|Wk;(kv4=P7N+`}>L9a#k-A0r#^f-`APEMgy-DHH~mXl!YHU%aa=`b|7 zh2pWW)yv(;HFlrW|04Q1Mw|MXufM&J?+37t)358z`p;SzdcmXWsOlQ@4pv~q-bfCX9M2p>>u-ipuLOyE`T$hSGW2GlifZjw0OCSD|FR) z2T!6-eoMH!xrcI3H??{XurmE%X#$bYCD&3?@h>ttjLQ)+*(H6h7E#}AB=L6Qzm0@% zht~sY&QWvcssDk=!sxIDg`O1=EZhv-u%ToPg;!5b85nBu9X1 z=?!%C0vP^}#hRQ$ucU>ciy}EtXF8wjMf8BCq95|OKZuemU#k+joxrDAnih5udW2g* zHT*~P>squ|%klma?m6xHe>>i%ongG6SAKPQ6#|~tY8d7BtF0eUtx;}`@3Q}j^0ZZ( z#`j31M+0gAPI*=#{Se?806*&fCT`rqmiTtF@jnFN^Txy1xaF_`q^%C%%Zn3S; zu28x|`=ZnWJaPwtvzbgKM=rDqkJH-YqTab$v=NUQjPx~tYJgMkdy#$u@DhL@{{4%s z>*^oH)ygzGEj>d&O!`Z`capn>^7Cin(*8Z}ivCSxcr)Z-G=uf@EUItpQFHgfQDPQU zIC_Ae)(zILE`5sJAx<;T!4{p*EtgmaE_33|af0h{V!ZaDe-i9nFti-W_jLP(ytt(& zME!N(XAT~jfpk8g2;i(QS5_I)mA}OOh0obIL!0%hg;RoiSw}8dfwinwzgAkKr(in+ z>$2Yd-n3JE@7DjH?_qP9HeGt$`A(@cS@ebxl}eGy#<#~Hy%!L2HT7$6cT}1P*ahIn z!uQ3xwWfuCiQ3M>zeM=_<<@hjs<*}SXrkZYB5q1K;@y{vIFXJ2VRL3c05?6`to4A_ zR9k{&u7b$cc-J$sfxS+wOUybCx6Z??^K9`$J1aBUI>)VNbOXZ~|0mAJsg?OaJR7w# zKZ|=C#l0)7d#UT0iSD`#q}~nOs|roCOkC*UyHT_LYEucMvt+C+@w(=kn54 zUlI8y;!}NQcW+k^ipgpOSk_pVhjvlY)8k>?$7rt0?eQ7pc6Z#Z?)2N;=Dlv6TT8m3 z3w4+Te}}Y;B`})AVCMBH3?PAYM-Jum}=ZDbFFbAEg`u0vA! zO5JvncQ^1qY_XgnwJ3sD55dH(z_k~yA9ANyJdB@@Tr93}pnZC2%KA;T^Fv zoj`|TGn%Q~PUzc&PKqWawod4jJdn`^rDckn!!5A#V7W1{W{s zF4K5~n5FLj%HBNB>m*nJrQRh3p_$>)h31F&D1d6OHx3$ZYkgwgO*5PDm6ZngWys$I zaMsiRW~$N)xc?e}AKP9P>!^dbST~!U*0x+v$B&sjN^lHn^@{XP8M)GNS9&-dy0O{` zty8U@%C}+gll`JRkYF~_DM?8801N{-<(Z3g9pD}SKL#8Y<&jQ9?`>*X9?L1wfC^x? z&^)96g0P&_(gf=aYdfBqWt~HiqGOJIMW2I-^k$m)938ot4w0U9r$Zwt0EGbxe1dWZ z?QWMpgD>wVuQ&PrJmD8bSY~jivta zh|u|h0uZz&#M!+9d~)F32W0i2oNz$SJt*&{QUjWSeWj>}l!R}T+^-c1bw#RnpoSWO z&1f-Y>NNTq0sLsmx3q3HJMC*(?_r~=5N!eaFBc$U3XcK76%Z@9>fvXEpJ(z@7C*&K zK4r|}sB4+k4(SKvRtM#+@&TD0lyjunX1d~26XfU;rmkV>x3X5ZpyzpOq@y+3W0<59 zT}`%HMx;|i>49`5eI;Xy8OzW$e+pWLx6*%YOkgdcD=3sBIKothc7k>&*$P79pF=d(_ZfeKo10?(pdn0jN2pj&54(sEFWmF&l?W??NHdpP=lc-W1)1(V(oC&>C;LiH_*&jY zUuFrfu;0n6%)gbfS6R_kmi8+1Y-P%;Ot;Q)TiHB%fDSrH+x^P0i|rx@MhA ziT9`aU-bCl&r*KH&u)b~#tR5CLToM%K7YzLW)Ie;gEd7=dic8{jGE z_hL7O2@1S`5DgwX{rs-%*8*?22kme(t>9@h|8cWDyPrHkvaXT`yUt->xYe~Lh5{#c z9^nC>IwIOAcf8sPGspv?$A^9C9eCP@;$0eU4yKC;nM<&nz`KV|o+fk$zQNa@`-pNj zWGVgVJeeM)^kw21>VJ#+y1~b%J2)2WP`5y0Bt=#O*1(g=^9mH!c2P9}T?JQ;%vEHh z!9HJseZGP{XC5^Nx!b`x$}Zy1^2p$PLAnf`s?wMkpSv;c9D4XSq~%alas!<4$>W%& zAfE}~$KTN@$6C(kW*AxTQPtx|_nHDPzkE(#%BSxT#HkjD`E==7FUQM0x%)hfCM)N1 z#jiff4Sa%eJbSwJc`24I%3X)@l!#l>I;3|1b^)An=eF`mzay`PMY#{{T5LW2%ZK7> z|7+t#xlPQw#(4#L3g~0A5^o@#plY&#JkB*vHjwmYXYYE8P4)-Ie>|2QBJJ>|t8Vyt z>7Wh-TRfkVf1B~gO{R{LGmH$cmoFw$XfL?heGZDyyoR8(;|cx|99B`kN|f87BOX9{ z6X11#Q@{O4{|umUqJGHbl7Hnz=z(qNUA?2;{z{{Gd>~yL!nSCsQ(;} zhTTk*7cqnBRkYCP!HKGkgbYyM;=U>}fH+%6S(a#rX{evm&ufr=5%31UX@@_MPKZRM z)&PDy-a=o&y4ma$-?E=`^APBp%QC84sJ+*b9YW{wS#jyVg3vmWW^KJH*TAjSAEy>Y z{R((K0j?1b)Ry{`j)9(~HK!*eV-`&a%Y}}jDd0P7?*ajr48c+->UKZ++^3`JVQI?W7*P+32?obS{;6M?hY1lneqni(!`2y z%SY563P-AyqMfE7?&A%#oaP%5)6LCf#E~c3XEVwYvv0kJ^f!RS_@?o*3h9RcuLJn8 zse>r5Qgm{;?N>;#TUzYD);ci^_$mUEOyUYYbSGI)$p-q6VqF@LmYjki&tTqk;Bug$LYy4!YR^w|}dfJQK*-9B9uxV#@B|CS(a{@=MXk;Gd4B_i^Dq z+ylr{)C}6rZFVrw|C37D`&a>rZ&rkWJ2eJ<-a3`I9x21o~Y@Kyw9 zOkKnDLKU|~d<#(rL;r`%BOc-Y zdJu`5peEudZ715h9_<*j?|p-GToTqyfYaVLAiWOoE`T4~`-pa4cj)By_FpCD?}jt* zBdzvsWJ=oeqCvh(-!GE}d8Tzvv(AauxvzB|Vx6P=#l$CzA zifF3pHND_W89F%-%K9>7d=X+tLlG4OJX+*^DwnxZl%zhsQqz~teWqez)wGxqGm_jP z;5|H=No-{(XJII(1W|1|qrv!M@-Syk?;;TV497nVho)T;v|9|cxCzPjJg(pZysQe+ zpdh`T-bm;iLdQ`cZFI#E z0B0PgT&wjXvRl|WrC?fMUW%d zrLYKp3iULe4|k#3=5Tkce$r6oB7t7>U&T?@P1NTY%IWO$zO<;61ZV|t>cj0SIwRi$ zz>mMH&$u(xXX>cQHPr&^K(SX5jOOr_9L?;2VqYL_N3ngX@-Z*?N8z(%AGa{PSh<>C z#1r^_=~<%O>rfu2+&hr|7;pgKlskQ?D*YRI8vEbMz55L7TvhqCQ^r@7S95L!{Mbdy zYEberyTNlwAME&Dq?aOS^!;(U4RK(x9*E057zYbH-uQjMf9xTeAJ0O()rGfXX?IbN zVJKgW-@wtXPe_`C{IvjQy;%L6PnwSW8RetGUu|o|%oGCBrTP`pajb7$IZl@c$*3l+ zwZGTU?0bJA?M;tLQGnBKziOJ)2Kmkae*7IBIOA0P?UZM5#pqErg11w7Gr@%H3CP2w zT&m|8xvme1v>N)u#&aYWAq&(*wL~t36K+hpJ}_dto$%?$NkTj)HS`eewHD=a*0b-B zZj*ue2XM;oYZaBc0Tu%IQTC45*OX^Z=Cj1?xW30*#Wi`h7sKUbc&yc_%m@<|oAY#pQ_j zX|4EaC}++r)r+Q*Y3f%@|6VDY)^%#nX*ZFnu3u@&_wuvkJ30P)d8~UYG2c?%uM)Gs zT~Ex>?pKJ}?<>rRNv@hJlc*lb?ZAM1KLorp+By@(rqCm|L zcBl7}tVmq&O~h5$K?>f4d%YJ(fdY;{$8$*N5zsOZk%DN#8zlXEhz(psO~~GCY=f`z(s@&(udLvUrw+DelyPZ zbqUtc0SWDrC(t_xnW$Wv)+M8Z=R(>oE+^Ol57flvWOne4q!U7}a2zMaiePu8!lQK& z>8kc*Hz70zP0^ku@X9{c+)CVUQ11lKP4K=&Jh}c?iLW47PXeREuaHnbC0$OFVgtoE zlro#TyJqTEr>c^SU*HwxRhn7J* zGLCbxo53tt>HbdMWEbyX*L}jK?`DsaPgu|!+QIGt6_xxO8*ZIDes^5GhyG3a9w&wwPtzV!Iz6bU-rlZ`7}YU7 zDHKBlui#u@^a0gW%tZ*B6k~Lq!X8#yo;NBncT9`jOuP$8;3FmWAu#sP0^*xbv?4!V z;NebuiL6sKTQR>Q7ynMK`&d3p>Aph_=~3F(=<%i;+)F|Y@E#I`Q)V7}kAWA-Py}R?;xDN zv{{YU(95`2;0k#Yry%Yo#tmjydadVLa}#mH*~nRXsjsKMn*PH`F@g1V_N*fQ!O;Xd zDgHI$w$z`FGuoGw!higa1U|yYfosY+rw)|Cn{dDtGjepFhE?fe_jTUsp2vwdNJ8#= zh(Fmk9PUCpM@%>llu1e`7Qw>8acJu(8!6_~w4A2>{BEQl1w0LK#(C&DReA&YodACP z9UfA5rhRFAwWD)6V(R#s(VS0NnT4%s9f3;WQoWav=~_&rx1s5lOqCy%%%2n$zMC1} z#4mv}y}>-ui)Io$RCc~7e|&D!Jmq$1CCHxzaQ1O-hxRvdz?(S*XtcAH>Y(J5(acV* z!T#PlyxZXi9hno6UPt~jfYV-aeSK2zxzN)D@T11I5B=!)Vk`fbQ#Y|cHctMlE2_$| zrwLTE-^B7sT<>d@e%a4*>tAH`XL;Z+GW%K9evvm4>Go(k%p?usx+~K9p@{lcaNtKl z_F+)_D3}3iDp`p);KEyTak97eLeX9WS~r#dDx}8)CIOuG;`U-wkUy=SFZSBWmUD$d z+a8IAh7BL8qlgow)deZAkOJ=01Gu}z)`ACR@!bZz^CDFKQLYr67nN23y5={vTXvhM zQ~_XxVw~+BC)(xClw~J1SzFs)=9sa$6C0?>u*eT2DM9e?ga$FX;}EE%}0W{*vOJ`%C(| zxYfn_MDu@LB3^zN;b)@96oj`g=?7%cAaQz!RZu<`cEGOX3cFc#AEeqIxWLH9-=*fb zNN22{xjGz>dP5NPDOg2iEWZZ0TRk)MR`B10$aD~h>iy>{edj3&YL@DwrkdhCiF>k$0)-P2s8l^#0MXveShGQ|A1R1 z(h?)2$0Ho$6-oqAbR$L@=Rt(LZ;<3&r0;{I_#smAAo1NvIxLj?FMyL=AEGofxt;s! zl_X~_DPb4PEWV*+rmrY@9qBW*{j`#)zA7kG$b*@V7!W!*LzXN1%X2O%iCX&!ElF*i zrqgqvUFU~eYU2Bus=v|b^Jt&~@T9Fj!*fLfta(ZhX&UZqtatbcg4D=M2|Fj}ypoTI z|2fKK1&6u zs4{%ew{jU?cz)BEuOC1AFURu0cd;#g$kjfS@t;ZXi{2Y3>kYsUOTe>P#+~WAa06N& z;*RsnS6uGKAK7h(JG?@#m_**Kz)X&16fvrC-HK9kT%UV7@8wl?!*f3ToQMF{36o&Mj9NL zxRm6(6RWgo;Y+~w?G%@k;7&ZB|4aO!Xr-sr?M_@tLes+qp-m*bn{fS#4~W7I#!-EE z($Tbys;jSYL^1oiZr>irP8fJE$xSsX^h zJp;OogZ7i622!%0m|v34Mmw#Y!%bbz<*_9gHn-Wzj9rz%bPUuNZ=_s_Zw`fqRtb20 zcqjC3YV7)>Ud}%1X&aT&0IdN|{Xfn0OWl$03*bk~{m8o6?6mom_3u+LX%d2yG;UQ8 zi+1N?(QaTHNxP_I0-W;uYCTeaCH=jucf1rDvFOfWHzRX z8dE(QQ>l>tuGWYC!D4^1`($ac45HtwdKA@^4TO@WZ_?ONIw&noPE%2FCDVtfg)&d2 zQC0CPF8Bh$AD5QFzY9sUnv-vJ**(f)rkyR+r)_IgV$ z<|LWT3Ra2@eO3P7 znb}K1c+vml^X$%U?)G;2Gf(+Gd4B467}s0uGUH>kWw~3f*RY|+YCX7m=4I2SUy7HF zI1VeEb84`kk1^Qz{C3C>0$m15*7JkNZw2iF@w7&@B~bJ1<^u(4Q>u7YqH#LVvM#yXgY5hbDH@(R*lcH=QRhM(&|q96u@W zHrLdxO5iai^r#Yg4EV35?K0`)wu$o56;$!J&ZLtn5+x(CY?N>ZJH9X9Z<|P+gSg_Q zbSey^^i1cam^rwi)F8Oyg`lHor(!8dsH2sj2272FHj;G_WxGnYN20Q+zOGHBjw)RD z2HA2ye8)-v$_EwBxIunq|ITJSbCmti{~}-57IJ4$vRyVI zU*0b2xCkWDXj$(|o|b3(tB%7LOMS=FHr`;%_et_|{LIBXC-^3{;Q53t4nR{!j-4(< zwAq`rpVdto%;dK-aav8q=`DHlE_wPCH#uXr#w+H(`*p!W6^P>)$3e9X^{j!CDcx{U z|0alL{BDb$N|@YoI@Fm(z_3jAx1g51Z-cfEbV_;kB!1FLJL(Emzj)l&A$P zpAxM=&xH`tph6m3ccbi|2hkrU+Kq3uk2)$r13<}svl97Zpv(@k-yF34<&BT!xK8$) z{lVY$8{2RTrf!*sm7mo^3OTH7C*t&gB2Kr+n+^03wwU12(Fed`uaI3K&+ZDF zSvmEeXZM7Q(GiUCs7Fg3!#hF=WL;%1scm+u*U3hIQklrx{8`oK_IlYk-sE=vJhb>| zV-)3l7a#TywQO$LC{T6Pwrpq>(f4kD3LH-L6>Dbh6=OVuxR1f0m?LD%+ zHtzl3?KaPjBlmahCgi!_sX|)&rxeq678fr{32p=K@#b1b#AfQaDo)>MqP>!>_9<_* zGP~6}nHapjSE&9f?S@Ei=RxqP`aYoghxA%Nd2~^`1bh1Vhqf?FuofdAgfl9jDM`pU^PAsqZCk}VEv^%lO&3*5t!i{U_ z*cmgxqD&|%;|6w+$xMQZ1)9&yeTYc7my&&SFB7L!aU3L$naz&RE8?Okd5hOqwdkp< zvHS1&1*^JAE1j>MCT<;}$^AK|AuL4@0rYnq)5?LU@1y~IWT93-;s|hlJ{-Ba(Qeu> zt?zNtvlfhcf#%z!m2S}r)HocEHfjYd!4ilDcP8nN{i2t|AP#KzqL%iJ*6T^F&33Ke zNzFS~ix*qi3c7MUvLTkcz-ts>Th|%wfQ7$Codo@YdL>vA6xVdA<6$_pjTq;cquu$I zrJnD;#+Az1m}-1$wW{`1s$i#bno*!1q0Th37vk3xxVUs_e?=_^n^y z`YXGwaGTaXK41Jd=||l-PgGBzK5e=*C6i9A2rU)Z&2f7KiLexLy$ zJJI@6;lmadkAtz=iMDE2I2IXKIL>oHXAuCHx|3#G+4h5~?v>@K$FmFVdl}teTLT&a zO7_Rn?or1LpgIswZ3|^Te7l8zam5tL4~^W}VqHVO`VohoN?Fw zJ_6XZx%!})X~VQxGcTS7bsA89E*5s8_2f3Ws3^Qk-dQ zG{i*^`RGq*=HnWCoFQEa1BD_6K4sYJp@Q8Zm8ORDQs-z^mR`zAAW8|I4iFqaK}3$A zdtWI1jSxd57(9)rQ6(m#uSXFoPM96Xm7B>(b2O}yrESnP5LMre5Hb zt|S$797({5c{H41cnC20e>cwUKt1riR|sS!2A0>JWCB+QK*q!IKmnWc5#s1D?MgP; zD5ZB&R9F=^DY{DmK81mp9+ikz95k}^F%anwhoedauF!7Nbu;x8SYHrNx`nET9Cxkk zZxub7q2>Owuoz&4Q)e`Gx1zEc6Q|9Z zJYfW)r}UBvD%${zcer4P&|H$~ zMsJ&9#XYU9j);NS)syOYlV-sj%nO*+B8}_}BKus>q$zIi=AZ^C6c*sug6bZK8q<)E z?GE~QHG&03L`P(%Kzh|0EvVA21OY`F*2(%BQxz5Q-FcB0B7YC)J5aK|uId?eYzKV_ z;%P!uj?cnRPh2nR?D);+oy>Q(tgo8s)5c*U5!E3JpIyi9R#!1n$Br4RAU;`StXIBN zeP5};FIDR+bv43QP5Y1)iNI9=(szHzE^2niBkueOl=R6TGT=WVx)tF0fez{aQceF# zbvjlkZD`kk0TpdjEz3|cA(zbvWL99fkb9_a1r6R#t-Il|lLg7Dg@weVb>wsSqP72& z7JWh$fKhxx-Cxj)zU245phcfk@{5 zx!`LoZdllyv`NAO^o6BAy@-wi@@GGKIYR9sJnkxDJV5k9QmoxWlvKWJ(zr?LdN>hq zkQcI=s?(Gr2;2gyTe(P^h5);;5k|D{KZ!9}uP_k!7e@RXD;wY9scy6EAGN17ji;B9 z-woOaO7;)=X@^7W_5U}X3cJXCZsWT3x8sR#Y^MTnKupSwUzs%H^a-5PmJo1lG4rC(5Gn|$t%jUWT%pV z%&Q&Z?RmLHn IeknB`C3=}>C($x!50)tJt05SpIlj~=p)3&|q}3suYpNApO*$K$ zLY%`&!6$r{HL)1sepy0MA5vgVwNWX7!okJ+8XpTVu*jqRdh852&rN*)#Q82^x2x}t7W~M` z{aLi@35!AbB=l>r&yykGyUboRZ03a{HQtDgGcX#@7suP>#dLA8T3V?e1(YK`5ZMH8 zxkbi<)K%#IR!!-a`Z-Anrmj~$Vm&@)Z;+2z%EGm zn(kI=;#&k&D|lB$tZy*F3ActxOk1R|o0XQw4GIuUfIBxX%_>NDTB%OeD!@0oC1~KV z(*6+oNvRfiiFVsXCsD{avYk1sjgq3cHIGsxtY35Tchg`!+^tHWY)B!@=TR%Pt{r_j zX3Ska98q(a>O&M3Q+HM&#ULfV&mW6V5NtV4WcuvTE*>oX<5FMOcHz zp?3So!ZqxWopY_Jo4dr)=e>Ub(~xKMA}mmS)W?@nw6vBVuV(huOGXi4!l&dN;W)wou2L}x!%VQX=p?wL%+c72JrT+I+_xn)6WE6S>3{q8m6CQ10AV9g;Kva^C zd4hP-oM zOCq}ndlVZVO^c}37GkApSsqs+3V=(jPfPTC%~Em+RxC5o&4}3NRZ}DIR!8;`cBV-% zxJ5?P@t7$dQf2?n+Gf8)21Oltpkh$6e~v=_-=HNRp5FdV&Ii4JmuLHZ95uF_&A40k z+eW6ZXsmU*LfalK53TnBOu*D0){(-S2E7uCl~2bO;L0 zMaFHqV~?3d&j3H^HQIeiBY#jYZ6Ind`U#wBGuk17Uif}R|Bh-j>Uz7yKM0_4Gw~8uuKf z^>8~8_p-%tnmFc*}1--n!BHAB|R=`8n;s5R5H_6RT5>KNw8=d_&IBQlo9~TA=e1L9nVvzL{ zk_R`oLpt|x64HnB!2B-;Vl~}KS;WXB)47MuC3J)<;`xNQ&hSihjig<8n_i1Hyh#P4 z(1mSK3_{yu<|42pvif;yEEEi>{v+95o6!zQdDlzGe*`)RO19VkEp|F0Loq%;JoSHD z_Wx7G!5Z3fza*s%Nw0;?@&;m7&QKht?AXvzY%X`Z!HqgR(a5_wfP#2C^+*;!fto*dSg6GTB z@R_WKwJ2Xl+&Txn;92C~27L*#Kc}thZ#mfTs3QR4Y2qug{7;-B?pc~W@A;mRpKCUq zje|U?F*p`t$4fqFieP|Bo)Zl}=4)nrgUHNZGxHl(=r{p(1!omHRGpE!P?Y&+N^Ptt9uO}*cVk$M^?!AGYDCV@TF~53HE*JYpm7ltkr8QCfsuA z>r8o#xn4)-{0iKuODh0ir11*N{Y^J_0-Ff7c!t;IRoIJGY=?y--C-%OFc;@>9lar| z9R+*~5qW7UmK;-QgE04f|z1OdQ$7C)|MiHqfV_WIZhV)bHqhX4G*3 zh^Mx{%J$y&vpkENIPA0iG&i0s2bBHS<}(`y_N~A(!N8s%j%SPGY9daV;@Cg#N*W1goRvJU+}mi3(l zQF<(4xPP-qS)e3-%!1Gz>Y#+_RI}h$3$z|1p?TmwABPvM2d>`KVlP{1<$48E3!b$K zz_OOPyq{aC`ysxD(1-#f3ch-2TrS?9jwQWlwQvLX>n`nK0;swiUiT9rbF0FF5g>78 zJJr&#>M&ZM&mq7S9Lwu!%t2Z2`%s@gyYdbq{})I(t8sj(G2}}@h8-VcLhM9--e>dk zjpJ=fHHcb|66SmXHzyl1a?ut0hkHbRG0f<;awckAJ$R8d=%5?$s7w*gm9v)#?Mj0KzW^j!v)~$7fOi#YYm_xE$iP25< zc^dMAK_ftReX2k1(HzydPT2AY-;NW@v#%vi#6^>)PMR^X=Zq_+j^}6{eDjg4XzAFS zI*rdvFA>KJczvff7@JpJO|jzKP4ry#LViN3IxOFBAAXyU8=ej!A3O)Xte|AQZ$W++ z=zS1Rf7tQbTFOuAzLWBkx|Z^jNizmaop#yO)29vsF#hx_tg zs3@6A>2E52BcCeU`pPb{^kD|f^3xWL;ixi-6&4EP3?UwUMn&erO_TjCI=0L#GKp|TfM z*m`3I@^Rv;6<^o4b4lM`9vAX@n~6t=^N}Qj%q8kd%E#Kjgm7gw7p_#EBwQJQ`8$Ha z>eFN31J6PICVrId(*N8hIo?F%F9%%%N{++FJL-@P|AUE{nvZq`MUruCgX zYsN$=$(6Bb1h2DH@;c{pv>t3Jsbii$3#N>D?rh5op@`Rr0~L%R5V;V`jZ`nCXYe&d zq^h6f`x)motrNM(w*w6XCHu{j$o~LR&zJpXsXb0tynNz(+whg_H@#bq*TQ14mFD|> zIPTg}gKCvp?Kp*@`cZNm%f>Qe_#qZK%oZRDNboQ-4l#BZZ~CrUPIC?@k$GxrdKgS| zgvP}mLXY7O+*{*cJe?)@UEEQ9K)Uj9!-)Z~-bhhmssiMzoPc5(O5BPB{e=H{fL){i zM+weRjoB(N)le;b#$CYo)hf1iIu@uTT@AaU6%>(iQXn4=LdfDHSHbK;OwBW3#`G5j z^3Rz-PEejBN`UvM;phwt0GWfXZj0L-V~)xCz8v*v*ln;6dF=w&i-MB%eF*u;h44!T z@w8;O>=!XxzH5KgQI#rjNB5kyNZe}dFDKOZG=vSsnmrg+oWc{##vDBXV6<8D0D837 ztQj+>O&K|Ggfs*dd!%Km;FZo7N4Zl<75Pr$xJ$BCcS(k;&tlP_lYUi-jwzlETE<3g z^*Ef-OV!hF!u({vr!B0W_|Xc-JgXCS(N6ys21%|wSaLu zLQn^mVn^Tc@IMMszwjuJyMlp;(+XIKzr*Mtv=-CyeF5lS;d|j^DIUugba{N1zgwWY z4zRSgwN6vhZg6UMIsY^ z@0-M-lGF@kWJ}?#rg(p#fq;KN$Q#rm+2QpV(lk|f1DHJ=>eJ|7Sof#wUr(UlBw{EEKQr%mZI_1}}GPn#Yd0*~j z1~+S33|D)R5`W3x9Xh6I{rNZY*MaTY36{9U)ZvA$;nn9v;jkosKZ|C^L?*qNA+s5oR7`>xf`MNxQ zD7u!<#*;Q^fsNXQVo>&T?9#JR%rp`&&-UJ;tJJdui(DT| z<41@(gyiu_5WWwDz!k-d09hBJxAK?;e3it%kg6Qh#Caa-At~>99{KX|QO6!ovc5W1 z!)FroC5Wd{U&(bx?Y>A{Cl6H}<$So-wOHJ0?9V66htreQ)Ko|4L$eSe{IW??CrDLL zcCGoPcHAJf=KDmPzM$eXUtZn7#MN!`<{9GRHTkc>;^GZ?@q+xoCF0&TdGDCKH$&VT zdEDT2eZe8-`H*Es{QgdXzJ3EHA>owBArkgR2r`>_<}MQUM}Wucj2K}Safhv+S>Zyh zGVG4jE782z5j7l+bj1}xbf>QP6aXM2J#lYn*!o<_JBB TF+UgI25jyD(21at`(dSePXgeci*SNf=#%VM^ z;@04jOH0$QhxA;_(Kr zpWO7n?oQk)?RR&0wl#Dve$FIg^Bx8F3GMPq%fBwPgUr)Q*I3>st+o&&cY#?AHJ}cz z)Vm`B-k1N0}7M5DDc)N?E)S>`%D+Nl#mke={3sKQQp#tho-?9OmAY*$Q zu@Is98KM@!80u6KCy!6Xr6bbrlk0;bze#Mah#`e5>-6LD@5<+ ze1ou|fYDZq&MiK(sFklOO)ZI+#anf(=$wT|Wv8XgOlwQG5W1CgNb#NPP4SKMmckMc z?|K0^tW3qPbz*&i^YsZeQuIRv_8i2SV#3zLOG{l-JbpB+$7vae3G4P*w9sD()BJiR zlovdrM#AB)=*c7&iFS=SqrMo8_Dl_@3{7=I9GaKz11`Z1l2rGZ&wOz{6|VHCmVEW}W>XgC@IHL@z8gBgT5=&j6!Sxln9XNm zO4y<}<~aE-I7%WKdEMpQ?Fzu0neT~50S7NiGD)tIu9uiOym|SK2zuu3FoXzzm9g-D zp!-Np=4$AfM$zjikjU`KnJ^HmE7^?-+jg*joqbu`%SN29%XoHJh?<%6^am%drglOt@F5v8GUKq%D zUHOJL?49pY4}{tt47qM_xnSF?>uizBJP@il7@`(bDQJQ?X2~uPsfQQ1ywOtCt9!ku za-fIMEbi$$f=p1~A*_P8SRu5&gVanV8?pJQ)&>n*#;(#-wXNb}X_~23uzLv`k7})r z%62j?k?mBAhABtB3N!{}x0Ch&^3Q?Z0`U~elk0BbdnfMu#vG9QzABqfxqO#w$7bWG z84r?VK$_SOAP6mx&_Q1VABflbm?O+M%5ufdVY#|N^KaCg;Nm(N8zBdEta0_AS&N;< ztD{GkY!?>d(RonHXd^-?>X7KPM56uOHk|F(ECXMMM8xNWsAdcGgV7z zKIsdC@cIarSKxp5*Ir|>fu5Y4Sir0~es`tQ3hLzBpZ?ec*t;OSLxw&7 zO=+6cR@UDGi3YEw9n#Zv%|!{<^RW@@P3%r9_Le9&uN^o ztPr&u0t1=dB|xTbym<;l-zDY+Of>U>~xD6`hb}G`3T|D$wg{A zTFerVUBkSOZ%?Z*1TKI%U6~ppa8ZYRD^r%Y|J0^_pL>?qaRIKUf|BJecvE)_!~Ibp zo_=Z(cd%JeMRsz_b*_5qzvY&6*3|JRRn8%%9%hk4EaNbsmAAOlsrDi;g0 z+>Sc?z3TA}L+ywEfc)>Ee%D1gaDy{1`H>HEab904vESa%Jbn^)srT8R;2obtiO49hoszgBl0hR z_JNZ1_XG03^XnSfUs~eh*^g^HU48Pral*?NmDTVKzCVW=Ii9?y#^!8wHmU1EH1} zzCR}0?k9Nvg#DKDi>PB3uCK7mgxmDksr-*-a<2%v>mNIT%OyrsPycuIbg}O*J5!kO z;g!RVC#*1ke69U^`p_269JK;D=E*)PcM zciNg5`Ho1Ag+*+9NURwy(&)E6LIRYSmUGUC9JkNN7+}dcL;(N zq`X50zDwR!-XZ1h5*%ailAm=UoVt!U4qAh-&Xy6UmZk+ibLV^oMPaV5f-a(JFgv2a zi1R14@2{%45EwT40!EjzDzymTFqSj--OP0(Q}?T}?^To57?v}nxwL=ZLIO*P8>oM` z!oDmMsz;`tqrvA2Q53w0Ob)wuL~hub9(^g@T$8Ten@&Eltm{i)tiPaG>!ekY9zmK4 zg_A+m?Zr$@+@GFL5q#Uz3A;_d%Z*@wGY~{5T{A59kY!>yR5hcm+S)zGt=#0s3ev&+ zy;#p?ucS}6dg~c5uS8%Ya0b22_UwI9fbuM4{TDNH4KL5VEWfW&z!oOUw3QiKm}e_v zDXmGd4~7VS_PN`9-woxcUiNTC>h4(fw_J_EIpmi#VUA(aUpY6?4T5?EctfZr*!?3C zg^#~2eCESoj!%c;yeGKo48S>jj=JqSc7=iP;*C_Pp1iW4A3ndkw&Twnb@Vthp0Wvtjd(^tUdvM&+r z;SyRnR6-a(q8|8Ek7nnU~A)?XdB&uYOyObMY`7me_L0sw2{_cg5e1Z*0J3Rbzcj z4!`CDw8ijh{;!t98_KTJ;KBywL%p)x{hUJTm4if_o{*xgT~hK6>GCeXI*+K%qiW_6HFi|}Qxik4k7F$~`Qh6< zk(Hh_Lsg@JP@302UJKl8DRWfiW0O)yc~0l}T5Kd(qhaQ7Wf+*%#gI}(7g=n9Y2K^` z7pclJFjATu;lW~hN3?Vai|Za5WyR$Xj=9R&NMi&mRej{vw06J6*$CF1-kRoK-Z;`C za-_XpT=GtF(_n&g4A2!ltc*p|x!bmRS$EdYT5Pd7D*G6+C#{p$Vyi7{k?NXnD*ZKj zzG1n8+BuwKKS~E5qEj%VXg5u!d#l-4lQq?ZuHODlKIg6sJ; z@RYtpDI#ZSLz&CCfTVbfeGwkw5=~Q1MEpVJR+X3w?vV&k^4_-Zd1j$z2EN3(sHTqzoRJJ`YNI6Ig%&4+&3 zSLh*z9AWOGEYI<-uaX9z_o_R*5!&f-ulq4C3J9 zKd{9LS)%+!7Ne`6Xy2=`AJjqbkYVqV$Jsli>m5q^4{#FO?<>cSjlAZCHOwBbdq=z- zZPw2-ll{EtrFbcw4(BuSS%JbJ;1U{h2O6aAtKlv-lh?O{Ut$& zx?Nlf!Wqxia<0#%UvsOxG%PVd#OezB8PPFEV{R{^4^l3{90w#d`Y-9b*cUQfx7yS0 zX}y&qWf6msu1Zr_qn9H3Rd-qc6<5jnKN}nVGm*atbQvhwuT~@f6lgbyr#ZjK{<6k- zV*QJM;;_{2SI7RgUqLB>Er#5EiU6of$dfQBMv%0nIC1U*oeSIcBj7Qh-zs#h@t4uq zVxMuVF9K^mh>eVNs0fSA1XeXv+jTy3u8;5Sgmoqt?=9iV$#hg%C4?ZQvc~QpY6-?} zR~k~he1+|cW%7t^xH+au)<@OVl5eaQZ@28fEeg^G$Pb?o$4(%2(DV;d@_5V4Cq zvD97IZiML-_WL!k`3&;C+W=I_Eqv}N6{W-B zG+8f0uW7<(x)k}@pv9o%IDG{9XF#ulc-m(B(VR;E`E<*D@CkaX2|#Cp7_$Msm7j65 zr!VCRxFCejJa3Z)8Z<<&Q^!GAgaot*)Zk1j=ELVVU*YwB7$X`hh<^#;lMe35K)X;#@C=d0^p@p}{a@2QrXBKCpx&V5zMA8|4#jm#d)8+2XL7YK zD!Xhff;Yprf(ENPLw)P%!F%?_j%|T-H5-)c<@>Y+HI)Po9KrCzl=1=2QITAjI+G zFFdUIP#oyk6{w^cTm8mk{s?RivLRtlp;_p87R}am5;S$gXR-VJ=0-n*%|8oOBm*bi z%9mOWXVVo!68M|EK^79udzOLSL>&xEj>m!6=k}HDu?yu)h!Q#+`;k8a%B*dy4>k$; zTF^oePsU+cAE6)R+5T$dY01g*rl$6i;wIF=&<6b8+`z^nD6t#(S`lywdVNPY#;s0%PY#Q zLl&;$uJMbP?e&|l``M3Pb`&cocCEbM4hDz9Yiu}l^#i!I4IEW?D4D^uoFmxuMuiQ5 zM7#(xLvEftkcWE3;N@!jfL-MNvL1ccHTCP!$XA0d0VT)p1IWJsdL6`5%l>UYt?~54 zsq4|+;)=kaCv9Vsm2H2hrn>r4`5BlvY=hfZRbdal0RKfDzXzdVk7fez#UkmQA`m&X zL&+gmEn_Yv#|fSlb=L-dve_Ne%p(=yW;=7@~gyu+;wf1ybe?zZtn*U}Ze*DI3?_mano_m&K38J@z z>3CRJ7c=kbV8QwzCOP*EKF4QedDnUejUkaI z3BYPi!GOwx1!0Py0}A90YrKdYL5IjOc95v&!lH2tr5C_kZ!CPy;(CVjY&fM1p*snk zMG;UiJu}BM(fU76=g@KzoK38Dv4QTK9DZ*h@pOpICDE|;0YS6|KmhTdt^o^NsOBkO z>xekIh8j`5fjHx4jgwH1>t(Dpn#a1G_~PCgAMW_~VyETt1a}hnZa}_^FwJ@BuZdB1 z|DP-8!-R?CK79Lc`Mjk3fRAkK z{u}7e^fL~4W}j}8S7E8#ntX(Lg>4rY;TPnFezN2Ja1Q_^MHJ=vkau5*?F|`khSJnw zh+kF-d1$eoqqtS4$E6L11q0bl2fvQW&nH>0(ermeTH~fx3VS|e?h2usK;BeL^Z87{ zHyd*mm}cC=m*_UofaODPH4>GwTv|2rjo<_@+4`2f*)irkS>H=gk6b=X!ANaE{zcF(kj+ zig;l-VcbGcbM-W2=s3kzNQ|S_@+gj*h2g zE7xO_N-ZO<8wLYuIyP#uW{z_oV_3;OPt;C`oE|W?5v4#4a}ZU`e;3H|T|Q6RK|g>6 zW+n1ZgWduq`_q7%qK=C}b3r^!$dLW$J3Fq8xQRoZtsmWH`hZjojZ$EE z{3$)fPWCQMoYr7N3%t#{l*O>)TV{+ET}l;<%a^9$T*f%IM|x10K|C1tJ{(pb3LBfl zX|SUK7ZF)PHYnbWin&24-K?0)6?k#40E6M9?PKa|N}wJ$Cb})RmrgYDm1*!i16B~* zi7%C?X%N$}g^F>r;##P%bhavNt_kzGg&m^i9{}0*%E~=v5FW`R8(LC7M3^SMNOJ0s zJ-$ek;aYpTh$wT2wvCdGI)brBNe>fW9gFU!qPyKkp`-*9E;EggJ_JI?8<}xFn$M*} zk4*xGsrQjJTRTCvQ*3?{KFtv1F9uBpCD-wK?jzf2;d65ROSaSIigeQ+ZMjVOy}wIAl8NqHWaR zu8^sDc88p_9DoN1!GM`O1z1Fu8gOWOQ?Ee+?s zocZ)(T7-#w34t3org}>M<+=xGBz#)#1M8U|42ZqZEa2NG$$FlL`Wu7mWtk4g(1jQe zpk6mO*0Z*GG2{$Ou$Q?-*7J&SlCM}hbuoWTQ?m6hOA6)vkN>~MeN%lG7Pnw9W+1-P zlp?fr``3^7OF0IZvK8$kW$`_{fX$VV}xR*2>dU>T@B^ z6|GkNrw3xN!hFTMRVm!2gtjWz(``!Ut%_@#;@+yY|kObz-XUM;6Mq-Nx1d%%>z z(%v)-QsDD}Sqs3Pu!6b+6%~n`A{GDDE{J7f-3u70W@#(eT;eHK+nC29=7AK|$jkqLY)^?(-|~oekLud3Dk~!9dw#`eMaIABkHgzGk5=iP zH~g*l`Mpo36mL&)KbgWFNa+can>2MeZ1n4)D)M%x!!SLubp5~Kc7KK4e}+^43VZ$x zyK5ubhLnPbQ%0$YM^ZX$Ng4G>inlH$m5%YIf}xsK*%i|-OK}TsEz{-0Xe8gqNcubE z&J@^mE>6jslj85eO5u`sD*;Mi7y&Q~614FCK=s-{I08W5RN6Jgcq=4h@}SvT_NU&N=IRHsIh>;MC%(R__bnv8!!$A=*Nok zk>bITx6L!kS=8)l_D0)uITw+Lg1yu0z*Xw2l*CH;iA_q$AlJ|`;yeA^f!+bZq1{H9 zXW?qdxx>A~f_2n*4jOeOx%1I`>t3sY6yn)Q;Llf-N4mg(4U2|c0>`YoeYIhZwlQ_F#$%w z&EBFzi{`{<7IH4g1>zTgL4xJg{oH@biP@6RlxL8O==U7Zauj%mU%3&h_{KnZNm#%t z0X9$=pY1?@;6Ti2cm}j+*obxpuN4n@Ue zSgz9!avGkj4r&}S>2yDFr2qsx(91tObQcMhMuyN8n3|Og?acV^B%_)FqG=#%Bmva- z+ZA-YzeuZ*6~ZmQXJua7#|WT`^E(V_o6znio$}fucvJ@K+AgoHzp_L34m8xm)ph_r zrdF1mc5ctSwk5t!z4F?o1)?+&Y>WG)k+~3^0f=`J9@9G7A$298*Ha9wiS#16miC&$ zIw_s~BLdm};o}yQvF9K&a>DIoSgL>cMYG9>*@$mZN!o@h(K@SDU`it{3(3_YL{TGp zEpeJ=cjq_^!>D=i{5d{;#Z6vsrgjxr^2(aw-G4HP|(hfG? zsbLSSZZ%j2U!K|_WfUN54k&4azC+TjjI8trXremvCYl}1>Y3}y`9=ZesKxD9AVBb_ zN_Hgg!a}p4d*QhS-oju(dU32MQ4BE6l9Do4tL|mzw(^z*TXm~2%ez;cTkfq0mRqG! z_`mv!!ui2G3(sgGAFv-7wq;nws&10&Va=^g_PVzqe-~&aC@EKb@F~r)9@i62Wv|)q=s}{=l9LnFpe$VO1UkjQ8O1|f-#|+0ZT<@^)E0VY+r+&|wlV=Fa4(`D+ zf?GTH6V}d86UUutSy*e(&9nDgOKkiJ`zwwmE&X{amg17h$9R=sqc}^Qj5%`v zZO1#*0~B)`_Z^;1uzKKzNOenOJ?%sJllE0dkpBaedq-0})eR3iM&WuKh^M4q<*D>9 zMmM>iIaO6mnsM6nX_vxE3BvFTAPes@gl|e#wj5Zse=LvuUc zMAc+Ilcv6qMu_vXRM=pCnyO%*#@!}>C?_9p^&s<3#776^f$V;x-Pl%l^u+Zb z5Kl90|Kn5XN!r@|=HOHF#ph2|FH%Jyz*xpCAQzG`B^UAzF;Y~_P+MEMKfOK8*qf%l znHHh#6!v_YxihU%JGqn>)X$Nq9gdMGD#tb`bX46c%k>1_H`&e~Aip2vzN@iZjN=+~ z$8|ptPyf`;75~`I!>i#r$H8TWo`ZI7+pL|@wy~oukQ777c5YO5xE)7%JAaaF(dQ`s-FmH$tT!qd=hWCN190Ph!~k)_%~ zG%_#PZFav$IjeA4jm7jgxzBW;tPeY3D!6!B zqZuFehvT_Z#S-$Z)KS++74;=TjMDvx+V-eA;)v?zW;t9J)XGttF_30_n`Zog_V-{Z zF&>S>rtEqZ!P5}&j&6%mEnR8*Xq1XzYWn&p`y|c$0$s!@71%!!L_JH9xFVNe`RT6Z zU<5c}r!zw3+=`Ad0fh!HZ|zc9PsdPxJ|2DpWh@8w38)(=Sx*-tKOOXcAfA%`siztb z{q6Da@&DI&XtEcsC65Si%kJ0mVIfsyHz&3DcV?8)jM?eN-1G=$%Pg9%1mT;SX8ztZ zI{5TB9z1Y&@W8g({z0O42m37SvH-ENm3<$@`gBY}RBw*s-FK?6a_ z@~^1S9W}VF1@V;hPgp}G{~~TSJDg{i-|-Lq1oE62!b{=wi$=_xG9etQSTpd(+E4=YBuDJ=un!` z({n%uW@!b4x3GLPr7kLf5%L%;u^qIH_X48?bk=>c9r}MR`(Npv5MjJ!gnhe{o!4*Mh&G z9AOzpnOCg$ks~a6l+}b0s3EWd-8oD6qel<266azb7jd#_IzQE+c)NjygOT%Xh1&N? z72A|xNoYF+mD9S+At9XF&moV(pe1~e83$MbN9Ug^tOuZv3;smW56~xMKL`mw9}J_! zQI;w?PK{$@=5Po>+B(~3=L3T~2CP{xWTNhuY2;OiN-93e$Y{v$ewIO(q-k$buahiD z_flg~`b27cof`jIJ>$d7h;{~yNX%Trm2yvJ)*8f^AusU{?V=^uV@puvG5tzFTSnB| ziR-qwx-4F@lsIRj&m<7$S-n4QZjMu$VU)vX2YzZapa9g{XDufTA)38TI@q7{l49(D zcuaj>K|KNrOJSuL>VOs->OL>6kb0meG?X&&-3n^vM?Xr7ev#(WyjjJxAe5>Fv_MuV z^@g09Q#HO!3x1V$+U-=WqX^-^>L~jX>A1 zp!67)5Y-*utD;Q|_Oa|cdS5-=LT*$d!RoN@5b0<)F`TZR#dr3U^;|#M1g^0pd z`ag-1{fUmbh7W(OVYf_&QTMt8U?$&Bgf`&Z{= zY1ly&L$ZXfhP|tkumCYUDO1u);@g4wL%U>#vVqE&^C-T9Pcsq+*#8|$zAMj_ujI|j zXL%#wDMb>&0)`#00k5=BalGh^;%J_!q(Wj+1HR)SIp0lO-L&t02>BhLy`bc}qpXQK zyr2RQPjl>j=Gvq4#dY#9#$I=7|6$!p&T~V#;Rs+ZZ5z(UX>rnwwiO)*RnM3)_F}#c zH;`AQ>DWR=7?PQv!L(+X(Q&I{Y*Su>8ZaBmbWOb{-B_7!2ExW1q8B6bFjI?y>4nbp zMFh^>&fGjrZ>>S%1q5Nf3*pPfR~+sV9w)$q$i|MZfL6hGvXeHIU5uFv0Vg}6Ue{3Y zZ1-?z{lSjJW?5f%q8>)sh5m%s$^F2x0wwEf6Y~2&KY)1p=@q#?gev|wpR&T{XB#V^ z#eSF9S8^R_S{o4jhf8IIAW68{Og{}P1XQJ)5#DVRC7bDJahxoUpUGPzHi=FCXNnO0 zKS{SL<~9!O*^pgMV?HOPrHJl{FkQ?9Fg4;lt6U|lM%rvYEb8YS>$@n-^cY7 z)8=Sl6Q3>3a`A5%L1Q>$rH^J8Y|O5V1&ut|WJlPLu5EwIF#iBprMt)IDqRVI_hpmJ zQp0Jrax+%$%64hNHvCF=!5#w3F(CcJr2tU?SRD<*flf#u>QxAD@jN;O92F(NMr%22 zu@I#s9+mAn^ns>va5?g`LGwViyoD}B{y~uOxa{}!C;LC0u>atugV0_>O$ZCZS-dM( zoL5dND$6-ry@fd5%;BJEmwx+F?_raW~%EmHR=P(l?gEy4UF~*85bXafH4E`IrPPo zWO=8l&`pXE!fo)?VXDfXiIjL;woh!WYo zU$z)`og`mYWA6{%KUqJ2VmpH8suqkHRK4o0+98!3RzE|`cAUb8RNk7;sDM9to&v?d z3HTF8ad+gNh9GrR&F`#t0YlL_08^pX(5FwPxq-ME*Pw>K!|T7>n`U&O9kIA4Xt8yg zhUf&$d7IZ+=XI{~vO2HfVoEMC!>lc)E)wa9e!|zoC7ADd2f2g7&*vTI`l;D9@rg6=2;Jf0w3S_6h!-1bTb7qa$8YSW1ml?1R%$MJ-wVz*$XC>u54TyKV64&=N zdw$C}Tg~SAX3rlvX-YLhv7s}098ctP!<0Jv`3=pU-^GJg+5j0VDEa;^{gqGg{OT!i zQ5`$|3jBiR*q*OB>h0%OH9gDm*7?*i2-m}!?$P8tmpnHR1c<3&hjJkL36qnbKnaG(h&VkKd8^8-KUu`gk&8 z(*Hu@0>Iq#u~Ie+AcE{V_Zr_SH>q>qOvP!RB2Ikl(Ii8?{bPZlioZ(c4vfKh=6R3C|%r zNZ1u|&nAI6z%yRPxJIo@{EQ& z9cp(nI~SU@xw-5}Cb*#=GEE;iLYk3A?oYTLNRU+tH#`y3UnQ>=sNGzvvix;fE)D=( zP)y0=3D*+|@`(Jz3uHw>KJBJfBlJT)ouC$b;wChlSo(n=PkpYuh>Li?%#I$+4vsX2 z(NQjDWErhs`k3%WLLJ)E;zYoi>Mo9?YN;bi5DLsR(o&6VLvsydF)lI!#)yI-f>3XT zp&*E%w8B{q5bj6wiyq5Y%8XX*hD464{E%rJ z$>g4*#$wgFHP`T)$8*>(ImX^R^UXZt*Bs-Q9L7>~%hPb~_gq!e*P^Tl*8Ok+kFQp9F*Q22JHp|=vKymC# zJJThUK0|1CsN9~UbQbiu8T2aXsMjOHZ^6Lg`Gf)UIF#GdKg${d)AIeK@ZYrDJVtWx zKCseU`*rg1BsTDCes?n>&z*4@H z+tmoHG~=y9VWkW3mJDwRs1U?FF3ar>D4q1OjNJ4@qA1g+C z|0MUFWAimX{gd36%RMoRLBb1-5GEW1wtKH^zj=5s{{B2|ME+^e^PnW(d9FI3)Y5(QIJkNEtSgT7Xb!*TrOsFg{EaW1Lq}^KkKyto6l;uI1F#}=Z)&FK>B+rb zM~p3e6y_Ug^Z>UF;U0L{IvnP#(~Srjh*lo+hAhwJc-Lfk!bZfg7}w8&lKt^!+`9|3 z0mReNm|VZM1zPZ#;=hQG_rOAtYb-AmP}8O&Sn%YY+){p-(cvcAYCg>u7FO0x)H9#r zj&nYZyavn3*Jzoq8P&MG(4dinZDj%hSk%G2eYL>)x2>zL?=D-xPDM zk3nGMu~z42(rNeQC+^8VXJtNBdala1>hiG-7VpvUmMrJQt#UuXP_VVgzXbXjl@%eAh)EYC^4rN8+!p_ao_fMXTy5Dv zj!Dey52Xa4QV0NMekK&S-$@bfXd_Cx05Kjr)pXv$QUO?Pzb~t9wn`!^penQ@W_E`eI zzQx?-^BQp+C5~l_g?whPsQAAi6BhckH;dcT*fT{&eNhCPUX_+A1a2udmKFmv`q4tL9#0n{x|ced0n_4XsPWk_6yAHspimg2}w{FYsCYuULHV`18#?T@tELBjH zNE56ZAdpB%A_<7?85I>#BnToZYD9f@iHd?fDk{&4PgF#dSReL0LB;a)|K`rz>}FE{ z;lFV1ojZFor%XR{=8S4NY7BClj;&N34_~>@QB*3}@R(2}1{53(Ft~Y_HQR1mbn)ZCL8|I5U@V+YwPrLKEcKNY=`y`QVz0fD^68xk*(JZ6pdEHTw zxc22X2lBG|-vxd7gM8e}Dgi;6Ze=8m=5())#2r++Q_IVE%8fkbkW9A;Y1ne<^d!zu z1Dph~>*sca&8Lz{I)GoR2mac&1Zd?KwaXVdUX{rzd{RusOib ze_!7#o%id!YEQvF4BqYV)seYaCUK%=hB<3GG$KA!>;xc(Ap_b?>3(1mj?k;pkH&)a zTNLM#n>t@6Ex@qfL#a9lRZ(MEI78G_ad=cGKmd?}i3*Gtbey%0Lrak~9AJ!fD=v3j zhNp)ResH%ae|5+wJ3k*o_`XmQc^XfgHwEAGXpj%q^>!*;m@}!*|rY ziSV7~g-25|1kRmZq(R~_pKgsO9dUmHPd-_1A+YEPHwr#V+DQMRzn6Z0a%Uuc@`*@) zYa8i*^!L&qK>is?zfPu~^Gs{`9E-TWA)kTpM#D3}x|+ke%C?EB&!|x5#?8xxgu?_bRvX zQ&8^}$DgKJFFzvAMgBDEu?^4Hs9fL)#9hifj<__y{fNtH#FZx#F*o{FW~e;{r& zK7xuI0~w$49n(i^i_8m@J1I0NMMgYNsvgP=dI82dm*R+YoFwls?uSJ@1@)mlA|Iwh zzIHy?<$8&fIRwiVKi*EemDBS8^35j?q2minJUfu4J)ZgUu` zu>tTqoXg$eT36^BL3iN`LcRmgz+8av)qr&Xd!BT~_kQv!-aiEJYxQqJzIVD?(w{d= z(7#sdS3<4lNmjm(Dl07(o&t0GPA*wkJsW2dHS<&l<2q|1a2}rl{ArB+Kr?r0uh9=Q z_f9y;S+W35a(<{Zf2>SmMtriSIJ!7BXS&~ju8$w=p?y;6-cyMogs?KKQjdd@HkSs} zqUuhVL+EJ z^g|07xOAaQalXIEv1<{W<6O2-t669nS@Y509JGQX&@%QGljgx_>PEp!aWc*Z$Z%l3 zTn6<-SJ-Gf@dFnroGvPU&Rg9Nvc5wOLCNf62sZ#;0@(8U5aF)@KLPmlfNYoATW@Y? zr`*i4>dWM6^h5LEaEGt9N-=SZhGtQP!qC+A8O|H64V3(`7`FR$N#5&|GB5^BU!CN; zBMDj0881OMfD?6i^M4BJMSmS9*zwhLFYL7WcGqx|;4l1gWWM(V!mk2$0c`#@zXDqr zz&-%K?i?=4!?ryw>qVt(7b*{;JQVY@nv3Nslw_^B9$BSFc#EH0Ts|8@;I;#3OYBpM zyVR-A#@(jUI`wkpHuy68(^%!$t)w(63A+`oQF+iLOE0{Px?yK#VyCd)2hJgNX8{|g z#yQV8Up>!%zT>>%?C8bXIT{YD@KCi?F?vRv>sz%o1xLM{B8^{^PV0z zqyLT6z@9Qal@(}1Pi4PS=US?j=?u0`#uZExcVw0up=D^-bQZ9GF~#ponDz-BXfXcQZDgq7C>7XH96U7<_Q_p%dc^aMr_XG?0&z zN$|I;9GJTBqvfAcp(BFQgF(NMtQaWthV{&}!rTCb$6bv44*x33$%=WYleYJM|#C{cK%M@{!xBCq54Awil`QUKsz-g>b$Ir7X_b zw&!or+dpEeWR+_y zzRoH>1;>!f(m%GexQuUEsE4WU27Zk5B7R2PpT=fJc3w1;VM8!9*^JS@3OlhyKNlhk zdEI`I5B`RVvE4~(h{b6=xI0Nc1b`ugALHpoya%wZhTlZ~je9jR|G5O=YXGYOwmj}a zcq8B$0KZ1RF63bE@3JDJZhy2R57CaH%9e8#AU7Dv?OOuMx_+f4Dg1$BPGH)^0g2@=(U6qMvhOV2kp8w3^1Uv~)tcqa07R zs0sfr_{(`MGJc$i@OglL0BrtVL->8bmjHg*V@=rX`UB(4uF%LIOW~>qBak?`h&dE+ewe)?k zQ2l(N;qWp2!-WbIANr1kObwu|>6FzkW9&oBFwy5Z2f_fgUp(3hc<5nCT5^u_zA6n3f)I4-Aq+(KReJk(_){HKV|#>x{wEtU5)UafL8#v+)^%% zC*~Vij|A|`?$PPsl@oFqVdbyiDLhhw7eC(}sVn=feG^~PP9O+VU zNRbB$V@**J(G~oK{}S@5m2%*GZ$bDizy|U`yxgXP{K&zOMy$z<2n4%P}T2pSlKXDZat3rpLOJh@wL-HW@F%^7$hw57jBF6iwhCH9IzH(=l25$KLywV;Fo>w?CR%R z)_0%WSKAO<-=PYU1fEh_U1E*h7FXLuLUb>QnVEXtqQ5H@eco~EJUW=y-DBX7>sX~Tc*Tpj!c54y z;Oz*XQ=g9~XW)H4z~=Kxgx3IW2k^`0b96&XKJz4>1+nG2eLnNSSNIdo)VNQW_9>RD zzsA|^LoRV2yTsiY1_W46y2Pzr%Kfhk)^e~qIs=ZJQDTl#`zd`{5&tC&bnZMC0`Q`p0EDa%D}zWRL#J`K#f^qaoLCL z2Y(}1xR1RK{NZdd!PR&e2+aUkTnz?S`TUm>aMcHdg;c@MX3!b5C?(|X3`Rb}`zMli zL^EG|dhzz;iZ7ya@?~^p6?(@BfgSa zD;U*ITOM$P`ulOEn+O^gA~`2NTD! zYG|D_ZGxGGzSN0j)&^bhQ4bnzz22t?{|NX6VDmA_>maUoBX%*bzb5#RtdrW-a>pRL zyb4~6M2BJ>lrpo;$#U~{ca1xnyw-f3k~$>|z6lGs6+f2GJ%)AWOK;FVfdzQxkUB%q zy%;q4BoWskd>`NmfL+hNMcDZs@&&-J?LCD2vvXSJgYWEG>-Rme?c|)&#pvTp;XA3h z%+#=#gj(p|;&BrmobCaxeD@{R8R=Unsl~}P^ldYLyXl0J7?+bTtU32O@1@^5`J$Ig zbFm@l=exL_F3s)qH0zA&xuH4VMe{JlU>YAX7%EtqcOp*G%F+h%qt#hA*LiWh_?U74 zsw=i^-b)qWujzwpQ;*oG)nx7k+xkpm^~ zL`#377T$dFV*ny|33)d~rJtI`NMHx}1=#iH<;R?)Gv4iU;_Y=Bn}9;;*Rfgt?c(i^ zmEIZTkH8FAK)_B<3=>IM(ypHSoO}`c8#Y{W!7(6esB2U?NN(OEPrN z#-!fN>2cnllzvYVOxcfBbKqa9H&z)ksLq`(&cWy~ge5Qp!Jk{^H|REb1g^xL@DTtQ z0I>NRgYa~~EC9cHe<=7`(vIH3H@ua7PE>hs;U|vsS5H3@iilLMh*)C|xQuS1mClOP zC3sz*il*nMlub0lnVz~Y1+4_{%g(`|Yz)8&^ImiXfLr|X_?*{)hY;OnMIC zcK|y8HXZvBc7FuliaSO5T`TSK4vG#@6R);v;$_x74&%`^=5vfZnb{YH;5e68_bTYo zxqo^Je`z>y#jC_|9VjodC!)As#+7J!q6ZwRE@oZXY1Gn@3!*~_f{!q0wE1}Gd`7Os z`_%xOj|Fp`z=p)F1ps3 zm#buF*BK}*ouMakJH|1;ibK#H80)G!-)g1gDSh-LzeiNuM!wAoES0EOTigKFQC~eI zm@N2R3SR7SZau<$9P$*Nc0Td*7GA=82jh?y{Ips-ObBIQ>8BT#ezx`C7Dv6fx@sE6 zB6#4clXUnRBmHy+!u z4i;bh%EO+t{+;}BndDVt{#Eee_z4?k4*OC)_MhgkXW^ZB44meT#~}c1+&g(+#kt1A zW}n=B0LJEpG5=0T@Rs#Z$FUQ%Gu`8?-%jw)N*R@0lrbv(%sKu9kfM*+e2(0nTRd_6WQ@D_Y3NTwwNNGwPlhe7yp4);1bY`r#Y ziITo*;Bn=f=&am(+{5bVu=m*1U%c5yDi%LcE43uk*kcZRfezbevj8@Rb7tM4YvV(= z8o6xPG9?ZYo{YJt`=sf|c{RVzXgu{_t-x9OErx%2yx)bLI5^@c0I>e;rT*us{|7S; z=Q?OLzNB}tpOpsgcKc`h=lfR=^k1g*KWYAN-2PuZeuv+c z=<#o)fp=-AD@^}gJn2*Xmuac9{Qkhp)c@O6)SnAfFKYffJboon^Z%*_-q8heCmxri z_%|tuS(2uAJk~GN{~=98sdELOIgv3m;U!J`$m_SL@IT7@4{I;bV|(?+&$;p0{;o+G z{`5eKKRG_0|3MPY#0zjVr@0dX0d74I#Whc|N8&8RJV@Q00`TO6&m%(NKigt@j{5I1 z{a>hl!~ZGsFJqXL@Sc4H`t*wcJte>*!3v)N9|!po1|M{|dviL+BAwazyQ2>X-PvUM zO&+WtN5*{56xc2j~yr->>67PJ02dLjwQs zA->1eQS4WR-DuJ)nR<<``1L%#0U(ck!stL&jlek)-iE^m z=Odb9h2s8Laed&4Tf;n`P~Sb&yIYI@iupINz)G6%Elrw|_=}$M4NdM&-?t88JxTOm zJwXSW~m75w6BJ?dqj1J%4!x*M~*ca0@Fc%KDy&t!AS!jO6Qg zUPWvdB4LTQ0cqOvo&5-Z{yrgkaB1 zXAnwNu3dnkJ%hPNKhPyR?*>WNbl}Vb@BF$j;3oI)GROviP1jY3TLe0HJSY0mHuMTn z{kg5G;%lOo3sDlDDCFJBJej#vFIGIi9GYpVNjj$1iE)`N?;Kus5s+fk(q}6z}{B z6$<)mK-ZDVzj67^uDBq(zSf?I*!-)Lc=rRZoj=dM8X(`ErjsUsoj<+z7~~bix0^pv z3Bj!hh{Ei1JgUei#V<6Y;UHu1v#Nt`oQ zIStICW*(<@6*V(BS>WA+H0|=X#ly(FCrwfg;1_SF>~+3YddD{Lm6k6mDl46hC1@)F z+(Hsh;Wv@?ko!xxEx}FJ1Ne0?c(^Tbj7*EF3Kx`Cz_RN+q!E^Q8i2>1H++TgkAR;6 zcK-c_u=eeN^REqjXluA*{)Ec;6)xnV^Cn7EfK{Ltpg^H)osMH@QZ1gy6d~Vnz!elR zq-2IdMy2XxEP!8}?~)k1p4j|skB*Uis|!lk-1#u&LRuk-Z!_@utkj|6I5Kj!_^ujL zJ{#UZNm$|ues`qt2wOdghSW$rwZLQN=MxD3!s5v906RaQdDcN*K>UH~MQb@m>c$Mz z4b;iwQ8$n!;tEa``DcC~sUIiwWMm-T3jqB3d*wE&jyMBEMXBLeNaCwO+BUz3)BcPu zlD7BFjgP?7DDmux!gDz7O%xs}P=Tjrnk@f6MCA8(+Lx&GqXdWfxlF$v=^qJs*~UgK z8*JpuDT0rbrU;(Hkyi|!W|=igJd1(naN6UT^jpZx%9-hcpFL6h98NhaME?>iGmfWD z;wk*^k&#;zo@SYGJfRtapL*aq9JxiM-$G_#nSRQT5k3w_UdL68IJfA$HY9ec;G-rA z&*7A_7I>m0RwwZ^0?*;dt3~=z5^Ip@kK21>~w+0x9{-e)iV7kf$>6&D=6{Q0MC(V&rT^V#}<*Pm3%DLN_fE6An`Rt;X54pwSg~6 zh!{7VA>=syr^AzD8+cj>vqs|C3Oq+bj`k2UR))bd1wVN|N5(2f#AD-M| z(~lKggTzx0JV!!q+^`T1C5yOLCsuS;J{1Z11%Hj;I~@77fzK8pM@Ps^fp01B9Zo;h z2A&q83`#uvqxd?U@>jU9ssskE^GmDRY<5Y5#8>j$k&$C-d@aQoo+aeC4fqa6j;-;u z5F@FPctXEN__`kLU~GGMC*syW>*~mlhh+!I&4zM?%1V@n*@BO{Bbq)uQAmeLH8Oqj zM?`;kZmE-83b+Dbx97_cz8)|j#%?=m-X29i*G~%agT;dWFw(c{>*331Qt^E3eHp<$ zKFO<2;@Jv3c{1+gZ`|Zmz?lG>kI>sDnThwh=iAk{M)84J1=l#2!Lfx*Gc-r=llQ+P zjE9RX3Rk1Vv-U{iu~fDZL^X2-KjhCNng2pB%taO~k@=7G4>SMKyUm6z&a8Qudhv}P zEviYK#IrRD&u2e-$@Bz+oCdJ-zu4m@-y*&Pe>xnG#ek(awmd;cPz6h6{{Iz`*FMyX zzW~bFk@{jFJPqG;P+!`_BPBOO?I{#$4H91i(#nx#uw+GmypQ+K0k(es2E;uI_%Gu4 z6>y04_<$~+@=u!|1+DG5gqkt8FRB%Bj$Ue<4|}b7R~k7)9i5iKW~Mn)=XC-Jqt)|a zj5SXY)CA`Vd4!O=KAF1nd^b61xPzPwu;reNxGsPW^i?)~h`qVGkvf@v1Jbw4Phln}4pKo{#u%N2{DX$qoC`t~C0gp!9@4eP?=FZa-d5V36iyX$r!SSGkbaQsCwE@@w98 z0kR(Nk4DjV9pY{UG)Ve(y&>poFCXjMr*B$O8N4JDXK1P6y3)v4YK*O9zZgBSBz3y$ zw2YZ!QHXnjFznLGP{+c!3omG|Dg>Q*n046oZNyH4jK}*VfSs?i5LXU3!+4Yo~{qCiMcM4u66B~H;b;x3ujiza(B!xMt@NEEj#8L)}PaM4DC;9Duuj4 zYNURy@F$Vq-t>?^0e1br;sc%BjQBfa^#0Z7RTlT{TYOI6=7Gd4u>%5%>ynDuI9N|e zvEdwn=bk7$uOs~6(+=`5z{ay1ao+&!c4qSd&OZbKIF^vhML4=|Jzc?QEz?&pXRU3J z0h4!_sbSkQRR=2Y)yc1h1+tuIk#_IeM4ddbKao5Q;1}1E$MW0EW?NpB(ejR}sF+uZ z!A7G@e=X7vS*a1y(RzAgdv@v7e3t{9RaeX1RJ?h&ks#ermEdDP@Z?E6ZLQagYu`Uh zEQZ{^Ba5m^XN_1`U4nH!nSNMLAz_*RZF^gN-&XnJlR|jz z=6~BL)8CKu$62WnvbZLJ{QNH34uD;sET{Isi;y+SGCCg7X!LDv_~ zk3=#`&9zrz8zsJ4q!pC-+A42{)GnX6u&lbYvWWKu)|x)j4W1`+$9-_+?%>UxtekckF@H9$1mB3@S*KLiTQx2uQ#*8#- z=*ii^p*%ufjlg4DwUsY>4rM$lm39`{7Ylh6CPd0xN8^MY-yUpv6G-Z0 z`u8CHBTGNpnZ@Uk2ARGu@c%-;KmtL9YlNH@1CL!lZbA68B%PcEu*Zk}ZZ^qXh>u-I z7J7q{qNAXCtVA$H?|&I(Ve6A8N>G)fqll= zdyS)rI_lQI@LK|bI|FCm5jcu(6sx0L?J$ZBhn4@?6j6}hrGk$gpwE`y1o%bYi}ybO zHXqivh}V+DSFv)mh>yB$X^)SxqPbPDsVjm)t=B%ovDfH|Gb|ntI35dhC8QU2HQ<4( zLDE$Sd^TNG2*)ptBZ&YzU;B@9k`%nh?gMD84~mFUC_-TzP}m>a>AOtiv*6HWLJkd4 z_&!HC)}ETv_8sD5`Hz|}$0BOBAY`7rneZ!sxl^TTPoNql-h$)^pQj^yj2Av{0XCoH z7lV`{-Y!3_<~P!w*eWNsrq|X-aocTLsaF`>Oml)YIjC78_}vzTw+UgEl0y6dJKuu{ z_XZ3G@N3I9(ayB;MrLS_j%Ymu>tP~S+8v~8B9isFndG0Ch^n*k59_!J%o1y_5tkj&3_s< z+CdzhL{`SuYx$?mk8j&4-z^Bp?Q7c~d3-}~YFB5hx9l_6UW4s3)V+pxpP}tF5}^bC zGn@S{oBf%s{x946XSVjgY$$yD!xIk> z@Ckrl?bXwISx?C=V!bA|-NI;;I}IE^xoSx-A*v1Xq!%&MvL_VOj9(QoDt zdm}%vJ%8Ri`FWzI{>p1=kdU+0W7!EbP3eYp95xI!bsohRxPr@MIp`eWt1Z1mtlqnw zc3qN#b!GG8W#BD1O9M zbG499CFryBeFeg60S^LfKKQp=@%|=&U$N`aR%FyI=RkZwg;WX`pdQ~$6kIG|hV~ih zdyUk6M$jt0SyqP~s>z4H$4l~4m*&Ig<5l^r7Vn)_=65CJDXM}K?nfjQ&T<&bqKHJk zTqF1iW<=V@zuzv44z9}T?3$Y{!R2y+XK56kj@qw|+u;^?nwv0=s7B&x0-ij9ncTQ6 zfvgAI1+d$(dk}sU5ZjJ5*E_2nj@@0s$47RHA`)+u_`;cycE2M#t3$^>b}m&>OR_cGuR%Sn{c8cPWF&UDy2 zSM$-7VN7%syvIS^4}K*bzAlmW>nMcRch$*V0Gp0KKEpaQ-ec)#r=91Ey5e9ZjNXT0 z^qysn-qGN!6nxi3;d>t8?SPK~c6-U^V|(zv7r?J6ABpko1nFQd z{EEeAMMm8UTjvv2Da$Q)zLCy3p)d>y=o4Lq*nil6soCdBa{z{6*Y)nRviAT&CgSWR zOw9VjwE*6%E4*6BWe4c6``NO8X-3!FS&m1?%=xLgUhua$3Qv3e<@@-iUH_LY z*|JR`$Y9nLLm*sh1fF2`h#bct{A!~?-T>I;+x><~3K4&h<378Sv_=A4mZw3MrjX@v zT$VKw?^>jJ1mq|;%(fnQ1aA=hkscBKW>WXP1=$x|EBI>!9=jgsInWgV0sx!8W0tr{ zGTsj||5}>q^9&r14AhW;&QhY5@+y&URf#%@Ka8}4ygcFh`}LtE+U81uG($HEKDS2U z=}4aKo;PIGhZsCj0&S3Z@^T~Nf%f>}dWw$c@pAgp#(+3{li+7<6rT3@;d+UV=kqP` zM6p8}B_7iAh?b95+m)=0$1Qxb;Hwh&f- z@!{wBiWLsi{s(tXY>yjV=&9z zOg+PWF5?8`i`2;&*bad{A5OJju?M!H+lAZ;`bOq;tCL*h47|?)*!(%9koPcSzPr=7#aO8B3Gzj~xq zAo+U(;r9R^0Bn5xr(JljiLpzMD*wfNb}5RAeZaQIcPzArP<_rM>P$jTP{n2vq}3?# z=Hy54ZXE3)A2+zjrvMx8%ZU3opo9Kr=E6Cp6=Tb=VX#G=7tGY%E%>Vi9=krA0NdJF zJDM{QR|@E$e#?DaIJvQWcJov+c#lZGDJuQH*?yE)RF^>I&Y8fuVlA5wQYZ0D?|+!> zM;ln8nhz4ZSMbvaJhr@Yu8t?(0eA=4?R5QOH@W$nL~;j!U$)$0*R$(OYOUX|Y28jo z*+B{KW0Q*)EGk~mf^p~YYs^!xHir;(_>-PfpYRN^663e~1f32B^f{b4xmkOPlF#`K-pxkAhidSC!Dk`TwC8ml)z1O#??bfuc~nxA zv1XTE@Mng)TCuPkJK?OIVRaH;BkH+&LeYN>mng^eLnl8i>i_{Nen_&X*`@DCq`2q}>(GJj1{s zg0HZ2g-dIGv_KksE4%1X41S|Z)Jfd+bvJlDcOp(>XF&YuUmg(hZUA57B=Jm7AZ3G+ z$ajdd+oe}s4)P=3*M8S3pZjrYk=e0C&xQ-aDHA4-E}l~ayIIcR)8!8$qt8^wNN2gg z7kW_O4-byWf9u5>*@X9}0Ji*(+Je10h<{OjXX_8_r!9B;xv?GjM?A{q_N^%AUhSro z&M&T5Xwes&1MPkc!4D-bpTj+mL-{Ko7U~+wkFOxY&tQc2UYA5dc-rz@+arOD!MjcW zE@>~-OoAoNHqUFaYEZ&9TKE<<$qUQrJe-99@7hv<_K=WgZ4`c6p3g?%x8-T$x1Y`U zWq!_TaUSNZipuj)g869~Rh*2?OX*M{@sPx89v$JE;~j$cVE~)&gTNbAMJmc?6=Uvw z5;k2}W}LDtS-97jL&>w6L{DlY{-tf;-+XBJqx?nUJejhJs$yPC$IqEuU0PPgOAP1y zfeeAULE+k!b1TefXz3bzmCCsP0V;HO5Rp~%dH2?ae{YYQB~Es6${Yu@_j%s zSuC9ek1QhVYia2UoK~>f#Odezb$$f;DQb5G?y+%&H^}@4E}zI6Qul^Np1}JvQS@AT zshd28c>BBxyFH7dr==Wmxy>n>S8UOTu7*EFY7dL_bB;Np=}#>$6BxoW{dXA7<+yCfKBIWAaSTz1eCaR#!hejNUg%Ll9~l;r``_310DDVsD$NR? zMv12+3eVry_q4_%H3lTSQSh@Z3eVr?C)#^eGb=)Lj|n^l!y|J0`~0-PW3yu6sgZaZ zfX6OB{)ZCDk9hwbV3(gkh#Ltw0l=^J%FnhrqWq}0UTu9BRqw6xGkalWnblMmRuwOt zT_Fr9S{5Z(tCsEMN|{mb#h1MsKhjF4=5Zm9l2B_pS0nCF>1>j8rnEz6iDejIoqKx{ zx|obS;je z>n|;in0g#>0qlO43pWn$_I$04e$L9b@`_PKvr3AwwO1~*H*2!2>>-U3-yYyQ%>JQ0 zeB-P5F_-x##ZqDs5PCxJyL4ovzC4Zan}GKLc0POwT|y(?{{!IHju%>v*R9B?Td19U zXv>3mZeO@ih>VFb5viq%tiJ{qS=;nQT$~3bo?76s^Q$d9qubyAEAy$fCv`b0jCF}^ zNi`(#nxl^d-j*J*rCzX2ykUvA26*#i{?1;|}_Ct5TXEQ$T`rEo{ zBt9~>b^Di#IJ@2*Z2yrb+rKI8)Vs>|x(#*ItMO(3lD!4y?N19iERLe9BRyXS`XEal zQA#0kJb0{!pl1ZWJ-}BWL_>aj4f!3Tr!Cp*CG7P;t8sm7{cQ)I(A6US28nO_aS^#! zBOF`4b}n#}ixD5&UbJmTW%VZ9Ac${(7&s9pzDxT=XX)zJvTMuZYb%{7s`w{~n}o=ihhmQxI#;_BV2|q9MA9(EctfO_0ZSC<9o4j1^Xq4&a9Di`>$NIx*E>=8!OG090if487ic0te z!AC9dj1#Ppi!V(e-G4MlPk>#H`Xc-&zG-N0|6E&mEWG@*t32CVJs~g3{68U*KYwfe zt(n1Q_i+~W4Kn>O($ACpbd)c#>rt(fjg}VrlP!Y&h9j3g=KRt*An0w7=?5o7^yr7O zzSRc(lTZRk%}avr#Yp!E$)%-Jz?Vh(JCMFzj{f}4-RfKm1O6xC0cDLX$C+)=4MkqW zZ(f~Dzu?3pnLZay1P3oJp;rVSbw?DBRS{YsYLs})la5$E%i1Oiece{UM+wrm+rcAH z&tlb$B>XRdXIm7Wzd5g}E~^@crNgQc=)p3r&p72QI^W?Ha&}n0efF zc<(^();_)|#q;68sb~RwVFh0m{MG-z;uA{>oWUB2FQqV&Z~Q!i5qKX1kXNhoiRYG9 zmn@u#iRUbwJycHgN}$B*ti4Jvr}+$A@ee)2KHDulS|B)+9l z_$F*n$isMl?6B|^EhsI*ks&vkCz7w3Lv^b-##(h6PPvxznvkn`a-@H|=@yOj!+U=K z?{{#q9{)DTP`t<5iye?(Yb*uqb4iUd1V+5D-p=8twTg5}jl{ncX$NIoJ;F}{o&(tU zyL{{+AL9Lx@k^}|OWsEBP~M_Gkn*ZRuE9CxN$N37!x^zrppH(3xoSCkgHb&B6@Ej= zCpalm&iTIiiFlt3kXNg7b2^esPETGEuryy>9VrcIq+Us7SQ8Lejl{baI7}Hg^ev6N zg7?>s0N&Dl5My7KH>uY8orJEjo*mI^=hM{k`O!pYHnH9M4~#akJE-;eNjz>8 zkBh9hOzVChvmR4^z!9J~nKM4noIADe*azCko!WB82U_+{&HO;qc52HQ*`f4foqXQs z6la5?vy35XAD23CWakmSpqc54R|e_nn#0Ug2H+&*I5X4BI#g>A2@! zHgX@+-3iV_hvLL;H<#k>0SHpvJyX@pJ}kuudX`XZa>GHaE+xryXh}Fb(rLQ16!|n( z<7dzLESR9Escwf$V*^-lSa7OGP3@_8l(jEI}qZT4cFS|j92RlR~L=czb*`4o5vazI~Q3wO={d*$c>yV!XBFD&E2)_r|39!o{-^cJd-v4en99de<`xiLgpV-et z9-U2ISJQEBZ!SN;J%kB59o z_f|^2)pAj-0-TI7>O`60b&`(#z~PfZ8!;n=Oax2;*z%t6nTJ&4{SpAbZ2fwBb}{?@ zdEoXE+Qw-ENJpGh70oH0gt27>ws#`|;tzhcX!6&ZEA@DS>q?CR~-E1~dgDALS{PQIC~pw>)Z zVIB`J)32)O|5A^Kn-|!T!O*h+JF)TBAn9)aU3n6J6Q}$16ygTh^xv`DAc=VI4B*#+ z=&wC=`o&NMYbIBUvp$U#{H#wbnuKc2VJpmWL=C;540~R|bbGvdh7u&$O+Jju$#>ny zA|FaXpP&%_bP;aLzegS7|4uou+lGY7Lg_XI(~1#rn*xNP-2%UPMx_1CM|e139Ke?2 zFaLFulkq+sz^_>QJS#HlcIP3;QFwX8U~hp%p0g5$w#ffpBLBZvdws|Ae}sC1lEw4C z7tepvDClkgJwA!|TZDfCIM0mG&40?odp3YyvGYtTGU~SZkmwduC-}h`i5fPBI0x05 zt`(-Y*7UD1S*__?VRj+vpf8#03+DZr`M+Z9d*=I&b%A|`in2Ti276xShgSg~MI68C zBwuSmug%vMgx>(X2e9*lf6Mi`yAj8)1M&6xq3|WAppvgNo3C8(vXi+!VA$)+Nymc( zKN0y*a8{)J&OrDaz#@QbPt_F&-wX)+w#=f}{fby;w_b1yc~@G&;__J)vtilUZ&uYJ z*dAKW7+Yu;SZ1B_BfCwZb;{?|di)0uH(6(Xt)so|$1-#e+sxE2s4Gi1HH|$^Z~GRu{I8om%$-RFXzCeSJ*OUUFPBk|!Bk_3k#=+=4LUJ3EhUi zW}W#zO7^iV=4J3>VRm<@%uok9oWf9NK5R<6!=`kg7`{wmmtfQ`M+<0YzYy{;iz0OP zK)3*KEWoC7Cc;&K&!vCY)nABw?tsn_Wn~1p$^KxsFJHC;AA=mrFaiSn2-L_FJ}n`_S}@KaA%<~H^}{Vhhfv4`1Pn7*wucR0vK zOw#%@5ABQ&|5D0)s;Nw1L)op|;r~cAkl|0w)zjUoqN@Eckx|S7Lvb;;ZtAI8nkThi zn#W}t&VZI0?>VWn>G7s{ay6fxrn}5^t&7j)NR?c}8%-*^lF_5tQwoh|H*miz;D0nb zot=a|_&`3FT|wFFj4g5%#U=0&YU8&;9!=m?c0FX;tQ4{kumix`6;kD4pLjzO1->Zgt5?6I#ZW zLm4n>A(wiW8si@4UplQZ)&#A`4r)Et$3I-IMLG%ZhnGS+4iLRxkZf(|2he zvP8{iNiVXVml`@X|I+m>%=seg5_xm`6nF*d!qQBwfA;~tI8U51!FPhm9L!|qTI#r- zV)#`{l_ceEYObaz1ejziv&>$OS{k3^yPG=oEHz7Yup~{1H~pqBKJF@HXeU<&V`FFI zi*p@|gVg8Fbh&bZot$d2S5NAp>S<=Of_gk!Q*@s*o;lqG>Apb7nd$Ixqdddx3mwWN$K{0qIt|T6HO&1`fL)LejVCNg(Y7O+PtZ*qO{6%O^;Iy zN5H&{vYCzn$&4|5ik_sDDpNpwsd=`U=$!1s-Mn2*-=;WJ|2vAdT>+<=n6Klg2AmIY z9d)gwiEF4s&1FZAj896OQKtGHqy?L)!;Dk2x++>+&#Ad)PX!RrN3!W7Eg}YL)aC6q z$#D3_XmpXHr8{n<>=8Pgb<0(H>6XOqg1W+es-`5eDN}vZ;&RPi+B9Fn>G^(jC1rj+ zhdJW(Bz=~t=~I-+$^^~GVOepVJz88!lCP^H+n1{;bCcqo-BN(WrP0NXRF==CXISX{ z8uhHC@pVRT=KftXZ=q(kIY*=MS5tjGHSeO@Jx2cBhBi{&q?&saZHn=PYJRC`)8jmj z@!ILebE^5d;tCr-spc9r$>V!~#@|o7JwUZ2GtrY|K1xlG#*X)Dj-1ql&I!HD0cL;m zdy1KkscPAZ$C0YJ#x$sI&D4`Ma}%cTt`oInO>;R+swJ9UpTktNn+*Ls117?WXbeBo z&@4Qw&@Q}1@IYJQVq171iDt1)41q2fZ7fsp(Kh}Tho<8L{(B0d;i!rK?qW-MAQ6F; z{F^+Q&fejz%?*quv2VrKF1AK|?P0&87VxI$I|@6XRZLw++5L=t%Ghkjc=u@rn(j_& zwex1``GWc`WRFvJBTKl829{Fo8P&X4Bu+fG?&S8z3s z^#qwZDn%Fm)Mh)oJA!VX>lRmBH%~3|rFjSYn72AU-Jce(`I~4!O>hMg6Ru@R1&ME~ zNi6Y4XG%fx$dvqK&Fyw+ZZ9C&db$w4jHM-{COA!p37BUFluV^GMNhU2t{ateL?c&X z_t=E|`LRjj@tzE@`;RWWO)>&t&kyZqi`LGX7_KuPS4cfQ3tG%!Y=}Ndi=&Cu5BFo% zRi_Djl}Ovhxc@63sgGl1BY@-Me&?Tm4(BO-?Q7pop#|c|@JV1HB&-WOJEHhH|3#Dh zg?Dh@%-1{9bn;BC){3vFdFOoW7g(?m>lb~nnPAqul8S{@#n$GRzIo(U^DGjd&W>VE zt|!CQAn_IgM}Z{f5`?b-tc=3@^!GZs74I8gT4sG^>m3e+H>c;UlF>LOgAhO=akOP?aLj37gdj*d=ej9D9^ID6uM1W z<$Vi7u{!e#WfcZUtC?pNyAO|~RqQ4_5?8TX#KZdq%ly)cOJ2o-t6BOg)KTk$jOdrH zt66XryHTXjE&7+t)fmH^-jDTW$t+p-=m~(%x)c9Ay4&oH6&aV7{-gUk`i&cd>L14<u#RO${gA5Ggz@F+jj;#+utJ)X7dxX zQ_f&Bb3yeL3UUxc2iY=o`l^>*jaFPwz?*@_oMj^ST*>aBD?M%v^(4k+c+!1qs5jBq z)fm8X!S0sK;5 z7Ui~VPRnxZJHFQXJ!Qsn>v{0ycHsgH?u9r&}Nxg2M$X6{u?Sb*afRucPotY?On}6WIfGS2O_7sux!QD6QDWHcuzg+ z9kr7)fhA%vq_gQxWC`8u-bf#H`%$DBtw;E7_fhE1T>aG)r{dX);(E8+Ox7CQS?{>r z+g0Tq)xTXGm^^lq+I_RCY*Ia&Rqw;9;?XoGRjI;!3`$%DYBJuPo{erVKz*#5cEWzJ zRJgG0#A+yt8zvool$XKmZ99;Sd`-b8{ZX(+13YvQ_TvTc=VO>j`}0C{0-B$PDXU^d z?0hzpO=ovuG=;~h>>QM-hbfqPn5F~uTE@ixRqm(yMygeq*HOb`W*Kf!=Vt@W zv5xLe#o=*!9U;HFTiMk-G04{@DBh|DayfYO_YPtZt=7-J?fTs zwNFKYdzh+2DHiTdAF=Ul6Mm+PpLnVYG$iQY;#)BmWOXzpg3w28*Qt5#mC$1m5ryr*VZzkh?@ zm*cxWo9SJ26U}^At+-bASqX>_&fH9~?B}rVan?QFx;vv{w9WKmECOJ7J}KlGJ_T-N zJG z%nuBeOIiGttP3#2Y5LPldyMq}hEyehU1&kLq3gU6f7IQrbW_rLI8*y|($mx&R-ojg z=xK%?m)}Lp%u^KA>owD|lTwdPbo%n#{#@4iVQ}N4jz?G`EA^S(TuFh4SqAV`{D}q% zBme(dTmk1JOuL_T=UhF+%m*1$j7OOHFw<0r^{y!%`L4-#q{)9@$2{E=(adFKT2DGO zOX;G;2TyQ|X74ox?QT4Zw;w&^!+bWIhAtG701rz*fBP(zKWAW`@iap@cF{QYhKBF> zJmO^eYbyTuUwlOj!@u*?Y_^JOE2;S_V^g#l=8e?&m@1WVYcn#&)ViVxwyP);ez6EMT?3HaRwdQ#74T2?WVMmUM`$2Cc^0=YTdo`eRdVy z!P4GmQ+KdJ(gZbL54AweRGo#H?Cg`%rcIry8JfF?;ZaPx#o8{HA_t?tVr7_6@gMG` zNQkDfskD%D&{OiiFrsIkf^acl5y0mEGlY{CVf+B#SIQ>Q&SZy0yJAK1n}4Mh%xP^m z(I)@pR(DW-z-|NMdhFcE?ORqc7mZSrvCa6wpiRbG><1|M)|$v04cJ><10 zUrp+%WqnFB`er7g0zt2!8_ZzX8PC$4*+w!{pe9qbOs5MmE|%(dBsugntSWv*opDBr zGY5qSzJh6j!|(Sd#VMLM%Y_Q7ntWhDgPPlUVusQ~bNaoIC?6zjqcoXSqR_kXyqThj z&!WB1yeFV(Ph`(B`Ve0Wj>j@^5+4QJLDlmyD@tPvv6P&R={ME|bJ?3zd6N2*Q%*}p zs&5#W>ZY(9lzB#Lz7b-7-w*EVLAu8S%dVc0SGMgOS}p z>fre|oAts!j5g!hNF$M3A>EAe20e{+L({0?z2PX4KhqbdG>^v~NBDWb%K%#*I}qmA zf8?_;#!kxmG3sXCCggOm`8Tq7Zt20-u9^JnDT^UL-*`B>8R+#wbeJ^nM|n0Dxfk}k zusLHKuo z>wF=HZ>9gA0R=6~L#-?ioez=!Q5Ey&m!e(GtVcJr0h0qZ-ntLB?tM03sbRf&zeX-) z{aC8*$G|V0nX1F9nP^Q-Pn-+o!s*tl%rcLX%&wuO{<48)@SGkCbQe=P8SzG6tuL>X zd=Y~8d9eP7XQzv?;8Lb`W3Z%}uB@f}o7X7%ZD_dsl4u&P<%o&e zk)`|n9wkf5;`$Mu*}X7a9f1rViLgdOg9P0v7ewr#Q$CL;WAJ`Fz;1tj&(=vE;=><_ zdVUCc9IL$_v$%K`2z-N*UFh*J1cw~?6&x(^uWbYW%SRCZ$qS2N_Vr~1cY(lLaG_{- zOHq)|M)*3weE?am^{){A2@rRY$hRdyQJ(j8YFVzEWVtSs{pADeg`5sHU+2MRg`knI zM7^w9D^bT<_oJ+PAL~Bbx(D839tiVc_7^j1l-HH(*)eOFc0DuJuwmp+tQn=~-Izna zoH92`f#EY;gW!4QY($M3axT=Py4^PapzY%k#Qj+^;qHn5l3bBs2ym5TM~Y26E+ zW4uMg3iD>H@TKaDty$@4+?{HYyO(1E#5Uqa=C5PkTUgF@Y@pla(&DR9dl^S_gH_&0 zY|%vz@&I~t=)}&a!OWu7caR~&0>8pfJjswPWk|KJjGjl z#kmEIzY}Y`L$LH}-lDl~)%=f9n5cFA8M=ogEU#m$I&>Ep0_xqAd?hZf&rLMp1}zhx z$F0`9E45y1e66Of&@}2(y06xfXqt+t9)$xfKa@%sls-+QE(DKVR#QrWF>p&_;d0 z6y1T87ojuq^R8iM zUa2N8RU0XJQtjtV3;5EVfq16})ASj)DH-dPu6MAp<;G^m+`kM*jcNXZi9~9^wT^{1IFfX?ra4Y!6LqXY>AHdCG%w7f9dtW2 zy~gu2J;gLktP%FUnU1>DgM>aAWVygQi=8UctrE^2*aEK(+v;CVlMaqOM(v4 z3x&2D*=exp7@_d@aq|5qhS>_H^w!$l&6F?O!L)lRWqsMHu5NKyIUNSAb1h}V*+gh5 zr={kk)X;>})Tw-_@f!-=W>5qj^e-U$!)?0x5P5QDZsgd%5A)(6q;x z$GhVbTq4p8m`?#q%sC}4CehL5MhJGWCmlndCtwKW_MD4Brwf4{g?1`G8K>f6ftPux zFPGoG1w_1p_)h@eomF^Idmw%Z;0gc_<&OQr?#;FP(ORb;T>G~)ItA-MTN=;~(tzk7QJ#Xk zs^^0-h?f9v0J!A|%T~JjR*lb<*Et-`~`aujwk86ji zbT1?%4X_KPY0Y%6ud%Gl7bzIf1qnjn>!WQzcHKoEY)@B00f_fZ9->1;J-XjrEsrWi zd;wrR!0l)Ab&vEO(q962NOt&CmtVCX*0@}Mj&j7DhLfgEoH%OqM0nQ24hDAr?K`-> zGV%&$9vH*)u&+ATgj}h$-_Ifkm}ftWq3U;R%(pD%JC?Jb=?5Sp9ha^(#|kB;liF{j z$vUNUr<%vZXd=`3Xwyu&66#USp@o7(dkDKdV2@OJjFKWl$%v2aJQQ6f@G)do^*px< z@r{5z02dzze^#Z#NVC}jACG@F%l>`t@8WJJa9P`1tY^y35g%MZ@#1OMjK-kG+?0Yu;uIPQ4NmQhxv-1cT_v(g zX*k#?n&waP+XT@SLX(ivhEmpE>&tOg^UCf7_~}cfpl8&9jE!?36yJyt9U<_#2ly&rI+fLzDQD66Y~k8t|piA9>_VbL(V=&FYu^6q4^7JcLXDW zZ);?tK$j1gD&n;TKUpO3^a<+k(x2~M;0cfdaObI2qkK|*q;uaD_&MzOQ#@OzvYo(# zhpIaotpzI9VD+?;8!|0`SP3e0nB#jWxwIqPn@Q18qFm#7SwvB#RevyPDbkMuoIuZD z-+bDAmb&E`gd>+xhyoWYl>#pZI@Iou z<$?R`?6qSELXO)X3-eh-O{Wt{Knrg)N?tT>2pUFOxnXQEmBBg|WPFQ!f z7y^t0zGc5n#!`1VmsI(s!ZD&fj-tLU_TBc#pXWZ2{s-+*x4w3ZY!$76QaSlvk38I5 zo<{t6z>5HvKKVJ(Tad2nFX-&_Icmz3Q8&2Awljy{q)Ym0Kx(nH2V(HvoU3aB_ingJ z6;63_?yD}(FvKSUt_QgEQHJg-UP=Lvq(F+#S=rUS_b#IWigE@0Y*@7Oa?4i`+BLpXf z$0@$c1A8+B>*b&zi%aO0u-m2s>7{%ETf^<4@wbw6rJ#p``>XrOSj1-mwgFr`416FW zO#s{n;9+`>=pVxzd8Pdjmoi5`qgCi$`_b9ktri{}CMT_eu2A<o4(i4ZD1kD2D=a2Lc)Wys#+rT3ntsNbf6h?hOuS6-vh*8p z3B`isXCVxhpP}jo>?Jo$G!mdLo)Cmybt}&H810`<_XWt)9%!~b=?T1;0bTdsbYs7+ z?QX2>Xp9vaC6h7|;$EVepHe@S|4Y@+VR^ILYW9ZJbZt6Y?MZyn14ZiK{u~Can-~Kx zzRELvh0>kW&>3F#FSB>Bd6Vtn>dPBt>|<|JM1ysMm>JEL!DbRANsefcK+`s6mWAK9 zl?Q=qy%b3_Wi=L{d)Jr*lpsfiqR6VJl`qp9@~Pe>B+$&?Q~}F}zYYQyq0C?)M1#OZ z^ctuA=htX|#P0yS4RG6k_=B(~0W1XYkQo;3zkWmQakA7IC+D;}*Y>Z~Z{7BvCx5Tb zmB~E$Gh*L{+SkQM_-Zu#6{wJfb)*k$j2|uD|1;JXZO1pYK?hhMaaD1ZWPiqzVH?4d zK8G%ve9k^3@H_!R_>m_6#M|XZsvf3=lr~L&QckRdVmu91Qr`)x9jCBtIYHIwayJSU z^+QpelZ8%`s>wpP$wGF>Vl}xB1H;~tG75T#e+HDWh}d#ggjrTmlslf-S z+Y$n8Az;N0Wm5!QOdgA2kI*pnFn>d)vl++%$ zS879~lkkhttje5TlBSCCY((CzL=mOQ9Zcy6(kE(^ho8GCKZO0pI_IFAeH=P{c?3bO z)b1qG3}|Z(;|^T9D9!i=k-(Evu71eVE!Q20Zv(suaPed=gnm6B^O%@7rXLpfvzKea zq!M>lb9x6(qd0-nC`dVJZ(ob-Yd8D)WX&&z)c7LPjn*txgP=d9L;vUD6sz~G@m$IPx~hMZwnv!0yc<4;B9#Z#5yt|cd=aAlTNvXWto z3QBJf_3MYSyX}7`;*SDW0^Iub$N9PCNH_ajwEM*G#r^EmYYA)50m-&aRnq6BHh)%DDg@ZW84WEUIpw z&WH~L3<0?1=k^&_A$|V+yjJ<$7P`o3iIjZ4bbv<2Cn&K-RakX#ET?!O*qVnX;X2E9 zD0j-a7G-epToI(wex!c{IDwwQAId+hBb)0X!5sa`T;^*PfRP z9C=Hm!yNm)v(X*&*gB6Y86~iCR*{Q%yT)?Iw)L1PY&{l`ce%;ODsk&?Cl-jW2H1&< z?dv-6Xo{V<*1q0iUpLqM;Y+gp>}vaZjeWh+zTPi>K3BZ_mAGAKe>KX!ZWWKNw-bHr z>s{hEqwT~9`|1>Is+|y}(7CC~N^M!TRHo+BMiC2&cU-9^>3L}488nqz&(fxApwt;8 z(C(HM)-V*&X#YV^lB$-c1?A2~C zM<#?~d{^Pcu><m8pHPX4Gzq896Y4_PsDRGX@1| zW`)#xZ@C58PMhu!1=+?6;?1SZ()30iZ+Z;%tF6HTc(v=~MxN1n{+N26xDm+kvWscz z@C@2|#KohG@lEg+G}C#hm^Q*3d9|^UJ)b&TXP>?gNuW$|WrV#kg&>_oj zimjoe3{Tb(EIXQlBfCp>zK2b(_ zH8oW-<@eEl%jP=c+auNTqppZw2lxTt_M`1(u<-``3E&}l*DU)UapXvL0+%9ZeG_=> zT=`qRztDwS|8pU(h}en=*B;~=38g}E{SmQKLxDEUJYA`y=<_}++vYa z!Q{P6t6zhqD1 zhm9&&x4o=~%P?24se4&s1&iIwx>f+8Ez)VSnnoLY^ktdgcVlS1Cq2fC8D72&68x^a z4Elq}>x1NtfM+N)%jnf!S1*8O!_`Oa0>(1~92A>SFk#^==w$g#KKvZMFjTjKXBX9N#M9~ zu1AQD5>&Z~DzgdX1q&5xNA}4-^le7Gv|#SY0u!yQ#-}C-LD}O@BrxBRDSL z_YVm$zk)l3eG@d38H_5x!^_>moC`Oqx$yX@VM-&hv4O5!;}&wV2L+vOe6(7>a@=z0 z3IX;5Tsj@{SVS5FxCX#Orn9eC_guGPXCL`yXTS7p<9$xUT68+rCfKu%`{QieJ}%`t z>#MeC`mAq_oqEM62=e%WYjXuBEBMx7HupNpz7GFe{960T2>ZHE{A{S5xXiwev9GJd z7nj?K!S?kE`-&VabgDaD(pFjF zf(UKV#7agyMQfH$FK%u{BD7sXZeet4b5G}6k~&y{1ll&J<;K$vjjbrYOOLcmxiwL1 zb7SI#RycvSiq&!=w6K9?;h%z8u1ex=*aGtfAGU+PviL)ksX6T|AEMCZZsS3@q_QP~ zPAbs;?*3@f9@1&(qyrbdVahL~ zbJ-)@=f!jId4U@onLtppTFc+dNCngEA?qg$MfED!ZA{uo^4A5(ng9&!;{sX=y%4@J zqJBM)>hFjtC~EmV7cKYId(_tiGhc?s(7MSjJ?(7uRX;pmEg&h&?ViaeqGvIYal~Cg z$6(`w-XgEV-Z{OPbmv@dK7~RER>MD0DE}Z|5grwIjI5~U>-yXimbxL`8{qK0%789j zX$+nhJLd%!el6w&=U=1(M=#;*{i~L@muIzaiPS!wZX~Wp(B+~$tB`lp`SbS>{~EBr zMtP1Q?pukr|4x+S@VB+g)6e1a&Q>09Y<7X{uC_;XjRm+_(zDtuS?Zb&h67tSS~EI^ z&4K!u7&0+PdhK0~Inboy2#3cf3U!)Zv_3z|525TaQIDA@V*%0!Ay0S%@&5pR132`l z4mzbu@lQmg`T!pG9TfH0=HS_Wh|3|TK7|h7n!HiGceeWRO~3(T#nAxLXztfO56V*= zr;Qtj9Yn4dcv#G?n1#L4)7mXRvy5NZtv|DZUzqkYlYc>_e`b+im?d?Dj)xlAtY*BV zrqI#Rf)p)PP6^Nqv#*K`WrgKL8bKKFDN$AuY&I8?So}u11wIFERSK}>CBs{!5~-3G zl&=u@DMh`xUJMVjOsZYZ^DcS*vYSq(His2{j-U46F*X0W~NGWWU-_NrEt-AvD9iD)mNmizTqQ zG$kJHX$D|v#y?!|k9nDe-3C1c&ISyqd8sNhSzJb1dYoyR**ahS?<6=|nmcPr;Jg{a zDpBw2Q73oYT8;R6z)JwP-raYo(rZXBbo2qvRlgcifU#4GCr`wt3)U>QsUNf`uylY5 z)LISJQ6tjn^R~8iGdi5|lRHJIaLN^UvRZGV6XGKQV*qZs_9OlmVDlC+eh$hN_p`Of z-^$e#PByPAq^=F6h=JQlfMEo&LHtB zOvzSTL7`oT1X;FZua~xji4QZIzyPE(c#RmWC*b)3Z+ndZSGANiOEY7X0OQXzm2rLq z;~`_r8U!9E1{(=9!3efxt((SpOgZYcl7JjOs{YP}kUul*5<^$Q9$&Ago#-vti=r@O zgSJF>IOQRoxe!_^d{(q?ztz>_X&|0TGmwtK-3jNtuQUnzu2XRwXI}47Q%ibGf+j>s zk1^6Z_#YFY)X8_}Y4SaZXaDPb2TZi{2s);2cdMeeF|Ze8-TIz9P}jc zW{o92jL?+dl!FR6qXy(IsKZj!Z^#!$*iFw|vna zgfhTdhm3}}h9Q=eTdLehZ=vbS2jYGBy^BugJFn5#s#ZV>fg%p z-(ZEB9uu1y3-i=0yx9mg)+uH^OMx@s001i=I0)vJ@QA@ATG+&6d{zQjGDClrg!J6T za;AoN8Ou$`)pMVvao@rkG`s#Bx|L|J5pQ$mw_1YSx$fRlZghA*S5F;;NwF2mvy@^9 zaRDA!P*M(IUBNZnG(bJ*^1)gz2D*}18Gk|udQgJsQG?;iv<>Y7rwQ~L`ZR27DAZ8* zE9m+gppC>ujpGz(YrtJrSiT5CKP}ahpyha$5iR5?Cq}-$1_QFvL!ARm7_j$eaHhgT z`3BJ+D?oQq`-vnykxHfTv*@1yC(!YA?6b}`o{4n=Cp(<(GyD8L<0pFw;x;QZx@od0z4=VzV5lCN+Oju-Y`fHYbKn21s>j8MVOE$&XDIgO?@0TaW?%E{tI4;6*J~@$t2!-Gq7N%rOt( zKh4#A@1>y5Tx_P5Ky1@Ukmf~n3Rj(O3in8`u;r6FIGj#!HNwb383*__=XEaKqS8yA!gglA(RN{%VK1dXrSjsGtRrjT?1 zZ}=WKV3u$5OVZ1te20*C)P8`ivga^n13ClT^6{^FBK^OjN4r1pa`AC>^r)$0d7K|t zC{d-L14P~`8nL(ZA+{l*Bi&VjUR04&zIn*o?JpY<-wAjd;Fj+Q;)(0PJ30FXMb0@m zXX~$+CkB>W!ws?Lk)zb!CK$@M2{ved$XvwJ5u>PzZRU1h0xtq{rzn08)WL~QV>&#B zXkHTFYbY)+X@jxrPVN~bALCsMKm@}abV{HfZZikD*ggG{KwXI@x|<8A-zx(D#VC8B zgMWTr_cEj(1Gx3$=XEFI`OeqRmp-PVdJXb~&DaJF2Xx0z5&AGzKcRccN+jRlrj{;Tzuq@(%u0x!M^S6T(^>*xoV*-WH!q(BJH{isH ztCBDWV#^on6T9sw-vNerI>-k>5sJ15yh!V-?c|T|QlutGHwQT3Oy@iN!+we=7j!jy zOe&sMg7WyKoBdL$li&6DZ`)2E8LUM7MZji&O9yWu{xM)op5T+teV)5+t;q$og zH&Bk752e7O_I604ujM+_>TATrmN_TtA1LRCxi5zj+J|=HJLz_az9#UK^FnobFG2ho zz!ZR6-n$WB0hs9MZ*Ds(==W^>lIyQ=x^?W-D#&A^xc5?fq4tJtyRkuA#4w?82d6{8 zSWJUT7cc#GXp@N27h&kW06nlBdSeK>4i%2a(C-P~j=dhrmYtlf*z(s!eIgsG*PX)= zzZI|$;Ldjyh#v+RFA5qMlPKC@x8ondL66kWksFOa$NeO?zBnL;+b`Eyk-Hj}X%+JA zY_Ciz?k;JNQIcCpqD9UN+SSe@Ck;aGTXQ3lE@xXLP?AQbKpcrF6|TsnUc z@y7vA0$jTJ>l#zqfOOc=qd9kb0YNvl^zgZ|^Vl_9*Lma`5c@`M{~U*%8BQ%qkq(;f ztXv9f0YnOSI{kj*-^OBzF3UEY+8L;Rc0?&DcBMoVj2DwxwY@(>4b;mUXmcJPn zRPZ5TB*w`MutGYV++$?O1%I9cj~mVGDW!0i)4nfNkL&j%UIy3-aQjgY)0DnI`X>Mn zu74F*-`0Lu<1+6Y`_YwSORB1bv7h^n#eo8|2tQ7K;H|u7^=GTGbJUKr)wnroc(xjz zqq5m5oudY3tM%rnhSWQqvT$s$gG!pkH3_!}H)kzq+d#M;R}L7b#K~>c0Ldb-`s7X! zVo66l3d8LW_K)NMRx)_ZL*ZKj592pg&mRvVz8vrbz{LYUFK08-Zvc3x+&s&^yZoP> zsBu|!j{Nh~t0xo>9zUUE?7(6Oys!u;8iBUeNBX5z{8XKsl+@G&I6r`IJg)&SO3dKK z$oaG-wPX|oZr32yprn&j;nbtxYk@qqY8F(^EuXo z%PG6qn;uZxM@tUl4Vh4N>C0pJRmp4-Gf~NVWoZyv(w~66G)V43N`xiAa3L`+J~5ma z-%pl9aQL9hAv}VyCew6&cjt{?oq6H{3*ai0q{`phG^e^I!|NIdEjNhsOz|&mT5TnV-+@pVXKis6BX(U^-1jr z30NmOAk2w22V3ymJ|macgj;6tmQA%wgMGDkL_GqpRJU(i#D@T`0=V^9fcR>_YXBae z{!!H9<~s7|=oVqOvhf`ID{tTau2sf7`ZBfGa~p_#E3vOCU)-(IKBM2W5kIhZ$oEYB z0b@}aW;9r)WQ1-bnPZ@gbftPdKbZ(y`T+{od?sl=qUWTfqQ+`6s?BGuH@K^gdf*7g ziCJq9_DUiOO!5m^N9^ju0d5CA#9hWWA|cyXA13M&VvZbLG9-rYvxX_VQtby(JRj1J^37fD z*`*CogLR=W=*f|LsG0Cu=)q<#UUZFmOz0IrX0O^Z`=a*+UixjR*2B30@p*s;0WMy? zMqDb7NFe|Z&2)i}>1u7f*k9tZ?;QI<$>giYPMUBdALc8_$3m8WM6il|xiEi~wnfE$ zVYW(JM1CjfJBj%QQK3HprLSnFKOG|g4%LdMQJi?LwugG~kS4MP%j|euvBlO9#3cI} zZQ_M@H5@_~O$UK+gq`FYJwAw?v7X_Mf;`-LguTkURGC5H{2H4+J=t!11Fi$uB(7$= zn{Ta^{YT)d6nJRmRCW#GuK?Z#xcK@E@gD%c19+%=ey{V>s@u`?#FxuaVn}`1o*b=P zy+BsZJxZ&4mHziA*1d54w;T2Ug{_$3vvmVIB=7G9 zDCb8GJ-k-Ek8um)F90?HoB>qH+6rI0fDavc@A|abbTIE6_vwnc^`9+Lz}7f~L>2T! z*;azT(Y~&s_HDbk4Yd<(_EIbaR)|N@y%=0JYtMP5P_CX3wWDPRgc|%6Fp}~X@ z8wr}FGq0|_L^^x&J*MH;_$bVWqcQ^Hr<2^X=L1-c!^4)>b|$rzk}>paXFuU)rBsG& zi9)9xUMzor|8+Knk5S++z7Tlc2pqcn;dhA3uj5=mfQ#qS-I|nxw0mw#T|J1k&N}66 z>oHr=ts2kJKiDKTMc0U%P(VoEg6*Wu+H3_vw{!^I7`;h_F&u=vGMyp=mvk^Nl9+-j zG*jM|fr_HNPJcid`F2;*%y*JB#XWQH2ZYOZrbB^_}wdwO* z%h)}zf#f@2ufmdD8l8>}dCtF$Q?jvY?7|yLDy-I!fID7w-yV^M0VV-> zXyxda*QK{ZPCE^1eQx?;D5by*r0I6dZMu16mk`$#*g`v)K5%tkBrx;}rQb2!yKK4E z^$#`#YZcr0I8{=wuUY!~^18An*gAFTcnR`>@CA7QC~Fm{CX_=9B~VI8Er zbegpw)3$O6aw`{TP_e(Ocl|}6>9p2S@S^0?6#3(n5SH2XX_Sb*X zU-&rmyWm%F4Q`QVxqjC@fgUI8L2oP0wuNCU8d>!itIsKq&oY-|kK|beV#N+(Zoz&h zoX;pv5%4V^*^Fsdh(4M?Z_3AO$ZcAfVryNQn6`FdvnZGmt{+nv3I|i+C%*@JVjTY` zxr0=+Pqga+w3p@(ancUx8vq&tT=`pb#ChoaKcp}2S0ZoRv`JSq0jnCwfe_B?_rUtR zpVWd2;h=Eo?n7{oHrU^CL~!L!`4&3)^D?}G_-??D0C!yIyA$#;fZOj}J!Aep4}o)> zN1QXt*q6`!&nmNvxbsrx`%9t(@TPv9$1>N&De2YL_BjJC9RQy*d0mqjLFyNP&&*;tRM;3Y8?$XhpopJBYYJT^@cQk1k;86g_KR=h? zam1IuYW@%Q1?t-G^T`F{o|EtOHS!&QnJjfmP^GQ_9_)PU&|?XHotHQE+Pt$3ywzK} zeTz$Q;PO=aFQp16-^i}&`LrA2mjlKCI8u-=yY?54G!f|=0X&TVOw2>`oP7uTAujzK zy(gc_sI*#%C(H zxK~+yFzs+wPw>oq9Y4;w-eYQ+FJt-9uRTsNY~2?_H=KoXHbR77MR~d-FSk7Z zr}SK9-XpRrbn+`leo?0gKjXZ(BY{0o0`+P_Br$KTf^`2$T-0dDxqx@Vfpk>ba^$eoZnUU#zD}D*$diD`8i)3hDoG|F##{;Dep~0`FGW?;%5#S|fcS zzzKglAFsx^SA6xA`D3R{nLK4G4*0unLdp1nC2&xR-aHBi$VgkYcxfw*cmJr&tsnk7 z>O6cO@gD#O0WRNZ?Lm0}Ede~t&lcltW>)R>WWPQ_E*1Uzc>GW05(l%OXCHzGhlAW0 z;Q=us??TEzf!8o;J57%Z$U5BTK}wLP`>lHk#Nmdui*VHxK6&5oxE<)PwB@oXPXYAF z`WihvZPfa5idIN_FfZQ?K2D%9uu_(aj)-EGI=&)BI`M_oAI{gOE zoN!)rT9vP$)bC_aX_=Ex_xG#mvjp)w0kZ%ueTLSBq!mb)19(u<1zjFasZE~+4t;7J zh2Gog+fnqJs`AvJ&_4x*ZUcpyz|5D5^n;up3dH|AaTL*U+*ko<^L~PZ0u8Q?*d2Sh z?Lm&Jr!~l~uhGZT&}b_AIUzQo|AxyF#V_A3n(BnWXTb;6?OBZYO@LB>ThE(ztI`8V zF9O*0tg{}rAJ(`uJ7YiW3JukK6km?hV^bjm-=;l8q=qS! zai{;#iBF+nmd2$edDybTEdyeRYuqA~IrY*$tlpn#fOs=ND}Y-s>upWyh4cUb4=$hJ z-rfIm@7>R#UK8zuB*m$`A$V2WrrjbHqTFd(l$Sd~{Vaw-3Y!OmE!=py2f6$s7o3sq1iD>vOFKl{AEdAa z*`XhV^lY292DY#H)X4D2dWxJ8y4svnpZ=S{hoxR$2Y))4w#&{TszUk)2JwX|)3WK^ zlnt{UCax;&J%$)iuAvh^mT_`|+LiW}d&;wjk}rE9v#)PtKx45r#bfUj`BJ=O@}?aD zH00^ zm5*_@`^Tbw`<(f~`H2)bWBs^v%AT9uCWn9hm+2`CE!(s&sPr{SR|2S%3hnEqM9$+S zgKuxvrDaH;>wMspueu@70!24r zi_;p(3%Qy|m4v(O<*qL@>QfRRtu-OWpqIdR8zO}sQ7-M1YWYVC#5)5n2DtrbG~&|$ zr2rmoe$}BDhi|kW*0>BiWBuI8X zJ5lbaQ@;I({|-0`aPc1ZuqJ8$J)>So@R80+RODCgqX z^m}X6yB5D%gXZwZhNxG;r`7qqQsI$y0p131Jac))9>fEOJBpCxhW3c- z9uA2AD0T8#SR*RkvT}Voe$vr%EFMZvOdr))sIs>x~)+=FRA^HKMU9ocw0i$nPuoQuzsR2;k;- z81Ykpb6LmN$5KxDeoubJrn$KcEc2!w|=w z>Yw}r7CYmdZ3v5nY5rAHCr`?|WYRU*j~hQ~VzteM{XD1P7#s#YW-Llq0{*>9*t!q{ zSt%KEt0`^Q9+0K2Bwb5{MJK!n9U-~!cY(9jRPz*Rdz>hDQ5g=V`I&CSb0?K&z%UD5 z0A>FP5_Ub7-U2=$2Id7Y1i~?WdbW0lEWtyC5h-5FZ0@$EU}g^Gn3n*oW#Y)|s{OlGCsXGhAX|k5uk5 z2F@tu+vP1XDaTS~3+ATn+NX*%3-*Qa^m9r~^)bRneZFP5(Q$os8C^taA2N#O+iM=4 zcWF?ReX?n6Elq$umjDabL{O36f{PZvlL};``LEH_W`mis_F45 z#6JXl3*ccN6MXB3v=0BeS>Sv65V4iqv6{Fh5_xBR6@KO_As!0Fd?%9Vz63~6_t z{Pg|8$)_uKIn<&V2w}1`1Ae%A@nu?TtW-QwnUi1P*VXwAMf?iDl>j$CZtpP}>2u1j z$0VK`jVUEVZ`F|NzodD{)i&hl=$Pz8=f=@jLcbKMM5&90$tKIl{R@G1&Sa2{{o?2s9G> zKDd0K?im*U!q_|jst!q@t~^jRcAZLS5sV@nPex@CXM8{zHK*~HBfbvsBEYT3ymqSe zAEf6y^OS4xw|qDmK^l+r=2y-G&wLaT#!m6J*-qX?9I;r- ztVZ!-VLk2R_QxLE>r-D$Zy^}?IWC|I6P@-)o&g7MdlCN)@F&16S0@M-QocQ3yrEoB zCWSPHxBTOF%jfWx*RfjJt{o-PRvUlZ_XpQ>;`%XgtOa}mgU~XKr$#K!GLfXaiEBuF_@#|y`cp=1uO~VpBuRR^KH`C zMyo9ShEfNVxHvV&L}?A~&5e2#Ime(4;EKp#+&bNp1-oDxR$vrqJBQ&%{PDQQT?ThRbOv*&TBp+y<-ZWHulV{7}vk-F=@=$>w%tfa+gr%N!UW*Ja3jrd?{`X zx7dB9{Rw7P!sVuYyIb7$6KNaVlj#K*^q)4(C*kC|iRDX-t(U0!(u{J`d<9QhSaEuy zYDVE`Ti;5p`%Uv6Q>E4ps_mru4(iG+`u zlg3P#0#j`?_96Ihus1bY+1E8Y0 z>e(Dk!U^WDWQ8YKq>>e#V7Zm7=Ly!NlC1-6BvrDdxPdwP0yq2>wSUskzGm28fFTOl zKLK-;YyUYiMb^nDIMWnszqwfa2Rp&KRKiB%9W{N4N&Ymnx0&Z17V{TTPU6@ixoK7t z`4-wM+M6viIM4Js3}1BRPu&+XI?27&2WWUcRedR2`IpLcHDQzPe@ZW0u7y_F>pE>}UI?pFFDo>=u!D?!$ubVuMT7gOFZI9!hS36E zYz)Y&LoCk7WdGK75Oxwi3H&qye^l@Y^PNGyH&_#`siCm%kRh15A9c04(SrSiEa_tW z1M@aFywG><@)yzY+XGsc+1#Lo^LDd*oY#`169h~`wvr=Xj;)>)jMCqDXFh&%`DP z>qs~$W+|-EW4<6^c)$XS*AUgoNXAc7jA$UOrw=~XGL?EbwKqLEYNf?SS=wTPqk~vn zmsqYYDwSnB^#5b^`1mQ}F+WA5HUM{gZ1r1^zWM7Y}_U%!zg!>aqIEs~=c%wk9=bjP- z3|hy^;Emu3*2i8vzzc6B8+wA(t7I=J(mqeV(jU*Ed2B|K$#*&@;!4#xp&BPunymQ~ zX^atywpFG?Vwh(6V4z%hwSSU|Wfr*c8%PgdjG6>3Z%+YEE@SmV@aV(hqi}HMO*CU(c7Zm>nW%7KI`~V5V7RX-TZU}%YybcQyhV{oh0`;Z&-1r)LN(uB| z_Txb$%Be^sb`ciKyPnl)8U@x>eV>Va5qsVL%n|l zc3f{%rs~6VWcCz%96+tzfYZUXe$`}|@T4c>T&-CIRG$xTz~Eib6W|Tl9I8>8t}o@f zbPYnCf^sMj%nS!(vd|U4m`O#=1pW5=rJ8>4N4x^C`(QQw&iEZ`?!(ZT1n{685OkXv zFX+^Mh)ZFI`|N1h?Q`w>8RwzNqp#wF`INEaCgPsguVl)g$yKR7qplv?hj064%rmyr zxdxeMlpFT#*|2>}v#+~l`*xjuy)8J`p!19`75nxoeudOF@#9r^k#l>GC?{BYB|D5& zF9!Z{c8mp%v%oQ?A7}b8#*V}3-U-GD*;yU8JHg~icD+sJ&*QfPs=w-cf@M^)#wVB; zOk>kZb_oCZPhYpKWGB&q#2Ok?L-q+4vL}>aCEJK;36soW+NoYr3>iZ|e3)x{`dujPDrw$G5D@J|=&| zf?u;ZZzPhxKto53ZqQ;A%?yL4TpsDx3hpTy=Z9z<4j?D9J=VQ&hV7s0@yzq+b3HNh zJh>1Xn2?F6Br@2#N~M|4s{W@`RUM?nj-mC_A0UBSlz(fJVpUTOgA$t={23qAxPNm_ zcvx@u3uZF~=K*>@rxRvHxS)WrFVZroFAxje#zt7qy+A(JLI<=!1NO4k`8Q4eRSSHi z<*B~=#h+)_9Ti;9hYO8%aX1KcK?LJM5@9>i7|r=wC&$9d$*}f9NZlCna6EjY%N`cf z0V1y$ILF+L@wNcvWz^LG8 zHp==)v1hEM81gj>z%x8XY<8Mb+2@_vgtkshu{e@{4LJ>!5cX%)(6Ysips@Cy!!T$t=c9o?)h+GUK^| z19w34C!aQ^IeD?G4`$J-Y%}R%er$m#Lnd6lhWYU?LHePi z6EvKT0L_o2&E-;}PJ=}V&GH6)H^Z0J5rX2!JVWA|lLF;(c)NjvoN&sdxL!(c+D5q! z&UNGxNM7lc@a5M=`!~7IO{=%iskDubrFo?iH#+`hlDLvIO3ZECsCgb|n= zjA$18>w9!vi}9={@Nibnd;xF3r+am9dv_9WihKs;%G|a_dQ7`SIpKN;22+E_XmThi zHo0?>oE%7MmrCHk-koZpx}M%QmC|Th;~cP>q%ivxnj325%kA7q&J8qbmsjWICVlhp za(?4h{N)}kx5@27ZLM}40KvBYcC77cwLQMM$mnQw$nF^GKsyIJ?WSGhx=fe54v@7D zKa=jEZjtT-yFEvH8a?Xw?9@Z<8R#*vx38DCceoe5xM82d-mF)XehGc`eys0YGQj9x ze?X`H@_<19+(C6%gu%lHQJQke6(hpKb4QrN7m$V;=@rsQ_m;`NbnnD|>3!3oH*%@q z6Rtl}y$|yk;x7Sq16)4$C&byGuzLsaFw?QGEdNf(1MG*m1iA>h_Yg;K z^0>wx(b}55`OYq1Wc&^fjcDo+I#)X9*-n)%EdJV{b;70Jj zcDPL7V8vd8S$L1@Ae$>;+_c*^2w~9i+H~qBy z9QY<2sK6yc14|}Pfk-Q(O#OmB0)s5|C`K((rv4;u?}=Mq``IHR@u}BNy&zIElkHSl z(7w%M_U&?vgTUs)ST=(vc@L{rVSZwB``v7I;}1Nn1$g=on$c3BhDF5UUPYYJ`^=Lzf!nGDG=Q4;|K% ze$@va(wpibv|ub_o>}w@JyfA5eWCY$NB8EmrfO@Wok~|ztzYvM> z@j;*FWhL?767v9vc0SeGzE4pW{}HcI5DXY?!+#QEIw*ZPRWAI)3?DIj|6!U(Oyx~8 zj$Xdgj58ZUce9 z3s!i8^*DLKGB;St-BuhOKGSM=h*`e|>ER&!M98$H3m2Q=C1&r%rn$sa4g$@?elg;LTHNKD=MiR}($wjC+7gyeFXwf- zjn`?0Ipk61U4W%&mq*R;aUSBW@ zr;@wuFvB~|zB^2Fr>QLFHC<#TenyPtXx`PdztP_u62ai~F#dnQ0>jeCSC(g=rF<=u zuVjPXtokn*`YB1yVrj`^Q#v>t!~HVm@Acee9Y11wG8sVU^C!zaxco&i!I$9iW#mFY z0$-H|xqruGoFY|7*L(122)=qkhMKGeOZ*|EFa6qOF?gbq;2*BU`?ug#m}>C%3*jl! z=0XzaP}tDEt=dm7;%;~9g9)%x9U<^Uq;`Hw7+(0s(YKnc459y~TL^oGXzl5vMBYJU z{^WV04u)r(f5Uy5pWpV1$FCFLP2R}?h<^r~?R~d+ueC-&H1uz@?n0vT_xj-RP2M|* zm80DN&sXiCgEpTe?Ic!~AyV^5(tE^esiekdY1gF&vz{X13HEQMlenAh_h*r$DW0pt z4OIC5?nxWPwvSvE-#L6sn)u`T2jJi@KMTSLtE2t9Tk03c+exe_&wQfsQe5S~4A(n| zC#&I@*mrE7J|9q6z$L>g=j{|$SVJNDRn!nmQU|i1DIESKX}vvfszyjpGJ5NowjtbH z8W`_y3+@!)bk^W}1>QIsIKZ6YAJmtK^(; zcJCZBTWMI_U=z&^H+q)jnvIma$&DKnG|hjKGz~XdO`4iblxCAJXwaf<^IKbuZ`rwx zAMW&Awg&%yVHX^X$=yeoZM$_2ck12Q?4)#^+$9mKm6u5OaJR=vce9()b83%TDJhb;PbEq<+c=&8mMBv}F zMs8PhD?)8Xb-5}$q3>1Z1ZCpns}C!a#$V%|;<;8SCX+D_*r8~;n12tPtd@_5Phk%h zkOgq%<1L=jq%KIi=eiws&SyCzzhUn?VAFE!lySE6k@(^V2{ejEP39BhLMqJ(Kt3Ka z;3U_8ZbqyxL63pX0P|2K)^E%IFNSog0f{K_G>>n;A(Y-J%Da%4(SCw+`w;&R;8TEG zUR6qvk^$2leSyP&2)^lz<+bI2HOdQDgK*VN={))^98vH>uT`XH1Nm5aQ(vr}N@KJk zsvqaO`?UrMel1qfOh1l3pOhF*V~OcmC^6U8*v_}r?&)kW%w0~q@PTs%L8csbxb01tbdeYk(pgWBz!A5a^0b>z$rzgdlF zzSoA6XZbGMd~y3++~8{;&)}S&6Wtyr?F^^`49-={0qLq5+!pZ<`~TIh=^0S$HY z%8*Xxhhs6!W1fA;=U?IrdyM;htrz)>hke15UNWRUlsG(4dd-Vvs!t9jC!47;h8*vz z?A|Tg(~qBPx!BrL7jU1g?Ub(YnRCdt>!=U3#@cAL$ah5bvsAS;GaxJ_+Qptt4mU5A z-6QZ@ASYDy&(7`QrOrrq2RPwx{_<+$4c?okakYvYqSLC-7|J};@^S=;pt|Ul(0B(D(#fd?i0UtXj2PqAN+;w_Gad!*m-b+ur zo^rZENA4$Gnl@Yf(hqsDt6PU(hkRQ z=?Sd9R!<9P8q2b*P^#KMYg#~C&}bQ94-u~iUX5lF<1Ta_1HaML0M0m6R4?|p6{_J- zTng7YAqJyy`-vW-q*Clv+TSKAsZI?1qWz@mZYWKWw{Jy^Z&i#+eWtQBMp+%xZE;LJ z8eS5ksT~P+JDYLq3-$<(VXW>Q{!X$7+kbxsj<#Ty)Yzb{}cg3N2kg6$m%H-?g2~Snu9(J zN8ysR(F6OZ2xJZ2l$N|{iXIa5*IliqzY&O+0A>SR`dfqe%Yb(PJnVDqf2Jp#p8lqv zRK*k|_w&|>_W)cBaOdHZ?|P+sk*=!;T)V$_AJnI}@a~0Kri?sl%*AX3C`~ONCR8V&vhLrmci2KH`iLn1DwNH|-@qu9-bMNYfD_K5KbX_7ZaFWRbRD;u z+@(SFOu-))I{CCR5~}o+EJI1)1M^+l@Y$+0N8LfBFQLJd3BkU$7@UBzL=M8mXV62fOY~$G<+!ssD9(ez z^9J0YIttMZu<%Tv1GyhVNg58kjEh5Pt+P+*^e|cDXM>8V4u=@KAT0v>(>E9CFS*sExM?Q@dPKTylfm(?KOg z<44k>ZZOMf3djds4;Tb!33#21jjf8GjX&Vt#P!Q_i=(V-C~tEE5^Ua?D_$pi+NZ6+Sxh!iYQs|LZduntJQP89R?OX z2dfGSO+!U(-kf+Chw-eINu*r@dHnbv%pF zy3!kHCMT=sWExM9sT-aY_*sa0N1bOcAWlLFQWn6)&y}lu(o&?K1MpDSe~0tas@tm5 zuIDhn+FY93qVfHHkTJQ&)_@CZ#QPxL$Vqo{?ryrLljiq6_T=36FP3wX>3=bHlFcT6 zv1~W-miia#bP}4YPW|JN!U;3kV|XVJ{gXnHMSjLE-e-m*!yM#<;?AwyZ;J@|LQklMK!oAXGdf+9eQE z-S2~g4XG*8EdXx6Z-8fS!^7p7^=Kyut0KXfI>_IpT`HA%QfLnPxd-PYiTuiH*e|R-e>#i?xKpPvwZE>zmW;LC zPVhC@%fiFxPnGf)t&VeDN20f3=XC-tf^l{e*qVbcEF*F%Kcv~!>r}Dj)Rar&!mPQ5BLUfV0zo-m zKzL5zJrY~p&TSDN0GJJM``>Sfd*GljjXNns_$RyY}XCV#uq@;s)~ zIXY%$y~fOJR$%*RGuc6VeQI`n$`zx{RGenOzK+dfccXp4bYDU;m0t8l(G*v3o%Nwm zJhVuzlEH2QOWp=!AcZsa?L_Ovy7Dd;UGLC8@ax*~ypDJUz=*GIhhc~p1MUX!u-?%R z`03|acG|t{aoSKQ z`Sw>TAJ<%>H`E+xP-lA{g6C1}!oV#nxS%&V;o~Ko1Lcfwk!pOmM7%FxCcvfpgNQ2$ zkPiTO_$Pe#bMQU!AL*VSOLiKZSCCi5pmMvoEtmH~E29?iCzPreoMIWJ>ZnujacvVn z8UP%8HSx~}@pms({nC65#J^qpJMr`R0lQ`&!$CMO|Dh-2U60)rIR3W*Im{*gy_&I8 zt8?P8xkxdTE#wXv#NT~CPA;*Vjsa`KH#zBd=jLH{sCY@VM=9{^(*GjFR{&N6-1(;cd#bbz>5X;lWNHuK zqbENfN{zEZ-v$*hVwD>r=Y#4zP3bZ_*!3?+KNadb-}mNkeXbL?}g1R>3*ai1MqM*d>=h6zNeiY;kRMP z<8%7y5nhele~8(g6BgVq>V4AJ9QCVW$^PW{pOUfoRPDv5x-QXN6MI_JKN~9+q~`On z?CDs(T-}gpz8L%eX!{cQD2nC(>7HX}@6EMrNPtbak^mu`33mb^+z=otiboIv1j1ne z!8-yff*dLeDr!`GprXVdsHhzAMm#|9zzgDg-Y5EaPkkQzztugH-Gl%E{ZBr%(=*wf zsj9B7?yjz`(w|9?(^B`d3Hr_iIW2YXN^m`&pgrA`Z*S^;Awl1rQ1EI3I`>-E!%(g5v*V&3NP$9k(Nch$=IPTGt8TeSDEs*GXt~9JN8Rv56 z8fW5Kr)P~bZmm;a<5br=6L^+3v0H224cj=o=}18o~wXB2ism<~M zu4Pssh;uC3#6L9Uc9)O-u=Uy%pTHB4KRp5Cs(9Aa*8~fGF}$g+nT9K#C3s?E{JKV? z1=X31Kz}K4W`mDEeYcI*@Ab#3O>~EUxTBq0QEo?6E#R8_s-!C`B_b!IP@V|58(_=H zNBh0Xe{rp(N;#?DFB9F-$KVF#1Zr6Nj6_7G0=YqHt8oE+EMnMj9A(3#QIH8RDvS~_ z$;yF+QTBz7=#i1ZM9+7VJ7u5F-q7{0oV8N|Q%nhr*Yxq+wOkA7Am831sgRw&i*qVbr&VeZz{vnT=J(8al;FI02 zJ&5vVz*d0G&uWzS0zLxJk!y`}j_zA3A7p(VvGnTO4cdX`)mxRSooQ8KC?Qwbjxc4N zJB82xO&|F`!k;?riLeihB!!ASwpttBRnNp;X8aF}qa!U+YF?9c3~mYiZ{ac>%H@%m8As5S_+I6UQ zF%h{+n5^Nr^Q60@(LEG~d%E9?ObpGv7(UX6a4Vi+vgEU9LNInx9~U<%2Mp zwvAJ}I=lE<=_y{biQf~@ABH68x9M0*PID!+GD7Jtm|QxW34Bl&6Pu3puC4WKe_}JU ztDbIVxVqp4*yEm@pf^SIZmsD6Y;`0h|B?gJ3Z`B~QPbLKFEE;{lxpj+!0KpeIw-l` zGDt6nUB*b#uY%#OB z5tc$FS7BfOW-<>pmn$vU#=Ovz!gKj$v{9L);u>s~@WXtBAlzNG96pn_GAlLvBwmHB zBjbeGC@%p#53uE6T$@B?2H;iz9p?{|{l~r%S>NI&J}UgukNf-vI(_kqGXd$CN`V61vTIMzbSR{8Ey`h5dWA0!e$OGPHaY6JDw2?3s z{dbBQ#13^GTV&1GHB~pPS(g73&9O(Tr&ZkRp_gCb%doe{VeZ!ozMu|^cC;)tVP(k2 zBI%3P-uMj+QTWcslIZgseb^O_HpVR?72jakakF_*%w~~*rn49@cvug%#)PrKfmzqv z+!%v|=aB(H^?R}&e?k4&@)h3}wpc)GfGuA=P#yuW<;%|3cAR`+>tRHR7w^W%rueFh zJg67M33(HO)rAKG-TCMkmpPILy_Sd}*KRBiV5M>HG?@K(Z zfrG6-e?>VaEm4^Xu;nd2JyGcZ7zLo?(`+ekTRY0LxEb}ay}|jqpsIx2e#VxSPArLR z6{h61-MeOvwAX3NFMH5lsz4_l7Qi<#D)kJks8ozF-IGSVIz0bXY8Cyl9AvxIfz#d&}G=ocD zSpN)L4IBf^whWuzjh2*)hmAnw2y7`vW2+VD%8#X-e2My}b!<9Z8Hq|Wz&QY0PHue0 zr&QtkX#gEBz9RLey>2CLMt#g~aNIJ7q@%uOLa3B$t)UC-A=jL6| zHXUo^QsqtAq_T%peYvMzMH?H`zTEW~st0R}J5(CExHbLBsOs^&q^ghAuO1q?yheTc za;6;+S|T!V8H|BT53p5@8n}cHOMd3Gi|B{5QJw<08esGD9+bBLUIx%H*s_1b|6B4k zij|`HWrO1u>Yt)mitcIlJ>)^Wmp)R31?MxYtR#w+71y8oyg&3*v9RJz(Yj!V>}YrM zKOKp`Ih?;cT)#Ok;=%tpQWu$^min=Uq-6XM{!gr_Ru}BAYs$x|MMft*9|3jnz0YW< z??eNd2Zf1irKU#GA8H?wr=BS11BL)>{TTa@Ln*;^eL1SDABDs5aq?2h?%)(hZIAgZ zS8A~NDgirIxjEZ`JSjX5D>JZHbHrGoAk9CLfeoebKP6sOc&Dwm-$eO+z#)K**Ow^& z3^=9U&MPmUaG8Y~_+EGo*0lm+UF&hI5CW<0ypUcj$BD$q1k%)9;KDNy{ceqg&(sc* zPaW~UB`8+{J^@&AVw{(Wxe?%203EBWI1Wb+)YZ?`)_A}ZE7uW}50;AW*X8Hzx%LW9 z?YICQMi28JC`$1Qa5Tc{2~GMN4LXLUS~+d-RT#g*dN#bm_!cKr!^4B6XqDs1 zYOvHd2s?+K@`vm-1v!E6y}{E%np%!1FC${G!*cimHpCdk`y(ru{xW<5pgaEsUq_4^ zLI`-HQob;;#^C>kJKpB?bf^1v*RSk1Q~li>@S6!!>-ZnZ`Yp@^rZ5wXBU7swpTEVl zF<6rAgVAFv*Try%zMoNGgoTW+VR4z}0-CoD!jLk=+F%R0F9{6Wd_Gq4DdX)Pvkla4g#iz}=Ygia z*QxEpOmeW6OB|evwzCpMg}S+6JYc6ymd}x7Ot-V(2-vcFL5*KV{L=uaLYbXG+_!K8Qbf5(EUF>@mIa&|7h2Ht6Pj=N)1>YFnYY>ZTW$hZ}B?s zVlZ^YKjYP&_39|=?n5xi8=jcMKCY#F;Nwqt6Lt4?FL$TJ`;2SN8@Yc;F zYb{g_-l1MS5rYG+wNi%(YHGf*_yf9adHV_F-vO+1q`sP=oDQh5_@X`6P<@eqMgPmJ zxLGZZs88`9u)eX!{ERCL{hfRPEHf25!k&OTh9?*Sj8W3fFeTWfCJc+FSgAdWFXUut zl}GV+62G;0w_SfPp!_YMqD!Ry26u%$39uMI$Eo%we0qX7IF`%JIU)@QWfl>kFK3lq zN-^G6v9gtvP3>DIPMIqicJ^K>%J<4!=dA?8_m~-Cjy*{%O2PDkg+I)_VT_pz6K#c& zDq55#=3&DADr2a@Uj}Y`Xlt0Q)51lhg#X|kJ`=6~;J>&UC$DP%YyD1M(-Ug{(_X3c zV{6fF+S=*((+gKDT3j$31*ro&a_3HQdx_8WBr`T+XxsteklZNKtp5d^;yljVeA-%{ z_84c6lIy&K);+}P2z<*_3{_*%*~#?}O@&{E@G4W`ZmA-g10?lzU$jG+y4RPDU-U%!^flpfR)en9AkWX6gRmk|KKJ{NdBrZsYFjN0Zff8wP z%tue;mssavvJ)6VyNWP*-;BUMWW&jT!gr{Xu*#N$cs35K<{0j|9*;XUp!qc)wspq( zW6p`i7JUS@;jvDDDq=g0i^oQ+c&yurvn;ol@>y|2%4eus#6PDu%EJMp0k*#T6=l79 zqO$gN*>3Fkb#?V!J;w@Z9){pmv5!sAQN>1Y7V@>vsq9CECTO(YrDHA=1A`{E4!D}Y z)~55$2&(Te4AY4@y%B1O+U}`2d!S!*cnr0#o|0fDz%5S0aMtN+k)&U&w?u@IurJ;@ zUwo%bkJY-W&IPBdj}P^bDkT!P{InGVDh1!nMxb!cALX zTD#*^M4q!MYzvYbkW%i1O*dEq{Y7P+Frs7G*f5%oUl&v5$fNi_lAlw-D|;S$Gs^b? z{sFN0IWjv@IS+6K{FJ*@XsgjKC;Z>5*{EHDsZ}nqc?*F=YMH7=F*VO(Ds6NorsnW& z7E}EeQxgSKLttu4!PI^{p_ZxsGPvUlrWPPK86w>Yrs9`^sY7a+iaUa-eQKHNJ`q!I zwQ@srfo)<`afhw3}PGGuCbWu-bZlC?`^1T~H?b$N-$}`WlS#`2gF#pYN07*}C#?zh~@x_?Yt+ z73x>)7-tpgm}A_p+!G5EiRda8Gj;*YA%ie)7Lii-aqM`NE9JSrOL{lrUH1I;1(e?b zd;qZVA@sk;wT+KGuWcBg;mEL~a4l=Gy~@xMLy#t5#Q~<=AM4|Tcrv7aR)zA@Ld4BZALzlNOR-HC_R~mI#i_Jgu4u9%j z7|V?CO~zs@H%++7xHBx4n1V`mY-gjt=VGIYkR3++H< z^x015<}KCc@Vi$NP(dLrQFHBNu{)63r0bI`w3&IUsEct|Q8^1qlHAxm1TU40PqMZbi8Yo>*HVoo?cH&aW9UBmRXj5cfqjDVTLQ=MVA z53ajuJj~JSXd`*pU{Aq&!NbibW80vqwmB9%VIiE5r)yX69ivl z=)Rt=kh92`;n4EC_M*rL4>J7oTtG9X=Q{IRJPTi0*G{fu$6E(&7=+!)!!Vf>`C||A{2W-Pd{^++&APi5vfyW|#rV_>ETw|C`c&l)GyOAWLQ5TH@O0qAIsmK1FqyG0 z3@o86X2t~lj-c7q@d<)WlW68qhHY<<`9 zsG;=h3m)#0?`>G$m0U7!LRkS`KBjcaR2k2@Qp{m|G>6$Fim7rsgX`v0imSaeV99&k z{6$r+OyS|7&LN0;Y`|cLEoz8%j7Y%P7T`sIwnW!Ze~V!2p)gWI8wn081fadzT&2lvcP-16ao? z-aDss#_Y0^;U#mitQ&d#>*i1e5h?I)KPnvi!SS$a>wT5h`zY1xlPJFl_!eN>jn?GC z*9Y(-fR0ZsdyvPnGm4v0ADgWHV_bv#jwa5UF?)g)4);a#2XZ=nNqV4O2gd}r@}}8y zrIE18*yvbkv|DB1qVFnW6<=wjtTLu3t$J`aGhsRw-nEO{s4d_)ieTL8NOeGxQ}rN- zB}Svj0S$%dO!S1O;W{7JL%B0mZ`J~1S$N|bPIV;gdKia0n)dZ^mW&>SYvX(Nhl5MA z*yc_nW*9eO`EV_5h@P(}>B(vbJ;4m}1X^LzTjMjXxmyjZE%-<^Pw#IG&Na zgIOoGLb{l#C;1^)G@ZE3BS)vBG=%=>W^7^lW6XGy=|hYx{cJPEaRW2Aa2m??yj-_Eiknd% zf%_U7CqZjXoGx4;$P~Ga!4}yW5Cg~<?)`eC3f0xeX&GZLn>lyFf;yWbcOwJlWIF+7 zh4r3|c&2GR^f{DY2fPKa`TQT0Jp&SzcmN$oEjhRKt@YE|Q>zB~O!js}N=F6^@HzJy z#Io*((DS<~mNinC@itiXVP$b(Zz9)b`DbeL)Jut#T=iX&M6R18U47v19Dr3l4&5$d zJ(9(;J^DlXcOe3J`vo7Hu?&eyMJvh2sleN&Zz0Od0k;5beZC&$Er64cUniHBl++Rs zmpdK=c>DQ4z*45{M#y6XRK`Y07p8(va)9%~>ADqAZHFZ()OxX&T$`sV+}hH6`H^@W z%}~w)bOYFUjY9b%Kz;jJ{d%4w*_xhNGHWvN_FSMg3lJ-SMEGW|Z0BSKLret@TZIv) zUZBT0qLOdk$5>N@R}(YKp?n9NY!xN9jl^>|aIoi({()Gl1!Mqh`51=sbim~RIs$uS zI}Fv!2PbPqQF0oT59EWh>6krl&eTF|N0)mw>kYr7zGrWiIKsY>K3@HWdWC1d2SmT5 zDgOk~y}56j(F6f}w-~{#Mu^2cVKh}IqP=o^CjrE?P)Cw5|z$?Y=F%l$~!j#*F^w2>dTqU z7yCTCLH^94yhi;?rj*VUTU$nzi*1?pm{U4uEIJ#qujRHy?e_q*?nlf(9O2tl*qmYM zjCZ@RtwH1IS{qsuzd-9x%BT${>hV~NDu%44OS-CouT9q>lrsh;DjfkfT^mt02je>c zI@*6I>)npuBW^~0tZjH*vo2jg{RrzI>hWChnJ+#Ei_iOIH5ZGEY2tHu)V~Wvd8+vI z{Qwc(ZT`Dk8YEa8iE6BH} zrh}}{Ra942eLRWstAIBFc70O5ldp08BY=)m$!YN^}<(nbwbsLz;>+f!BpbZ4q^3;F@`I%X_k!5f(C zdJMHK*;okCI?Vpif`4jBEM^&n!zU3LZq+o2#3K=r=)}=!Jwy7Z3?ZKA%Xa+^(keKr zx4kMSF4b9*AG3!>_^}0LJ`DS)05(4!N4eE-?6(5Y5&lB9*X^Imv$z@c@yjdA#Z}$@ zM(Ck|GbafxR8Rdx>E_w9W=E-T;@Kq8E(eOw3F32&^A+Fy`}|U*Fr1^ z_bNDE*Zva$4H)uIK{?=ApR?c>%xAQ|6P?Ck_JkIEl78_xcI0E{06hva%KpGGPkj4- zEchFyIO5wMv-`B*{aQ1Y#@}OiYr%W)!U6);@p})6shq{_?O5= zPfDOE8*2a4kN0NQ{*p99Q#Q>0C-qqau_jyFfn}0zxB&SWo`>*g)6n%fu9KyYnF9;A zbZ-@f=x?PJ^OYj=+fpp`!-VW*XK6=Cb7ZRL8!39w=z;)&j-Z+8818E847$4Vqi{X* zWb*HsH|R^J=qEUebENzofn3@8#Wf;PX$oiou;s5m%4Y*E2GDV;_W0$A+au(!XcpyJ zvipi^NCzpDm8!A=t<1cZ8%ufcM($e3`3lW-qsEp(0{%qgWh4!d&29*qo5lP=#IZ|7 zPcQx7?UsflV=B`g5594Fk&DjXb%gz!o4Lwz66~%KOCc=V*u#@n;(-=UIDlZ zK*y=-eaWf$fq*`u^2Z65Y>>=&SWc_0p8rElsRmDI2Pa}E$i7|0%FYLZCvT|1H`NeJ zcwfcp70h%TJ0>}A4ox5i0e0`*)oPD{h^!EQI$+l&HB`|HZ z9Tj~gp8|yuK4qa?02l_a`7{mXrGPa6I!?u>b&cZFS%Oh=n6ykQF zy3=v*OzEy_9NnFi@1v=EOi=fr8vF-Qw;9ys_Lul=YyiJKe*wQ28;M_MWg(I{)w2Ii zf#A(*@D@VwE@Hnt!ub-if%2xrF0_ZrEoPT>_JV%z0cgPVYRim6Ftd~NbAywmbn({D^ zw^gInzQ4r8u!jAM2S0@mUd+D{Bub3LY_%9YNQW`s9;A09zx0o37tb^}K0tq*4Q($bLqjT>C~ojJQftpeDsud%+QMfl-eu!+70NdOZU)%+Jc9DefJWex z1#ZJ`!*X*0IRd8-5pN6(c3L=T;_PRd*(OF{XTYmKV&@iTY;u!WZetQnjM*jMvd)U& zISAzo02c#nxw!`AdjXpOjgcGW{$=8O4e&L8mdsBk)#80pVyvO%)2ebbBJTfyq+npj zgZD$&V;&-t6OGwzrATA;IwLqJ#oMJ)Tqcxaxzvg2(22#vto8x??fM9ujWH6S6~L~K zo+yt1G@?F0hp3W4s1BO!$BHU>k||3ZDcp;J011i@m5~M?0t@vvq8fa>pR9(fG1$X@ zaD<0T{MO>#HhzzyybZ7eVB@zJ<$nSif!{!qUy>IoE9evNM;Hz%D?&zW36?TaD;skx z(GYl5TX+?o6T!=tk5b$_Q~8KLv3v|1TRLT?@+6pOspn5&qWl=U=7Tj1V(^9N;VMQ- ze(jFJ>k!Ic0lov+{8DdlD8J&mG5kVp#^t7iVXga_XzAN>x4rn=JjhQ?Ti27JP=- z^c=B?T8ph$8)_}9qg%`DNNa)qDwK2%KKFP!FT}kwrL*@*=qy*hk5IEoP_uyrHxf0Q ztznH)F-GE5(*RyS{{_4XPlne#r2-qfEjHgN5W9^9Zzsg=wh+rLl6WmR?|6PK$GtP< z*Q68URiNAw!D@-XY7q-ACajj(>{67oC0>E?$K%xo_s$fr1t-C4g0en>*rn8VFA0_d zE2KnLs?U-5R5pOmQ-1-Un@)yLnet==p-F_$MZsbqG{vP~q$;!}8Ggg=iT)Rj_P&j45lufKlR!1zck8bUx^%4+JN-{~TrzLg+>!xPC(yPxM9Npi zCftmu&j5wB-ffm|F)G%mryM4^7PEJ=9F05?f02pWw^B7!ai@k$1FbnJ=wTt9gTlae(9VEWo5JGpEYy( z%vtkiTK+g^+wW5*T6E#r_PSp7ghXX5pcG)&<8G8c2mA)0Bd4Cft%XGuZx=%VPC zj5y9_T+5%iv!<8G)vY{2zhqKlz6w*|iN7{v=InWMM@^nQr-a7)vu{3b1-aZO|j zXlc0LzzT)tX=|{x^j+>=qs89|pE~asEDm|4eXsENO3hWnNE_yL?EL`PcTdE0yAyA z5y!fzSiCdhVBUpZLI8O>DlU%bf%{Q@46qMi*YAH(c1=uF;sA8iyd&G^iSiH}X;3dk z)o=fKlP8yyquRjgY6kZD1Zyu?ZUj~s-sOgKg<&o?v=v6|awB<#k+j@!tT1AfjTkrd zUdsXqE05$bh@rI>K}T8aT9&+qC9P$Ms^y@@N!&uJ3lC-J2DgQMaw_uFIfyUh!zvQ0 z!UZ4}X7nU{BSugKeoU79SOq%6*1at#zXdo1u=#QJq(tRPz;XZ`Hyx7vc)doRt)JGO zvyu?jK(?%)*G=ktS4d$IPkMHUdRsU_%h1!D4r(yT?0Pa^GKxx`=064h1T}^z$ z5MeYvP6uz0%Za9(*oKA(Ifg^k(QN&U$Aqv{sy8>2;AJeQp~&Zm7{c%Xb@NgwUtcl) zHbUdDCR!XxnLH}WEc$?Z*t$3Ai3H_mTpt72@?`x>aZi@=RNs%o{=a<=H7HLrXU&vr z`uohLjs(-Ee88J_P3XkQtxZvy!i~z_;8ShkwE*w4`Mv?=YQReX8?WBCIhEgV?U^F+ zsvl3&#>YPAG}u2!)r-*Eh2oryRTuU*b zDdUY`Y#bCTQ{lwu;ok3g=-&wB8s8+T_FiD28oEKnJ@PRdi$s_$wQ(#DpG9Q1fFEoK zVI{PXT5984IaAh8G3c}Ta0SW>0oMa;`AbXlD=ToVZCfgSYWp+QmB0LwveFru_A*Rd zVeTjs6pUAPV~b}t!)&}0ZFCe5m?hu)3!Z8Bt4*dNej;E6z~=w>(nRGl!2JL^0#^RT z6RpE~#y7MM8^!-3>Nn9W_ObU6a|O{9v5A@QW7o-x4&v4b@wq|X8Y3>Msz=Rb>XR6=D(TiOP1dv2ul^(EmoV5Cl&YId}y5iG9GWlU~P6R zelX4f-)yA8Rp*@RD#}NY3*;-uGu!f1j-cb*U7U3Ry4%A10KR`2oFJ}tV8^wacT&r* zab2G8Goc-XNWkM<4fl_>cU?N<)@vDK14&yO)yfryI7;L)(pT&lrnM|GUgG_!wF z-is^&qaFtaN{FH0_F-qXU&FQyr`HG|fnyQ|nZ(2ENRf7fU;SMSA25ne)B5ouUco5< z%_0V`mv60Ri2nI9Nq1m+#2+pbt&SR0elRw`AvQ?f8g3tCi%Upp5J#At3@&QRQ#S@ zQZgOQHMbaczP=rTUkrNNOR)2$n7b+MEFG0sNxb&~C!7CoVk^+sxc)v0?{9Fg_+8g2`kN_bvo6le zoQxd{oiMvCQXXT?kRV5NFjvd>^W3b6Up5%>B7PHu0mw?A@*O#frO zc#pY?A^%%5EDgu;49zc$0F~DJx8YrO|8@}N!+=kt-oN2lLpg%$)o;l9vF8)@>L(IE z-g+sIn>}k5g{D}}l+{KC>Y#wTXxNph4MA2e6y%Y6jpRe_tVsV}@S>?q#`Sc7&4(*q zH~YaOC_e~z2w>r_Jw(% z%Wh-aZ*2Yt3Ra0?U3w^&&GS#uf@Sfri=4o9in87}1&-MWRtZdSgcr*9|AJ@Rhx-!5 zvIV37Z2t8_`C@=wU-r4~`$}h)(wx3<7a}(mP9Eh&hO@0q`+}<-l`7|)FV(z%A%f$W z+tqA7n)z;16IjfrM$8wKK!y6J;qWKy0O*HtNMD@WqNgWtv6<9cb>nMQc9n%>#bSx~ zM&J~-?oB;cRR$y)N&$clY_B?{Kfb`<&Oak}&WhhwThBMkBl-jT{*UV9#ZU7yxbV?BRr)bn5Ta%K86rZN+7M$aEqK5M@Hg6kxIc1Jz`FXK zJwI(uMDH}j|EhX%CQizqkqD7(J-^s`{t`St$GX?h^Q%v~|EV7T$6)SnJ*(1s{+D=u z*t+*th%1@sEjn2}2FcIK_pvqbe3BtNuiAS4;JFd}|ND%l%))g!fDU^8O{c>j!e7d$ ztF5B76%V;y)^in}X^#^c;?t+6%cp|!Su^m0T-jmC zM)Ww1_u3tED475{PFo*YxYKWItmm)A^8<7P$6v<3s2n32N??iO2CE-91^%bip3j;| z);7^1!q)T0%|AJR&fxh%l5(x*Z^QHdHhpnUd8uR$?6%hPLklAMqM`adEqx)LFBL|m z_51~Rej3#Wj)vOtX|*%)e8C>2#(MriJl}3#4LyH#y?taS)z{+rQ9=rw!%L(b#x5rDjy=f}<0PX|Oc!k>8dX(?q=~miTFQuQ=j{EA{Zd7_ zNDI&;A4i4t-r0Dj{odPAz8A0_V88eC=MCi{T%Y{CbLK_`wY5UQ(TjOpC7UNh>)px_ z5EviIX}xD0o>~7rSDp5I8i+SZ zZ>sX*P4WoeB=%}xM#LcLWq7&MsVBt=;P28?7FPq6PnOTa~ z7}uo$oBkVduN>Ex0q8iDU2f_L=$C8q>6rzTA)T0LQviV~7 zy4BY6w?#ewlktY~E#L^ie*RIEj{(-#8$X|*ABozgPM86=n)vlm1L5oPv>zvz^0s-KuJ{lr20@#Gyf2giGM77aUw3{pV?(_n zUNzagez79DKg6`{j9rBURF~k_kcYLBo>jorrsrjp-vfLEu<7~!SF~$f7uFl+o<2Q; zOBTR%SH@eo=Xtb%g#DH=TA4$nhIoy^4+L>D zG6eRpf7~hYFT4)83D#qZiSk;&-2fZ^)z@QxFkq6ES0>-eGt(ISCq(LD00O#{5dkf# z_>D1e9IN6>;6FwLJR7UzchovxXkOsNwlkp3T?l=S$ag7vrV|mWu%zH{@z{`dmU4EdYD9fOY=fve%B>BpK6{5$yQfw;~$$!o(i2;mhAM&r8B)J(N$k`{|7 z4t!)D?&O;B{x}5xjm0&RshWdLkKfH>@Sav`%icY3CkIYUNw5y<=XAnVk=eqVoY3OD z_TCP9dS8gUj#R?!xf&CDp*r4-*Y#f52WpPgN9gJt!^;xX^L-wy>-sbG%!E9I(BkK5 zeblowCqj;z?abCrr!dV^*wH&E!xpTkh;a3|gN@;{`4s*PQ!`;xy}-D{ah7W)qV=U( zyGBt_>n%NTgKYm)bzT2$DD80FF>;UF_FwzlnEE2?aAubBuGv+=27hb-WE8v|LvkFs z)_W`P4qM)zLHRAfy8v5W%q56<0?7JYw%;^soxU;euiJiQ4@&D#HSC}KFk?0BBY7G| zA+g>yHyN}t7Gt!*+Fp=nLH>ih69mZ5T!@jF$_7Jh&NZCKE9gZ?`XTCS5l)>N^-2m_ z37%gApB#QAAHy%zk)NS0t(H`5ko=tr8gd2YigF*yPXl%WY&t)BFG2YT*XO_bSM`)N zQlO^2U1s`tRi5^W7XGgJv!)cKXko3H88kXcMHQzv@1otx)Pcls@3By(bI3r`g-ysh5V8QsFitCK_v&odd?u-(E(snP}q|`#jA|=I^d&#)>vRvQ8T}{eBWCYUAHiD!1A;J_K$2Y{ZU58=uH0@VV*` zliIk*zO00|$a*uEB`V`A`J0dOI>09Y8VBIeN8W_^qkvfeI(mO4<Cr?}D>1I{9#H5l-%t5pB7Cr4rJ^Y03eNsez4ahKn*&L8(wOz3`|dLK_+$JLPc5yXD>yIgJ8*ZbC%n9w~u^=_WJR(?xG z1~=rJqb5dLWF3MsQmx*`5C9xa@Bn8M8P4OVNg}rk;;e>Mua@dag(FLbFN2452LcPm zIYWU|e`>6%d)-HPsP=YTsA+0qYBFvoBx5}Pl$3)bs9#$S(v~MGg8;(;cKcn5^6h|e zxw73BIUC=8kFRHFTXfJnHPJyImZ!JK&%27>hLamrRpn7~JMvaWveDG&z~{qtX$-<2 zCqv_=vBCOCIFg>N4}lgQ$NIq|!&JTMJxsrmdCWLnyHV`!qZYBk3VF7K^LH8F!1zEm zUK_7}hA>vzeo8P>xkJ()SP{`*`6!PETo16@!M7;?0`RSr?O?bSN6B74k7@#VE^zE}xqg($b$ zqMKXwADA*t_E-Kx0SKy_$A7|n-!tEL$V3#2{0-EB#T=x6Ie5Z{%=abpkt;OyXujGW zZ4mQ+jld>4vYHtP9%?$BNF9I>d3`X%@v18sMa&}UywQQ)8`AkW;|Cb!eI3JYWb7`c zrBbA=DG<*Vx=-_|cQTV{$!_GSbFPHv0Ajnh!jpijJc^%_^{^KFwfp@yQT_}NTotK@ zb5|M4N?dOM&~d7IsQBqj>S4s3NKHJbiJG`hRtRc>6bWh~RWKh);|bJ6?Zqp&aBtO% zMSY}*n)(_$KH)b^0|@`~@NaAK0`t8cur$PhKx9x6zn%WOgZbXH|NBOuU39f?Q^$22>30PeE{S zqy{n^dK`O#`BJ?;m$-1g!U8HHS{HjzMcx$CP)!vCC>1Zs`pLODQa_KP`~qMfz-}ko zcbdvqxIPM?qoUq?MBI$}SZ~c6Phaup&nurmE<|V0XV}Ihj!5_Su$2_*{u=RVtQ76+ zNd~v-=5QVDt~`u@F$U%k3$0Q6I&Y|MKb6-5; zo%JIN{lt(ir^`>Q>5r`WPfWMVj-TM}`XlT16AS#vbn%a-_P=ZI@SoUkOqtt}H^rFP z-N)bweaWT2IaBlCM%tg}b;*U7Wu8N8(pT@(r8mlb9KCg(ix@8OT19A8(O(Vf))$kz zetK`cm#!t`=-FL*;<}fP67QZTn(JL+*06>1&pA4&FFBZxW(t?qB(&a;%C#;k627Xs zI6KrY7%CAFmQaCB0bQ&zCv52`epA-hZq$RVPmiG-ye&~l0@!lc{IxhGAJ_Hk>GbOZ z12Gc8eTZZ(hFk~a0Bit_ygo3u>B{{=1Y7ccm_>QuwG|P;0wIEfy#*@5>x1emF;GID zFoZ*DL6>xd-;(sK1+F$d`%wM@@GZcmXZ$&SCE@nJi=G@o(DiZ*xwj5M&+5v)1_)}2 z-c{$r1VQ<=1YN~n;@HR*MNrKFNzYW^TA!Z#aPR*QJ)OEMe>R9TDFRsw5u^p*E8mrP z2G&II9D*|Oy%^_cJa5GH-y=s|It#{MCmFvlit(Ee@*&2kD@3V6k4f|g*7E#H;`u6ZmK+j2qtybsrZT`mNkvO!izx!4~i7f&{TKZ04* zw#7kFa#78(bI)pvm7hpDjx<0=#-0D)=s-jKv_Xs`4Kac-2}bd!63?~3slHt7uZQPv zxc-~?jfO}O6B{QPS8#B)uG&T%Ly33cUB~0S6!)U#e2v^?&l+R48=aIxT8hiLM$^_JZZqVZEz)AI z*Os&EG|v)j{aJzQF-x@6CK&8JNF!bN`6nZ_HcEZ9?lk1fz(!428)DVv%<$-|nK{PM z$)c&^Tgj*J-I4knk8(L+KESTeN|ZMN{<<6re$26Ygj;3He^a*nfqN0!=>v0*uH1xZ zVkD$_2pQ{v?UKe8&0t(J_#i|8Ph|tp{FgC1lkp}oG*gs?jK9J80MZ31^gkTMKS=tN zdm{ApLYeg17@TeTNUvRi>%WUW2rP}A79Bs-c*Y`44x8For1=DCG;)u0*jMxC>Wwlm zZu&{mQv+Oc1%--|d@tf41F``YJ=%h~A!R787i-e4v-M9Y*Va#zs!^*%@r=fWGN(HT ztl-zm{Y#e~qg_nr91~;19}%VoM0Uoef*nko?{JwTXiq~=?I8xk&lFCfw8BRv9czK3 zt^eLa`9nYrz^)hVJwy2t*MEIoZ%83!3sDz7)g=d1m^^)#s8If6yu!% zi}(WZ7WY=gY6@N-+bXo+TfQ?y2vaj89R4E z`P?%wcZeTTD24{#n~5k4l2Oz?KgY&Gw9GANrY>+U9kYcw*e%$V`LUsP2&}EcF}t3G zAg(DXty&;STBwEB8{0XxrQea#-jA+EZ;|iRTe*<<)RR6uj8L#0oe8P8_J;vk=qhL zN2{M@y=bazSJqE!PwP(B4@c@})Wy?EkPoC`M&*~D=wBzTJ8&LiIUTtb8hq?Ye=90R z>~e1S$5eR(FD;o#yKO4?W~p4Ot;%bKc@cK!vX&y%K~r9WP7&!aSQ(-T)0zT76P728 z2rkNIqt-Cr$INqxxjsf5y1?j|F(Dq;&dFmkO1k)SVmqaDMnG0}ae8)!nXXG<{?U13 z^wIio)E$|FFM%QAT80-6$E!CZRRJOpz>=sa9e8`*OT8GWM*5gT_#libXlh@Jq86jJ zGn(opSYVGuDv~lITQ?0|KoczwW)EFj0*Zf=@==X?4_mc6x|vh?7q0&eu;s)15BwjH z4xnT9DaL8rPG*OU$Ojf7(JDG23d1xokvt!Q$||&xKo5*Kaz=r{p6qN`bzC_fl4jz_ zWncu7i6Nx3Pm)6l5khrohL#qW}?wZJ#cqH71rZvZ|9*mV7jvU>y8 zQ2}(+oWj2PdgJJlyGdwmcpXEnRk88p^&u&p&vFE%vC2B4bRa0rKNktLJOgs_^#QJY z7@{Smizom{EJ!Vqq|Vc(u`HX^`8-FE8Y_vb{6o^a1a#D=_YvGXQ+jI}M=v?KP-v6D zXi!rqs2P=0sE=|L3TmK-{*?HoZH(YI8|7;OYXCNW&!D^)@ID~AKJ0yklG@1Ul9Smx zBXTxo!u+9QM~yr|qn8Ut;tS-pP$iAM8*q|!mTcsy!?avgXFaW&qrfErIKISn%3A@8 zb}Wlo&Tr%);eVfG-W1^z{PIq~qtZDeOUq<{ zm(y3f_$e&Q6R!2%YQnpFh(D)#InSMVJ&zfnKJ1EtxBOe@&yAi{C?-DlHNYp(y zu~ixGSGlR>aW^6vJcHN9 zUE$=RK7nm4?J1^jW9CyVa~sp2Vx}nLj(%C+%gxL5%S|or8ff^d5p+Ia>Q`l79lFYO zwO7~uPM_gv%ag7nj25#CS>Rfhjo^Wcm}@aJH8+tM!~JgQdGI1N{X{hVRVpmsgV1{% z;tZMfkkX1jz%dnD#h?n&4UExRt9ja7jySA2{4(Ct-Ay~!HAX#0ZLM!)+OwArP=LOD>!_yJ{KFDT3Pf|pfO-xHtE11z!zkzAls)+^fBGYgrxgg!{G>`5!J(+IZ zW8$p4byr`f>C&A^zNX#>nJ?QLLTsLifuMh8Aln}bxcnVR4oxY)flU#A$mu9w0eAvn zw{!JT?1cq{0d&;gpCoQZeFPqoanl-qMB1Xdke9~co_!*f9<_p_E1>7L5md`!^ zW^qSZ#=lvYqfGxd)5T|^T-S)YgJc0Dm9%~ks~jQia0ePY8$sWZg*P67_TXxyR^hcO3KG6*HHLNdww}x_6)+&+-Rs1s}r z9t7IRFuLeEdU)MP1P*qY!*Q)Iu61Vh6V*;xKULtn?ceKsEKx}Wqyy~w8H(}+fD!;5 zr>dW-#_3@!YMep!(61<*!3uUS1(LexUaNLSU>q$+4Na8aiRTZ}Y`$-|$%$aK=K+?z zp6L%T*Lvn9g-e5n1r{5}5l<+?IrOp|JMeC5S8Mb@Im2pPl1~Rgzs;vEn~|FqFcx6* zX)ek)09FDT!>5Cd=2Om@F^Vj@mFyPDsqr?aM%QvGPFVsAdf;*5R9TOF!KuwGTZp5p z3Q0;WPF-nnsz`EbCSOy_sUEeQic=Ji4T;2jOY^G<^`ggNJYBH~|?A=U%g}HV!q)3)c=5k9A z&X$5u#_y;VgzQ>DNDxHhBiASS75Kl1JlOiB8}8ZsI$ixz*l2#i@<)qWfp>_m|FUi= zqFxWM?6;YIfVtk5%AnXU@!Zw`o^e~w9M1)f!;_5aXHc!;XWNLnPl&qpyxRal-C>qp z!}P<YM}FI zzbMUshu3PLIOQYa!*wuJ`yW%gJ|y|Dh-WY6`XcUHj6ps|tkiE~hZ0!i;A7>ew-n=D z`WNQ>ylbrzLT{wh#7Taw0w3zj{SUZj^Xqi=PpF_#{SlE31$6of+xj8p>ImFS{P;-i z_L}6!N}j!n>u~a0g}n+;%JN9C@NUh1L!!FT;w*6PO*q| zMuK7gl13@3Yb8UDw#U{=Myzs_IB_>|Vx!h;84{(Uh1|un*Kz$W?pg=Qu)3$KYb8UB z=w_2-;9n#ey=o;RRtX0szczvowp`f#_+H$z`E|Pe_|((j7X+gMi`0RKh^lwB>_;V4 z8+i6cu5aM3jfjhD*KckUiSM|lBleOjQN9UqH^9bs2g+{&J_OJ)cgIq3w*BA4MbyX9 z#@Ta(^EfS_S>X`RBd;TByvf90Ip&U4ZX#-)05x%2`2sR?Y~k5kxxR(Fwni!l6Li?7 zMcWlMw1#QZSw~q%$;cdK4Z5xQNkvG~UHD9-o@~3_9Ne?%z7^#S0NZYND!De-FB4p7 zuw_Q(k8pxi))H0EQ5{ty#v#OR7tg8Y`Y!IOj!=c@#Zn_#=CsV8MEuOuf>u+(_emH=)Cj&9*ULV@(uFR2KM#+Su3pVrpYw@$9d;{uOt94N|ZMNmT@EkhBz!Mb%?aPLg(XI$gxwihqoB&vR<3i_|Cp#Sik|8o65 z-1T2sL6yxVp2a&8l`8Q4VNmq|%Fh7~0W3U?@Gi{70J8ve>^>~p`P^>uEN|8?8`cL1qBv((S&4{`Ql2SJ_2RCNESYbGaubC8By>IlL*koQR1%$BoY1&?3JmU1J@i6Ic}%J`PSnwJ9)>#uZ{ zAws*sAPrd|y-WVGLlIO_SPSQ9qha;Dkau>rHeX@d41J{83;r4yh_2?YB+8Kd9{II1 zY=~lTW&Z@bC0RTJqDn<8Ss!15&ytgh5~_yH2+#*$%T3@pj5Pqu0d(9{D#uNgJLOs2 z#7A!W&7!#Eq;|c!^&l2|tpNW#wbD4`yVqcq#v7h{4Hx+Qr~ZxJ;amM1&t79Q+h^SB zP>!Ud^fL1`!v$ZE!k_W+j6Xzob!(;4q>=`XL zJuy5fp=Dr_-qzbhPeOhwkGD+#PcmC3v@%58`+5g*$9c+RCBp?wU~Jj zA-V+`0vYafXR`#X05k0BOo6&hKu!KiX5hCpScWN#-$#B_GMUSNBr&*3D(M+~9{-N< zVeq%+cQfY#*GT6%o?&bq*IHu}+ogPLTuOWbo2V_uZ&+&{#_p#9Mj$b#=^)32*lygK z7a{>0Z$TE~B`o2777EeJ|I`sk@EecD_+v~5gB7K?vy_V()OV$*QAP1Rk9i0n6=3;k z@Jmr%2w45HY?s>aOU1Vv)BZ%gBf+j%lttL*YoO6y)~=yC-(@KGt0`s+kJl6D@%r38 zgcadQycbpk+O+XZ^nfTve zCnEX?rHUpi1}y)_v{W9WhmFyQ5Zc+XR5h@%*&}Jsq+U#*9djUQefCPX||9MY3 zly3o!@0LlP1AFo7t8K8bVuoK}Y^mu;B7}mtV=qw zEO$p@>pe`_hA{0dutc7%y9_r~F=0FgjjIV-8IW$zr_!)h92Ged--EyoX0U#4#%hzRjhSC$)b{wzz@l~@<=AVVI5y#ixh>|&T zCQKobt7KOR+hz<6Z$C93XB3|u+eW9Uu_RdKh+|_{7rfuB_5fV!^J{wqU;fwYOLR~p zCt&mK(I}P`Tkj9O65-bfl+Ol?2iW}Dli*M$;`&7VIx_O)?eTbi34E-~90=RBKr##2 z2`i9Ya~277sn#;Ws9us!RluP>pT3CT`8V(hOU)5hEdeec@s8aV9w~d^*9w>bu*Y}nP_6>J44`B8 zUWw09D-MD6)7sOEryAc0JkQV*Y*@)Yd(ng@UWOHZpOCI z&ga=<3wr8(a*4yaP5T*rF>!qkp3A&M_h>`4&g2nN!1|hQC;nl*0lP)*H^3f2ZxBZ~ zU-E4g_+j(yCzRDUFy95(e7geW8vyG7bR2oz8kZ)^v$#q3t$E|NM)B=r?v5v2kCZ>m zpD=fV*vbE(CidyKRyh`~6yqx#p zX^VB|BHh1OM}{o$I>GFDtKPP4O#1 zKubaJpV9bwKQyG_m$Ww#>)c}QRVc3j>;TyHQe76OwA!1f^a9Y) z-l~^L@pbEEpH(l)!*X8Pu&mnhJaERvC6i88Ij{fxh0jz`71!eALRIW# z4kr=}Q+1TrtB#(iiYov?tp%nPNTr}GW~h~o-1z`c`$=>DsQG_Fs3Tb~U9Z*K_K4{_ zAUmZyuh2(dtLy*OdWw3{p%YwDb>mihin^iux~!kKc#DU?Tm7i!`dLE_r79%V>U zh03{;bL7i%Pgd_s$VNt+Zr+Y04N@=jmCq>kFB?L4zd=^g;(Mi=n{s>dfEs6 z*R~&&)qR-H0PK3oMtK0B2tdc|r)B+wHZ-hv%1&w@vg?W5LCIlkSeq`QG=($M`niU9`Mg(@k+z8yB0 zrDN)be+m^6^*rETJwnpG8}u9$6e-F#DE|)dy%nK53+3K`5dbK)Lg)RPHBk=K9}=}6qUp9sHI625{>;Mpr#0lW@xmW1DeZwSJdX)w2A!UDo4 zaG^ViaQw6W^FTnMq`MmQ*!BGd%Eth0_ebcSgYq?iI{zI0% z_(tF!8V{4~EEmQXMR|v5!;!pcIa3F-PHeilp2P3jO|7dT#I!50>0cX+hSdvM3!y*h zapK4=vh?49IfJ@FUiDk9G676-I#B;&h5q{o-@@Bxx0Gprd zNBEWRajm>lx4o8GahI&>sXdv48q=TH?!+Xv*GYN(1oCP(Sx3H6hWuXxOuOCwlW001 zX^PF?&f0QpwbNq<#1y>A^tU8s`-w8N*y#w_15Y=htE?u=^Hn?#hFbf1ic&n@q91hG z?RFi?y8-(FHvNC1?0Oe53IKHM+a~qZiRjQML3J4lt=XiihdzA zQYE(D(6JVLs1_wfc?RVd0j~n=dFOtV4*`x^Qbpcr^mPm=fyJ{XyQPXB+jT?c$r#n!(wx9r`LWU~Phk^l)cv=Av02qg%F zP(qQeD4_}ugglC(&nE#90YM^7z(Q1-f)ErD6(tHHHi$m;As|Eq&$d)ivYpI83Tz-oXlSq&Dyk?wt=W zH{LuyL6rql|5|qb$ZNg&5t)wp$^<@ zRMLA01K&i5oQ!)Ns%#7J3lVbfvh*K(vGv~`zpO`|&A;;YFGV4>2hw-UL1Mutf(3nc z!$V&44|v9Iu74!S{Fqb!QizL;D5UHU$oVYBm$xE40$}4wy&Cw2z!HzK zl0NT&(sF2mJe$7q%He28asYi3VL1b8j!9~YNeticjAE{TD@i#j#Gs^u6BM=rCwskz z!&65XGjn< zish};z`ucx+#E~)foCcGN8VrM`^Av?o(gjqVE>22{vt8{SDtZ^>%U3Vf0z1?T`USI zUzg^4ALQA1mY45qL*+Y+50?_2vsE#DI>cJ7CN4j$X3kLcM^w=w&9ZMP1fr8AzQw?; zBwzJI0es7=&z@DX-M;p`o%9(9uOx&QON8@OF!pgZBTv;AN^BRYXjszkwd4<+g7tYl z(%XaZwE21zc@(bKt`vpJR~W;|`C3>@Pd~px7ZB#pO3df-mU&=xo2S*xr&N7~L_VKb zEfl(?8u&Np^L>(@0f$TT{oly5^`yLfPY9XsAZQz5|B}RhE%E&YHDj%+ual^+C%zZ{ zOX8^%U&(iB&ub#j#?$6|sO|a1Q27p{y4{54>k`d3c>(<649;1;|C?4 zYk^ZqJ^myJ&+_uKX~_HpqMs9@?@2@piJ$MN8HK98L!!A;=y9&Zv*$OZ`8g4Jf0dsJ znRA7{U%S4M6{=E9Sbi$8Ji$|kgPotKnIEhAUWsQBu`_qN#PcF>D#_3IBLO_i%g@Y^ z_(^z9R8A41UrR*y5j($9Gxn+a0g2{8Vy99#L*khaoPyg0($Aeic$QZ`GehQQs&bL= z{GY^giJICS3_Yf19#!@4B&H{bp^r#BGruj(&%2Rl%e}n(927b~Q{k)xi2f`QJwpur zNzFK;>gOby=Y@XGmUx~4P9^zS@n`_g^71n$WPYMQ$Rj+bX`%z$sivlap?|2EzpMIX ziD|9|hRTrps3m{kR#H!Q1>sv>zUG9^S0MZpAv{kaJPT`b&1Y*Fvo!rtO?0<&HIGs_ z&yxSKEBSgi(zyUz{x)A%B9FrL`md!S^EJ?cHTMkkcVKG?^JNnAvug9|F}&SkEpw5k zKOu2nN~{+BS93M+UqCER(i3yMG~e4G&!(rme4i0I-$Br3!hV&+KA-shtd^0l>Ca2t zSAy^93nZR}z^SA@9}B|M=6k69S7FF}N54==Xl}C6OvPqo`b%2odQIOb5iJPP=S33V zobO8WbqVtRDqoj|&Q~D(F(LexM0hLl^>r;{tERsx(cMP+yu_0Ki7WZq2I*b^8&8|B zlaUvSKA#AQul6AWYed$@bE{vW_YvwJNYqbhZR*7EmhWkqJ2m}%iTrNALbp)^|D`C) zBt0j9XUTRX;(q~p%B#=oL*l#NXV+1}{tJnH5i$N#Eu%=&|08k#9E`U@`jZmhrNAv% zuW3B|b`ZWc|3e)Qdpn0Rju7}lPtFj!-$-n&Sj^!v4zc~H|2ON5L4ike}mPlgM8=*`{TVA3r7;H%#K*w3Ng~CZ-mxlsPab!uFwc z(OVJJ662AgAbXOlwN_&@N zq5`YLg?tAb(6T~4a62a(l!at=GEo}{SI^Jrckr9X+%dKx&$xTwKj8YA76*4QShh}t z1#>129bi3dp{ms-x6tsQdvlN~~jJh3P&l<6@bg_UrIRv0oSTJUwAIBhxV+vQhayu$@YZC)kM-M^ZfDuxSJ#aQjc<*`N>R%{S^98+3h> zB;;jsxDS!6my3Q#l>C3-TfA1%shkeTqXyzl0T}?B&QXX@0z3?$a6Ng{43$pWH!fz6 zKo4PiOM2+vFt^NOXv2ID5xQLx`i4G*w|rC2cth9Ul7zlZ>&&$2xhk+>J_XmnSbAs; z@L16Ipoi-u-4{WRz5hh(hh!&^a3(-E^)JD80t4QX@++(UrG`W|)`hHNPWy?nk0fQg zbeICXuV?Pk^$#RvANm~%=DsBH%>`~=Apz`#>6)?<>9qhWgy6P0$pwtt{ z&QuH2l8nk>us>@LBawK`Vf7M9!UjoqKFYHDiT4oy0&pB)>!)%SXXyad02Ff5q?{&I zk#CC>|EpU_aBB zI<{Ka+VsAUykL6238GiA_L0h>H@h74_Q#!fy~m8s$(R5u;P&GuV@>5nBJdY>+eISq zS32U2gWg#e2;&t@TVN3bd=p-l`1d>)7*|~SP*a{kdNsh0zeQUC@+e&I{Nto+;*T-M zurYlm;M|~J$FAPM`pu-^J8{?;2vUbR6jRc?=y5p0g|O1J2UIUmqFdwkLg zc{Uy89iPm)CLMPBFR|MNrr*4f-?4~ec(IrdT!7I>!e+_e0^nee8|;0uFM{wY?>O_R zknpm6pnhhc(b@rvWAr<38yID7H!|Kf^mhy~EO^(zC_a0O#B;#UIFo3}dMe_1fad@< z|EOQtiS!o$3J0%ak5mvMp8n&BeglUmVc_8JTM7ckSciz1y^@#@K}?U2jLZ)W{S!&d z9s}J6I?*9~MM(p!pl@KEZmXm>^FnF4j6`$;fwN<7a1Cp*mk1$I$@bpRXBeTbg|Tmn$A_opA&^{D-C;+inbf11_} ze^;-Qy+=&yG&WiMZ?}cf` zDYFH4u-*)p6ybu+jxdg_(3m@3WR5kg_n$mQtIG{Iag1>_8EO8i-pw7{tJcJEL_SmP zIGc9-$$;S^hgr54?hrdlLx!$6w6>h9_Zbz)_CtK%=)_vU!mEj9I<_&RBjt;b`<7Kd zE(ZG98HnctRsrmO_Vf>ivK48wgkQ(2b1A8wq`7V~wc%E4f&o?@dmS!|VRTkSJR69o zYd&l#PMHN0ZT^!sgzZ_=-UcKu<*mjG4+ zY`r;*_@4mJC8;;5*17Pq?#0@7P5s6=MVPf-UBK70NWNvnU8BcYepczI%@!OYhwrVI z@p37L%zM;u7?rleU?W_E&%#99;bQW5?&urduL2I>nOx%$I))*p7dASaIdr{8yMB7# z@IGV>gEPuZSA{7G4Ldtwx>p4Un4Re5<5&$$@Z#_mrY#L%^t1ufMQ)Jf?|}o>3AiIw z$2(o>L3W|!YcBW|BeRrm5I+lu_$|QKu87|Sm;j()&&SL1D>>zwb|e07P#jJC3*7BC zdfbRxCX5(@=`oxvye!?XzXq!lH+b7Yjl>N#k=pwV%h_c18=;b^w^K`m_r65jp6cT6 zVUfdQ21X8oKiN*MTNAq`c5^eXH|(r;(N)g~-IWG6l=^KE!`cqlhdGDp@<0^vx(kjg zR0cdxC&s&ANHtnA`V7;WVDdnk+W}`4Vub%#m!1fV;g;|!7YXyTG+?5u5#q8eCrgyf z&V5(%e;@d2kE^SCxpEn4^>=?8$vc#|AG@sjXIo*98!~k4h~Z#HENnYtF|M}CNyj(s zaz2gW%3Vm02`Xn)`O4`qdg8Dl6R4c3%2u4!&~TNSFeodu%G-)>+j?~X@#BD#0K0wJ zu+va3B3<%c?(5K(HSsPi?WCt+6z^{iyN8Y&?bF{P zXu~ASmEnkw2TTOmcpM%QrA$LQwDYBY{q2nlmkE(%frtr2dMif+B9Q_SOemT_{UVio z3y<9>BgMkw6ym=E{sh=~xUk;k`4eM4>pZF5?_HlCBT*G{*?n}l*E!V-OkQNlMX0;W z7$i`^0gpi%u6)ylQf2S7>OIO5^3mWQ0P)R$&j40?YNTAoJRC3tK;iQ++5a|8ys{t8 zv(6Kovf9(6Yx)Q9^Y3nwW3^m$vo0>-hNoywHFyqp-R<12x-y&*t~5B!bOFMgVXioM z-k*uHFK8^`4ze-x88JNZG7j$hwAPo{9a&Blcd8iE$#xzsc61szbEK$5RG)dTjeW6B1&|8W`Ql+K}gsS~BfYY^Hj@&e_R zM0MAC+uf^@{(;1E7I3iV-RlwG0r&}E_pd$Bn2ZHHM2!lJO`;{d^@aRz^U*-B1-tAS6zv-{@G}s)z&UM%f z>TmFfW#lNye3I+Sxw(w%OStbIn&~|qI$5I|K zJl5Y{v_`xSU?RYl$6JW+2b={^*nC;)Pg&>JQm?uH;MXg8fQ^;_x5_0z>s5Wb7`?P~ z-S1q<;IFm4tDBI8j_T=S@g4&;;v^&xZn(b@obPmySJA3_aLpgaFl-%e`!v_{YMNVh zIp9J40Pl8?>xX$7?CTDL`#588i2I)A@okO$7!eDl-2n4VJ_MReK9Ht>*`HZ;az41N zCKwqwOzbMF{y(**YBQ}5t3yrRO&GAW!x_r@xII$WaJf6qQlizPKPiPpl3)4Y zgWYfKLi}sM&j6cWb@f=K4WK)KLe7tK#Jj9@N@b1gKd#fS1##*GSOi~6uS$l&MG3VG zEnW9G2cVmv_CR;}oU#od4^+^=`6|`@_H)$AJb}lkj@fF)995sEraeYaG#a`-SM@!r z8p2+W8ps!ERF?`?y~<>bhK}_G-!fQ7dGe_xzXvx%(E4{8z=r|10Xz`$Ka+gAbx&Y^ zvKG8}1@IccZs+^7bt>;6-Ks=8kA13J;7cJKdsT8-$7aMo0DJ|Y@WA)imPduq#NP&-0N8v=cg8Ar0`3J+=wq#CmvwG!R%q?u6$jT2^V4*2ZH_8lo3T!YR0JJg zJ7o58EoRQe%+*O~6?OS;*L}`xi-{&~Z}zz&pk+tE#7HxmyT4YeqtXxBr#?Vz{IA+c zShS@bRJ{klMs)ATRrfKK4zYDFRy)ye4yzf(s{XB-c2qUL#cwoS|5EjRp+*9MH zEhy>Yac(K)Tb5G3Dv!5~Q#-L%>`wCx;~n6G9%|T!P9b5)*h%Q87eg+$cAuK9WuO2$ zu^x^gjWg(cTk(D=57SjzZyO@dmPdK@wpD0)D-XEM)I_`aht$N2QWJksGcKz7Z)zIs z!+w*RcuDpBsu~wa6N?W@ybG^__qYEKynBX*HywCeDzToAk$<$-X%2>95ETr>=4kpn zE$uOSiuyBG^F68=voui$iVs=(?+);L1>#!(p95@udpxm9BA_vVg56G(b^fa$H2n`g z0Qa<1|7r4N&?@mEv$gBrjy9}|OLwH3vU@Y3`?7m00Kt)ldyN*$D`{E2HhFLp1V?4h zYPv5yGUGW-U!|q3*34BP8~VOd^F6N_`D7_VT^z>M(zV%dRu_kh_3nhV0BxJgKCU(Y zH*ceM!HPvX8WU<>O{lNc)1`kS`Bnga*zLv_h^t|-N*KWATl2HwN(-br11QWoBHP!p z_%=K=d0PkPu%32QuBY`|L91%_if1SBY%89X#1o!vBEZaIhE=mQs`(n)||Vx zYigm!p)&m=!B$Td9HYyM7rM0<}r$YO$FB@Q2vq4-1iB?b!+mT{1#qUZKCy4C*WWl@>4KxS*~HG8?NLZm;BfbIuit? zIRB0KZvZ_az>i9=MJcV3?gbFR@&j}JKawxjzxrQuL*@sTKl0f9sN%wu&NAy~GUF&gWDd>C3rcqIN$#u0u zW(DPl?&6vzmdliM>pP}5F#m3VczeJUfL*V4A$|aG0zhG_#W#DM$U?#YIuqLb+kb%X zv|LKvE!E1{E9y^6*L3D|{md#|V2yufO)fC~XBIDy@rgJc%Zt}7wIV&aY6U}tQ%)( zzrOGXiFb-GpeMZ%{|8_Oz{dM^#P zcc-O>5bqO4#!^Fn(nx!Xo`UG*hHsf+EHP+clK+#$+gl+>zL>)`MY=n{#+&AMB;SXS zN8x(%Rk~cu?$p2T1H4nwaAGDG)&i0&IhWfiDcUF{S-@Ic(nN)R!RYd$p}%CLZJ?(h zYQ5oGXBcZqk|d`VkW;C-8t~3NC+SrxmX=d3@oCDjkM3`DNrvme0vS!V-kl5*}q8qr&KD<_h*pzSNVQ0biRXtA0+|D zB;$)E;}07d#fJW^k#>}x0{bI|?;FE71jc9oD)CN<4)DD%;$s0b0XE)mB2M+A2yY74 zs~<~xo>Pz!iTHho)4wSdi0Z-N#@#TrPiLp1ciNI}iz&5uh#{bY1IYv?~4X&2}z zNIh@(&KbrTy9NBlZUMJgEg)5<=BP~48=Kv&*Jn>@?{^jMnw)Ro+{p+lgQhx&iwnzeAl6+q)`M$=HvDTrlbEK`Or@;P2hwlZ4 zu^N0&V3Wo7b7M>MeI@e#D&O-%r20Ye{`S6oh|y`QD%$d{0&Wlmz@N8GlAH{P;?^K(; zEg3(}>^|MpA2!ow(o>*5!}L958o8#`&=k5P{wY;U^SwXvZ2Zg1_w;h`9Rxfl30Niw z=(NZb4g5kgW09#pVWusmr@(%R>04|X3t-@Fg+h!iTWs!cabfvqw# zR-5_@X4;GN6eO=Tqt=+lN)i~&S`XPB_@=-V29D<21*R}f(VN>lN?|NYZe!$!OFqRU zl$LW_T)X7>+G{Sz~74?P71ADh09Ok=lQ z&vJbd|D}ng`M(``f0h5E%E5mSa8eTRjpYA9$^Qdp#z9j*Y^D{{Q(%9{^nGm_`vd&X ztswCqP(8r^sfaHD6aZ}ek0DOu-K5`URXFBk@nI8IZ3%4;qX-8~^gogYwEDU+8DPME?nae*5Km^NZMQhqxYh zc3a@o7dz9I&{I&b$mv_?H1fzo10g+D;(rmimK;AM)C%BVUjFBmhyNhpWl6vblK(3u z|DShetaR#YoM~(6DX?Gd^sRCl&(V!BBBaMl{I>#Ed)@aN#4iFOYX|VpK>T*VSOA6H zKg<#Dxku$&B!V6*L&HCCfRudfeh)v#tN_04#LGdRc3~0e`nU5HhMvqPOz2`^OYQ*k zKXx|au}0+`PTB&!^=;=O2J>*oTh5HPoxXRRY41ACx13>eo3GFrv)$>~MiL{dIGjK7 z&~N$YR!_DH+nc;UZpqU6aR-eIhvN2>SLk@4Piz{+!ds6+Sdn1eva00Q3Gl&|x2F#5 zYXGeQw!V%>dIkJkJ_mgVxb!HSf_5V22KBuQpj?bLFPo2gdSf7b5f3dwwjGcL57ltji3I3gV z{HP_z1ywkW8|d}@b|l3Kl26{G(tK)$yt4RY?@N}&r;kJD(?sQrr29Kb_Yq0=H_nVB zPW_lO?KnLJ-AA3iZ=FUl(XG^wc<%;|c7Ojp;=FFG5(}{L?uPgfz&HSf>y7J*L&MuX zusTngU2O83T}biXj|+Tk;gZDw84AwCCG9X0`a{)60mz57-BwFe62-la!_Z<_q%g*I7rQcD2Lkkz`9<#D3voZ*_ruIroZ9j^`Lm z9d(!|PZk>09B~~G<{am8qNRM9SY{40yqjEMFT3y3S>P8pT z(cWzs8;5fsE~U7xa6P-d_|W)>^(g;6iUn+& zbVq(bjsPP!lEtGn;kpMVQNcE2UBxKyW1)D>4etl81gKv7oi1TDw!_tBr%Qj|mA0Fn zq6ymNYN7kKyNqo%=bpDY_k-0-_2L!TXk3>>rej@=rqoZ}1Iwsj)3L5{&PDX6H;{bG z0YB_^Yzg8|1D*xgc6_@(2v;^DeQJm7pF*_Pwh#11rFVlN;30FAa>7*whr4hhMN4wR z(yx;iD=hsAEj(h919qxy5WgMpZ-6bocM$&)@I8P+AM4(VoldFmA~Wcb5}N$vfoXA< z!qd`$|2a9hh!#T(j~rsaE~^4s({WC?{ibn73qTl5^?kU$!h_0i(M4m8THG)ae{oef z_+wUe=*#mNJVaJgaTA;NII08(f7=D%k^=$TpxF5%y8?ExYK6QQ)u^0x9?%M zG2QJ}@^6xOC#3}1#h!@Y1(*u3@!o{^F2I)n3Qe0|yImX*D&9c{T9-*nr8lIZ9t@Uf zxCw(L=Yy7IJ{{JePS&NY1feihdeSXSm6o_iP&2sLowdZRFLS3Ycbm&3p-bJqC)~y& zvYewulU%zJzhPBkjG%5qTbIS!OXE-2D6t2578*S1(%v%u#Qtd$$)}z-m6q?l$g}xW zUipp%Pl&RFZ#*SXWy(^CleMYrz-x3L-mwnBb0iNCjT0RJY4 zcL3bdG=TqeEn!;>C`glXK5<;i`Qwk~*`E^c9P7TT>^1X6`s)3kG)uP+lTdTp#6*zRDd*rrXiW#5F-Ccl0w~ zV2=98$PyW&r$#2O3J5gB)0V*P{uUv=~~XY-){*BpJ!xoU}D zbPoCf6)-LMKejPPck>bx|I!5*avX;V`aSN_3!qD1pB?;H`r60L(ajudqufOx+b9TS zTnU+k@7@afoaXmh(B}yIUzsBlcA$9V1!JY*!z)xrGX62bi~@#-7%o>*9h#ZuXl5pW zj|(_VLaV5WTA{?StLX&pbGakK^g1e-ZNbnBC2?lhf=C{-KdcF$~B09N7&n9yb6 zK*1o5?a`7y(gt%D7Z#gPIR;~#zA?RdBX9IdmA$Xl3y&{SlfG8l!@5VWi4!lpvqN_^ zce>Cf7-$;|@eZeV9=ZG7&Xa1oYxLU1&G$Hem%|Ld-wRA%!g{=-QZs^|okGc#pXBxN zv#jU1`7GzpaJ55^_MWZi7|a(`oYtztJAcbPn^l$Rup`H*w-}BUp%Dh|Z*}vmB(o0J zQh-kut`BvtSH*99^y#MvgO4NzxVQOJ&i8QjW7hX0=9y)Eyci$v`hC#H#Xr(Nucgx% z|CM;*YUHmu-_NzVyw9WD^ME1Hn4vb{o#(3pF^Ouf3REAJIRXtYeW`$MH#p1$BaSok z7{h(4>WIE0J?AZYiot>k^JP&>xVe~fXiW7UiJqMnmcJ7V9trxchrUZtNLZeh|BCy8 zf9CuwpE}$v@SBUfO4&ZO`)N&}7%4K3;*yTer@7~4RiaEE`IuwH;5WgiF4I+gn#%v= z;|54xFD2aGXF@!CnE46gAF?~=a*z1fHlpl%Ntvks{70s>@6*9^!b03a(~s`wBd3w$^oKdH%U9!EP*X>JK4cwz849fDfRaTlbG|I!D@r;+C7lb-nZasu zcARJatU@-D_h(4#zg>ZjUp;pd8-9^;!KueT7;G4|#2(c>2bq-qQ>2}1Bz2PJxkT?1 zL0TlVlW42mh_i;tDSEAlF!vxct9$M6BxfBXDW*;|PfF-u)Nb$O&UiIWi;YThn|i~T zxMsJQbxc$|v$B4RO2^x3MdNO(s^}$Z@L`R7wX0N1Y`}4c?k(JXw=vEfophUfVArwE zF+69eu17e-4Nnc;F-}i#ljtLPlvzI`#oMStfofX1FpDK{wn5E$N9wLlsBe_0I|T!Q zwh+f=s^i~Eah&Al|2Qo9wGF(_OWczqxi}W=|3~IGQm>@m=CKBR$dk;j1P$Tql4Kt30J*#y~Wv%XIxc2l^v*!nQCN& z*ZRg?G4-PK*u*$|Be7crGrD<<8EwXRqr9q{>9N(FBx4Nt$dg48N45UwUX153*S(vM z;kDougLgwq!N=pw;7t)vGT(NF({d3*eGMXS_I<=6NBEK=oBNKj$a{PXcqC;v_r1tP zc5B}n)ynyY^}DC&FYC3)!M-B;*N>U_d#djR`$JEuk)wPYSmYqz3f=yiL;Q29?*}!q zo$oQmJMxwlC&t_hgi`r1-qYE})5}r2@`Or_aNGE47XBuSoFg%5;aldgKC;aLFAqH` zG#CHUyKmEmGozl~ z!Dx>QD_}3n%_N80?By^q%;K(o&KH=czT54|a+@CRR>OOQ?PI>=NVhKwbDqXvCB2=I z{VOCzxhwRFGAr;XwNl@Tvw2MYXm?B&M1iSwY@f<=)%c`1cYLon?776LRr^-yoKU}- zJ0S}yWeCa=3m$o$b*x_fy6$>eb4mslK~Guli;KUSCV@({MIRso%(*lGVse;f>VBJ#PA#HSN}9Y-)0IcPbeKHnI@q zJgJ4=^41n+OJ|GkwN^=K?pD3h%vRXJY16m$uh7=EXV@*7?echs`t98vvf7&+V6dOj zqvHxSGr5!AC8Bdwm+qYpv2MPuI9l5^ty{CM3ElLr3wU>5R+a9FSuf+jb`SH`q+ae@ zyY_P4%6rY_nHinDnH4&Wr(TR)Uj;N?N4|!zbRbK!=v7mBj1- z>x~vZ-Qag;l$DnYJn{gG0oMA57GXG)HAt_t_AyG{OWf0%caZOw&)IzP{c`(!OcYOI zfTb*eL+?sPD(6OLHPaDAe3-|09^>@{OQ_iN$&xQIZDW-}3y)TaX8~>n*z>q)h|dE& z51_F2pqvLK4V3l(*1sxV3!mI;&f_ePJv}B*%E7%2yn3#lt6$bwuD+XJ#u?m`=Fbjg zvpTU_C0sLP#3o0@RA|p5aJaNGf0k)+W;ch6x8tpxF5atJrXwavtV)RH%^_R10;Uyn z4o85u@m5&AXuypK&^{5HD9Kn5`B~%H%-T#!zt5sS9dkU3h?a?oZy*TR#9w~_LhdIyGro#0C~ z+w&T8cYH-vx2d&=kVQ<0M zd2d9t_fsCx0=G(;0NwitQ_jLNBhIL7M1G}4AB0yxTnO0=6QKwlJ|4bOTO3r)1F9*W zI5F5wQ{hLVk}eL8kkJ1H7Zo_98HWF;jJu>nBoYbH$9>4P;c8EeQ+3dJFK$T3yrTu$ zYZIX3zE+#Q><1;^Mxs7$wK6Y)HYdA?SO<-?{R8S6?@<>Px2)lcbZYlh}-Z1{DQis>Hhh z-z>Ds`5f^BfWtxMod2>M<@APU-?6y4T=U*1XnRmujgwpzcoW!TTjk}$U$0$WQhThS z09pg=b=%H}-vKCDF11si%64jyY^U~e{yz(${~;gmKELKzZ3#c~h=Z-ZOtgc{so~eu8qzUtpn4Uq;~kTm!_L16l#> z_VL|?4y7B?0|69f?2+}Stn*)6uVc?)`)d<7&PDee(S!AD)Pvf?*BBgT!-q^nqVJ}L ziB0K@wQ`GAombYQjmmWTran((H{s4==@%&XAxr;(m#zOlRCFtQkp2u{%jb9G?MJ%2 z`k#TToyP*L{>pQ3R@;R1zj?6!7hB~gbt+wccjWzl%kMd4+~{G-%XZmH`V2|$R(v}~ zNJ3GJ%vdE2kO8peH4E`YfL8z%Y`h$s!GH zeC-Jl!xBeDL})(k_QX3Qv=odc5qMOk#L8YZ#iOm#<5%m0>srm{*W@1=&1PRnE?TPw zs0v5~i~@9l?~`#f0Bi%zia>TnqqZJlCOV>BiOxGciS7>J@nNGaP8H6S{67*TZ@ayX z=^T)ES?%qgkR-LD36IF{DyR?*R(2I zF|q=iQniX|1EV_k(3WOxc-wJexk1M?vWo6eiY>YZbP4SLPDT7Nz;b|1SGRA&l-H2n z383ITcx}2ym4`0cM$cny#pr+Vd>kc}#PxE=cp~Am7{q-bi2G6Ve8BXNS^E20%x(~e zLz+ZfI*4;PkO&Z$Zp7%3glFL_DYw+FvC1sUK)4<8(SUmZHhqsY%i_xPX7s`Ct(JrxQh{4`&5p$SV zkGK~Q??or^w$X@J#}023c&%)R!{k0AnR^F1v7;TO8D^6%NpQvJ>D=4hyr14HOm%t8 zFzhk#tnPZ}FzkL{v$qd?TdJK6ox$o%4QJ7MJoPTL>pKbK=6HB3_=sE<#9+;Dp^C+7 zI@yahq!WLG^QmyRv4`7Ql(@Ii21A6)MBtNsW0_}7W;)em>18jcI z?B`J&-D8yq0ELo%0E1X5qGq?y>i5J^<0p?DesvF*$=7!~JCR1bqsQF^KZ`?zLq{H+ zUI)Ggn*x&BlGe4wI)WQM#5yTQP@y7t#FM&nIUGMY9@f1xaVtWET9-_hW-(MKyxYP$ zME+b-l>L~+AJCm?5qV@}w6X~4WdOV1Jb$~PEhL0jZeD7n66|J{Bc4Utbx6rkXe zS~ww_Uku6rYd|k?h8M11@2_g(U2K(8gm0R5X75;DA zzg@kaV_j$B_^~5oizAyFyVgggXSFp^d8)56Dy%jdD{o+DPIqCUg=?_UfPWo`1nXLO zRfl-7ZY4wZLMhiNz|XGl8xVgT@HW7%KRXcL1F+X!>~|=5-b=#>X>{SoHhUzRC<9H@ zQEDU?p*N$3iSvKTUN0Ma@DM7(BCCG)3CME-;tv6~0&G5~_J!>TU?6}((TO?YJ@<@! ziv%7$EkEjo=jMs5tK&aR^0~u^kwYeros=ZZr5GnnNg^lBFDR3?@*{`iTFJBj^6QI{XwNrYYXRxVOoT@E@wMf z{auVFjEly2fIGKFgsqEEH$=Ezq9>*$@RVn&s!MY?7@qH_uHW3|@9wbQ+}@cU_DvY` zI6FsoGkJKJXFuw1>unK6q~pzqNV6iA#Ug3*%gT15g@(yiLot zVD~1b)#A&Tj?LSz81Kz)CWj-q2d6&y)(#_w+<;b4CKCoSUK8F$@#Sb%2_j^H6}p^6-)qYVjy)fj zHqxgY=p*Typa*@2@m%$-8qZ7)5D{+@aTBn zHf9=Ar?GI|7*G3C>P=ciLp|Aj6BtHa>2#-5*@u{hGXXhRW~JsR|0zlT8PI0aU#CCT zIRJeCw%$I6_y)jE0EMI0_;$EMwgVzF=+UY|dG%JBV)_}9%X?pD@I0HRUB*P^C`>ut z(?;&n65i9A?9wW{r`^0u)8Er7?b0GJ8wQ^Y-5Xn3bGfkmXu!JWIp$sDFc&)1C620# z9j-+VZJ|S3;)r|AbgE&@wU*Vv3G&4b#~K!)>g(CfuQ2^Xv(hIf6p5I1LPHOOKIJ~G zAs@$^u?jle#JAH^csQxr*I4mHpQW;@hKp&KhhtORtONEs92K;iG3X9coj{sG`U=V4 z^Z^0>-jDbUz-)l6U(Y<@Q5GPbzEkRzJx;M%T4c?KLf0?Rkafq6Eq<03u${2@C1##v z$_2eHe}&Q2W0SSJXzn9JdcLI(z$L-Tv+->>Fm!zL%8f5pyh@@x$FOl!(w~)h%>oW3 z@!C}mybAs@Uj2rQoow@Ri661_=OkX<+XDU4?y0)c3h8u!-7nn)zv|y1|NN^`zSr)T z0;@%0oz0TqNi$7Zsiz64C4bzPBCelKuJB^(auTSYNX>Cn~)) zu4dY-_lJuyY?Y#KR*J3i&D&*rKuFa$9d?VIWkkMHB}x@*LQy2<{nLLOGg2Ee4JkX2M%y~Ro9 z8j99?xu!4EYA8x?tYqS)q_2_uNf?Ydzg1dC#0LOI0c?DyAU+qc1VG`aM~)LF#a?~9 zFepTP{a0{qlvi+OZ|AN;URiOy!ivqoht1Z-=52?~x(dcP1=`?^TI&MswvAd{Iet7! z+l8&MUDykKHKs^^a1C4jI_ScPXfSJytJm;L5fWaIbeh?q%gReeJPpthVADx|>Vb4$ z0EGsJq&&)!OHPP%TGbaq*(=9|)fCr4kmx*YW-Ho4_L%0Jr)6U(!nA7QCQ&Ct3i|t^ z#5*7O*z$QF@%?~90FK{K`*#HK9|8H+{KLlg>g{r%U+|k^6l(c=yT%GNW4q=m)T%13 zpSYp{g#FkCIu`=I{>rqTD$T?5*Gaqv3<=;x{G5vPY(PnVE<_%MlK6?tpvR05`Dtxc z3m&2s=}SCZsa1Gh>npfdf#FRdf4#(085-D+uZ4Ijpf|wAb28%701E&VR$Ave=5D#V zey4=EJ}FH-suyVa+C~j3JaMBIQJ`7+{V6+aHYzsL51T!U&5khssCC#(CRHzj1LaO| zKY#ldYNI1+`ZsFNFICG9^pIhqWUY_tAYSyuj(#R z<77p}0ZxM_D9V1ObyRB;e~SwwK2uOuE@dFBK>TIE7J!Y<8;I`$#8~a(_3*JQQ0gXI zo@}cZYlZx<#)>uLu;wZzy?$I(G=R|SJ(gZC#N~iOuXA6PczK84D7+?27;+!#^=6BH zlru$=uRMcz$$GsBd2a!RAHK33Dyv@m>6acys~2f2`Cg3`X~tg7Rir_=$#mvrA{*_@ z!(7W$Yf|y~uS$H(5rO@lMu?Y`$1TX~4rul5U%&@PWUHTnyWi6qJEIw=H5dHpNre7G z2%!bf=UR6aO0A^7Ch=K%!|=Ip(uj%B!>zKMgpq;vArtZ20TThx6EV)4kNER|0ssZO zeNf)KdiyZoI_-mBPWFk#YPlM$!Bm#34ZEzma&eayy1;{Gf80ab=m513;x5v|W@Bm} zmKy!#ZKO{aJqxe_Caxphs9|-DmQ~*17za(ogm)mAirV(4yp9yJ+4`b&=#PG}5$mam zOG(L;3?chX$%mx70{x@ShoQ&|=EH+X`}xq=qQ4A2jP;+{^NEQb`)oQT&#qqX;==cAQ z@rb`Zbi8}$h~d_?Bt*ncSzcjKdB+gH2vGkK(93YdV*xb)6zp-v=hiq?WClH!UT2&U zz-I!k>aL!Ldm-i|h<9UM{Fr{L%bbSBy#TiZDD1TC zTFV;OuMd@P_F+f3PkCOtPcd+)xd4s4XxDlkFvn3#)}UwoakACgWOc1m^CQ<>lR|^5{kj zgoICJITvqKIo3(^!p~$m+2b%blPp!{ApSJqS%9s#s}SD==wQvKL++0{_q0!rSI>tT z{R*As>&6P*m5+M}U5hZ(1qj9c(o)>#?ID)HCi`=VpE5q6zt`$7%U!SFse%eB{z8^B z>IRkrzllore`PsELFN2{c**|Xl>^%cz+a(<92}yRcs{t5z(j`z)}j$hfY~Uu4z-Bx zeU|)Dmfdds-TbA>lf7S-vpcAqzjZzVIi?al2V^-Z|GYu-qkv}s@a`xTGa!B!x%5u^t-ax)V@$!$!a@L}p zY*Gw_BE-K2d;_rCQOZAt^u_PWIX{)AhYBs5zA9Jf;uY6njP3x+4yMJ^m!XfxB_1i0 zZVmTSzhM7H%Na_9<|f?XJt7BA(k`CvbPSIe`Icha?;Psa$=_3AbNyZ5Bk%e zWjXmMCmZEbu9e|4si*mUAYkoWFH^XnDXc{9Tqa;QkxPH}t>hf68(SgUb0^$9+T(`rpFK zvYe!U-JtqF<)DIRvwK7x$h)EC&`iM!sw_`=;6~Dg0htx@HCbNH4K2?$K++X*eSeu?BE*)&ATJH=PH}7)7gnh%UqH=X}+Ofz7fO&YTPp%zIptKM7L<_;y8n z0AL8f#`hnHPX;^ypm05W3$L?II0@Y>aK1YyfG&?pX$(6dqmd&eLUAP@G{X;=wG}OS zmLC3yj%^Gw(`^j_<9t$c9JT|Q0;Bl$W2Wuf^L;St0a0G0scYOuneMVvz67Kw+A*VXaK83Q8^9BiGKoKz3on>4~- zBcfme%e>(c4&^)+R%^$>=(Z9Ir(>MNwi+0pR)UcXxDY1sI)QS90-)?mA8JbUG@Jtg zSb>LR*VgE2_>3M7=LY9tc-fRJCKIgk2H@Kz%X{?iC=aV&!$waWJ${_hhRRE~%G(`O zURpR;E+BpB?H@j~D5Q6g z^Oy5&uRf1id98DRL43~|GGXGVA!Fri=6Xsqastdp#lpPsKJmCyc%O*9jMHc5%`ctG zosG;E~L1S8`AL=s5B!N&Aim zU_eXPiO1l+4?L>Dzf%TWgWicci;B?)evVT3U2x1Xfj17T@3}{-A5q^k!FZbS5fMGf zc6kTqW8n@dM;&C0#m(`OPd=%yh2Wdro_>k=5rBI}fWKLYj|7YdP{=M(pG0QRBOyzk zFB=JQqB0E~KYpw*2!-j|2r^j|=L9bE!C}e;v-Uim_!w_Kk4Ju?4LfQ?eQQ+tQmgzJ zchBSXztHqAwMW=vJPd{yuw48P4mX8;iPlx;;hKY2IKaFvmNYD%MXg{}nixjZXUtIb zPgwnAy-9P!rAEk`s@3o!I;=&8kn0(CZ*ko)tRbtw#SHnYP?Y72e~U}9>9|QEE}xC$ z!_+9P3agB>Gxdc(3;G*{>^+-peX7gC3t-{Y2^VEiXG4OmiL&e+(vJ zxo{rH!arl!YsFy$dLa~Bcx^>_UhBJ5-@icmD+>o?muk;Sm)ChBnM`C*^}c}WJ%fw- ztyGl!OrIIhzhQ_^0^A3%>7My+l=3Fhy8#pm&&u|EmSvA6GJ_r`uB01--yro9$c>wP zH}>-2#DL>4!RvBKYBOFgpf_JtR`WzSdvlKVj#5YPkr9IZqIsp0| z9A`ljhf~^hhdvZJ!}Q_i7=41~5N8E4v>NKajc&X*9dI6v4ornRvIt(CRfm$*gkwLP z9Hw+cx--D;XKzAY7Sbip<6C?W&@0ufud)EgVUMAls;iNc;zSdzQzSxLUw((l$ zSp0d0mHozG6nauR8;g_(*%p2SW|h{9@yN6BE3aO7*UA1Mz^9v=25Cl-l=?#EUBJ}& zOrOi30rQyt7&8|${V8T?fkjYd$!~LZ zX}MNIUg&c5<4ck~LQvE}irCcTl6qx#pOHwRvG4D2}zRvg?EaDXs%N7#LYb^2QfLJz3v22xM z*~IiMB$n5hzLlBVnU2doMCHtmlk!*!ob7hpu4fyN7ot3Z@Fa1}RV{HSh1T~k27TX_ zN93b{?_WnALEpF9>pWG6A|YPlKLy_^DTh4d{nh%BT3QZ%g(FFbHy^ zWf5PKB#KEA->}4k0ZAN?k~ktIae(Q?B#CdBeuSC-WBQpZDo9}!Nl((;((*`0UOD99 z$Co6s#ga%uRr&o{LEpFK@qEztLy(96`<6uBAc^E!-#>wG+44~4VGIR`0NC@9IK)ML zEirzw>fc0B=Ykp}9_4M0iH8Rhj|=9V${sZiPAVgr$DVn_(&!Oj@kc4P@W??qwq8H* zT7O9E<)a1q|4|(uE2x%xKF*4+G&6HkzP&mvnZ&XCtQZ|J<_KERw#9E{8j0+ z8=7e4pc*azGu!(9fX4#-{3qfxE|`vYuzr7rbTNR!^~MF=uGH_5W5*Af6c8u~r5nGj ztG>43^p{J)o&|>kvRAnGWsa)^cmemmz$+S&>*(0!Hb+i>rN7#6gKYZx3b>B;-kxZqA}T;PQ(jRZ_2Yhzqc6=MRoH7=+q z`Jp6AerL`P$aM(fqX1(8wp{7_QV!B($n{EkNw8QIXw}Gsa`jiHYdEk}U&=V!DsL;k zW$V$8ziZ0pNbd_OuMe()aQ&f`8W&h{zOp>4!dx)NDl@gQg0Nz%oP@l9oLeH^4$u)` z({*Q|sq{sKdt3>(4*!5&8uM}F}R~`?1KWV0}R6#lsVAFSaNR)CCP)5I8 z;`@F^_ErkC-o(g+8j_AF_+|oSAUuNj6M&}z3~eYJcqH(;#%QByq-i)E;c~KyYwn}@ z23jz5D#}RSlTTwDvn+%Dj*w7G;_F=y;LG+^x>5(}`T#4GHIE{!&>huHw$4{vFmG1A z)P08Aa_6OFTi?$K`hM>Ot~`wN?4a+LoIhFm)RVcN7#5p`4+UvP!=7{gj0PzH~vnFJK_R?ng!-P9gO= z<$y`{5tJxz3VIT+=t*u@F6nV_{OM%gKu_XBvZCFS)RywuigIi^KSlg2z#)LGw{N^_ zDkqUX|B0NB*?x&i^06P*kS4VoI~MItF3Xwf2FZ^xWu^gNXqb#wM?0(3)0*)(y@DW1 z$+yZ+T~xaK?#TN~iPAG#%R=qS=YM{5_c6nhK`IDTIOiVXIOn|i|c}Pvl(|99wQUWARe^}O=oey z&!NXU<}<9nij|VD$Zrw;%-vWqapCOf3k zgqo5cu5Tju-D5PgUV4-<3JYr0$#qjZ^b}t8PNTYx3wba>7kFmZm-Ouh-uC!zKjJ?D zE&yzO9Qp+Ah69}XK+-kK@_Qig?>p|FlgCdQ(RbpIyU6`kF`Ld0(`|mYi03!zVdeoH z9~#PPN1_%12emF-d=QCoh7Uu7mb|IAa(ulwy^5=+5a-_D-DPCg-nU? z!emKz5oj_6CluxW9j5Xl(q{p-zW%45q5O`tea^DvdHj?z^i}wev;1*P1o4BFGma|! zrkh>OQE@s;$0k&4LL-UCsHFis9!7jIU^&3nuVuFw%66pp04U^rB<0}QBi|wuj~ol1 z%q!O^{q<I}R0G-GFQ+Vix~wXZmC;)c7bN7b;$)TVGv?pmyd&sF(6HFAME zm(5qh^HiONcVi*H>S|MCuv&#QDFqig-HOFE4(A|q8P+DL&8Lu?BI!$57HIE=g>mI( zq+0^4z^~V@bSDbH$2euFlh#u5t?y33HxjImB(2w!xk%>)eYcGDN$?T`MymCZIk{oVexwfrY&}|mywgZu08qHNOX`okZ)yFj|CN2^ zyux2Tt$FqvGC>&$u_VJeolL_s0+4!`DaB5#0bX`Ea?MWMrMjs#il7%92Qo3wFShW_ zelmdXWW=WeW&mt_k5_alOORdxppg2O#MkyO=10_uXu@b^qm1-b8*! z2u)|Kcz&I*6;#sp>eco-N8tZzAz52*PtG;(Gx9 z0oZu<`N>d@A^jtO!u9yMvC*h1fjk?}^73<5+5Dtw@D&`j=EcfxV&^o|oGz49R?&P5zl%Zm*?bItCV*dg`B+#s zA7OM+ELr!Yz(uewyP4!uF3Pm)v#ob82H{a&K0Q?ipU8|Rm`yTGn)f?#=~2_18^k3j z&dn0PgcSk&Y(6zZo-N1n^65kwd?I|5Meqy;4O=CUks4NL;juOd51UV42H{a&J{4Sp zPgg1wVPoGr?x5zmHhg#V!FU5v8Hz2s z3V?6AmG?g4{{X6ntF7P_~B+IStO~Yo+{kfWIA6KnvG54ap=(T zs)11xR#27fmJ;8T=L7iG^F=6`NM`|TeA^>$Fw&y{6t36*WS7Bz3psS;8<u8&C-9AZ}Zh|FI+1Fc$T-lI8_E;as1U>73gA-{ z@n(RQ09(G}5MK`14xo_PRnG5s@4IrIpKYzjDeGl^$##rd3=WTlGgRTR@CZB>(p)&t zspN$<(!O9Y2{9@}7+tZ->`|*~s;WnOVjPu6n$i5h_Z^8JJ9e=T9N~K$_wlCDs8VER zG7`2W9=I+h2!{ihsi=kcZ?QOJvitaJmKXF+*pz#h;c~?)j(7tT$L>7B-7&l+@1u4y zdSK$3h#{$RRL5R7oF9ghGh>8=c9#CH4zxcRi1!2B4zT$XSI4RR3+b5v3I&#*n3&=@ zB5gnNta)68GWauc$mp@;#=D4}g=ZL6#Nzgf=4T^Uioz0k_um|`zdK;_hKX`hOt@>R z`fm>S>ktd|cWRT7)q$pQWooPZ$q|{EC4S#TJgXnpxO5T%(jKp*oDXZvs~C~If)uY4e(b7kiie^o{Wmtq>U*m8)gcbZq-cpS)H{<3})%3NCaa1&T(*~s6 zHQ<#{C^yV291X+xJTmK9%#60Yi-@?wyb*A2*^4efp+CSWy}OEW6Yf0xKiaMYFp45; zcU4#S^z@v$Pm&2&AV3Hp5bkg&pa>BGaq$R3Fvt}kuDHLeQMncmqM)LP21G?gL1YmX zAu1x?QCUR1!Xcuof{Mr@>i<<$*USWAh@uUzySk^-Rj=y3SFc{ZdX?NL6A>bt)8`EM9w8@(5t8Y5Zf8%Np77#$jc<_C1Xh#_ zKuYMe?$lu%yMm4;lUXqxLWk0S(9ZNG;u~h4W6mK6LT;Y#?M&y8gpqjXIg;EzVKmu8 z_}EGub>~X@Dtsgo|9&vimjK296n!l~`eDGt_a$GbeKd}5&tl%5;Dh2X2)4+G19TX| z1w}2@sscGMF}0u+_F!y8ZMylimF>fRfj*d(D7H=r7>SATEmORDYFaJ|$aRgDsE`-9 ziQfrlelgZ@VOfqUzE(6umj58irtAWBaa40XiaAk$bDYwiA<8`#1@&uN1DHA}alTs| z`G$58(w74!0ECc%Khjms@5P&(zWlOJ4t;WfBtMgr6{!ftp4Dba&wa52J{{m3?sjJ9$%#=x!VDcX{ezLLwCImAB ze2%KKRvFBokg=S-dEPnPa>rd_JlukeqgdAGfX5>IJ`(A%fbjs8|8+>;4p8x#mA)qO zA6$CXS#sh8lu$H}h#>5o3bqmL)*x5LGKj(4*l#I7QsqWayscZq{M!aI0|C-Ts$)HA ziPFF7oO0D6&yXmnru~4lwi5fl0aSa$Bh8QHr25?kIVy|i}Xmq#Q@db3nsX_KQ9>ZtM%Zm0B^ZNAXIyZE&X@Vt;+v{pJA}0HM7ov&Q1y5L(qjPg z0Q~Uwmi^1}ck1geAxD4N5nYc_zsgyb{*mSneOWDy@Fm$hheX@E;>JVb5C;PPp~=i zaB>w()*s}C+xrl<8JpdejFk0N=hOqQi!YHr4EPD4+9_dGy4D_`+DBbQJ4qM#l;Q;# z+Z6W~a`_n59wnDrJ*}>5{FgoH_q*a;@h|$V7x;_?=gC6!MR@{(w5tf~*^5VY=je1q z@ON?!<_36+)?FyeSBbn+dEsz8G11nZ18f5D@+CuF_IwiaTez<{&dynJb$Mxie(BVF zQBW>D#3!_(QS!Sx@LhHAIIc%s>!YLQY9}<#IvUN!zg;h`iN=yn4w{a&(E-}2VwJx>o%e{)tm7i zkhjk!M@gOvEnomzqaUCGKh|9=>n-?Xq+e{0bZ@{}07Wn4byF+By_!!^a_U6o=TS{( z@Zgk_fgJDSuL2pR0Zfq65$hR@wBb6Pj2%tg=wb})apaG)Xgm|!I}Qh-n@XBvzTgsB zzFOq1`t9U3@Kpdb2PppEInAqe#eH7@KaMY_co=nR;ppcv%U@nvIzfy1#${NAgXvW` ztz3kqEZ(+$;0J^6J@BZHr^FivM-LW2r0)c%3eDkkVDd=K1=vm2kKqjWJSe(U;1xe3NjDCk3|kWDgDa%)5v9r)T?8be)O0zrRAc9D!CTs4>wVV z4_M=!>@saThE(w{lg1b@nWRG;%fz1V0VB}LYyq;$Lnr9No{J`@u?}z})>%7fy-wqm zNWA8vyo%3PA^j|1GeF@r??0yY5$-<+@Z)%Xk02ydEgFs&U#11(`#*t|7Y!q^D%SRM ztllS$cVOC!^yOIjiP;rXjCoKix^w5L2tKyF)oL7k2CR+rLlcm$09*}Fe822jTe}|j zivB7b{~(1&Z4AB-^Uc_4qsOD{3Dsn>E zN9cJG(#rws0sK(@Q+3Y#mw1TN!CR&LueBi2^(0rT!;J24&K zWG6E|V8%}N1Jk~OGola5i5nEqa;EORQMYK<{DPKAFjS;qeZgQ#ULo_~cuPH7(a>1V zFcs9|+KBDCbeGFz%+#A)uNyb$#($}g`4G%m{K<#TpL|4+`$F;8pOBPYMBhP-_es;8 z#D1OleFV0oc3QIY5%K{z76mMK*=-T zGwE6ype2AGCu{#=r~S)E#ccmUrQ<64l};Q#2~AT&HquQ5Q~s~W3pM}|O;aLTrlUB2 ztBdxrKVU6(vN70SlUf@I zH2zf@KMYMp(0H(zc(G5iN%MxZD|Rz%hI|EY*IJtP9@Q}lSxo1XDw=#=!Di}uiTVfo z50gx6&-4|^c!?UDF}ccSk=Zo)-!$$mYFrBI)7jzXzQNJz|AFn5R}z?8hCy^Uqa|qc zK^O%zqoZhV-u%l5jAZd7o(wVDVSk{jU1^r?GVJ-polFaCm)lSL-b}C>b)|9ov1buq zvK0r@Cm+SYZar1f!&bDjqK7Y$J_?B25TS<&NM8k*1BgWrs~vjS7MtIq8k)DzO@?Ti zDY8ul3l@7PS_Jmf9)dlp%{JnF4~9IGGuIxhc|=|B<>?<`P~)<1KlS)cnm?tW+(*d) z`NBb(=0T0TOPah*j9Owm$>P7E)=$*u_kAJKzR3 z%3y!jedhhd=w%hS{Py25vKR8qig#n<9PIyximR@W^%#6UQjg_Ge+-D<7^%l|HesI! zz*0bTJ@#|_;lx9nYC_UJd(AJmibp4AhZzGWPai+FwD%b91ARi~8bAHa$zz9%pi8e^CF{|}WX1f}5z2w964_N9>W_`f2#2J0yWNnMl*0L6p zg4d|$RqDSNwlb5tX!Zv%u6{yc%%$!h;Xj;75Z zE}G$*qc5R|S&Ytm8g_&NrT<81Cv31pza=zJf1c2N1bAZQ@$CeE!~dd_em3oaMY8y6 zXG}49uoOPuyong?t(%A|(~g5{sey-GeA(#Vy}@TlplkeU5?7koIiYW&mC!u7OH!NE zwIn4WeKkod%Dj_g3tJ>b#u zUVv=@z>kyZ54DawyV=>N=_GvUFcBPZZ_|bAZIU>5TP4f}cc7C2x!+F(S-XW&7t(6Y zCMLE0ATuK=ev59sq|;!RcuyRo6DyOWQtU z12fZrHQ$x>7=fyh#G7R(%t76-g`i=!HxjKC#MPRmLism%RBFBj?M$ct2i{8F??n1r zz+r%@pD{0D%{Smy06(@m^68K>z7Y>`s{6T0BuCy8v;QAB7QO%YapMu!iqBfEWuH>) zvv#W`F6Z*O%OlpaK6vf6HM8I<2^{vYL|z4TtQ|_<1e6xA3t4lBefGFui0@c-pvz~@ z&0gjqoj+s$7QgIghNtjT27(56eZ&k`-rejmau3UVpLN{LET6rLneX!LarmU?BA=A| z-C%hyXJcp&;}JqYcn1l2~fX4s|A7go<_7m>YUzYf&^-t@a{lNs1 zI8{4zT6j@RIu-RhWLgwjm2@Bdm2a^2E)kbc9KSm(?IYlqnV5z>Y{~0}=x*LJ z5>4_7#}^d1#Yi*qW*G~}Y$G`v_3)qpgz@}QBkgemgDxZ=GK@zIFtk6Vnpa92=yyG3 z#Xu0Bp=03Ch1^0g5!#&B(m32Du_uY%*IYo10_$<&O0!oHw_tJfSsqTXl~uC-s!=bB z&tF4&7hoSi)!*XRU0U)M&?JB#$B*m9!>ChngX}^T!%C-38b5h-g;-`^(iW2stpNkY zBm^mmyOf)Wu({eo_)^EurN)gkFq7)DD6OK_d@Pn{mJZ!6faQ)@I`7S+0Tzgh<38(o zFn2+x*CPEopcbI;zPl8D>$s19H1YdKvHQEhS0bD|m*rJ-E1gd<$f?xDWfc*ZW4Lh4 zu{-0JR$H1w$A1qFIAmn9988Ok{uwvF;D&56ujCA{+06D}yrUzGl4P11(|E~@-(dO) zW^Fjpb6NefGbHLpv58e1vJJDP`c%x#fHhju0{#}3h%X@I8|=$K^hRbCF*;(iUzY*# zZ1KS7I6{jY`anIX`l>+B|m-Yif2)Y)Au{d z`|8+qe;!}gqHTAFiTi7F7CC5!iTi1nB<^*tmZm`^kir8a5Kpm-Rp^>JJU+; zm-rUE9>Mo~q%Q$n3sCrunr&(i<9-c*A8OuAUB$zw)4>?{j-TFpVtLtUP-N+}@nayK zit;h@e|0ZD06Xhp6UOD_ycMQE&XtuTYyYWQ0>TY`OIsS(H07TNCq zS;k^Q?<0vvEc(5b_6TA3*;uQrX_GK&=s**A@+{hpxLJlySsG!&xPWpNRZG0JHzIh~ z8cfTCsA@@0Xn5`#1c~?+>0bwsaVu_v9yS zEKou}>U_T@>ic}1%V)U%BI^6cuO~fG`LR3L_~{iqmzMbz(@IOVqgejAQL=EglTY!R z5q+ogM=9D>xW68t=!36EoR9lO0DdTbQ2rj`VbrNOhW-{^9>-tzsCDKp=G)COw9SZ8 zlTQ7mzOqEV*K`Xw_BH2DDrZ7H#{#3!QrWI`D2I|a)?2XA0}=sBj$U=XsfBQV#F?K_ z36%Lf>blLqpQ8$ZX!0*H99A> zPgPyzNX+ys!LGHm0G?@Q!9$Jj25-TqDOF0WaqK!sC<@y=s&W+VhR_2p(uo!61{*O8<^Ej)|r_UVp%EsP-Ki zH{bDjLcT@IWxmC=k$kU0`g*`@fRiu#zow=(ANQM`^_Iua{}b{zYk@48Iys~Tow_m&eR6L&9w=ZM}{7h#>M93M<)Fs0NCp;r3oYPcmNql;_3c%S-Fg^A60cLW=*7_>N(3Ziw&DBeGoUQ3lmcbx3~;ID1>9 zT-%ZU6_D{C*>;BmpMC|hIMv3m|Fp=9lq*cX+12z7Q`m&}!zRq@crobL+RajT zu`#=uv5R%s&630u5OkZ}48LBwoAp!gOj7Rz#1p)acY?dvSupYJVnyQD;a~%8FKvsF zv)A&wvVvB?SW2yB@EI{ExstDr=NvJp+?dj#l2`%X6sz2@jH?1*jb)Vl6E7t47kXEc zRQ~r*QfrO;Ta3h3kvRU_&`M%lhrbS7XU!!BJbdxD*iHAkv+%*|QXV8Z9;=!f^Xc$g zx$e$~F`p3Fv!;W~hG3=^OY57^GTa0qtS^AO&SjFACa@v;EyNsSTu-b%P@?UT?*4ST z-IK?JTudBJ;p-rhz(ujNU#2`1Txx(&O7J78P5;tfyG zJPRJtDKv*!m=(&@Gj*|GsPb`17c0=-iY^rYzUj~an)x^DZcoflk@gn+J1@N4S*v#P zDcl~(=L4h<01g2ZU$lB3{*HjZ1NgDLr6WI^otXYc#+09t@^JpFakBT6 zfk-l&5E`ediE!s$ssHNwm6A%muQ<2*RZ#yTtlZFf4_`o=4bewfM$<3p^W<|{w4YAi zL$iioIWj2-XIW2@E0L{(5+($M{~e~1ifm1 zh^od|$B~M-p?xKFT~62Yc`kZ3-3XE8o`hL78t{R%Ff*c)*6ewr7qQ8D7QK>G2zvR4 zkqURH?V{wb(g%LVGbMk2pFU8(eYDl^!EtnfYA3(49g+N$9K8+C6utaj zIXWYz9F-Gl^(5(blnIjbN9sFDg(MYZsfC`B^^rgTTP55x>tz1Z%E1kF5ralD>z8(17yU5q)C;oe?hEoi2gMh z`XC9h-Zyh~llO)fg7`*)%xsX^*`yOa7jY|kOH!Lp*!g-d29g}b@_6T%LlGPZ%0q9G zB7{|eVVfgp6BjIgu=Wk5Ar{he_=m+&@^4wcMLQ$)tK`z$Kw_$4=ywCw#fN!E_cwh=fNG&O|RwDPKci zSPD(yt$c`Rhv6R7J$Gn}jNDLjr%5h_a*_tffKdV_iq4ovxj`&5xtI=x&JyB^435fm zvVOM$ABDH#ukY|o;r)C0t2!p$+#Li9BI+?%HC)@kU@z#t&3Y7rJuS;u_EDt$7a+YH z@FPH#@2Zc}wZ(un0DdTadu+yu`-=gKWARtL@& zhat0IFlZxvceBtgHcYS?bP}*1(m3}J`Ux}~ygy_&!<(jHNVj-%mSDw|n62*cxvuZm zQ@D!qHIxuA;k4!lX`D;9Ow23eNfMs4Jp{uc{2s_ZQ*oEGpN2lC!}d}AGa8tlwO5B1 z{jfvS_?~7?&pD_Ef6}dEy5EDyB%jkE`zZ{f2dKH9;?6Dp#y35S{cXGA=1n?S*&+o{ zZ27>k4Xwa6m$b)Ju~2j$rR-9@CB_9EN%6Oq$~gLW^kxB`Cn>%rMEP!wQ~daeXAh{+jlj!~dT|+F$X1 z8J;;u1NpyRy9xpD29raJkdsd>zNz?N;HT-@-vE~a6rIgM`Z2(I06zveex8eV&2x%U z|56mwj!>^W4G{p&ADl#csrR|CzEMSMr7__J$C$7I;h>l>CiL%icG^JW?3OgKZ&9CA zGq=C1pWS!L02mVnCiKhwjWMB@V@$|!j0w4t9=I{#IYMs_8xzvt>(>LugkHj!Fhv*> z+R1t;d0w_>ur5;1=j2hX2kv_VoZ~m~-%hMuMW&S{U=Bt0Qh!C=({V@ zLjffKeyBKIhn)SMoN|RP3uEft$CoQ2O6$m@(j9L(E?ku6?_%?$@ggXsr6JBzDn1I| zFT-U$u0tnD=NihmwYvS39$cv#kLiXu_u~pl&NgT>LLtE{khEr;4^4!xsY4eyhrn-s zfO!$MxzVdRCKw6)xW>Vf5#%ob`qAFb@Lj9iBOs|)k~@2xx zSMB68U{8b&?m&7e;30sLI~$RH2e1RckK&i4yixX4@i6LC`@7oZq@o8a2P18W8HD27 z_yUG3tS>o=pz+$XXqc2H2wYoC$hX_U~@X@j%T1a6FKn_z^C>hH^1>z5&* z0qu-g0>92oW@Nx^BS9)6l{K=yi}yzA;oxkS_J6pa1yJ>%&v9uf?*?YC3!)X^9ahM=%VRom{$c%0H|_q zMtU#cdjLNaA6X~qC$5R9@7FJ9n2Rv`x<`t3e&KC0Mkpk?yVyWsFqsXGYQBpxp<%p+ z8>k^*FBnv$(s4Og|Mw%D=NS(e`f|=?lsv-7KjWcOv{}i>Pn_d~nPb9tdh>5}IKt@% zbRT>V){t|+aTP?abxOet@}`F?}iH%0&0uIU{jv2exjH7h}*t!+Q`(HPV*>A4t>U7-?Gkglab+I3w!G;TsH~5AI|aVoe%eP@~^`sJz2yFG;&7cik4*t}VWZ)Pthm|L#;R z-O&CfjGrJ~Q7x~?@2|%9ReS!3H2pGNa{*L6Cn4Pw&C#lf-gGGH0&FV;es!gbBV(F;yl3@-e|snhvjj6(Gh$xJc=(u zugZET`YKWniZ90EnR7Hyu7~jxd@<7%(2AVzZ^gG%dn&#Nd>yIh-^&*t#pH`{IU2$j zcv+k0s>cVw`ZZZEl_;Ysx6+qZM3uXt`ci>24|y+X@wFCJ4CYF7}pGjxcc=zl&k)0pn;HSB{=_wWXbZF!jSGeEk1s zkbgoO+Ha=(V6;7IkXH>bMSu@TCTupE6a57vAXKAsg=%!zFkUhO?aaeQ{11k4$k4wx z0@FjR%U;e9QL0f2J;zav&VY@jOO$GKj#))%W3C#FgK7kO4^4+%sI5?rQlT8RGy_nK z&UF-{02CukY~e`&MpQMg^QJ>5sAnY)75_Zs)II9Bf&3Fy&w_(q0|&{Y+R5i2zNzR@ z=|#y0Ppuc7SpG)kCpe`RY#ss`!-U3Wp^#ZGsP#r*Y3(5ze#-ml=wM4&aIZPx~_8h*0{tF z=N(zD9VmmM7xEo^X#iyaRj%)mZu)(?)(yaqwLi`i*O|J^U))5UN)E-ew?^tcntvaV zON;gzl7EYLvGc|JFjTT`yI9(8mbiMM?Ug(L3>T(Ni)8k2`=Ab8rw|$Ju`lKsNw=Al~r-UPSf{+nK(Ubn=9p0msRI7 z-gZsh%$c0agzt4RTuR5f8#!ERO*)EUlLu~g{XkmW9*c$DLakg+*jh^a8-uMXs^3XT zbL($ze5V+KB8RjisTe~wBF&6$Fv6yK67BXFjUz=S_*(koAtug-;27jK{P8*>Lf8fj zwDEXy0mfVKYcMWAW{H?mPQg^N5#$rajz|%$TD0Av6STjg6D3!EacBZBHc+la@tu$> z>){wDj}j-J0f!=dG8yS>0XG8FcH`lf^(9XWE^+cw0|&z;@tm1(&}Ez zxXWsEr{&1h#mby_9?8bcun?%2SmfrsE3mZ)-fB*hLG_KKIS(ei{}%2VmwEC-&bjWVDv`=ej@3^L zQ|^?Gm;H%*Ir*dHo9W{b@@M?Gt1&fF&f{*3n^cj1KKIi{=Uz#d=)!}pSe%=QbDdn@ zvr=AOXX4>uIqmn6e0{ulHb_nZzHWWUx_rd$b?sqAd)c$Pw!)o5C$kJX0>)2ZMCbI}5knSqDm+`*Y2^++C{O zCRVL_m~C~Uj4;fOw};M+Px+RTTsION{BdsTPr=U=p*67A3*7ghmXq>S=z=zaT;UDY z8|TF98dZ{@UU(N7HOnfRjqn0kt9k>v8gKZuGx=+`kw&-Uy)*8>TwZ*$wx(E9#7UzG z$%KnZ8vV$RXrWtl!dGjFzNj_UZqdKNO7|Jjw-a3NC;D!R>pY@=tD76)n#B9K*DYwc ziy3bBv`KzS0k&R>l#o%-CoH@1xF)%q~=8~#53n;AfHB4f}`*pv87o4 zOy*OAZz_49=0m>3Gey6@cRpl5Ot~3d9wj+{fv-I#H?DU>7Rt$uxo&%oJ6P#9ezgp7 zmXjMRY}8-nK3VSKpCaW}a_lNR`{VT^B-vb#)A3e2`PAT>svr9t>F)qP0#v^nIF_zu z0n|Pc%IN^ztyC{qgz{^s+_K zOUTJ5_)DZ6E=GD1U>ZR2Qzg=O0qzIzL-l{%U!La_rT(RL%>FOBej3sPIx>J_v1+Yc ztlD8d)?Xa89;MnFsMl`UQ$rcq9kRd#oZv2j7d`i(aRV|)4^DWOBdj3z8M=s0MTgiS zy#A`>OIgqRftR8~Wf%1S8mZ@o*#)r*pdJx^<4Ja^rP9e~2EM7<^M0gP0k!~CJ?}&M zXMjtSJL{gxF1S9XT`*i8MTC5-smq03@OFr;diHYZWp+E){Io-!oS)sK(l%Ckgk7-9 zFK+v1_XWc9{66k8!fScB7%+RVid{OMqn4i{6dvc%_VO;U3tm9Gh3)0l|CRMU68Ncl zP;`BbgEOkEf&D}nKS9*LfT(Mn@7Lj5sy&rmFc%A8R6YM*yI@gFdJdPPA;f$pmWs^u z!Y-KI8AKb*;^uFy@+(;{2XM+5EOr4y$+{^Y^{{*aeT6me8L`Ru?q zRsAS>|1~O~2GV;tABRJq@Hzxvj7jGrL*jb1g}*0gVAK0EMD)vh_13U32w4^Z`8g!B-=2mn8n96#AS z(XiNbA1+UW2p?}leJ`0A8ON+cP+PD`Z@mc%X3d`kF%~nd0e3udC}#IaKJ&b%{ah!LwnXb zAY^{uU97nve(qX;^%>oS7bKjGTkT?(2uu3K;^%9H2@Wn5FvP(N66-S%hy!WAIRA`4 z#?3#)d4Tx2*KXE+7u$dzT4~>Sa~KRB@npUST}`Inz1C~b_w?ZY$-?wral41S>>>X| z2Q#hN77zBnxr@Ij%nF5zJQ)}PktN|LMfZ8)@8@?y)$X#)V=wiLf^YM^Ui}B#zR#mC z@$|UQV=eJu=|+}#=wIZ~@A6=dH>;v-x_nPs`3F5nvc(%%V-A0a9t%Cz0#6QK{^8~8 zOOmPauy+Gl>CH)S$nj>rui{nEDemQo8Gy@#B=EusvJi_uI^&v!fwm7mV&}lV)E*Jy zv$%hU7+}q%tfPLOIhz<0u}b`2V$C6BvCgi=K#Oi6+~ACy$~{QuKH`PLJ;qXT_(`tOVWf_v!v&uxXXi zCLY}kn7T2DW^?~+q5ZrC@hD=V+ju zil##$sh0WF_{7Pl65mvO8;_mu3IH7eiZ3ok`WC=_0Ddq_@&oI4V!o(}sgFmOry-K+ zg7GH}897XuGKP+xj9np5JYEXFG!T*OvTNzT=-ZUk(%a?bZh0B~Ha6zPaz&T6!q*mI z2yFx>wrQ6^6VAyU)MrTdPLn(4b{g2FOK56#Zm3^Eqd2VBY<{{7MAi z!_xddd{4V=*!QEtRc2+l`j{T6dfp}LpeeNb{@kIUGh)tM!F@)=H;*H7JXc zQ(qzdGoYt0QoglF?*kZqNuQhZBwcQ6abo&h*)3MTTEBeZo)rE4ah={dB?BjFP zYAaUmprgDJ%LP6&A0{&~=_A$;^cMYUu}Hs%M|cMdCDDI2zLEN7(vK-0cdhrgeZfmM zd#&$%!h3JjW^bcSUIaLQh`hl&PO%WUVv84D<4<1c?$_*?H~w`meoN-_*#&QTgKv5- zLC07z^qANB$(uMmd!J9=5n|DB5tjy$nTMdPY@FDKU z7KD5M5IBJHUM+zPMlaV2_l@Z_vQs-sSU-x;8U0;DFafoQ*k$@AV*bN@skfAWur1aK zSuCB5B8@PY^$8JB7(kz{B0Hr`q;S5_?$%Om8yI_V z+p>a;wuSj<4nA6+ik_k|q@rUF>CF1l)nZ-I6d^1zY!Rg`F=ah#!AL#lAl(Mg9-!=p zqmjM_Q1zn3SB)QH^@omhFM6CHoXG{f{FuUk{boQ8^?P~5>=1nJ-89V=u(15VvH(=) z(XlP8m_amT#KKKbbM+&!-rWm?ydahUITI9$h$lR>VLE+-h`IV|m&9W~%2@8SLwa0> zmIr7HQ0;gD(h~qz0rEcBEhpM*5KjnuabA$xR?>j;Xut{%Pnk{I%%bNqy&vmefb~k0dHbPOxS;zF#d=zV zUIqyL{1wU;5$vRnpU1;Tr<|rBMpZFpWX=-x=#ljpO2`QBS2qyp3jvn^9DZPPkX`^d z!x_JAaQ5$Vs$Q##9nV52m1pwM$9<0lU z1+{~=UP$xlK;2?*z&W4#JQjkInjXZx;F#`XSoZ@u=})K8v+#aDE{QLvrVqc@`6OOP zP-aCR8HpKMGe90d;nfZ4Apq7`(!htfz$=L3|l8aZZF>Dm*B%aF0W@r0DgG)%@fyto$8mr#F@W56oao7xt7i-FC7y*V$78wSpHHej|NjqA0VOucjLmb zUZrbW5c0h(4VayE68eZ_7~JoJx7969NUFcl@uWX%PcNcAKJB)dPy~5L&r}pA@m>#n ziUlTUL!`e290n-7-N_kR5}+x7ADPbjpX2eacJQ{Iko6IZO(ja$TyBUQ%{(Qs`q05@ zy{_#LSY>mp{Qp5jLxt6yVXUqeSdElxc(xJxAAuDLm>}`0LV1It#G1Af=`R5X01B^+ zlnkviU?6}WD?gL%xuQ;9oj(mOZ5u1=gd5Djc&AMaI@-ks!#&sWGnMbe#3~2(aY7!l~2ma-_Ux12nr)FsBfNX$M zPv$?7ejKn7z>m%SW&1AF>eKydM;;Y8^1SYQS&y;m;^h6C;*jYTqo-9k)dfG!C*S}^ zs<9g%4<3EYTI155)ki{LWzw>iabR>tMo1--ft@iP#p8B^JLh?_4t>1yb)S3`|G7Zu!uw1mQw z255u|Pe+Ak(jKT`^pT?0D=F(mcM@7n=mElsb{<9SWKzUN>pfU1bPqouOHV^-bkc^j zf&598?NE@G5#Dd380pIa69GzY-iY)ffQmzSe0-yW4d@R>3s=S}1U3?6S1QhxxZ#!g zsII-M=RhtR@CQQ>ThbC9ah1g9k#u$wSLgAV?ts@R?*smE-=a|42Gf@efW!F9WBaIjOwjYA*bt^{giU!RQ1* zdHv#?CC)EKKe~FAV5fsj`w|$PV+Ro}EQ!S1*h$9@`V(_4E7j!_2sDOP42 z?UEJ z@!`z%Qoj)oaVj}hC6bwU#2g=r{<~c4*?6KFQT!2O!z9^NUMBWSYOm48tKHAXuX5AX z?w5^Kpv+?yLOt}Piw$xirq&074U*|{LoZ|>6TK5#NcAoZgqu!V;qFZgzGz3S8r?|t z7Z6YJ`)T?2wC5r4O!j{D>!)YwS#mmupnM1w%&o<+p{ z6SaQ9Ccu;}gQ6aXVW6MH-9y~4Q^LfFn5tL?8k$WDXVFro->d8Q>m9kSwn5iZjL)fu zhPIMUuaMGxwBQk4e+aB-d`9#8TK(u=8uyv}y$gQdOAG!>^)IPTjXgBqWm$AL^?y&( zgW)gird@Z@d>Be;7yFFtedvrezD?+7Jl;wWjPj!nTiT5Tma*PqyzAkiSbD<0I}YME z4q?{fAegyMf^E6TIPSyH5kjXcy_+Yx!3d0iEj&?zZF!LBiYG0lggH#;uR7*Ghazwf zg0!q7MlpogW@26IdX?CSGC#@tGMs<-p!qyFN|dvndV$&4xWsDgy2S42et>w-^_=Cu z5HW@a0ib_(Hw`H~Rck-3G z3Tz$ed^8z##hI<*yTp|u`mpY@O!3U{W1!wDwNpwd5E4&BRBXsqaHXcTbmU!DgrBZO z`c}XKfa0e&kp2dsWlMfK=&bV^yL+BuC2_5D_-SMV#u0*_`jw93+wGlfp9IBOLdS&8 zRjIEvM0lQYzluHw;qA7ww(L?wM1#lF19a#L+7?6KO!TeTZUEf^@2j!*PViN%{DOG` z>snJclVp#rHXTG1{UCje6UwVRD39#6GeI>IxZ&|KPBnw+b#MS9Q_1yMm5y$ED0z~y zQP677(pxFyOIpi%oPqiYIS;=;`d2_gC{mBbNKXdb0N}^O1G4^FZ>ZmYJD<>MV#dRe zLWS>_7bA|Le2I&u1`XPzh=PQwVoQwqj8?G`^BJjP$@5{rI)>frNMHIkAqY@3pId3M z^wDU)M2A22`GN2B9`EUWx9fDNZY|T3m+A%l=^?!X-$x2cG5J8>NVe;R=*e1qt|!0C zyL8T{Px0zsOi2supo56ZXeoR;gZFAZ>23mxI88RLGnXNr1y{4$qx%TxJ^J zRy!JSPw{Rfh-_{qJKcm<-Bz}Tl@sarh9f-@@P7bB*Y_j69`GiBAM2g?lTF*!Z;uiu zPI+Ym=$iKsL#8<~VPbU`h(9?@js>gqr|5j0RO#56hF^Nk#{#-z7UU1&Oy^<(U<1{v zjkdHqCV6a{ez(4W+@t5-t>-P%$qRHl*+Mg1MM^7wo$oK!7;~)r57#);RNdgE-WvSB zoM2_|EJ90oRbp!ltjN!WU?OZg(M!=EwwLu*)HqUa^N@ZSPzO-;Hl#_0b~RuDfFEjp zV8grh>#f$&g9`6Gwg2eolW&(276X&V@&WX;DHB1U2iXDr03!#nOOUt}i*qk=9xl#Z z4={R=og4MsbAa)RU8s`_P(3X$FV~5!ppcBY2*SBm*IqQ*W2}RT8bob3lgWE5u$^T; z&d@ikWMf#LyXgh@(A>Lei`&pWrHk(C!iQKMdNt!g*6LAaJ;G>>IcA^U^$s)lPBY>nTe5A&ATihn}x5q39^hwzG z^J6yRBQ~CHH(*G#$Ta$r0UhRQl21R>ou9T+@uaoTu8wkr>tQb1nrVlO_O~%1+V-&m z+URk8(i)xKjW&Q-`vyY`a-+djtHhZ`_B)`Fq*^xSVZ4Z6kFf%lZMXE8em8COG8u90 z_yK4@M2gFPLB6oiD5N2)t$7Ox{w-)TY)cS$%?Qq6XU=8T95(F+W`C}KPd?GDM8n+% zJDYj}Hbzpox(h*ifJ?U1`-fCAs>|R!p_*SphcVU*_QPf^z z1rIZQ1v6e{`HkTo+oQ>B+DRyPdIx6f_~YdH#Mi<94SEVe*)MUy{)K^4V?Z0ISv5*h_4jAvPx;$pIYr)lPqx3AZ%hz9P~38o zJc5i*_=Tk6F71CbGmsI?I*H1J=%K5ferK~| z-;61j`1mf;y;K=>#(aZR8Be?B8$rmEDnz6x)wVx z6#zf%LhQ4HW_|ho5eSot|Ny1qcPc) z+zKP5Ff(D4w9d?fugZ*{jWGM|Hwqpz^_8aexgkbLM+_^+m~Yx=gx=smFt{c)pMh5{ z14r>9%J|Yv=xoUrh4fV_MlpO{zRk6(HwYVqmjaxzIKA+wY5Akh)CvX$M*T}lctony%{2JvBIqfpol zSF1i>w(p|6h+eS~>9+to0jj_G3Tdqse5e5YQ1Nt5=7(I+06XCLap6YAj_^mw0q!f+ z=YSyHYsplagTbM#@inX$A2X)@WaJz(`uwa6))**eqjPSGxBBxs>yHujtAuN=*J92s z2jfO0aSWIE%|N-8okiid6wm%Be*GGNADq@M57TWOM`N}*b(Wbk+w3!kuW9B-$p~4# z)YiW(UlE@D(ejOKK>4nMSAuGRgS>cE=G6ISPLW<^oy1^-<-kL;6R6w@svc zg-90zE&}jFt#2rC<|)O)sMEv-tXtyVw5oit>gx;W+d8S$KO^Fj@HQG-C=-35pe159 zm0uG;F*jRnA2B=g-o6s*Sb(L^Wqs$c+_^0AMhr*OZ(x0|XSp}9gxh#`$R`1|aK*d` z-}tAvrgD1fhA4i`=nk=-u`Mmc$mV>EY;v(^zs&0E%C!=>o{r;hvVL{|Z$)1uKSPTL zgaE4jHX;2pAfv6UpQi1lTx_wczI+Tia(_bu>gTeFQ^rgPS5V^`_NW`tR%+OKC|A5{ zp0dbvR7npyi)t&a_E?)cmUdv|Wvs={p@GdNj7P7U)*EKK#~}E!=mZ+Slz5ku07O+_ zh0zmI?p_SmDR~%Somyb1h<%sRw;9vlGjg{XJ$^!T!T{{-fmMWILK|)inAeN*t~TsP zpnJj23tu|l3WL3B>aUsBBL+6?)3=xgVXv6_RufaqFPr*i(?DU?abn{_at?*^&;*JI zxBZ-panc}?LOWtGkctw$Bdq{guq8Le#u;35$pLcDV^2}8ga4iO01EpEv~LIkn1yB% zPqW}FdH~;so$^eG5{tBB&{@3LsPYC1Z6V#7j&|%p+UtxpBiJ$sJH#;9n=Pgj9TNaL zv>>kIi+SBpiEQT@w4Z9{uaG_la2G_{xh2xQ0p|nw@s$&wp=o~ob{0S4w4y=dR;uO?M3 zw{M4>dBEy~Q`@)4$mp*aO4S+*%(o3vYy8UIMhiJspB_+uz+l}NCb?`VKvMx}GzmVO z?VodrQBsxF;})ZDl|eVT^cP*^1s7~`=Lhbz61j11zA<2-k^Q8V3QZ_!g>|K!=(2+= zh~+g?F;q2l`i$jz6oyWC$ogGIC3(t9pG~{Xq8Dzlj5Pf*tAGzN7U?!NsJqFS`h;O* z8+RCKH|d^5h>s;0TiB6cyp5-~nixg?nQo(y z|2i2B`5DBuu~3UkF{J*NPCD#j>AP9hhkOiv83cKAhygg!1x?(K7WaHc19mQpj(~lN z-)!YKZIFPR?n8TNpSatjQ*e`@mAwkY6aR#TIgKYRm}FL4<^Lm-kCE)B_}v;z9AtiL zVTV{NW50Ff_as3C1L<8y^iQ!}SRk8~`HXJY9iEe{>;toI+OrYdRSNaw+>uoxsmR|NHwd0KUY4cBLHVkud z-@}BAp6p3i@UTAO+R+>McA$^5=8-Y<_?1?{9%9|((ktD_jv0?xT$eXio2{QT!w!I{MDwl3ZaG7({U@Tg1q1dmcH7u9=7;Ah_T$tc#hf^Wx6tdFnwpVSgsghv5sMK zCP;q0jv^1isJbUQPB9K>g1L)Z81#?E0Nx&ZrL|DrV0xnqJ)c}I$J8D1E}nG4L`gS3 zS?vs}zo8hy)7J@olVD%YkTshAAI5yG+hTAvf{e}xWHwLWa~k7a1z!Gos+j*UNNXYn&zNTtGaaNA2F~L!->b$Y z2FXaYU4__r-KS$L?da}cvdS5fUkf`&`1L}h9|9Z(D1O~pn4#r&$>3)sY&Y02jnGB+`O!d%SZCrYD3(8$i!tA5tmANxl-Ju zehM|`nDrI`@8qJ159yHA7D*BuScXR}=^YF*{3Z$usD*Jlw}QeurIOp3yF-DIrcj@A zEeHA8+M_n_v)oxWZTAD|bC~4*K#~rF9Bf+3`@XxZaratnGs$P}z zdzlUakt7(UY?+?BRBt&?_ZY0e#MX(~d)%TU*y)~P=IU#)Bwsf^b%P-x@k{r*txw(h zPPhKCTd?CAcQ-TiywN8ssoJns8EmzYd%uy;DCt=P`>6+-+Q^Sjl#^A~xV2V6_e#<& zw|n>N3F{tcV!PsCarN!dyYAI<_vi_Ay6*wjbves@fF;1ByUFcaW_oT=GZBzINw7X8 z(7D`b)eJYh!YU1nn-jnc=v(rzxU>abrsL;q{OlI5bVolft+kx1xYhRm6tm3H$z|*e zeTX?)Pb4#FJ97^)-lEo}bPTP7pF*HjeCN1@ByqGpHE>z7k<=}(tX5?ljWQ_6pbRJBgXqM!I zP}hiEX#mnA0T%<5zrdh9K5aVg8@7L#H0hR&o_^MqlckZLyS2`jeh>cn*p%)V-`1%T zOMP2l`rEM1fR`x6j8PyC7Kp1@FSQ+j7MktoN60&@A0hoU;9GzyALq@VaDV*1sWI(8 zQ_5j-o~}_=(5@{hz(%B2ckLdJ9}>tWut#-brdHzQU(zk&S9c@QcLSaPC^@kQ>0^LE zcPS?h1*M!=m{wmet#Za;M;f4)UOBxK<%2T1n(oq}im#^S;(X>REHpW0b^e%*`iM;g zp&Ilf3d3ngd&tw4T5p@9uAR8eNO;dMwi#F<1V@RQ~@yLH--@?A1SzzgQoP zygMUr@QaU7%&{6Y9!f5Xk)jpd;^5!&&%~e4YRa`9Vf+=oPJWe9`JFz#k^BU{$gAj9 ziP!$9d``bz)r{0{ajS9i8`&#TkEf4U{rnVeyef<4OZ>J*<#+n{)!V3`Uenb$`4txZ znfTSqPoP&+CGo0`%IEa)itJ-l4=L1Rjgw!n_n(PZBtO+=MYl=3W<=$4`gld}n^PaN z8YjQ~QTd%dJx1pzF)O-V;#J%yQh%q9S0uLm@tjuUb+^Q;aKN95S2(Ut7^xa3pX#W5P9L9eK8pObqI)Dh*1$g%pK!dC zFhW9FKn^Co4#`L&sd&kAOdhM+IN9Adh=xJ!$7e@Yw95+Jt8< zZ8AW8e|f|9opRzEsFmZe*E`=Yi~9cQ%Ymr41Cb^dWvzDdt3`gN-|kWQI89&Ue7|r| zq`pqS-J|kxn!ad>Y?nn*`TX&AkNUpT^d-*sk3@a{kGFf&_ajX&+N09Rr|jHFeVu;$ zN8&pi*ZkNaR@PfB@;m+ZkL05`KjeJBaBu{l({KMsK8o|JoqQHW<@3keKk|LW`8Ce> zk3@a{kGFs1`>OftobQ(miPY2Sw|jUpddk?+mif_bUvj_G?os)je!JAqPqhsv>^djE zqT&dCb@49k3&8ts8n&15cfZ23hS|%2QGK+uqH2lP3gn~c`8A~dl?hrrK-uL2Lrm>I zc>WtXeRX+hetzlH{1X{``GN@;5{vsI#A%NquwqWa!f;&pu~g<8IxmuMU!*^&@o2jN zD&OIFHX5MzIjehqp7`?8KH+|3eC zWv@LEzRLO;O{kSlJ~RG`d=xThfjTFjI^?71Mn4~Q2uK7dd{U8a3i!?b6S3$9fvT~b z7=-f#0p;965gph5Sl1XpG+~Xi92)DNJHgJ5RYjP-kw?vPSzHl;;N8N)m|0DQzq4Vr?+d0}g z8Qs?W^F8YiFNh`%5og$&9cTH(+?@=^4^xZ2PjNinsR07d^3Mw!|w-2X1V zROSWbQ|aVWhkRNzpx(BKMGfF1)&RXNl1rqas-1ia zF8WLIi8Ry`5}!qXVm{KU6v2g8Q;Cz0cJZHwkCTtUrN;Sw3BDg{K)WIc zN8K7(pXK;=Q3L3sX49$ZBa&^TLW`b~`Rw>>@(EXHwUbZL=tzBDf%H?mUE0$CRiA6V zFtsc3{CCx7wAqE%=vk=I0T^Uz<~^qWyq%??bML|Sy>yS z@hMs->#g>$$R`S)YA2tTTac#5pR+xjGmVGRR1;kgfS{{^07)Id^|JP z)K>ecwiclHn7wam^YQ$5@o{~362VBJ^%Acc$ge=MR?Kl+L+otzvx%xhe81AkZ~tGH zpQy_^C!Yc1BJqRxz9yIBegeQbsM?2KUF+Gf^0{Z{xua*CIiqw;ztVEVX8#2%849_) z)1Hy_wgTT$`a6#cyczdfqrTrS);wK)i#$ZGtAN>a(&%Xu@bQ2)8IG9z2sz&mULN^= zE2Pf=6aqLM;1HMIxQ}Vij^tB5rJTDF=Lv1FHnqFEVT!<01?PnRH?e1`- zHXkbHHC?*_>m7|GmYj_xl7$#z@>JQTj`k$xMn4WRIS<^2Th5bnu%iSK$R-tzJIRyp`q z#>V&Wqo<7+J@HBzr*5xA^#NQE8yVs1Y+(G1t}WvjcVwNSF&<%-(~d5VEvOKP;s0g5UBKZuTeATqk<;W;WJ4&z$vC{c| z(G?MV?ne3nz^ee&PE9uXH8ue@bO1lfob{G_FR8x{M$5iU{N7^83F7}m=+X%xTVE<5 z=#LXOzGc2xhFSmTu~V+RY+`9~>9jGWli?TMxSGwh?#F`UU#uR8ltMmbz3W);Q)bsO zw>WqIlnt+A^RTHS0wUd%^aW@7>>GVw;&)fAVgj#xun0 zd5XlXBfaz{Sc(+XyYZ#1*CQ}G)^7}@oe{gXD;)7f#0u=%f!~ooCA7B@G!Z-< z&<&ARhP&r6Z=44?i=*ZRhaXUX(%V4Oo<#Zuz!rc*H|#Ld{)v$|lW)8(=|_G=(+)a( zP`_$WokN}ArxY8&7JN!b9eE$Ic|E8dM3&r$h1oo;DS`VllBU^<0nP^>{pp z-GP=_6};(rSku<*8q$D_?O0g8TpLE4^_p``)%v8|n47qNU-ef(aPEpZQ{C~T9W&;Q6-S0o|Q!_R5CUjR$gL+x~hQ6;cA2p;}m8LJ2pVXRf=~dx7=v?@d+HdbPQQ@8Cs)3T4K^*+6XWtp6pIJ~xm z$H_db9nN~(a+j21)#0~}^Mzx-)y90S4YS}6XA%*Px448pVfow2&F$sxC*^c=dDds; z`Lel|>sS5;r#bWic$x)o$5ApF@C&P!FV;}D5dKPdwtbq}(|nMKELpBW4wyh30~w_S z*}$4Xfwg0l`Ut%3!Ub0rFN3`BhA!8^qwD3yX~6FXYz2h;J!lMk&I9fQ$Zc;cP5<)u z8<#`vKF#0R_Z_@_tmj(gD6wi-cmd0limKU4>o+XjW4UU7#Ba#`etR?7#v;!y_TdL-Q0Xq5V+SEyjGWMm&@w=y&B;610Df{<(fSfbN+yy0J#m^K2;s} zY|=+{f?e@Xx?GC~=>G7)dKJi{R}2|>$$zv|!GA*za7A9)%@ zSQ~jQi^jzfDES!Nf_3)Hc79ts->01l)cs9;+OwG{pIk`Onpu2ph+%|rv%jI|I-|xbc$I78KD%1x0 zu(@7Fa{2)woMNEm8eu#KKW|6Vv$6gaoQ1g2QQ9}!&KU%UB`2jATp^ei%atqPWUKR# zfIkm(i^tPT5UpqcKOKcYPd77Y$_mU&YTwiOtVJG*RSFH`S>SH~ZXaLIXKm)CjOQlg z8LI(uJ8=9NJX(MF{$TCLh;akQD&cqV)d($q!ufRY0tTet`7&>1i-fsYh(#g;ZSx|5 zmhKtB7NTa`i$%^N;Vc$RT_wQKg5&=H!~e(Vb9oJCo<0MH`}AJ^W7R!Z_fJCK8CfjS z>I+f%z4T(yT=f>ca7}(iztvtOlIjhRM~GkdF8OAx$bUu*SS0!_7BOQ7gZTcCZ$9XY z>Iiuy9$#lBh3|`JT=tPipCIf>LjUR6i(HY+e8_c{xNMorKKJN$&w0V6D_pkCqdPoj zrAt@4_RB7fr}CO7#H1YdcrKln%jW0OXL9WubG#`zY+){4oNM2lLucgJx8%^89QJH3 zU7Bm%nnUl%VM}u9vRwPl96Be*zB`BV_+p2xb?F8-vdD?WX^by*oF$0=?PD;p&Q8B* z73b4D{;Cyy1y1-}w5hFJ@sY`w#n@*)-Re8re7eK8KlSV#o}e}pyF9wv<8VYE|Nq7J zzxU{7kA3RXZ+-TSPdEGQAHH)jqaNf;H$O&sljID)-|aGpdX$lbnTc90D+38J%O2jFA+=?$LMduZYI82K;{?n!W z2`feD^<~jShUj1(AL+2w6*6MZjh;8zgQRr3=fWpsnT<8;{b+P@(eJ*`3*YxtS?8|z zlTJmV4LWlhqW~KceFh}^9waR~lxFq<|NLg=-OEaPpktNplDc8x>;eWUsxSP?eJPP+ zfjrOP)~7`Fr-pGk9ZS~{c(=I>?vv<7f;N95{9+Tczc}B^rXBq!U=Zbulm2OY!P0IM zVZPZDt*K=t-GuWJ@uly9IEyH;iWus|#j3=AK#z^Ph{JI3s z;GeMc`;fm>SJD#euh&v|?-&I^_C^X~gV1O=uEh_2DNjIu)PbBqn@JCfov~7Tq5zqI zZ_{)z{!*`Zt^_^^a5*5PL($ck0|HzGkXy&VKV<&j8`rx%H)}a&{E@oe9ikttx_rpU zIuDVDxM$3SajK6szjKshwJvHOTaOlLzr7gs3Y82Ma70k^FowulB+xf(6UIBBVXh#z z^QN;9NU9V(8%aBs9ZAsBry|N|+*`$y)1A`ylTO{h;h?pOyw3cHx3>TThveUyNi|o8 zcq+!@3F~m`itL%5CYh0BE?{7$t&Sp-C*zrY zfp^8S{oWxzSAV7XBKsQnatJP-5ByQUdw`HHUc46LbbxoA=8I!0G(SxGWNP>-b*v2Z zI>od8kS_xBwnK@zqehSKQSTO_sK#8bU0~1Chw(F|KkPT5a>HSZK7677dQ2bY>cblS z-yJxU47C!1O+Up-YB*Z4NaQURoesQ~yIA}NM}AgYh-+=!I9o~Lza9lSxQR|0omu${A1y?Q~KkS9Qrl|i+*N;MK1!JrH(OrIe7D5P91YrN`KOEz7VeT zsJYIB^O0*rZ}JU8oCC9+6eB*N7-_}fUiWz^8n>!rPz;=%3PBO-tyBDf>zNgC=#(NC zn1XSVAU}}Y>vFEk9h5T%S}1rUz`WUB&8Mrte_=cREAZ04BDNVIugSk;h`aB+xc9lFHE4cLfr0}%G9pVTqJedwFoeP|6; zUPKq_vlsMP=Wo;i>_@a^J?dGne38geZCT}k*Px?py@g%ANOW)XPxfNb%-Cd=*w#Iw zWvy_9PqUl)O>Dvmjp9^q40wA{o=YQ122D{}4l@~^g>sxiD;-JjILofi#IA00}-9BY4wy@U5Y#U)J zZnaw6W|hhw{7lwHYO%UunB3Y}6{K?mzCR}T&iBCiMCeZeVLA(dw*hnl$SuBG*N0G_ z7(7~c*l^Hv*3Fa2{=Cj?4}#4Wx`=)%1EFSps^prz)To8`L+c~aY_r%$J`q`)q<2l( zwn)KcAJNWMW>9vH%!?FI_7SS|q&f+e|JL+>4CxK?wHEkhz~_K4U+e~q4*(?qxrO-} zvqI-jzYTtZ136{uALpyi{8b5T+u#O}RGY67EwH`EjVENrwuxptgeQvaQS?@7jpuEp zf2uDIrSDx1OR0zDLAg z&X;AKFlAO8^eHo%eHwH&hJ z5nb+$o9O@KTUE%4abIDIaed9W|7P%9+TPDbQ@E5sAj>A%_G1`*Yu^2k=>g5nl-)_` zsWKZ(r?dZ!c}8`R&hfx+1WW~lvxsBtfRtZdqb3Nf8p+3?~Gd6GYC<;;_M&9FMoVrYcHr;`Z# z5+h3K!Ks@ZjL9OJ#ZWS?Q7d#lyG+hAYE=%<5COjt@ERb@=Yu!p8P5aUDLS9_3a#JV zSgQ3;!K22iKt3FN=I{2qb?pqs=?ko@=38We^$1gk=k(z#q0YY6hX?t5WNg1(sdQIc zwYq7pJ`}6DhMwx5ZIJ#>QJpH2lMxT#4(PgOL7~-bv6x~T)8SJxT5X%ZSr|5nqfEH` zN7R9Jwj8woY$1{r2#SaSM_CB5`J-j;v$E6n-M95Bo$&!P10Ce7S(A%;9)nR6|gEYHY< zw#Q`xg5FdjvN<;|iQPrM&4oe$fel_RJ=vU2dHKMzXx^pnH3Cr=L{;XoQCpl#3+|LH zkQ}r>8G*|87l9oVvTBfyKx#v1x!GIHBy^ZLfp_uYeipvvW)Vzf_NVaw-pjd<`2FAs z`C{@I5kqL-oDum$>6^6RR8g6GG(u+4p=Nt=37=3@Dr-h1v`AYq8IIVhb14R~#n2=5 zL@cUp0^|9%kOnhk0WfBY*aI|ere-l8u>m5WKDOeN=C{YdN3{XdybkdiljV*+cjxYIS3 z!QEjNtq^h2iKQ_A6G_`}R#-|i@_DSxZ7YsM#tP_9C>uJOUJW;DD0%2o9wntbCxwA| zGYnQ+MCTATN;n)b3_o|;YzP3A>J^Bf6j+UR;%3c z&Yi^RmYzXUmu9aZS@THtN|H4$d1MYg@{b52c>II1u4Ys8ZeSam{9To$EQ2kjx;(F( zT0if*2>51sKCQky`%KR>&IZf`$nB0Gp6-+b=EaJS()vE*wcl+&(9Mk)2Fo60i`2MJ z7&>HB&nlQstFCW`?(?cG)dT)hpFPCX*>!4PIv<1#8Ko*xrB>! z3z6Q(Wy`a{btiHE1}?I*HmeIeTpu;4w3?g0GMXsQj4`jsD;^q;#W*W?+9`a>ah`Vg zQ%;BbEoYV`5?xz(Ew6!xNN%=gP%1fRQABLWcL`4&0pk@rHGf2if(qedM6ituHwhJF_9%C+{q#`3|S_8wb23d!}0v%w9MU3hP$@^LL%+^>%7phNIyA@w$ zNoKpfh>FRipI;oOlW2(#t;brD(?go5cb$hh6rg>uz^_ z5x=aIrpDUhIs{ha=4qqBh9j*@nvbJZvaB>^zgcJ z=t~M`#va zejearfZ9Hvst#9f*GGL3@#X_L)t|5VwsG6qNZyvqBb6=9H`>?*dYLp0LbmJp!}MC)3_Wr1m)QAkMjxrMAb!VeftJ7l`01X za|k>X7|+N&?($0ac$j&CKy)^&$J#U7;oo?9KdaJ=)|V#SiRYm-)in4tDRMq7ixW4< z;@k?qv+y*H^cMHf`FsrNuap_U7UuI^T>EeH*)ALx|8YJ~8#QjkMWd>Qh53D5X72{S zbh3Xy_HOj^YJAD8NWIZZr}vE8>(hG`a9J)>aSYS@H1L-I>j82*RJqgy<#Ns+r?=0@ z(J%#(UL&UKQkktvWwT87-G1r4b;(BklBIHwpYuqV?68h)m(pN;sSKs_8kEX*f+3(P z6|^JA=zO-jqduRf0+)7)!*HZ3R|f0wNyNAk=ZDJQm>_?@({5m1U<^DesGRLXmf#R$ zy2{yJOVHEFK_xdHLJE`>G#BXB&I<4_>s@t5~C7L|p9fallQfiiFe+r^q7#{~DDiY5pm{tKL5IXy5|?Qvf0V7&GF=7M$+{ z$ZhA#nr{xJSN-LwgV}qH7&UCf*s7tj7FUyd$X`+0`paFPzsm9-sKyDPh=}_mbyg#`vKm3~XP63zM=7I)k5CBg zfz5~odnCeMaT}S8tZ;=}IFI&OLP6z9mNZ`=2yOy=Agq`@IaNc;c&SKmAyT}l=pzUJ zwI^%(4WC_aFE<7FU4W+nA^kc&VjFMa{9VAGp=AQ(F ze>|kN>i#-^eebT%-^IYM22=yW{5=VLC14{!Zs&ZV`D0+m#`3-q#2=dd`}&D7V@Hg_ z3JtYl0dC%R5ar0)s!hT8=V#_N2sXunY8hgW{a z-@TqeZMrR1f(UHyQT|QD+7m%_*h>(6HDwQa%5@fCI6-cVT2rEI1>&w!;ksQ*k0`Qo zt@fmOttgl)oLa%>iVnX;cy+XWt;i3rv`WM>FW_x>`=>>(g>v3N_Qt;oT@b@rU+Cfx zUFB($o+E2i$c0IauP=c)R>ZeF#87nNFmb(=#0&Ij*5_XPM1FplQs|h2$|o007%_7E zh*45QJ)v;|PTy>#B_3Q{4EzXt|g+~Qn8I0^T=V)gwfntO`_72Btpv$)ZB*S)D?Ovsl4ijTV6bq*+5sWHLcgg7nl-5=3 zLYl$n5&LY!RdCw)C)4Y&z+@m|tV`v?9)x#>txu>=#n@1qPDMTxMv%{ttQvq(mUJU&u<>#!hrF?%aSV zTWC?vhSdZ_G}+#)mEpwWTKu2D&Z9^0LRCJM=j!xbiEn4CD~2%>_94&x8nf64UfKynCs z@~IAcQmqd+;XrE$f}qIcSCcn{fHFCWScVeA#CeFNZw5ua#W+l7Nwv>=&j|I#~s(#8f~r6qDIdX$zX*IUSBZ4 zf`HC70iQJHj|(AiN&Xl=V)Q8RNDX<5&LpIU^i=!f)c#0K@r&OT$&KQICBj@P2B`fq z@~+6+DEPa=+9-;R*{F)8lsr$8%dzgM=kBPvE9ylV@3lQ@Zi`adgZ1({zCiMp5)MEo)2sI98ufq8LD!dz8C!*5hgDL;*qDHj zLQWWQi5%S2(v9X^N@~$+sl!!j|Cv6y0|(ihRw}|>wnVr~#m(G2f+4_K4y-qtW7eFp zgt3%D+r2Srz8e+(B@fAl(^*EM8KsHL4BP&iSZCAYX@^8+%1*-|W~^9X8G3QfiZ}&BMXe#^tO#B9I@A_)M6hn!sKpR-U+?(DTXF zvf-6F;>$2@aA?#jbu*o}OzeC+X&vhy;cX#)j_3Q$5jTl>38VTVP5;UV>&FGB10N6A z2?*&w`60xy0XzVZ+XsQ%bKpAF{?j!5{j6KmeFu_xSk;wEznfJ{wvoAn)RN|Eza6&3 zI2kloH47z61X(IFmI!O9h%XTgErbMnsaT1g%ULS8ya>zrIt+i|8ul^$kNj6(tH{kpid(3$hnD;tCErK6>wieBcSwAd4~u#sZO%Obi~5DWBjW%?MeYLMdBx|i z`+-^g8$N%_*JkzhTA#n;+izp)&z+Um$fW+RJU%BcFsZ*M4-Kq6H;+G%7nsyPnCCo_ z7nsyPn#bqmMV`##_M^l+J*;0F8xsCXewhf3_j>01rva7c0i-YoixL(gc5cnj(-vD9x{{);rl4q0w5c5I{m1m##p*9_Ci=dR<{ed~dMRT3AZa}-V%`o~XIrT_`KMfaV(o#c zZZ@VIdSP<4HAo7JTj6$R#K{{@ouIN_JH z^Ew5jem$zW`!sfmRH5EQAQK|c=5=%t;T;hv5aQwTI#PE8r7ezB8O2v>`WlbbkK0nf zTLX>)g#7#1nuKv4&MyYYZPiOt)$u^Ps+xd*R~>ZRI-DHUoH`c* z;Y4S!YO$DVy;e};9jw&p0?P`r#I%-~Yjp8#wG$Zhy0%`f%`jrpcH;G0PY9p4Pq zCISoCW=qYQ!E=C|LtCUL$iCNSZ|K95`f#zj!=JB)7b8`TyeGr>mK{dB&hgE=Fow*A z!A_1S6HQrj+C&_cj9AUF@B$$fX;*O+ZDBu1G9tOtVC_r0V8VVj$utvjO!OoWdek92 zf%VmyHvK2D^XNw;wT584U|rJP?*&T9k;OcL0R;lC7SVl(SS#DbGgvRM;LVf{120}j zo+a!rn1onB*p0-zl3izx7lZhhRE{u4qP8NjYP&(xx$hI2&b1)^`M@s+JPQcuEYeTH zCKu2aAh*@QIHGl+UsV^ei%-}6U-^8k*SYtg^4ho|7glMr3|QFSWqw1*9x`0*XTiW$ z3dSFzpUaS49soiHdaKf<;u>6AD$ZB}16iy#TPn~V>aNj5_h>&StR>={r5Jd>&QGL; ziF0UTze|?He6_$zZ1tfk6FYqOMuenfHE!ul_nN6#Xk{|{R?imwr^_`qq1x98*MznCJO1FGkB~zTFi;&fA;N2XvQ)L|02pZnZZB`Ny~F z^!UJKdhQ4Ocj*~D)|ev>`DOLNn}hs6RZs6$6>%eh^E4p*UL~#-;{5mNjkOEN?>WYE zq5zdv5jjuN8jV-*ohf)nn6HJvw*u~3P*;!mgNsqlfG+`Z+Yz)^Qx0ga1_$lc;K)pM zU*mG9y6gb?{emImMhrb3qabBD@B>qgQ>ohT;p(u{R)?*+fxB6^Y%IxKQSdDOjmf;=h*Ur#`<)!s=NH(Oy5)jhIzc;Ds?!Y_ z-qaYbL3-Ep{@+uts1W1>^a#=nI;oL?Ai%C&BCtPxiMVR1NUPF*5J^B*lRAO0laYgm z%z&_U#K7x{4xKKp|LSbO1Ix?fNSH*!i*hqoAAIBTe8{n3& zAX)52yYCt`)?skR(t;jiXTRk_4oknz5w!gcZdYSDR|i`9lDudI7x`2X73ec3+5ShK z8xCi@8*H!IZaBd4-;ZLzBEmJm<|k>C7s0nXgo7Mvt^RqPFl$LirmuqfI+dI;F z_(H!-^POoud@*;W-F{dWD3<4%|4MWHjjbNL$>vjSJz`?hY(B%*BPKD^=C|4G3r~FI znYY`pQk7}i>iIX>Zg{`n;C`>c{od_1r%~=rYMhVXEa&~S|9Gw!X2ts?D%VDYb>m;r&aV@n0gJ*4ko&?fl5h9|An%N5(A{68Xx zw0vn+uXc~77gExo8*ODM~L0RSIf!cL@z71cZtkB zf~|MOJ1+Z|pds!97n#g`PL{b_SLSBJ(YLuI?JWzL_=U8;IAr3LY5G#g#4o4mt05D= zmZqZvCVn$b!$-Xr(lmTDzB0|jN8OjxUihf@S~?ay>b;rf=5?EcJoTY@&8t#fc!zjL zzpdAfw~Dpq&Ew_#@YrFzjaT8fh_~d;Vr9I)U3{oL;NpbkbGCh1c0|rGcXfx|Ncc}A}?>D89NGtys)}K!$ z_5gY==5RVW%S``h_jqrXi7RJk#*?30nRpA<0KhZ{YAlM|foB4TpvEt@52D(K{pz$i9r4DddRTRUkTXCYuD$Xd16{m`>#hc8W<`zby%`?lR&1r5rr>G@f)yiw_wDMa=TG7@vz-a{w^`_Xj%xFPd z=C;Iw|pD+%J3^7akySVxp)NjdpK%*H*MXQ9B=g| zeeJ%H;~9=7&Pi|l`jURqy$iP5d$rv5`g8U2)Q7-72kZa@avR%MY8l_*d_mx6cyvHUbjI)mDPA<}v^*!TxACfOwn+Qg<(p>!v~LS3BgBp;{|=iOzef)wRe{MWrzD50sUH{z&n-?7ki-DpVI^CQ}Nex}a)!|X9 z4u50n0F8zGZ-+HA)C{4gZmonhsI$>SLu7;CR)!8!iD*h3!2e36EotX_SNfgAxHhAU%poIMzVmnv3W2mxzaPz{ELp8eQLav~U%p$tQ^Oo#5XJOd`46 z-;&ooDRiY56X#dKCtC>-C1Q#dZ=go@Z?fFhr5}ifU9hbruR8B5=4UB;Ybubqwx#Ii zR3LGEnW8&WfyA{dW$j4?64$>{(!^GaT;HecNnvk3&7w2H-h8GdZVP+!+bupT=*{iB zEFRwPOtb9peg~a!c)#8FehVTOGKCD-p)hIQU^$yo(FW3^twb(XCvx3jdDUUEZnEgq zFj+c@THu*!S@>ii30ibZKmr>g*KdMu3aDUjlc&~>fDjf$uHOU)U9>+Mh+H^@jff(L z4VmjV;fJ5LZnAiTPjlo}eOl<;hM$J4C0v>bI}@=})D<2iCrs~rDm?6BKNtMY{;`845=6V#sKhe;fB z8TXUq1BCyLB<>@emp@994-r0>#A`|JJtTfF;dhbbY+~I`;&%`}D?Jq=(KLueQ^_u# z|ARPA$wGb9ej0f}&V5VW{ok`gh4M{=Uddnb?9NaRegYROP}fWSvDpMlD$WN$*zwF_ z%Yms$S+sSeHN+V&#uG7!Ia=JI5O*wzD{;qG;tqwlBjS$qr4i%N%!nP0LDY!?5|O-Q z4#b^Oh&z!$+@XQEV<~Ybs>Gdy5_BR`z)8z19O8~V`pJwy(D9X^b3txXnvv(_NAqaD z3vlwR{74?n&CH>>F+enj=O%J`6lT+CkzX7w;t+|lN(*SLNvx^Ugf@*dF`L>r0=HQ{ z_7IY2X`+NfK(c}3yT#Fxbwo`RC9Ps9wJ1DcwZWV%>@5f!f#R5i4iCqs5L9>{(i^@J zdKctp9|y4oXL2t^mo1NLd7*k`y}WQM@CN{o0z$cE*K*fbit|IsEn^NUw~W+s%Prb^ zc77nfd`gY&5MPGC)D4Ss#gl59U^vD4nZrXd#;AN!r^{YdpROF>EdUjOP!IOqTf#UJ z=V7`IVUHm#8*9l8TKQi%7q0)I1|-JMNXDr!Qz>Odo(DxTI~)EY)mA)De}4+z9qPxz z@8A93f4|C@;WYY`@sxi54!o;85VFXNc}4~x4G8l$KH(XIa6bOMsp_sN8}zZUeAsv3 zi6`_PH+0AtN$*wUR`>~lyU+sZE_9-^ff-Bj@z#t*W1hoY&R`XgiaSksS&a06yGy5e zq@2T%Xs!^pTz`~j=32S=eV|A4TA58_&A}#L5q224p0XBF-jv(m`8xftBW=a%ieY>K z{AWP?rFuE@ta}}!9nOyg$Zg93`3N4ZJM1{J~e|PL)a%)z*K7zE;3ab)r-D zIjh*pu6kJvSS@Y+I@evn^;Bvvc*n080!b~2`GF| zml-Rg733;5!0ZXPvq3($EYSI^LHfgTdII=Dz_Wm`oH~E%7=Ook&F{sBsHo2e!t_a3 z3sMVyjWNY7fcpuhp6#a9v&P_s`n%;X*MIj+;3ELlfN(y^UJcs|Kr4XUl3S;$d4eEH&W{l=X-rfTSjA##;sE&bS6#uaDGRigYn=M5;Y zlr7M=FbitGM(iQR0=F$IrLoIR_-PbZS+*0w9JqfhR2rC9V{zY3;h5VU9;U{^4CkV3 z=Tw{6gLtRPcrUMyhjpnQ+-&6v+RN)jMa-tX;=OXuyVd2#i<6SKxr`37v&?HPHqC8+ zhs#XNK^~S@bOM?=FBbRGB;L)8dY&W8Qg$iJBf3lZfY28q!VuDw4L3W{$%I`jd!4~B z1zKdYBJ*3q+M8Lhaaj>q*BVCUQceHIK-c1ch_3+u0I(Yn(tpzwVfe4WMhhUfKjQB$ zzf1oH+)N1M8!@ z*kCvNp!2Jz4LISH2~}gS(zRkNDrvv(Y1t37&{%`?w0_Vkd&o-4rR-fq>beWmnKAl|{^*1Xc-6(H=x)dgY3IzFu07 z=9nj{Ok-F5cTLwWuh-i>T?2eN;BG)j*JZ%h0X6~T*7}2~>bUn^eN-n6cJmHe{tf*~ z_a8ELTveYDBdf+8Fo)IfCK(_Cg-}B-Qnq``FyH7KlbkX*UmajBgFivz;u6YBt)c;? zx$x7T2iMXL$+R3;GjbTG#+qZgQBn&}ORoe8z(o`t!pW=~iLqM3@qZZ%@CMLjM8_>! zq3O~0je2_A3jArn2Y`?sH?4v1R=_s^xqaMM>v>iM{y!R!LZjy%jL%?%_s29isp^UY zC?u~?SmZ8YD=Aq;TdDm!Tpfn3q;XK_1uXtxo(ad4cM~%Pd&4=DH3!1YZY*w2AuMK| zhx{8?!oi;H=G%G6e4ZD3?r4m|*pG(q~tBNF(BoYNKw|FZz$p$jJ5@aJV)hBe;sz8m=GT)7;Pc>2v=rI6m;#KV zXX4na?z|8p`#YS@-JhB20P)@>R7mxVJ%wq?Ne_J+=P_PTm|_alLy2^aW{ZM(pG( zv-dWvJ+onB7`5Xxle(UWM&&UBK6~WROlJ>oQ6S|5Nsjk|unbjLEQMekxY`b$IPp5Y zy{Yqm-o^F#hZtwx?9GZic zN42wPSfQ~G+F4*|k7SDr0|B(NMdyQJ&cr66FJN-P3%U<2X96{&<6IUa3g8rPD5 z;S?NnB*l7w$A}Eexk4A!I2zrw!;ZADL^GpLy@h0U|jjADVHXRabxw#F|M zXG1{=mru8GyTG#H*t!Y(i(TB%h#H(JJi*$ZBl zygWgr{hAVPij{DaGaYV}auB=#>zd%iK$gaIVosA{4Jw`%Yjo*jjRz`TURbK2vYW?q zmCQM|2&?$`1n%+z%r^6JxsZTlPn!u@-5tp&BA4F`S` z;5LBV<^^$*57l1B7ihjbG~XRlHFCtIa>Q797TWVq$McyFsw;-@LLzata^S68vi-pk)=ObU4 zs_r_te!F4*P8dDcydjgFS9p|7(0=ssOV*8$|4!S+R6jA!JBnDYVeIv zE`AO8RzU27y7J&-fNui)3m~^)1-d*~5LZoI#BNUz59^nMt|w`jPSyAw&|e1Yc4gJ8 zW|I@Y$=E?H)xMS5PZ2VNP_;7=nE$yIIR{#OFhDkxLLcMtkXBWO2%hE*od`YT5K?^m4uakI({x#yc2UVM0+7jufw! zEy#IrI&x!-UPH0W@mJGo?XFN0Cx@f1MlsgAJ<8vf{oNWFBclNQU746|l4j&1&2?Yv zaz5k3dVaeG_;kRnfRNuF1^xoy^}s%5T74&9y%88>b;G{h=i*_bzEoyrBAJX)61 zd(?Q&L?}0LoO?2o>BD{1VP>5~j7~p8UPT-wRdvpV52h;g=>jd};GH_X<(umHat!cl zz>R>geOw9reZUrg+@=Tmo^YN-U2L$cJw(6UAYOus%TaM5mS?s0_Oqrjk2ix7%Vr)Wcd>!DG4A`6%w_$7CEC2gvR-HI!>rvy%zcpY zhZrUzmoTf;yG@2xkj?73==OVqi%um+LjqQ$sih1qJmo0yC@ic=u>R&@GB6m+k+Iz{ zAWI4hJc*?TVOEQGYx)iUNYh1Le+>BZfE9p{e(wYC@Nu5e9U!;Re{IcrUBA^u?6w5) zCI%l=f8B7s{Xr==bZiwkq|uu9J^azwH=OL@51Zd0V-ua95=ISHx0}7*jSh&?SS*EB zGqcxiW=~{F%9ZoOTGkys)c>uPC9%LkzWxbQR;+8;gHg2CD?yV^^xY^eFuTy(Oijz$ zA2l6oK_AF6YWq9zwSad3q1^Eq@V$UNLH~2di&KNU^uJhD)_UQ_N9TE6GW6$R+5QhC-5t9493#8Rf8uLCM;b6a-)0vQMYG?U)o0G9#6`aTQz3P9*zAv`v&?}2)A@N9-I<=XQloA0IY)oY$*v~GVJIdLZ}6Y<+- zc1NJe0VGN?wwoCQzpw3rTO}c7qWh!Rx?@}rLQCK+kpT%VnMh<&l+YU)xfL=&?Z0*U z?alS$;imW4#@T6MoDY!cPh&jX7T3A~4%7pTziLcXMMc$R6^%1e7g#hboWZ~7@6hYki+T{fH03^$6`H*+hwnv z{LXG~ur9kvau;cDz{O2-6W{EDQpK6xwglbV=Mz>)PEh*n>izn=d+^S>Ii(C?#I_(F6F_dVybsiW9GI{0LoF(L$?>+EZ?Js% zY;ExVGw`my@&dM}uFo`{13V81^R*oKYXD<=GUta`@5(MSlf4| zY4pkwMjwFOWcs&0r{8<mZ| z?Sg*$;N@SJ{>C5a$9kD1LE??86fyL)1US zJ02nZ=tw>|6HYN&pNz2}?0xFu9;^z!Yy7kRyG?)}2Ive3zngqi7+rCG$nT!r{@li8 z*6`Ek;?q$;)017peTEneVB98?fwoF*)g;J2z7cM7fIk7450L4=Mppy>5D@m8ec#mO zJAC}5BP$SPd%{TcdnXPVBL`eH8igTA~bSc3AaaqZIX!6~aB>H>_7F_Dq z5~|0fT)i&a#-_4q>`wbaGU&s)@ecTKv8mHn{&_vW9SQtez(hcpzF}LzFMygLT_=3l zAbkiMqKrm*j~YK>Jd{%~<=v}|cOTVOyKD8~*T9BXA2#V*p3;Y_lzHzG{N)-`oo6$Q zv2FcRmM* zT|$Ht+qAvxW?oiiTRSJy%f{p;%16rlw+G|BA5FG_(JE-Ov21k&6q0Zm{BO0iQZdLD zPnXjkltHn&0+;>U5HlUn9T3W4^MEe}ybF-q>K}DEt!9nou;3NO$AKKH`0TWx97FQ~ zOu4vUjpkpNo>M;xuC)21DXz zYEP%+PSSb~aqcGk0df-T@S5gVewy63E*ZApq!v3o3?=zO?PQ;S;5#D)>{1Q;fAh+3ppQ%I9|Aat}Ye4_8 z6UL1jJ9^x>wik>XGW3!Q$HKw;Rc$Xn48a{w95VKjVWY1Y)uQ5pk)wxRV*Hm6$@1zy zw(7zW(&uO`xi7A!pB$*YYsqnP4r+#TXNGZ73ClsurDW8OWJjAMquB}SC0avy#h;00 z-V=2S^Yh!#a#qeto+J+JcB5U=`MGWB@Lj~*BhPXZiL)N3<`b01+Qa`irz7v7_I;EW z3`ao3iI_8QPb=Odh~&YOJd3A>{+&Rh&JXWMD{dx;9>LRC+f#7<2y-OAFIs+o)Nj$A zb~xJX$$dC|&*@AnnwjP5$;|U(aLITd3d?@ zQ52N@K54j$vWHUpL1Lc92AhM}ub%0N<7D|iP0^ZfaA`UDI4Dn}wm_GA|F7!x1YZOH z5kS7KFZVp)Edd<>a+~+sRCNsHBX!bXx8OhP-JdD90MX0|KG+3DsVrDq)u#BwOJkz)X`j#lg3`;I1jI2jUag#RCv1Cf}$;x#d#=SA< zl|bW`v^PCdssY=v7NnS*sd8UksOeb^I+X|XSP6Us;5|Utp31+z!TEvm|9{EyBgScl z?^`uw*oaZ^BhT6mZA;o^1EPQkGWIuOJYYs?(oPG6iI&X>I_y|hEY349^sOOqXo0P` zNT<8Y&iZsu06rNo4G^ZgU>9UbK=l{8{IkE&$Nw_ja7=}2T9<0L!dzm_r(^-W)r6iA zss0u&P|db1iHW!zSgCXq`62~l^P^dF7+aab>u5$I(O5}A65U^hndF&fzAbo>Nh;`( zZi#dTgmrAWn7})gr7?RYXoQ>qU9`grc%da71<@wW;1>es9ij_y3PFn`RJ%#9famj! zRH@mEADL~&WmoHXJk#>btSpqY>~JSpV1Y99VEY3{wtuB_O~gfohMKJ>CYtyO(7)9- z)%2VKT7`V@Ch#4AU4W3DnY;6hBEW(E@cv7Bju>~^s9{xOM~;Bs>pH4tjd_(om6!F;=!;;x&T+*h;v(2&8SP%dPL8`f^(cd<$S3AnfnY_!snD zfXM*4RR{JZ3*r~K!;_eg=xC?x2+TQ;WjrgEgxwmJjGHD_08*Y# zMC~Xh_PlvSU}DvzW#WivOIqk;+31>ySCS1scviXDiJM){?dA5w>X~##B9?xd*xYO4 z#nKZTIb$g+(TfZ^M;GJLEQHV78#Eu|F#r1^Veb)o4~6ACR{!AcB@EG+vuHlqA2bw| z6}r6kp&Y_`*5TVcqaWZTKv-VYz-I#<1jy~c{zYAEup9rsmzRwDTBV|9){sf&OgZ=7 zM(y+E@O*}|$22ww=w30+&e`!aV%aVTh`IJ=;s{DgK*FZUW)?<~a3b#5Jc`9h0iD*+ zfKExMJlkrCrZ3$^5Q9&)N2@4^lZ5Pggke;+({zb{S5KEdz()bb0YbVg0lpsa5kPK# zM3*W5J6-x>Z31)^+Z&Q3R}m)BU|*<-bSF3Vfk-iNglO)Yq!1?I+;y-T_9&WYVwY)x zH4#S)1-ULvUZlu1Dzk}=J}HxDR@hj6E<(Y7p4otGLBNg4R7QaMPSNcFGoTv+ZfrR$ zsQ-KG^&gvn?*aS-21DtDwB=U4y1UAdnW&an_vA6UL1T_ZY2}=FNO8jsrQv0k1`#gnXPgl`QspOd9tVd55 zStm-?isE(RMbmi4YRf9@u}4M@&o}uZtN1xAhrL+XA{%BwW>kiw?#uE;aU|Qy_VTj| z1pF=@CPFWF%LAWTkd3~h^flGC!8AQWN?5Upmm6C;Yx*q!T|znO3*h?zzXC!zNs=b@ z0Z#)OrgsC*|Icz=-vJY(x|g-Po&<@Nf_(-O{5&z1+vuBM8UXyUV_M{r%6td;A1jA3(^b-p?5S1G)j^*6ts= zz8|W;FJJSg<+Ksw`c#b?T6F?s)IV2o$BmKkH~vHo7%>jon-PD3u7AkL|Kg#`tHzEW z0at+f5$Pb}UAkU|De0p2C2GGBLOm{Bq4vcM_5&K=`ufQAh`&9LxJCg9 zjLPns|CWNULiy!K;HiIOJP8Q-Zzk}20P_KI>-fFqzYlsg*5htjS)=~H_kZiBFO-}( ztQXV(ffR}Gxi^vdGJNh+I%?q4`Yd}stYv37yZL(2Y=cOx7nvKdxJH;8M341?L%uOq zxNXrPoPc_lPa-7Y7C~#C#?l^YLm}(~l(>EAU2d~E zZt89~bB@dJcFj3%63QM&6g=qi8E&LVNXy<}#Hqq9jb%{Dze&)Hl7aku_hbkE@8(eH zrIeE1Bt0;pU82Zh7(F$;?O*EY-39nbfKvfsJN3;jVGP6hf$O~fM6C;FKM;yn_O1)a z74xOAeS?<2GXrUx8nZoEu+Tva@!SyZK+ew`L(hZtsTAdm%3eC18<2*OFX*r6I{`(2 zkS{s_KN`>vAh!e4t1dR!mH+wvGt5Vw_P>UHsb^}Ao=H6ve&^o^F$YbeuM*=Cgmtuj zgY-HG!W8`rtCXU>ETe^iG>>Wc^fgAjLGC#v?Cfd7$xs9n^y3;3^q*#7!_ zo&tOVU?MKI#8oHhzNnM+VT zwvg}4PYKyV9zuWs96rE7id)ah$*)`)&b7C(4%9vj{ybr_A1!COw)8*4%sk)m%moMn=vrcDt*nX^QL$rL#kISve$SjjLfri};D1kk?>Tc$?!9N`o&J{Ry)ZXh^(Wrt3?MI8(1MG=uwiOvd9xuuLpw+V}%BObgZ<;qqvA)=BTk z(v38C7J>Vh-O*^#a-8o9Ef( z>C}-4!jn0eRz%{P;i46Uw8m--IVWZUswcFB}Mc!NNIJ3uifgrC8#s zv53{WLrqN#aAXe zVp+#YI|MS~ECd~}!D1>mgMOASTSi#IprgIOXb!3-Ku+rg@RJJbFs=@ziuPu1grOy+ z=}FAch#Mss0a$?<4zdKy;QH}4X@(xcfCfwBIMxdq92KIY+6xn(hSG3g7m<@sODt8_ zy9B$iHoAVYK9P^$BRQ-jd0Jt*j`nBYLt@BT7psd7O|ZrdKFgTP&n~7D&Iw(RhShYh zaF;8w1!$Mfp{EAwu^K@n?VdqSvLUbKaow-$`cjF60+2&LEW(Y>(43S(0;>T>4I&v_ zp~%*Q^3KG|4XO5!qwjiG?Xd*$0mwxVs0>uQVCq_m_+Ulv0mkftOB329mcSa0P!-3K)cpUik93J+Ugm?(@ z!~nqX(HeReLaq(AOOQy7S6FFGk7|0@w76l*pa#?41nwZ;K^@52(w;DdILwc!$2#usjT!sf-3)0P^OyfRivuJ!Am=Izue z*-z2y&IB3v zA;LcmWPb*fe&0ud=*I#6QGk6M2vPD!1hmj-`pv$~n|y(fEgk(xFMz2!WEd<8%j(|z z&VasO24csvVAvg84oLLlObGSQbf&~fuppkIt|Dx(dJkoj#706p$bn_ObS2&)Pmp$G z!&OQu*%=FCDuwF+>4jJ+ot6EkIQ89#di3i1Tco+3RJwq?`hE`S*Fc|v8V@6%<6sCiX% z*de+p-=f~)B2}m)A(F5YnpIrZAxxMMDH!C%h~fmekOBjU^Z`M(Ec?W;DwX{v|4$qw#_DMw4}F?NRn!Wk_s&H@QFO413{ zqyDIFkC_kJW2Vf@emCDau;(QwAxFUf>`+xG;dnZSRH)X7g?oS@Ji~-=zN``H zmq335d3@V+jZYx~^97F_*a9xdG)>-=|<3(AaA~U zER_}u>Gu>*wPPGf`Nxv_4L071s@pm>T7D0o&kOm=NAKnS23eSSAJUF5Xy%=AcHXn^$cK`u-_jfQ?s0X!sZ zZGAl+A`$#zF}D&4{d^uCM}12O`6*?@FDd2d$biqq{K}TchOdYv)DTRTj?cXIjJYu zO*~2Xd2#BShOO9E!iVs7#X|oJ0iKB5i$uUBX^7(&zDZhPNcWZ5Ut>A^T!F4x2V5w`&bbWus1OPNn@92T@~Zcq+q?an&?&_EpC}bER@LLZLomD zo(Fpa>E^UA0C5V+kDXw5T5n+pL^r$%keK9FqQVRQb4shV(V`Qb2yDq3Un8V+bbQbk z*oK&gxO7#4Wm}|`=$knCnXoM+u!#im)6ePfcY2oquWCFZyM9OXe3B2F4_`ZyZ@g<_ zfs5vZWB($jzto{0JAHzbhMr@-yyR>Y-cF-+-J|R%HZRT?|RW`?SX=*?xQ6C~?=y~vQWO_eLW0((OW?@J(VnRY$zJzI( z`PHN?msQSj$}ua_G|%3I^yi=-KwiK9KAKdll%&!VB#-UyIph9@7VA{|pExZ(wtWcz zV!RS&*TXy2iRlI93Icpx>jd*U&b~R{K0o3_1N+4J&tUu6Irh1yeLh8o{90u^tG+Bc zy&^V7l)dpXUmOslS$dlY3uEaD^ambb;Z&eU1Ml4`fyuk>%kvFNk5$NykGnj1G!+4fwEbJ%c8q zycHIP{rgw&{v0|Mt`vdJ8c5u>#0qGsOLbV8uKZs3LI5Z;u2X zd`iVblzd0!R`ywyb|STES2h8%7av4B+p(}v8ZL3#Q9as+H-9{e^s}J9f)4iwe0CS^ zk3lZD@XXmYjt)XCABd%;+`mjvUP}ZpghN~c5f~Qxqdf)grA~fwV!zGLad`HBl%GlP z2$^GN>ir}0G1Yy41K#DWUtdA`9nc5O-oNFBfRYl&_|O9HvlZimUBBX^ke{I(SXD{c`Bn_wcf+4^ zo$&%uDzM;#jNFv9+D|oj9N4#r;yvI?Tig5|l59H-%7SiW~cK2}BB^SKx zj90DTE9{0PQ^(swu5D0KM^@S>lantBDC>=73|q=v;n%U7G-D87Wmp-sP1uU}q43hN zB8c>pqJ}wT1*zbn6uw^o+=uR@kVeQ<+22M1EFzu$3j+@N%@t004j`{Nc4-tPJpuU- z=v0t5{$4iSr)_6^T4__)1N1?ADUk&f8*=I|Yy{v|*%jFWhBdEe&hLfqO z?o~g{1Ow{GPU(H%+bP7GBDhoJzA3`?neG(ko8lB@8-u;6nTkEOnqoAn^a0iPpepWE zgHN*DM<9L<{)OdkW%O>w4{H1gECzx`5b)ky!xf%H7-SwHu+~YXr&HS!2mTCz>BoX7 zC~{%Jn+ul=*qg9ZWNEK-_?Jj)YM)g|p9MM_C~V)nc@%Z1p0{w(g6c{0W}mgF+O^3YF&J#DqmT1nOjsjXQ+$4;Y<~ ziicvO<2f-MppI%+eH0%hvGjc1^!B_`4$&vkdkH-UlI=lkv^tX2h^Q1gRjhEzc@Smr zRcq_DPJ9=L zQtOS+n6g35h0C4PXpaW&P&dF4a_A?} zxs|{&l1c_B2jtb;#YjI4dc*Vc;z#Z?#vRw{daJ6bL6??od_ZmO0yr8%Z}XNDd}E!X zig_4!77F!-sccotXm{9KaNVTcfEpobtQyo~An1Gi?f(`(EL7*%xgbea> zf(aa6>Tv{Q4@-?<6ehm`OxgBqJxP6u0y$-rG|s?QWH}-Gn%wA=hP4h~dnB9s!#bpI z1lgwqeFjw27Nh>}-d*SG+*>h^= z%aLb12AbK}^RG85cM88fuC#wsj8qPw4Z$tSAKJ$Jx3TcEYFk8Px($H{Z7BeNic3i@ zIvJ49Le$T~qAl>$M#>D=t#kR2nLO;zJP+y1LCZj1JG%+#2SI~>b^1#RJ+hsZA9Xu} zKx0wW%xc-zP_iB5IcjU*Zz8aZ+d+iX!>>24Ql*hH4I^+UgN9Cs$EelSCxR%7oHzjd z<)?XQ5@-Z=@T1{BZOBkQBS_4P4h{`^`5c&r{@Jp+Bd zk-iI*RUfjvj4NAs8Tuf7n9wEImO6d%6Tkq=S^6X_K03ik;y5}0i|XU(sd9Iy?7!B@ z|6=6L%m3X-KLL6M11pgABfpW`8TU5xvsk2~$*sN{G@ z&V$J3@bS{H7n{==*q08*zI46u5>a+&ximsYse@%6=VJ>!RF02MruMt}Jb*lS{CqMG z>o$-tziGUh(k`i71A3^?8Lt{XciO}L@11pyTSTQU?BEezcK&Mn(J}DbS@_wmW5@9A zX$x9q$q3ojSWHKQ#<%9vUJJG*Vy(!N_xbVFwTl+aYwb8J_g9A{45-J2t;yOf$01FA zeCUGtb1SRQu5Ois$F%A>a|X4$LPOHRP7dTs=LIgyTw{ua(0W-T_U<$@LhuK7T@LZ95Dr(?9eU|+Kt!;-)fx@ z5CIYVv$3 z^DSi}B@yIwKMH9^;s@70{|)XMJ>hfWuvA0VpdA0wznF@>Lb z^pTBoOtLVwU?Po$(2053Tl6{nw#smpH{Br!vNAq^|jnw!Gp{BuoYZii3ix- zFXjFevt^e4#(Zxw@hkMvr8M+2q(I~W9)E;qzy~&TgZP+i61^x_$3Dr2J;f97Ki8k+ z{3%3u<3I7_&zz@Z`ub3^6V|0;Uc>+MV1blrMAV!=iaY5oq8EJYjLWHcxzssqpys#m zngL{h^r?J{#oxyqT*=4>%%qhcGyNl0N%Z%b{x0LYS#l54cQd|=8E-NkAB*iN`HID9 zWp_kF7i%f4Q>|Z_|0Ol_FKX7Y_J5(i$E6Fl8hDKIXJ|NuJfND})F}_B!yZ(7JfQLi z)kHK4u@J)IUUHABZ&P!DguwVbR?c%V&~W3#8&v&fRgdSVMYB2qy^lPBY8-t%Y`rn* z{z#{MMa4l{_Bj<_&?yhm=*KXnBG0QCFR1yHyr?Eob=x8GQTYmLb~L*HlvbV8)2yKM zMD=*p9AQ?VV!4H`F>3kL)n;WC;X(Zx{Cd@P7Mmil#cqKbEp49^6H8A6BeD?TJN}&(*2f*xW?{@6gZhcWNM|IC1f~TOssx4|K~tB* z5T|m9-Wx!pA?RE$jI}=>a{x^tnczsg0B%D z5i^9&6EIRA77>I6zehyw712m9X#7Q-j=fcwFRJD%^2N7{$X%lHb}{S@q2Dg}R)H7a zhtG_8P?&G2fp_F*{vzU!i(!b+NS?-jt|pH{3@-R~`Oz2UM_&;pI`H@Mqpyp|n_}$i zV%Qr(e_in1Vk}y@NYKiaJm*Vm%HJ*irO9Vo%S|&TmdII73aBq@i^W?9EZ$%V8Kr%& zg8w3pW&TgnIV^z3=ffgzdm2tt(I=q7(VMBzy*Z14oM!*<%1vSDzx-oJ@~DL}1JC z1W}J;v&5|=vn~E}J7vAZbU4c{AWy1vG+-=mCh9Vhypd!~&$^J#%)N?|?ghmXoqDKR zonK6jhrv~8VKE&Eu_oU42`M{?RCGC~V<{UYP9tN;KGOaYQgleZb#!5R@rh~;GzTZ5 zG{>_6>R4nJZt9ivX+q9tYXDu&=JuEj(fkm4fOMZi+S&hn90EsnBCJC3HJDzUj=M?f zSR0!h#W+Q-8Sv%^GCfNAI+BU%boLq%aURnH+KJ|LG7Mp!Ab{mLI#i`e9yU|r(bMA}lh`VfGAvpMPh)>189psB zJ#`aFzmuj-N+vV9X2mn7ocwQnf*@h z`wXjy^slU_?5{(nJaORk0cQ`652_&h)KBN#aT1VQ*{ZhA!bma6LWj!*Ai9#*KUI`f7RS+ZKqEzg3__-T- z@Ye0wC0HASx`MoXE=T$r&|p^{GF@@j!^e=%a~905tsXpo+9J6W^VlkJH8k6N@nUhK zE$MmIuiS#rvvCAPDaFR9XPy}=rRA6dMnVhX{FT^Oc@724fP7}cWN0d`CA1ShJ;hEz z`^R|p$-z?EJ!&#o@XDR;pXB%l#rZsXGohcU_+WQdB-Rji8u^)$J_OM1_K~yC;ZLHp zsU9aHT@6|X^5h%4k^T_$14tgb_d4xv+dk)NKg22I>ZvETUcS+?o^Ae396Jb`%LaNQ z{zwC00%Yu`^~MsqOZe@Dr@mW^-zCy^i$t@fD3+?2Ot369PKHj#1Nw3ssg-Y0Zyi(Rg^ECSZ937#iMR)(9+%@%4 R4ou&?G;4 z6X`EN{{(sbU0nuQG^i0I5A}10ub#Zoe%S1Eee31X&GnwMrCEJ-Yc?V_{F~H+Ea$c! zB*i{=egd224aPdE73lkkmajiTl$S|xR{D6nI$Y=o*2+a>GVG9%yeqJOjK1`{2 z&#BjOz_B>T}t_Stcyo@%GhZT1B2)cBfzH7tZT7>M^%0DUERp2w5oZGx)2 zizs)IT-t^Df&Qb?+`BCJucRa%wx}2cHCVPt7agy)#MM^bdJAA@De;@|pWgO=@2jzjhdN>d1RiKR^dEEc3)4#lV z(|*|Ow7d0wF47isLQT~?DW|!PK~R$i$^aDt;(LQ}8$~zSLzJc1DRnhrNI+6x8v__Q zIH_R20c#nlITV?!z5HJqz!8c}G0cqfEZFM%NPoNG_mh!BJHz3`(i26=Jii|fhZ#Ke zFcUGR`{jS>DGoF5r}RmHR!KJACI>#e-#tEY_@#Gl;#V)E$AFFpdHlNKIzu@R_m_a= z@!$CMMeF%Bx9Z%}ZEx0rKn#=mv5MTd1|s7bqXoT+HJP7FUd9z*i@5 zTB;m9rX0SI^d9Zh@<(;VK zRza@KUI>YK^#bWZJE8X6Ij(j%uikjUTy2ng;{$46=Gf<3ooD^+n=8WhWw)>|R}uTt z#Xj$JZvMzt!@72Zv6Jz3;w0WyJVbcDI0M7^KN;HFV{#0-iU80_&e?a!(=6OPNHC56 zjqDNq_ljRs`;yR#^?lmKTEQjSJBIyawb&!{y`qbKF1OG7iSimewU;37CHHq|7YUka z5T#Je5Rmj^e~)QDjin=uUOM#|CNzHNzM-W$mIo>Et8_jol;nmvPQV@~2q~A-y+hw> zwxjmHG#=tA!W*awgb@~DM28Kao9ObHrb6B$pCBM7UPq!UG}B-y@vEWnZC_?|p=R;7 zP#j`@LICZH*fM)2S3@eojlgTm^cgVAfSYBuKny}$bA|CBwMr}+C=C@#bVbMlb>E3t zE?%MP>(ydf7-k_o$RLv2u7-9}{Y~sI;LVvUvP?u=BA-I&g3;&zZhZhIR8x~!zXxf0 zBqtnaagr7(%!O3L{tWnsIkf0Kp#y2oUCg{A^bMwZjcM8E_y*H|4bV${a{s&Dv^JV) zBt4rv=6wHA`TZw_{)`Blh$DlMovVFlv^>c37){GDe?w%x@eu)} zTEPZwfiGdr$~%dQ518)@Cc|Z=(O$ZRC8Rn7@hDH@ejk2wQmwu)kfmpGL{Wre-*~;i z4`a>3kC?h1#unuxjlNv#Jd_QgD^>r4ggrotf2QIm%9i54jKK7bha=Ktyg<$Hc(b4A z4+D8nznSWv61}6%ogfJpHIJkMl1B+$3ybKNNT3igY{4ePogIN9cha)} zPGF(-_Q2$yT7}VaiuF+&*0!Lm@CK@nuNAxOf?E%ofqSxZHPr! zJJ{CcqMJqwNi38I@&+CX27l1>M;TkgVplP?ilzNk=yf9TG-Z#{*biL4i^ma!5FT;{ zoDB^W#INV;Q5|7Ro`OxOUIsC2HkSV!_Nfqfr^s1CitKLk0kA0nAzcu*r8xuwTl+o< z>jkV=%1A$m@#F`R^k)JiI{Ar*WQeE)415M+4SpE`nA1qe_S%vObPMo0Xeqwe7Oo}E z!vgx2E9d*nQr`f;u6Rr??N2d$zIsYa{l3s-U>VZ~So9C^RU|eoQHM}Y^bi9=DE7dU z#RaTN45e!*y^gh6MG9Auc9Yw$QHx$8C122Dlwl{3Ol)L^lS~_-!FtGEeFCl^9zG*( zuTE3{$%)vnlD@M0^?&nbxJ zpHvn!ZizgO`y06m(j@rg^!fe^NOKv=k(dnxW3T{OxPb)qaT6 zR#!jW=cHEekLFa*s2wnGCQx8nu^3udyI^*WXE%|3kbNM&V&oueRPD3et$9rkCv!zTHwIrtTJ<@R^llmw?D3Vn1lN zOXThrx-G20N7^<^Lws>JK8j0&{OJq#2y3rMSH3jLpgxyd(|lS10j+@PdNw`&bG_}C zFjZBrHjK9E|Io?TnmyYb)O;I_(6vTjqmj7QNJ08mqJOW&2BqIle0Pu#45jWO*~24; zbZ;*DKGg1CdYqOX(xXosh`*$7B@v+H-%GLw>mM1p9~=62gx^a-z!>;SD{yY_BB478 z{}O?7Z-I3P{*MQPAyiHlS>#g0+r}7wiC%_IofvI0-yYps0~cf=QTL@xeXF?&W)vqJkIp#QeEwNGqMgH_ae|oaq z$X5n^>Up6qQT+Bw0L>ATM4zFrF&i-_r1+(VelMF)5yq+f32FEQvV%~}Mkvi0q{`n(?5t_Ppj z6Wev`Wj%SjQ?obgX)^R!N)kFS^mn+gS)V7Ul{z72Q>{AgT&Jw4}QDL*> zIGB$!@XZmbCW75v0-^JgdcHQ+E_+4L;pbBDDaYl;Hl(+MUIuylid)pM@&oRV!4JU0 zEtx0-97x2uFCC#tp>#mrU~HqxYi2Hx0T_Yu12Y$+Jw;Rp25Kf|U}mYo1W#pVs^c2o zAVD|MXfe<{3gxU<7jp7HYhY7*ScCL!pud8=_K-0M`%q9nkUVy{_z4H!Ugnni$jfv$ zK5{FDGDw|mRDH{2fMnX%T5<))5hjmcZ z!s-$87Q#Lj@atz+qpglsc344-Y1kSG5rhy;QBs_IY;Ts2pOB6XNh;|ek56s>98fB8 zU-z2R-aMR~R`AJ|8w^-fJHOH~Z{A9J-YpHOZl?C-6#EPqQQ4V*WqHVwj+S|FtQrY( z2Dpe|LCOskDb)zc;YS#N^{+7Ekvx7XiAj9^tyEo%5Y$K^c!^V_@4;hBt`%3hx`qQl^xOTbF3laUk$I? z_N%!6DjqgV5zPXA09uIq5nl|u9&AV{&+Z7-Gr~R?P$wASFhY^11yypRY5(9Ra|jk0 z*7H_qiQmY##_CqgNTgap%YtjB;nM=ng5wzt3l2p3^g|1fMQ@UrpZFF)DB$1+^F-Jz zo<`1rfNKL2S==Yi=Ev#P6jn@+z}JpI@#DwuY@*PX!|Bgn`9st5IrW7sGXRhm+k;jM z`GYBu-q@RSxeu_F30Oy9IpdG^P4P!}(L{b~O$5NgLDY|Z)MYyDd&BUi`R+NSKLZ6v zG_~)WC#NfmaDNF%9wn~beIM8U)PC6PRMT)2{t6SSX1LNQ*wx-hUzM7XuzkJ(*0qOk zF2PL@ZS_*wOvghxaHhS;&9~lzZU;ToJ&T9`Z-9QT3DH*Z#ymu*(Hn7KIlV z=LtjsNVmckm>&d00acHq@KKDz(^|Qdz*s5{MVz08wKE~@$p>#edM0QS$Qw^i8m!F^mIYh#U%^ zgo;$86l?>=04(m~5Nu?yWClERI7@5{sMiMA#z5M&5ZiH@spdu#Jdw)oK|Bb`?X#0+ z&r<#zWq$#-H^zJkLN7^^IzdR6;M!o~oC5S6nsWtB{Q?WgT$ueQjbt4H;TUOpUy+ZA zKrS%%YtFiIrHX)=WC;0#<8yfuLZJFosB`(_uxBDdEwE+>;NcfY^Irt_LtuJA& zeRys#l>;p+s>LoNDfa9T@LL&H(9|qHurmJ4RYckW|Jph2p>RyodgCOdF9fXxdF>%K z7WVX@?jU*Wer1_`_101LO|#Set;c(H_$ztqYIhqDjn+0Dc^y6i0Km#^cJ6(nz0P_7 zwqejqr;7esp6 z^8R|ER^>~<_PvWA1&~KYu~Q%0z-Mp$cMxgcxTKN=^6F#h@N{Js?#~0siI3*l}Y zkfRf$PWw+nwW(K^6MUrot%1^1%7+u^CqMp06hZo#^;}rj!%GI*R6-? z?tZ2f<8yO+pPALQj+;(B;OtjZQcv$?_65j_ma@j!&6TALC{$TvXm(}?%q#}uuu>l= z1{!f-eiTUD3|e61m{=Ado*K;0B{Y}n$AAKX7%z~JK2n=PUj)&wyl}@XgC&Oh{&6XA*6`Q0j!xGXz0PgkyQ_hC6f|I^5 zB}%1H#OBGR894!}<*I-UQZc-y!NdtsfHA3svpCh-@wNd>bxEjaw9Zy1s4c11lS{u( zxdIvkQ4=7l~Sm!N~9>=9X|gE{=pp3KKe{b zDuY17K^~uHAT5tY?)AT|Z&F%s2hp6*)8@=y2-n`ZRkhP*RXZW8V80X%)dpp2Ttk#M zSq4_|kkC?61pRM&tte2S30_1ygk;rEj82f>yWIyqT)uxiSMj!&cHI_dOsEp(BXZRH<6g(gD%G*!*OCje1@_z#;_ z?^F03Y~5I8Fp4d*{0lg+TuYHXS2n5X5WJU>4nQ#N z29r0);rwI}jOO4@jybyoz(?uydQFaMHxmGLqYTc`*xPAW<&&H2YSn&zWh(BcgS>Gp z9nXqDcRb+mweeQx+KO>ZVnjJMzp(dRV_c~G0waSHiL~m)x}cBq{;had$jOrOKGL6p z{@(2Ug@r-oJKU?=j_LhF7S~E~+x&U5m6lhO>Bz{*$|`8EyUUCe(h@g64Q_s>p3vks zdIi$gfbIf$?e1fw{{ae~=(M|8{hfJs_)AC5w>gdVcB%TbzTJ&qgaIhKPW_Dk%CKTQ z^H&Ha*BE#3*F|UB&4Rrl&WE$b8zQD0f}dtbFVRLgsvt)Rt?FO-yNKG z#*Z_Cxt7E*ZdW+NWIPIjU_NLA^*bQqtVEZRQrKyb*wW?Jun#SApNZ$&&9pv^4^*f|vC!?>VI`#fx_{jRN zcgxp`e$u>L!24M!9dj2>oIhZ~L=^BKd0735kb|V7jKI0ZSjm-_P|$Xw9ZbSC8uVES z=wMQKMiEr_2!(}B6twM-+my&5O0{jv4ab}zn|2JC0GNb)z9W19WWnVCB0~zjEyZf* zV^WGhD%cyy90;Z$$B&gn1)khlbPgRyyIU!!x5hyZe-@+c3AZxuM*3mU;~i>?uDMRfWoinG*3NwD|3{O zwd!?%yL6yZ41`2(oYGlO#`U*6A;4Vm0 zo$#q_x=^ds4uLTIcP6RB5SS(yR$@cMl3Gd-avh$GB_V0m{KTj6K{~Wg*br%mE(UWF;h%#;^(kGPSf9 z=}RYL^6!n-mjQ_U?XI2v6DmCqcrXAc+syb{s?U)_mvii|bn5Q_>Smn#EOiRjoS+hr zSAX(f&*8o$jzcTz523AXA7Ojpt(ITPA^*5V3P$cDXl?s7WgVn!rGTAuDxw$;{-^>} zQnnZf2v1DuaU;g!MHEp*kSTZ=G{rNOlc zcyGZXDF@Wbv?ghrEpfv69S#FZD89^tyLa~ts+;>KD%`}moEjP)X zeYrjXG{SUCFH|$eSMUiL<>>ySm?mAbWW;Ws-Zx$x*g7nO*r#kn&N>$T5{l~df>45uAAorjx_Xn+d?+AeI&8r10 z=^CR-SrITH)e8t6Qx2qie%KAkW#;itxi{lo-uU`D(w~C91bOZ5zOCs>#py|9EJz;z zEw0JI*6#}!&RH~bHsD94^UHVa38^`UU=wG!z{nNVK2_NY^+rJC3)61~;0_6?Wgq}i z!9B#>Mt~P9y7Qn2K20Kx1mh_%@c>DO%I)qCqE!iCW=lN50Nh=MM`?aZkCfgh_i$;G zb~@b;C5D_?S3m_v=14R+XGEHMg2R_pc}??X1Im6I=uS}caqB+Zw_NW+LOT#ckWlKO zG+qYZ+5U<$3~m>V?(gc;ntrzz(j!4*LGJIWL;o67rr`b-cOLsz9JyZEaWv}{C&Y&b zJb)m!d+6OVh^@S_dlmw5Zy zt;pY_peI3&^iZK+AuW%V?Ukm676(b_1`?ESe26OBU{4jHX&7>&dKh94G)pXb|7}1J zX{;|-K6&W7eo~IzTru{bGWn#s0 zv%l~4XZPn0UlzB1{Pg4#18kfoN34NjZzCx85(@a@dgCLe>_fSEKAcB%KaE*xJcgDj z-MFAcL*d{J8HU|(Qi8^&8>kJcTPdKk$?+KfI%5$Xqkvz+cd^bo)#2Y(@WmVd4j}y< z=x30}zu_|+{?)qI|Hi)+N6$ZLaE#|FS4h^CHL>nIavzbnk~YsI-x^PI_;vvN@c1@jCUjY#6G0x|UieX0F2wy(kUV~L z_gOujbK0N%5T}D~ziv2szD=&0(<;A+c}`q$`EeT^u;xq%PC8&)R&P8C9~1~fSP873 z!A@RgLNW#q14zhB05Bqmhi30Xs$k)lK)h7!FbW`o1Pkh>I(#^Y@_FOKsk4C94ypxt zd^m)(JlfB8t}Vw2`=8BDiAk--?J8_P51wI8v5Gp(lh$Ufm?gblJA0ukRJ3^l15Px$ zXV8g4FF8Zj9iHzza|X_NK5MgeEEI7Da5a>Vp|z8M6Eb)?5A`xZ~5D zdfSZp@Y?Tgq(16BHIQ8a@m)^BmmD$Pq*83x$Gk?MKf%6y5n_jg* zE&|HiVBVZmW-8Yj67sF70efG;7-kMKD@d96^v04QSe@XF~l6u9#E!`j)F9)+|rs;AQYLH8!Y{wQrzkD z({zkw7#UT6OiKuu?tf$jKL(0AO$n^@8JEFG!HVwp?rGcTJC?D>3hh1oClY+Hn2yHm)(Of_MR|?FSNsH+8pAglI?sS% zsLT`yL=}zL_t99wNDToj2LDT=@FDPL+3D;emF0+hNc*u-*(p~X&3HIroF~sM>R`9d3DCF9skYl_YsSXf`E%xY*< zO=hOZWX?qvn)l@Z3MW80q{agniDbM@%p4{fU4>a>!YXJF7BXxfv@%epMC9`xdLe~* z0C4^D5yuutlaz-!X_EqrBTYVuqsC1b0b|Rjr6E`^B;e0C=EF>|q<#7BDKJy^vC*!8O9>;5yH`ltt=J-znJNPh(S4CK{E&A)<5)_jZ) zAp3aAnVARUZ(GLfkC}iY;=W=-P}T^MjJ92%JG}re29@hApG&a&%j^ioz|; zO!%^B)B>wKO7eERP%pybWD8MWBGO{3Cwhg7VgC+yV^KHP;lpB-&*Q^=NIwU95#;S_ zp7=^ApWt3;QT|qpgN5yX)9wZ%7M?8S0u*qxTS(n5;Gd{c$D~>&Xr}nk9{dS-T-XJQ z=xNCRAPPw#V7!l)g~8D{&&hv}nx_0OMtV7@9^~c!4y3n%{_pZ%iKtqOWg%vw06pA7 zM4)cE)>uZBn^A~d-DjdkjJ6;YLWLY7qD%W)IcELcosDOz8)Zb%{qvo2+PJRy{FJ6CZv(V{pJ#qE&EBo6d-2dOS*KySg zX3v+=Xlm-3k1KLa`5Cp-#>|2=9wrsy$runT45v^(0=g-*U3kS8zS?;`yb z=v$DN@7xta3Dp9trp3Hb{5j8@49d_LS1kEI{@# zb%bPqAU%fW@&~jZ7({tpVQQ_D|2pK&%l}tMr!9i~6y)`StPOX5iTir_Tl}QIlI?xPG@Z`@hAxEG72cIQ!h*K2MiE z0M{Bf>dJ%Q)F4P=jnA3*0_zBg(mqNNp|o`Ra>Uwc|K2KTNenWWYKsupANv_ zn*op9ez>MvWh%liWJttDjAT0ZL9Gq1;dm5y`zaw<{c*tG#e<-3GN`6L0!T_A%?iS` zSGuhNOgaM*VPo&`rdi$(9La z8PA1|HV(ZGdKGKu%LWQzK8-PRC!ku`8>-=6xi5#N;6G{1{6PNaebs1r4PYV(cJX%& z@F2oswl_+72VP2T(#z08<$!a*w$hdio=vmKBAUZ4!20JDHJhx198~t>7Z5iKJ0u^w z2sX^voN5Nlv=M9x@}BeE{sI0~xNQ0>(rF^A)D@Iqq{@zZI`v99#c(jQZ^L zXVW}WQwt^**%Rp#M0pCX(lQcQ1%lq*UOiQ!{N2c>HbY%)IMnJ_7W(hn$pT+f7!jB(iR zO1aRz!A+NH@-{mO{mJt_2D(?s{N}wW_dCi8& z-*oM3U40U8NhnO+r4C=Gp4X(e*ogFPpyxr}I1xQRsbqt?faKx(beVnqwgrxw{Sc@7 zk4Em+e4RUP!Tf2}3*}mVKY2-gSBB0meb?stNAxXGuutT@C8GNTe@n1^;x^z^So=h- z@&&4+5XMx9|0~OjzcFY^fWLy#*%?=hg7vUROI#zOfGM~}unhpxh{WKfkwAdq2&YqS z64oVx0ocRFK9j8f#f^yru*~&@p3er5VUR6^^mbB+{t*TD^PvAia1Vk&UKYvq%AK>s z;b#N*lyD!ukMvKVUqEg&0zjN@|<4|}wbRA|uUDgqdtfRlM zU!~>h$Bvn5>{pmntT%2D%9=n|*mc3;97gLw9pWI^bHReI4U6mfq4R($5|3gGW*Na^ z7HFGqau`^~`@4;EFM)*j3WOnozpm59t|00ttR0Ghqm_oaX&CQEyNH=i7p}O{;mc-} z*^_I&gY>7Mzk@uz$4^Mhqoq8v75%rl-IZ7NXs8sTM=Pj7|1|*+`A7E#7m@PFY&6g%&iV z19|26)5)e%iu;yw*jDtLf3r@*KFuuyHswS)$Pjo?;@-_bEJ<8&)U9y%yBP2Ejs~Q+ zf}RF>`PzpxxfmF*uKlEE|J;gv&7Nm3*z4$Zsy%wm`qhqsdO6WM(RT^I1273BtaVW; zCeTjpJN3MiDLdyDi+UgX|XTH>Y=(rhw8IBw4#8`!zQ2uRL!jh zLadzm#@z(}@^LHJSTus?DgA|dtpRp=Yn=RrE@{f&P^8C#rhw#pjr^fpSnuI}0Z1Mt z{{x!gX(`FMf}|`Z{0a%(IzvTn zI$^1j0wIJA<=W_$rxE$|`28c&x15)zYzBG!zImaguuFd@znvHbgR5$*U==%VAb8wE zvUoZocDvn8nYz~D?{K`!%a4p7J19*kLqYP8_5XY7ePR_nat0yrft?fkClRm5dGGdS z?=?3IB?d|Zd3?x3x(L(~KjLWG9Spo2I5)hyVV(2dip!eHdtC!pZc7!)9U$+$XPzvS zyK(<}-rJ;B#EY+X-n$#`^~SaM>y~ob<*+RUdF}WtSZ>e3{X&pDT8?k_!)B*_N7Ig* zuRCqIezSd5>5RFp5iIPFnTtB+m%w3UG&H-Hp^5iRx#fGvG_qT%NG3Tpg_SM${S82zHdi(jd-^$OY)hwF1=-eKi z%Vy3mojbkL+^TtHXLl-dQXL#|R`VI{|NC*s!p6^^G2^fhtl2R8|NS0(7MgvnsKc=8 z+NQ0qEz|j&yo;U0^#&ZY;B%pqTZn>)_1e5B)n;d(4FF4$N)|~WWeMClG7vPNo0PcN zpN0hjKJ6U)uXF0R9rzXUo`tD$5cl7@JjCPQ?AJFTzHPm9OT6Cs{s}9a{O$gV^xr@` zL0-K`KdcXM{~1Ug1uf*D_QPf;^Kk>LSOx2*xG@ zZcD_j$~re6`|(b19L--1SvqJ8$g77T^-1M8&?b;PYJYM1M}dB%+^pX1AIiI{?9a89 zg)JMdlV7>fL91;flb(=<)L%PR)pgbrgf&EZW;@>~-*|lRfc?QC5X*i@ zOFxC_f4s04>or?U6|~oDwCrWdCySVwz$b5J@+U9CCpWS3KQfr1=R%Q3$$I`hA9ZNyDXmMV)n0_0Q< zZ#60k_`3ul4|KECc#ZhVtVEy`k$cL6u}EoHPbrO_7tf3RA<;3Fr*}yc$X@4T|ykq~g==kSBKIQ@RyRZaG7YmxpVXfw!bZ;{(HdvBS`L?|!#|@3xI12H{F77OiZxB;U4ryF&_<9~-U+6rJd69? zAbH%s%PFs?r)pMIJGt+-_7O)l9ysNd;~Y$>q>P$OmzEI?1fjJz8C$6GBV2eXY;+K_ z$t3TS?w=C!87ch)HUV(^*iTB|CiERrx`*&I4CZrTN7fneqOc@DQ!=F`!4NbYODzQi zODT-OOEtKZ+sB{X`d{0`kF`kO0=f<4@#8zB&2>p76C{sA?mYaT>_znBe`8;J4E(5s zD?gaAl|1BVrGLgnHh|Zeay`oxp{zi@6$01+#G!41+6yH500})v(*BBw7u-MM5yBrP z5*sZN)B=E|qUmY!Gq;n_9VB=g31n*+JVZ9yhwQJH$j}Q35WIVAsbeFtxp)cTrFxRt z0k_u@TfQ13r$SycK?UF_rjmA&ANw~ueB1#3cy^+1A^ipDYmmoBiPx8ZHR70qA0(JvnZAMF5{6tRmgp*Kp!-Am|Bl8=D(dw~_AO(g(Eay`l0 zNV2oc3>^+-2qeCWbX-oX6{P%P!Y?7E=krX1*rUcGs5MGxfmR8Ol0u+K6q;$Wzr9M} zNij(62$axrphuT#St!T;Ee>C{qP!kozC~JHkNqsj<4g3*3?&!$9YOL?UvT*GpZePc z|0jI0B~NHz_2hH-JVW_fCY7(7j7ORBqsC4cu&Q|l-Gz42QQECV@a5UWElzTJE)T9+m$E zPxhtX+CLvddpIm{vqzeXi5zM^l>v)487r7lufpOio#kpF5n)CafXwr{n&EE4^M=tt zZW>J(ecE>AtVb@Gflv}aWfvu+Gs{%F(y*LV=As!?fFT`VU^UDx6iyX)x%@ymz4hta zNPh@A0FwI;9IA{n_ATx`eNIb%@q=!^Xze;?8VtZ|2hE?eXs+%0KlPN;Up&2no6p0S#8Ixp9Wdev zHPzE*SIv>wU{Vg>Kz2Ff$3yP;u~k(rgY+t#!bm#ssKU@U_t*R(p(j%PiT*_~m@k`Z zjA@Y=aFO6h1E}F(ai@}?#S46?42J4f6b%L%V+n+EJ%Ph{TM!JRmep3BZY!r3bSP{;Sw@>%P}HGn`gEX< zI;-rn>{a#(tg=nOv)`yH?_)h(2!q`mO9Rwjn6{HM71TL8of5rSTK5AFgzy+Ab`W$q zt07W)4nCGg`vcV3f~jXzYqEZ zx z6&BF`5X+Q&ksKTXSo6tHk7YWHyN#$7)LlFypA`={`L2`sa`WAQ^xdHQL2ka);m-@@ z8QkCD;vg(`*OTs7l&wqa?c{$fhuu)h?M^t;p2H`}DZIgWhbV_on0|}^0mV^+ zaX*oh2bB2jD4gT;66h2G3alt&=^&c08`^$1KMi=7$FJv*-V1sc$jFHUgxP(%yg%7YXN0wm+;1IZ4qDGT$UAkJ zt-PgWYOOpm{o$c6CWgklY%Rra_t*6VU>!3e03!u;xH`bt?9j~FL2nFvLg=PPha-IlxXW7GlR4+7wKVi0+k3k?qw?K_qB z5W+RaPwg&I?=7g4#YYs*XdbQ5l+HmXOtmjRRFPQ6{ z3C|1V)0HM)SyVm^XAj2Bp2l6+mUPB6zMk-T{1#DeG&8B~LwGyHG1uU`lsR@KXOvK@ zKMuTl5Kw4<(HnCF&I_T+VY8)_j)!p>cN7mSt7}EQm!du)2kXBfy&JF}Ap83`(#iv{ zH#xYBzZG@f2tm1I$~+i4Gmhj2p&1y?Y2su-6Aw{kJJ8RjSvbRn$V3D#q>We&XDbnk zNB6^gAUK&#cGiIlA@Sh_irA9819NbUIa_NBrdD*~i_ZK9j*{8fsOKEPTlsV-db;{k3)-fMUQxXAb0H<8z zhhkI7Mt;2GSIOEf+RyN_`#q$;27C{Y<2!48no$L5<8UF zpSsJ^8G8a9p$qP+M`7Bx9c!PXPk_F0xML!{g~HA+g!5>Oox(`^I}A(dS#+xE7Y5S3 z;nuGky_@?a#~Vh&!S2vr(O&z}4n0uW84wpcJcP9a&>!HmmvP^QH051D?8BnH#-Azr z?VZ=fUA9xX)4xd*Z{?4+_K4EIoX>2!y{2#{15N*bVFfIL9l}DhsoiiWeM#17 z0kr0_WMzXjn~s1%DZEj_q*fbfoDn42K&rX5{M|T3N$ow9JWGdAYdaWOGSb=&yF+aU zBnOKGY<0YT8#r1xzM)3Pggw;SO-p_wJ$}VOJZk?&$ae%^{2t`ieJ8%r7W}K5daDS$ zUbC;q>0s9lsKo3$i3et?iCC6wwFM2vfPs(GIwF=hZHKi#URw!oc{A=H`bwAuTUYDX zBwv*B8?k;RqjykOtV=6+3~Fg=Z=&RW^gp6~fksfz4OH7B@hs7wp$VI)^%x!X76eHj zO`ffmXJH*l{(xRh?GNz{dm;0>fI3~Re?n6~q}B(t7%scZ;nX{!;2U}swZFyj>|sC9 z^lz#44gN>n{@6p*`zcXJS0H-f|1?BK_MHQ&baf0${gNX179rYbmAMXYX|SMp7A+w?SSUi`8f~X} zmJs(KkH>uj@mvi@-I)Lc4aBkJ(dOB{WyDJY6Z|cMT1-nso40(UybI%&kl0>vsj*gE zaqJlhHgj1uzwNA!cosH=a2d z!+u5pG>y5JYwh<3Ob8cV0Nj7UBYOtHbTfc>d~0}b^)OF4;|A!h30FywwTt>bYL|B?`W)?ybu&_|3H%3qTSUZE(F^XdTtif{* z!rYUuw4R@6-lN*@i0Z%N? zOe>X*NP_{KN{+!k2%dwAnnCJQZW45AbdCih~Z~^u`DjO|4W zr;naBe?i5F@(OrYEoQrROaOlX=KSkb?5-C% zue8fOW5LWnp;73)OU8nQM(-&!t5&<)+@+CP?Ihv1&&2J3%5z_c+^3Z1wu{_&o*BbG zQ{b_&R=Y|3|8D%B4aOOWhrLoYo+bJZhS9RKCqJa`Qw^8COoO+dTgdOKd81YV?`u=^ zHgHgtj}3(sY>m5=9qJewbC|@#_f4kedYw?!?p#~|A2gP~pH*}P9NwPsh&mkHVk&b+ z;>y)rcd0&F4mV95q2jQ#WDAQ9nDT7eQF{sgmJo$F9**7yP#fWSdlNG$kU+knEsvU> z&&<&GG(C{u%EzIpVO~z3aQ-wE8;jTcOkM9`ErePXeu*q?7JjD?ip*i~Y-uO!Zl=&2 z=}&Tj)KPL)(%Grrbe@i(sTpa*%tN%jo@nM+*1f9rASkW}e$aesuSek;GO(XmM&=S~ z7_86QPxM1@@af(|T71MNE@Spquvn?_q%)^_*+_3QLBBxy+Aylt3Ylh3~^&UaU(sw-Ti#N%syFX zdHecua8L5yO9F#vUh6(t1x7o&mz^A&*rJ`Cu8KiYn2J~X3(M7P%g&) zvZYp0(Y;nnoC^(^`=BbvIbekAxrO*l)9GBCw6hQ?&;k>ogBZ8q3x54qf-coz{>k;e z!;{cY0?q=+_5Rf^9%Ux-3jpkr`jJ!jhf19F-g-jJ-)79uGoUHCbausvd1o$IFn>YQ z)6}7O(-$n@GSmX_Qd`KoLP(C?jM_Y47ho_~YKSd&D|SWVzaXZkM?E4>(WGa=>GemI zeUKmc+0z+tMlc@RPHh#X0KtiSKvYW7D+wHRiS{^(dWKNXe8|{IPo*hy0Z#!W-Fjgw z^Z-x8{ujWm{r?hl>-f&bbW3sQ*7uHlSlWELb;iQci_Qld%eWnohI;L5_xl>D*Y;6< zJ5KoRZI$1i7P)=m_LR7#@PFMTGMPMci}-TZ`9&>RDIR@;G2kg!)&2lGM8S2Lb zTiHogio@z;t)^$MWgfBCQR@kk$tQl&5fw6NDJ3iP5e_MFGg4BiGg49rw=*$T$8XeD z{N=c>)a)-+`(xFLOXS4mY!H_KJfd*o5=+y9LnLuY3k1nWnnPSZ)zX878f;PGV+B$9 zQggit!eJvqN{%2V>5P=b&@u6;t2KdHT(y=8A)?VKP}j-amv0+8d*N($>ZbWp z%pblXPxZm?=Pu#*a~;_XXTA^E%QPJA>!h8Dkrd-Y06=t4qLzReQ{YXA%ZBp@muDsk zdf`!*9sgi#{?7!XtS`nPS`LpDWtesev=(#BE4zxw75rY0_6YHmq6BIX z6BCdDkp0*V>A?Vb&a1ioC~GOm9^0F#C;#Rqx)0cEsIn2N!p_hIb;7=OZ@A|28(FMJ znId^CgbX&5Zgf`RXNfjma-#~)VhX%h0Vj#~UZf8I>Hre&Ulx0m!^lg#r5-uj_;c`P zj3bbaEk6(ESs$=3Q+7hRJcdB9ITK<4ZuJfxp=ZN*lpsA3Fc~26XcaJ(Imi!vNsP1R z;vv-YY&{(STT!f$u|mYm8qp$uMm9*99(eEwbMb3hrn($gjaCYYXaX{z$N4qehbp~0M-IzJFz$5TnxZQr-t|{G- z?*))_#6eLRh-d7Y^^EA(ru;0TzZ)LSI7L>4J{JueE@uO}C<)qYYxme%nA*a+QNoksgQ_pG3 z|I2$-bK41_i4l>Q^<+RPV}5xsy0GXIal})pbnqLGazoCKUxV~bfLj4_yf44gR8}J| z`|tlb-+bzNBzj=<+}Tr^rc?xKb_0*6i83P-$K1QomTWt9Ah-%1nwytiWQz8-c82kp zi}WRcD*>{-R?ab%n~}dAz%D5_NP5N}MttP`(Qfrrd>xiJymYq99m+g({K=GV{FTev zk}q>{uvm(rL;B^Bf5gIM1DWj4(5!dreHitT zySMo42oy!vKJsxF9`aEQuq1=po6+rg?WIX%6 z#`lMIKE3isUoxc}2viZJARU~OiwI#6s8l<6)T7LhgIaPeY~uh00Ex$3q^kjUIOieu zm_j}}Jv`2sHZ9E9)sw?w>+m3s+tYeDI(Z+xwBG(+XRc)P!F;+Xe1f>KKMr=8LfCeZ zOI#}m$0YUb}?HK4xs$MlzX}dH5)Pn>950)`D*9;C-APUcZV0DKLDHokoBI7^g@8F zcQg3GKTO*Hk2hS5v4Gv=Zel3>iz-V=CJne?`9!;dN9-RY^@;YV1+KC^zC`*zfZqYKK2LsbDDK_i`ZS{-osrxwchI7_a|Hpy zF@uH7_BREdRyWyO2>g3yVjE$Qn#ypUrS)y-r&RO8|D2?h*6u-p|CHKg3716Bp~K6W_Wbx?Z9If2Q+oa=db7s`DytOr6TZel__8 zrYYq*4%lAu&2O%Jr5Rh)wfbfi0o2w}e%mK*b>g;z@P{e&=qas*S|WCY z7V{v?s520A0*;n&=p$cqRl$F}c~bR$ttP|9u0KTMfQF5V0_@TSHWO(hTZMtLcqtNp+tEpL<58j&F6Q!vcCYkSwh&9qvwjPfh$)O;dyW z3DgZ~h*0Cbl|Vxa{@3ip)eThj$xyPNpskp z4!{7SJ?L2sA{fRo0#3K<$Y7r)h^Ou74i$+Q(iNocD*7COfvViF86)V#y+ngu@YQsL zIoZ_%I+8)Oj7%c!jh_h3iS3hcxjGTp`{Mf~^r5ZXkhDuu-XnAg^>=c`Gc5=L;I#%t zP9kLnj)Am>59}J%u=Jybm1yR=Otk}Q*B9D>VXBMdczU`6o+HFP8S873n14n4!t{jg zSIkGg0wCw#U)Zl{7-kWBHk;U1%@I_OZ`EL4R3?k^YfzS4-`PIRcgX()aIVu?-y6zp zVy|Ws_G&(+4!>UQlwbBz*bm-qNY?^}R^>Et7w?j`;K;1Ue?%4W@DoGs5x@3EDAwN<96FB0#Kd4T4&) zjgjSV6Vt}>dBM;y68sU1eTQK(3FaM$aIX)tI;fLqKka^^KMA$G8?Uv4Ngs>ucs=KU zi_%5Ej7NP#PNl9x`gXwG068wIk=_cB@~QL(*BtW0XtJ;deT^Js)}Zs5HRvY$eTv<@ zOuCi3yJR>^ao0 z+=~1f0K1ldxs2bp9uaq*!AD?p6;IZjMn4u$S?DY?Cr}yQV`KT}EM}p($$mn`9wFRq z*^3F>#_Gg!1#K6$o)J|CZsliLsLF#xn@(4QVPN%YMHdqmd3qMUT^17cvtAC@uN%^X z03`rfzge%qh8*xKfL)87xX2w|Y&=f$ocbL&jrwt0C|C_(pT3sv_4065KWMuUkav^4 z+cYz*>)pyWwUhBW!IlRL=9)@d7#XnTh4VrRC-AhL(G`9@z0?b<zItT;?3Jd94TkDAT@tDBi> zmL+!XGt`iHhHZ-!4}XR&u_RFjPI)Bo|Jrb^?6F+%9H?c&UW9+w?T4KZEVNiDZAZO!C@GwT6Q}K;a_!U@{F|RYr32CM6*4e-sLmsBu8-%WhL9?Rl zHepwRUc)-jPK>YO1L5(-^mUgZe-%K^_rIX8YoK$DbatDdtgFGHB(AJel(HOAel5z% z<|7w`YnrZnjQml6bJ4lYwMRk_dZ%EF&d$nf2c zyJ{VLj{=V#{0V5(Yv7pyX#m+j1@GHROXQb7FX&mb_+$qkVN_8`rofHBM*B)-MmkKK zp{yJgVW(TvUi4EX$_P1lJc{%_z$*X;5A9z^4dqkh<@t&Zr`YM1IP~XF*9ix&!3#NN z*nNDe!9*AKtg|)~e8ODU4Q$_`N_i&DqaBhuQ2373{mIx*f%&sP$=@ENv@boEE+Zrt zlvG^R9Yj5P91PcE4$=z%mjGmYPTXoJ*CQ|EdCEA;&B1%<{HassvL1F8y4a$*{XD?+ zW}>WzGuISQwHzif!d*YqoSnE0eNjh&R~^cf?fV(vx(*=a&t~Yw8VoqtDUm5NnR09k`5StiVRw)y`$?t|!`)@KCUBz-gZyN< z7f}G4yBUHvKm?0Ej4$ux^zZ9odU8I}7XTIj3fL*8h=jRV2KCGw2`cGr?&WC#4?BoSPy+CyaC`U0CTZsK$ zL?bnYb4>~ZF%PnSCa`CNO2e^cFVLd_&Tm%ciS}KE@+IH?0@4QnZvtfd9!2^mfbxdG zGnzhfd~|(e!#Ej!#)6qpD&}3tE97!2G(`Cx6*Aq4qDr1NZqtSFz}&sEDzyRdDiy)c zH=eh`)WZ@C0~46`!alaMs8o2f#gx!u6ixWpSOulVmk$yDao#+A= zj|nPNZaP+Y*y#iiP}X^;H|-Kg^EZA2!SFIsF(#+p!lVFHz88fZ^bGm~p)=_;p-?O9 zD(XG!&G5Xw4e51&hXJxb*}1;wklzDfSCtd5ps8~WY0kK7M!N_iUkyDYq~>Z86lCe5 z&IS41m93CkhEnZDt>6YkmQ51-$~oG6+KW-mi@^JI7X!1Xo2Woz#tFl1gSBJ9m_!FFuLfZcp<}*OH^OZ<12RsU3*Wxq8_$ry**xseq zv3IGAHgD#!b7OO7U(RjqAa!Nc8n4h@2B|e}H~7uJ%V5>{!sy&T|BCy}{IzD=`%M2@ zQ2irN_D-k8Mt?ZlH`Jv3R4i5ApdO>b7S&~B!)m0n+QR7G7AJJUCF3+Sy!7H3!X2b0 z!6YHZWq9|i{a-*dgNuJB`IWRS9JzkT8i<*0JZ6E7Z41D-1ns_`&?g8^cTOVHw7#^| z+a)j$dYeh?^wJjwbS775`D$C7p%tx(`Pxggr}B1~4rQdrC>@dS43Pa@fM;g{PSvY6 z+20pVnZuikt=)`qt$?Z2M!N!jIMQ(xfNkOOtJ*2Q3S~+Cp0NxLmuO+TCmG6+> zQ@4zNujGKZJ3ph;jS=!@llWxkzz}QH^othFUNC(c8;IDp5C&ZAR2W;tF=w6-H)|bQ zccXo$s{8};CXV{hyNH$#OBO7fXt=5HXE6f@OFAG^)JL>mkHg{l@^B0IfkOT=fOGvp zAAMSQ&YWH`Z1%#1FqGwp1GP-$Q#F(QcBNB(Ey|Mf;W*OjKf&_=WdBV?`dWaND&|3} z^eFU}>qQ&27wB$1Jwe~6q@K3ppZ9C;V|#t0eauvjtDUqSAtS>GsTtl-Fe8rkYZ1&y z&Pk;g8L4f%#c5!Obova5ZxKhSH$Fa2i$yFpVkCIoU2F>xfX!GR{u7ADj6l?IK&)=3 z5JU?Xr1yCy`nAP!dOr&}?}jq)8>lzfpx;TfE6_g_?nKrTIAbvr*>@_>5dCrzb(ZT; z%Oi+)02ly}bmFburZN`!sQ`8zJ>@(ue;DzR7mXe}R)3}QhfP^9XWINr_&)J2!b3}t zas$UX9=B(IZHzi$4EowIPJm`)F{UwhETRIB%Td$LHKt&Qb$~1%rw%YCvhg;Xjv|B5 zSV$_>{T=!Td}Y0UMB4rr>{$S^Ud$g+0rCR??21;efu~U~R;5b#6kN(T;C!4zwuaCZL-u&`~ zv$2?D)T&RJyHrw(AmRLW3^z7X3OeTHt|9Jzp5^T0h}Q8K$#p4XJ87mnWROCCmpsPX zcqSY%`dx1h^0ya7I(k8h8X?eunl|iRmA-=)9rl7x3G!^i+-*~|4F(0?@0d> z@Do7x^Lg(<76v>7VAsbli~gN9tMPiS#IE4~-xzH@clyE6aj*rwPJhvVP$zYI@>ndAC8Oui zYv_2ht*g}TMMnGZAT|R(ga75=d}%6p_b~*H?JiaSVZcd6HoCM6^j&zPY^3Oi?DuhI zjkh4|t&pA!m;;dg@K2<_0;JcAekgul^uy8j8qc@7v0^q(|xbGvrF|>!jN3c}y zVYb&wNpEe8Yj`4lV-1C~C!h|xJzli$EVNt5Y2RCrz7OyKK(5nuIkxg7^7W^jZ*P)c zm;qL1;PeVuEwQbDda_m69L!=H%#YhUsd6voUweRVOu)F~6S6(_0#^{uQP-a%@KQbq z^Isj2?gQu#ka$f(`XYejzv`TIvPrzMb1qyow_-MDvBI2I9jV4b4efq9+p5^Ywkig) z{r^-j2kl_M>$3*yrh&6*g5hVI7cZ~{e;furjt8?Y7)U3g{ReRD6{TpRz`qu_$#(q; z>E8j`hhh9XBHbS#^(oER&;JAdtbr?m&+p9k?>5|64Zfwyc&0dO1#)TtOmHcdk)`_B zfC2OX&l1OcNide1)dxpoeVC0tdaYylc@{lP=wOE)!WB{|?YT3eUp? zq$j&gr8VyCii={OH#gcm#33POg(+C#CVQ2=!c+$5LS+!_#OlACD9f3?z~jSqh2>B6 z1%;&wg;97Aiuy#>XRT;w(9s|otZb5~UnTIC{Qh^|SY;FPPXL_prmg$dQl3R#_SdQB zHcm8y-q!*7dyA5k_yeY60GDCN+~;&z_E=OoGs@Ro!Q34Ut!5;yn1n$2n8Ja+GJ1h zqjl3G3~RA7ir9V3;mOaG-)#8_)a*|*Fw)pnvd*AczIN2*ZBH#Y$GkwZV%)(ozP=D$ zAqVNMbe9TE)(99Yr9mBZjRuzmXX%hcs>?(>j{hXwuh${H60iy&>-8eiuL1U-5cMhl zq;b8rM5~uD7#`L@TfjV3VRQ2~N~&O5a~m{3TkLOCU<=>R_%taM{EsB_s3)!3(j0dKL_ZvAZ*s(Mf()pD*fF^l5lLGCATR_4C8x94{2O_I2Y2xBuEeE8#4vw zwNssO9f5b(&tRVoI1eE4{s!rkqp*Dfuxn@^v5t>F*mylJbL?*hHuIbm=bsy+-oQ%J zj5>9%fhMn0_nG{*1RdZ))e1W2QNO}qEG|?g&?1Fd+#pV%PqVM$@0JB|q|B>>GZ>W^^f=Fa zRv?(ppT&fHR{s`QEL-3&2F$QWGQ*i}uiNiQM&q*K@}UM!FbGySgth4kQcNE=@j&py zmq5SB$5J8eTId6WZdB=Ggl-{f9nrQBdMO>Ro(K0ydx@PUmSe^rPc-`Jn{cw2zDv{& ztRv=%{;NU1jAwaVNuT4~Dd1^rF2$T>WNkLnSEFRnZH zR{p3-y5WpF$(D)1$BuOUiA>`-|AT(Q3F^(*=@aHRt-dzN3G;sPHR}1KeUj+wi18_1 zt#2mxz&py_#Hc5$^}s#)82v$F)KdLnV*E_?k?6q;V-CEhB*Cf}`_%y5h~;m>!k#^T zf*3bqo65ME=$9BT;YS|AFQ%{7XWgR*@OcM+Daq&pWdZ^=0=$M_cdgWW+@bq0XO@zD zEdeWakg@PxliI~vqkecJBdB?~lsYBj|FQQJQ|8S0O+Cp zFN94Oy*Pe)Qc2r8h;@~E6KOMrUQBy*?5(xza8CPcw99fXg{BRU){yQ%=Okf9V4uQm z1Z|JLP$yV-6SJPU0`?fsgT!4+eGe1w&(t>(jzTiLb3EhRNpM|YanRu|GGM<#tP%Wx zUw{z?Yc=uT2wz_2%_J}m9!COjPodKxF;~XH0*qV}n;tis#>WRVe5&IxsUacZIFj4r za=T#^<`1}xG=Cc7?!?S0x<<^?vg5ELaVFaeq#ppdz6{UPcai=U(DN%XPxsUb`da*~ zn6J)Pl}bk+ee}B1o~Moo1vwTxHj`sFBpd{n{G@%GzFOac3GxW0-Ui%zuh!?>qj%yH zYV2y=dyl>o|9K4m!Hd>Cx>vkrt=6-6X68Nm3e2%5@IsK!G5iOw;pHj!=$_U3E%=8E z+hF-xYbKtZZ46e=RtLwPoisQROg$ZxG&rp!dzjbN(i)}5Vty6s162&yWM9mBxa0D* z*Ji*YP7=m(4(XrB$1(nKJqf0!gebf)vU!LH#a&yvJa3S|n`G=8#QP?hsArZ88OguY z9q+`lcWxvFh|yi(?KT7he*>9_&mF`YKMMDKuNe*~~*NJwBU`qmWx4N7d6X{h5p<4wRZxNkNFK&(Bz6}S!fzJ(uJOaHJ7LDtP-rL0z z#darOz&qCG^Q?$fo zP&*~+yX#6YFONbrGN^*p&igTbP-fHh9eG>X6_Hp;^h<)ZvnyY1C=EcxV5_I`a zstG@+i9f3DU)7KeF@tp*Y<<1(X5a>^#e-D#L0||3e!zeCvSXf!*bC?ge2c0?J&ywa zJYEBs#(xW)IG{a1)^jw{vjB?#?CSld=&xU17kB5Y4Y!Ta>Ups^80!#U=m-AEmdZ+c z2iFg5wd={}hI-7{M3v=mOVI%%4X)uiKN3SH{Nhvp?Te*Fda@@=pGrCifrM2Xrf#vc z7xZ;Yoso)&7`KC#66WbbsfTk5f}d2BvfD+yYf&Fr?@y8b1wg)Q7@suwj-g~Af9gI! zlYHQ!c^563Uom~mLSac#i|Xtmq?Tmz&!k-#gR3I(?e3C2F$T;)&XVH*rsD(T z3{j;PNn_M%nh1R7V0sn77vF_yGVipqy9J(kKZNPWXr!kBW&b z{VR_q*E4$I*m=RcV}&s9c$FUz-)b+@m9;R8eTv{1zkz7sF|PCMlt8vV$37pbdM=W^ z%)&AB2PN6TP8VRA-b=LEaBKi=VMl7X@s_ym5%mfD7;g8zNS6Yp1LXL7>V%MLIQxF_o|PZxM=PGy!SUEzlUpZZt;*@Yq927^+2nX*{U{B$s>n;C za(SX!ojy0<@B-5VYSLo{>X>uPX#Kg7ea!GE^{AuolLdC`TigE3 zwhnYuU+M_ACzNg{2Lb||`6v|o>tVZHK=NN)ms z3Xttr{ck)2X#WxIxAbi>?z5k3%-5H_Am-_&Nm2Ovg?x<6o*|57nr>$9#u(mAr$7@1 zX}ikYOK&qtmAS&^w_Du&cAW6rx45}6-H&N}TrCm9>uuw6gB&wfW0-l4854MB=I2J? zF=O)QM&dC@CCiqsJ#eiT&olg*^8mBbo%%%gth@w5n7TI|fXJn9>s z7+1iu&A`Ypql|!M=K8$)By9q{lak3$ChG9)K+X1nfM4I>B|mv*4tz;grnz&4jVLs?A^PHJ|A19`Dy=pCoQ@DWEF(H7cBXE}4$zwS< z*`By~j@eif z>K|y!x7C&q_=1{D24@b*>JQ`RvFW2i*OIn9@coNb_IV}$Gwj<1bK zKMU9oVAo<@jIU|Gih0T(MtoF8)6X@Gufg*w1SP_RZF+Y*J4e3PE>}$JCWmDB)j{}4 zab_8cAW-wo8n@5$Y=*VSR(mik1#QH|aYIh&dY>6wYufjjuC?Y__nF?c<{TIUFsF}; z34A7W(ozno=3Au;y?JD#+w$BCk(ugFZ-KPbsHAFgaRcrm2Y3n-8TdkdA z1=px~_o{(4s{dZK+}&VA@*#nF{|?7)q?W*|E1gd#(cU<@tTbQ+-Aq>_sAHYc&oaFF z94(JW?EyaQD%vFat>l-+{q{cc{|21eZ{2=vUcZIL33}0F>qholLD%N?obzV$`Yz&U zW$&T4U_W3Za*-SDvI-6+WMlluI_Fx=yiT*P)nXTG?&no&XXDPH)6gYztl&#(-hMUk zlIq_tJ0r5OBYzb0;&IWwThMNDUYz+GeC`0|0c86=jC2j)?*MiU3_5&As4?G>=kQ0x z&2Ha8bLUT~h-ivGn;VhuHlvLrUvE4|>gW;uASHFwa}YW>v^u<2*+#7HhVFBZiugzY zC@`;9mg}92lr@@puV$~&V%KW!w^i#fZ}eE4h0^Km@R(-KvVtF|c^|5Q4^;n$>U=kM zN5Zt$D^v{s8Y*ZZ4iT3!n}NBQ0KME`9wPf>kLq;v)x2@>QU8SKm*U^U{c;59F93;3 zdPBd=LHZiN3IMx4ju-t=>YVrE4}Qi%zH?+X|FR~)n*4M74z1a-f9!h5OmCr=QMMN zW{QSIj|;(E|1%G1&DZ}GSK0v_xrQ?TBxfSO^aZirO8K)%I*oHvqEd#McznoU*h0}nDfT54<#HUV!wpxhgVz?6S;U_x%D<5Q7NDx(e(5mJue3wH z0Kl$I?+g4|9T#`!s|~l0qxH)S^u(YA^Dl&~IAQ_Q(m^<>L&(5`%vz&A|6GNP7q#hh zr#|v7UB6Q|@6z8x$XL|Ox0;OHK(GyCZotv(5J_~U>jV6-@uL}PqLIbCa?RI@VU;{p zo5?br=t#7HN$_=X)jlWsqZ0KAIM3KQ=EsoV?9>sDPiG&#|NQy<^iaE8aR6#3&Mg-> z$Cj0Pr@WK+ZMn`I?4l{Hv~(pKAp3uHrLA;AUfLnZb1hBwe|Ao|TtQiso%T$nC>^f# z+|V~^AsqKtl(HS7zL(;+Lr#4kM0yk834knrU8R>D^ZRS%%X3pzWX5eU^4bX>p32$j z=mlUf4F0_sBF{VX0A>r%+X5qJGdA)p8S%v() z5#@dVv>3CBE3H+K<-eLTvb<&WUgcusFOMisYdS9QsrIPh@r3eZ?QG>@c+`N%@03@M z-wrvIahvH%OF%h5((x~mHmr0d6Tq&x7B};|=UH**8GM{@^wd@7MOl|4+W{7{b1wrK zuczCvQo=lZyI40j@^y2ky^NCNub$nfjL2;w|buL?^sO z^+VL;pFaNS=b!F~C;D5ojdC?82i8*8Oq@C@N3_gCTyym~RMpG%`MPE!4V@iN z8$4~ZbiT#*l)6GsAXb_e4Qzr2u?ojwj}*!@?go4+y%g>%#=!vx>|hIM1wG65cowT{ zJ%*6clC;(e!9|li=|=Ql$my)tk$wm80YK7=OGX>Ym&k8&;-pA@)t}6thVx)-puxDSWI6)-_(r%f zXK!a`8WC-V=4pPW%`JrAkG5PdsO%Q_jJL!1T#588fTsX*-2FBrM)?_eSPpTlH#z4w zzIWnQI#p@7l}F>##W^s6YGQdcGpzRl-X&OQciL^vDlBe&h$g*7+a01QZ&3`kxVNZr zh+>#wfL;6+O;%onj#~eZR``=<)3nWCrK~4$Y7b4X`4A4)<-#d~&Dat0Jr04MK0;bN zN?@>%Yq;Q=2a$0RoY}XX_+!u-!?mv=-LvLs13$^Fp~7efCq@*CCIb$sb?ss`S*hJ4 z+Nlin4>`5G1?g3Q`v9`vt~g*Qk0Ae7`fcE%1yh*Q8rF9+(04)fU9EjLQJ&;|*IA2| zeaB9$h^uU`z$?ie#;XYF62N$X>_2u+wjB9O0qk1p#0`?`p!l`$=gDY%hUmXA%86xn zv&d^cKyWg{yUpOjv#(IF(LnWIw8DSGG`Abrq*%?b#fnCU5rEU=s6x>`QIA^S7jn=z zj`UA}UjVXwJHqV4^8B^-b;LF>L?stE&HJLgP8|2mrCmhx_SM?a+=k{=>K(i;MVS(> zbx3ap)Bt4ruD#o%)FFQuz^-WR>)9&kb(8a1o|EK4EKn}LpG^@zAUNNzylA%>QaE%O ziI@aBbXlx@1883?(8D$O@WGu8X}nkel4#!|FZd#kAxS__v$~8HG0Oq z`maREgJG4+S8Mp5fZq(;YDa@^!!=n?dfdp^Wl*Rib?qKR?V&+`3O0s)Y*ewWx9^yz zlmNsl5O)M=2SBcvs(nL9DHMn7t-c`7IQ*GGP>6FCy)4>kyf3`&FGc!xz}*069BL0N zaVhJNmv+>ro@1+uey;zNcHv?%nx>lEN7#|i?e;FBoR^6WNHhuz8ixpXh_49zPNHnd zm$dh%E8PHv0Eyp4zq*ux$jfu_Mc;^il=Un>ZTzM$Ktx!an*@sWEicIM*M})osVoxQjC(M>*d= zjCW;yI}eCe^gy~|10-Fz;~BpaK>jrRtu}6d@MNgZdCJx4m@qij*i|@t2)!!sFTrn1 zK9|We*CW3KAn`xdA4Ze&y77DviV7juY=>O4LHbWEa>}hk8M59?uF+!B6$>Ei{TJk# z$a;(N9ry#gaUM}QRlF2nCD0@sq`Ax|++nJ2A9Y4l}6h$d}O|;`G zd{?$3lTYf9e-rROYR3jSWjo}QeK6FnbjpQgXhXS7PANuyC_t8b8gj}X+b=?1*$#Q7 z4$f1nopP6=4B3B7UfGHKixK6XhP?8}avS89?T}lJ5LQmDQ*L%_xZLxQo(EV6kaQlw zP?hVEUk+ebQ*^A(p%YzCkIsv6)gb3Es`ePD8jj}12-@~OQ9cH15WIn0xs=3dLB`pX zg2olFrMC;clH_r;lHy#t;h@knVOm#*{&lmcXZ)*!tV@H&89>m9kMY>)deTf_&B63y zt5sO8#bXW2z=r6zDnu5xn8Axf7r5l7!{OGYrCCVJ7{_}#<-ve&BKRpAdo%l}HdsxD z&;pc{`Zq*-7RQJ6We*|!1mIbKY|pRm^(k*7{~>@~#czxCL&_oiVZ_IdX!}(WfoUQK za?@J*MhuYj5?uB)V=~*pjmUPpbU@F6mnx6KZYAGG7Hd7P1&f(L$=`{)hO`M#cd7~Y zr!O2-wKH;bxcD25?ctszO>^sQC?)M2up1NpZB|9yKmECGA41e9S2 z@T<%zHz_GxE?WnNAU^`&T=X>7fkyR>SOWH73AjM|*Q#{NtwI^Hzu7wQ0`hw!$~}#B zpi#LEOTZp10ToF~wNsvz94@aX(gOj-0NKA?ie1WR1Qc> z>~h9F1!*?uIasLf@KGThM19pzr$#g}%8rP7 z*PuR9?sBK5D~W&%fTXvJt~QkJ$oB{ED+;}>jlO<0=!2Xlh(>DVYjD)WMfpUC@q6r& zVcmugRcf$S=laNxTDR}m8C%OvOc85#Uo8gobp%_cMu5I{gAH>5!@uZXqTWkUA34t% zU40Vyzqk(o1BOVNx(_r}QOcZhPU3gv{9^R9W!itO|8FSIp{4skOO<-3yjl4D5UVII zMn7*y{=d}sHw2J@YpR9+GcsR5YZ18d}-0!m=CrU*oe4;bqzCa51 zuqS<2^!HJe9dd9EW?&xx&=DZ%#ra6j1KbE;S7|`d$Gn=xbh6yR^JH}XtN~w1ChO^s zLjQj+^#5*UpZzc$(YNHV>L~BK4T8Gfg1SDAdMb%}((Ls!*Vp%G>4Hs(j_`e6ugkcO zQO?VmVFII^YaGfcIM>P0v#(-?RZP>~iM`Xnw4P3l{@-YQCQ^%qWnsfCCh&h>1}{r0NF3=>s`v*$iEL@S2R7~ zxzW}y!3Hzbn6s9@5$31G$3BAa-M-ImGor9$1!<%P9MV|Ly1GHliuaVt4@Et)GsE>@ z?Kleg|CJsa$QE=}_Oq5$LLZ6pY9h*G?e;G6p921ByOlS)-8d?3Mi!1j^&_>>jhaab zeJt=S%L=d4HzU0fupJ=rJdU*9vibVa#nJHO)^-i%UDCnlV?rT%^2z;3Svt6rnB^My^iKhQN5ncbuhcOpC znbulrKWmuI^MVGQXYEme&$X}_4Dl9I6sF%S2QC!==lX+wvj+OjOEdA`&&Bs^@UCoM zrr-Ps`Og57uKfl5W+eVt3Y8jYH!Go$ar`+t;;dP$zXXhY42_W%44ZUW#yht{! z25j0!snrDiamw9@GGu)jzjg%q_W-iKe?hN#YWWV^Rs*(;ZJ;?a@G;Tek4N+uYZzCyBdph~c7CrM-;LzYHX#2f;D6GN4QyHs*tGrJw^prF?$L;H8K2go zZMw4KS7{xioQ^zuYWqd7Yc-NxQ|g^^%TUfLr*cytFu2G)$XHlskse2 zTaBHnyhBpu4=P`Xep9l;F$Ko; z4!&D}Tga)z2S}d;xY~vBEkSxVU@3rI>+=NvR_gf0a=xnMIrtupF84KVQN)3EtU9XI zkA>E9lh9fwGp%Kf{h+p5A9|0TyjnN!(f6v#j}U~1_Z%?F9%+seI?Gh9v&@w`%TyQ5 ze~%9Rqs|gX+_=&*neDAHZtr%fv}_5b<*gcAjd7)A3Y3;OTs|7=%C=^qHI>;t_JkpS zF*5?ZiVoni^BWZ5qnXwcDB&tP;q-q_SZ{d_($fJK0%ZTs`oK_bLcR*XuDN@c@q1JI zmtCJ+&j0@>e0+l(9d6iYqIATYkfv{7I!iy|B-PmM&*;{Ftg;b0OWPvLwQe^-nvSJ7 zk>`Grv;o761{jTvry}MEnMu>hj0l4j}1QZ=_2Br$@gV zR=Wz+01RgpDMsH^;lwjL__NLajwtzTGqw$ETgI6wFyLv}ZG~5HxV->BVM0l5i_O+b zLT5AihF#wZyla7z)Ppeo>T~3O2FU(9UH)oQv*vHOUZ_OhyLI0xFi>~v8LJorHHdF7 zKWg~a@a^RW25Oa&@kkTf%ZgI=y{J!RhcG>2e9G;l@R7*|vYQYg2AtA7;uyo7TxAwB>E zU$m6r_qbg7l|S%B+rSsGtKRv3T||8tU-WO}e+}1%{x!bHX-~!#Z39u^KczzyvWb3;nKQ~tLR{lxg89-Y#aJ^)#MQ~c02#t;1?%nwzbbmj%VE9n!{S8YYU z2Jk;4cyRC?D4Xz)+x6f zWkm8r>yUpiqTJKrhZ=e<%nfY=H}oyzhU%U2tj^)_!uX&<qA%R$#?7KD#qq4$KmLxY|efsGW#*$F-b{-lP=*8?5_NV?%4VJJ@{f9n2mbNH>2DHXH8 zgp@4+`xJvM-Sg#v%J&fy0{u$~T#%CbXBLL$7vL7xx!%*fR zzW~6lk9Yk!fB0s!c_H+364k3Xs_=1Yw;5enI#SsLZ1pO=@D^RCX%_4pgz4isj6-IY zc_TRz_^5U8uK`|?ZnAaf6XZX4aK&qOeS^N>Fu zAp7qxtUHnAI;+kBtU84aA~>sa84>-p1!aZU16*v~c^CN)Bg$`T-T9O86Is64N;$w5 zsezH(&}d0fes)2)z1cc70r~R)&h^*ZyJ3|&fPq>fx1p7gQ*L!cxon;K2l8)4lzSTM zRAhZ4R;dG6r6$O2Xr;(0H>rELzu7)ZG4ewJvc9LW4;@)` zxKaYaC#mNdS`_2!rsJrBTUfH558Iy&Oum{7D1ysb7ldnw(xaf%YLEDs!?ioP{pAWo zjNm3eLyZ9GqIN(G`{6jA#^_14=FrEUVfx7E^$_Gs0CK+nH}qOG1ZoR1y;hKEY!Zv1 zU#5%lYa_~M^!Zcd{~P*zw0ZQo0kt+`3db^{8!`kwC57SkxDx4=fQJBbyc|UOYrxL{ zc15#?{v{ed**O9iz7k7w=xTWV;?{AmGbee~r29IE%{%PA_vya1dM&w6Z?RTiLX})D zslI6Vo-&HGpgM1>(c%S2sIIU9bQ(nOAX{9R0rX6Mz{Nv*W7Nb#GXH{woVHY$+b(6Uk-4_NmRMEB)VVU#_b?P+Z{}_EsYRu zLx{h|uA>q7Fu69RH|!?>5}#;tZQkkP(;(XJV4^MTu%%?n(NU^h08`TiEXE9E{W-@b!YF8%W?tq9};pqK~+yd`V zpYXcJ_>57=j|E7)PeX4MQC=jou>;Ho%srhSEOW}OMj5icjNf;Mx{%S7F3r`-C8avA@S*e_j41;}zwn}3LCzX&E`2bhSLnW$Uqlsg_} z$o^ye!!5|Kh$#28`G<&d8<>b4U?Se+=FCdHQ|`%#a+$o_u_#^12S_^ITzR+3p#y=Z zg`PbsAK4f`!F23kA}@)C1u@&XXrbo+koNlk zLCQUcsq3GVg=x@e+mX@J2jFP z-Wqs_(Ax=J1Rt}NgyPgC+Zs4C2H(5N*&29R)%vN4IK-C@H_vaXmtLsVSgZ^1EPuIk+1HsOV8{%=u&07uw?#T?fDI4aab~cMBM)f%i_G6 zN`0)T=TX!tBpxZL1Jac?fc5}M$9f<=1kmg_vDrDLvk`{6V#!L2$@;JXUANV;+M$`oZk z@&^D!yho?w8N1e>693>&=4S(j9N(c+E}Oro0vJwI`liA|gH3Ss3qKey%I`5S%;%kt z^re970dibyM7kF6GJsu@&udC9Xce7)G{z6jSVx@r-tD;?hNOdF@?NuxA2Y^oGVUTu zF`UEQsy{gN_Ji+NHYAkai!uC=aV+UvbUhwq@35|wN!J-6)t^=H8 zJjzXdis@K_pA%P6f~bG?nc?<7Ug%fGAU^@%j2DfatDJ(ovzv*ProO z{gMAK`K*ZgGA`>ia9N5{=9IS)zaMfc^E=Z1vtaWJkmIfOm$uRc`ThWQovI&mzS?kW z^;A^7X9FE-Kr9*+3cm;~+bXt8nE=SbPUUNM$+&Lg=P7rACgldmU0S!5*r|-$MbyVh ztQPCbVlLT)?Qa|XOyPD{*#9PLD$ZAAu^wkvS&}pVQO^*6qA1MX>;>fiSN3LOW@EFm zj+>h)O0qNFi^JtHd$Xa)|2Ot#J)+Hbp$BwOX>(5D1ZHiAE5)gQ1U{_(yOIB2)*n`G z|6nap?Ua{2xN-f5Bmdvje`xdT&rxYJvG82h0@bOa{xv9H(tqo0=)nOk0dhVJL3%P^ z4uD-Xjvt(@pD*M2Q$Nb0;WK*H{7b+9G0)3QPlJe;a%U`>H?=dUbS-%beqtdIPv)P# zT{!5v!N!ikU10b!aLm|e_~TrrjunOlCLdx=)PnAS-IoRfb}IMp#7OkB1f=;wI3+j_ z9tp4xmBVY!H55?|JxJ4eGf)!DMA6ArDaQ@+b%%RegDdOm z`x1TdQr%vnn@jcQH03y&%sNH~e@^XV)cl-MpAQXadHd2VaQ$(*X1qaY9AbFs7|6OO z$?ZizETm|1EJ!;;L*d+NBot z?cqH83hCbg6NZP|MIC{)63_*}t}V{^`u>&1?GkeQlpT&12a=C3rk7|uxVES_)6FW` zqOP$wgDu%$7cJ2jF4b=*OZ31}-CCl1MaHvK@3=%y;0=f4p5IaapB(<#n}5bH(OWOo zT}$--{1e}6vqT@ZRCh1YOYuax6&sB)$2HF#rnS=4?lQCQG#k>Xzq*+Rnj{bdc-8m}9N>yk}cm=a{+l@6;;uW(4BcK30+Dfx(wXS|XmV zNTs3yU5H!YMkH8#-p*X7c624!Hn`XCs0Z;3>>U2tn}5c;TH7w9{IoqpNUyt%)e;fb z;2s{%asY0(2EW{@pYal9?Zx=1$GDJuBa3ewEJ;@q0Fq9gc6?)pPD&!lLVvQ+vJgW8 zrvYfX6XdwwDZdhB$^FhpklqJ)6CmfceS~E`TVXaHzj@BQUK(v)vn9$Q zk9;koi2t>Le$C>4Jt}Vfc;>>di61y0Rb4&pVnj>XgiU#P1dnkc09cZhoWrIyX0OjU z8z*9Ilg7o-o5{_PO?bDcfc4fZoc*`{Vvmu1r)Mkg;gk&_|Rl+wBC>KLeD}4fxRq7P^%H@~fP<+44MglYBtL_;JR#SYBgG&V>)0 z79$w#TSb&_5zf0K#)l0JP78Jtr!V~DQ{AT}P>ms3?ci65vL*dKg!FMhOlf%hmm_^U z-~|A?;`WMhUvx?wDhdh2K^6c^O^W49m2$NZTRVc3W5W z7NW-(f!6y-oA}IxESGXS0)_7)h{a1M8>CvF_np-ifqLC{sl$Fy?f#B>%H zGl$k`d}}61z2ssUAoF^nJFWY}rvN9U^ zaR50_J+E5I|FQSw@lh1%`_(<&GnvWEWCG;ACIlgX$t2;BBR~*DK#_0?q9#K!IV8!% zOv0rr(e*$S!CiH|$to(~0jq+y#`V@!Q9SU1pe~*WUMQf<@2T#p$z(`(56AxV`+R3U zPrcRMRc{?#$6Ie5?ZtH+`)&hYN2^)hLhlRNdg+P`FUU2+UwjV<1)eAwX>ah5ogz8Up5OpRwL;t(0n0PY=MJe8LGQ~mg;(dc0D z*p4vWENbG}*z*ST0C+s^_^XBV#&z?{YJM5MCndkSb}Z#Sw4|fTT*$811+q_~-<$1? zZ`yfmar8sFW5fYsk>Ex*gJ1h_HGdirCeNQcU|$D32=I8#{LxIF!FAN*7A0OsH1ws@ z&{z$_d1&|2j_op>R60;}B5DH=HN3Jh7|Ik0X?WAEBh+~1&gqQTMX+B3+z#-5`gPcM z0DA!X0fw$vhO?Ud}WWd$RUU5#?}dCV)UTn9r*hbCrvLV)j$a(Lo$U|DN>*X|p_Ni~O!-vpi&re3`IWj@=^1kz5ZxMkeTf9va zvcwl+@D7mww+hlF_)?^Jr)U~2{f928$D)n1amMjqFwIfe6aH?Hm^7Uxylati&^p;n zmqAugs~c%QRedjHUT1qf4fYz~dVs%|4%iO@@#m`VWRKZffT zd}>XGY8-8VkSYcRp>IrXT#GOeM|aB~%aY=0#B;LTE)RQKj?F+5LEp;@CUmv03HSm# z{WbwK1jV+?h*iANJ5_Z1qm&_8r&NTuvGZ3f+ z*_5D`?Dh%jds&S9=Hr69VSf&I5#a6M0odj9FgEx?ZJ*cfP^o5S*5UKR75?fbZ(~hu z#hJbUG$OtC$=`~5WnrJ3q?of5GjT8Evk!2NJaHD`d*DUpm>9dmVYbH%luUvNd1OKp zfbxx)LlY$p#D_MLEy=Fg2{yq_hw@Yq+0Q?{XP(_4grP2)o}y#oQ8-x*484!)*= zmfA_TtXS@13C>xAeN`jOC*LgC?xt2OU=Z-@_KeukRf zIc1&c-2{6(@Fu|1`yK4Ia;z}`=x-+bj`jCnspVL?8D`vWG_Gaer=8x?z~(L3yVk4_ z4ZcO;UyH^OlILxtCrCbpGp}sqF6>Qm4s*2hq$BvUOF2!9qZp*aKP zqyYWnnQDG5M?UcS@GR_a0iOUozy5?h#f$kgfd1D0rsmV&2t5GLvQ5$F*VsU(coeE2 zn}lK|H*PVhxv^1n4t30QI*}W9nX{oBg%(B1N1ov5Sn=Fn<)QoKnZL@;{c>OW^(mSo ze-ng0Vt-_X6BXzrB|FYhvI8Gb9)-SY8ZzW`v5q?5D2hX|O&s4QAnj+V`EfJS8J-_6 z<9Zvw^W#_8tlppG6ZI|+SC8YqNJT2B^1 zVK7g;*fP#`j%cN&<#ephwW5(hKs2H1dum6zdH(zkyUUkB`T;zD9&_{}XXCmapug2` ztWc=d_dCBg${#KLCeI|aqfe-ZO|Ua3ZpRhvVtrT1P=0?0g?G+!IqoW1UM}|_uZkz( zxECB=C?#!@><>yYE?Jx*oz`=PXv4wUW$UDz`z4!GzEhGnN@n`46MWZ1Hcgaf;rkf# zB{$ZXDtCP-!QY8EPdyIH7<$kImdsZ3rvvHc?MQqT@&*_L@cg;*BQu$W>oS1;#xp(G zR;I60ZiX9M)bgd-tsEiWhGooaYQ~(bw5+i-Ti&zQohP3^?7V*G&%%jeP{82b4|+hB z@0WXvi5uj8>t%ci#FU9o8!A&m_*$J`G*dyN4hbEGUS<}HTkF|sdN;9j(A#gpz7yC3 z@Ota4#`i;jnETXn@E$woM>9RO4Yg2wWvv$fWmzQ(t1;)bN_3!mTq8Tvq~}Fbykjou zy;Csb>)qD~!VF=YoQ5x#W3P3(CEgq_IR&SDvMMcm(Fcr$tUMX?IswMRRC2ZeimOI2tehBs_fqw(^$Jfd9ajNlCZiX4XjLKc)SJpy{ zAH}RT@h@vcp-r@|5p8W^Hn9aBu-fjo&LmRc2f?;Sn5n)Wyb~E;QXqE-XtV|r5B@~NU16Wy>fgDE(H3~fYI{&g$9BQ?p%U2+7SG4S;@Ri6 z7&0%>Ov(UWE&h@%-5_CV_TXrdga;DR;K1l?ggFs@-@!v5A-pU zMF0=)XWR<{M;{&&XIX%)(T{naMtYr|)vyTLl#FRofLqeIb|9!vh2Vb{5`Z zgva%O&CnU|f8R_F06e_E;NEh8*Bis|!qewh(?*k1GG9&K*9fnK-oWqDcVoy3U?srA zTMhedz`khp)}i#Nfwi-6O6xky$&GKtkUN2Y0z90xu;2A;Z1nS3Vd*-psfm({2dr!rHvj$($03qd-YC7A(!vD`fk=&UaOa2M)boOys$XZ+}~?;);1NpPG*4;LCp>!~PwRRg2xn>Vl%3hU~bU#rx~M-5?`;xj|x z(o@8=7_*gzY}1)-rsA?QUelyh@STFYQxvW}#j|!{(k;i9Ve4bhaMHiq*~giq{_Q#R z?;K!v+uffCo^PQ$N@%v&2Q5oz6>NVBFnfDtbLP3?9>M;tko=7xe<~!#CdQ^X(qdES z-;wG_bEG&D@lT^>Y>G9_n%E)4S)BsZPnI~vOctC%60Uj(PHPyHXK)4D!%Dx#F2m-! zTIkV=5d}Ueq&#;i&z+V;p(lN6Nn^ZJe@;sR<(!smf&dP67&LtJ!?seqVvFw?>$0gb zQ7$%w#R$nj0zQS5EM6u;x>Ii?iw(>^2IFIT?;|P)QcDSLSfSuvOJzcOV+;irF5!~F;LV#C*n?1h((sz7_zJU8?fSlbE6T$r=RH!#X{LAmY#~G0T-Q4!=u_m ztYK`6LBS#(=k%svp-KwW=_;jm73s=73)Dbba4OGI@H_?Mz24&Mg7A4Pd?};8BJp-X zoQ%H*DHY!m#X|850b;X9`mca{+l8dutrqM6 zyiAgmaVgdjUaGcB?m%aIHyQSMKm)+XPdCB70eA|aKfeBA<|$p}*iEcm+Ro+&ly<49 zrAxCE(cDaJX>P;{e6e@C`Ev0&v(RpS$n+eRm2bB`BGkrSaHHrOUUjEX>%5>J`9&lc!PzdzmSn1Wqsr+sP-Y%C2^V4yqq5R{5)Kv0 z6H`PeZvH6>L$FqfPLtdyO2bWe3TCIQ$o1uytNBsT+&Nz|2KG|m9DwJ?z}YTxA+E0g z=x=R~nh%2qbj^>|EI*2w{Hx?gBb~Dv?`@{D9s9%wg}tJ%PyAini+PUi)^7w~Le)DW zxd9)%Uue#i)4viDzJ(qF-{6(0a(x!IdSZHY7}U{&MX#7am(6`6Ks60N=&!F-K&PdvCRz^R`9UYWXStZA5arb;J{L;gj-1qA#PTb!q96)TJ+*6l;eFyZIGU&8w!} zf^uhu_S(}_=;&Dz7rhsff-Fxkr=aHEZl$wcnGH8GQ^_PqQvw5C1z~G(gf*G zq4ZMRjsF8{6i{od&KHC@*B$KPDty;=F8i!W1vYn zUcnsSC(eP>^Tp|(3G$Z$K1KeK5dX0t-e?;5hv@oEP=nhnmcB-Ly@*1_ir7tN;Z*2x zX3`~3{l!a!67fqDc2?iRW{AqMETg|vfZFR9i}63nG+8K#DY8`AutpM>c!~5w41mQ)648HI<X}E-_I~$ViOGF7bhJu4LRwDNRpJQzqf@CQDYU8B3oRpq>tx^uIHLp@AH{55a>v9_J3o9qp^os_xm}|=#lNzf0C_Wvol^Hu- zLWk=rX{XDH^0Tq$6LQIu@`5Mi2~WyZ-^uac%awcNgdgPhY4yn2BRhVO226B__Gjrj^W$$EX=IVp8th z3Fq{d#$P4J|4mLZPx(+v_*iNX&X!#(rG(W|&Nj*Mo+NLRV&0STJ~QEy&iGvON;z>v z%)65OrZDei!TOTmdX5#t{-y83N&Hrm{D>rcY(lL6G|8<}+5uDM?24iOC?{o*Wit~0`tJ{?Pq za-f|NKM=4)W4w5cC@w^PT^mkaJOA-$|DWeNx9EUK@L&zh59tFjbl3<;s`@%lm>Y!J2~= zo3cG7HqV*vkj?17EwXD~oYm#RS|w}G(!|sRIT-<^j7*o)($X{2@X9bg{ij-9Uqw0O zdeF~c|ACgvrJd#V3TG0@#C0J+f9^di6nZTi|0ow>Mz2TJ@~pIL6-_>rR<%DGUyBt5 zICq*C*_DE>8)y%s5-2rT!d++UQj8i>1soKdAc5j{YBtYfY-yr7g8fMj)mG9~Ro4=znXx3$?5jW1kJdV=RlE>$->u@^LJ;!8c5Cie z;`Fb@xUWRpWnwxS?1@NQ9QhjSM=Wc5OuxHF+}a+tPZHCWRyStiZ8U*5K|Dlbv;gC@ zEb|btx9JpmnJZ3*Zq|lL2CFZv*J>MK&LIgT{Q^FYLRiO8oqdzJ~Gh z(MLA;D%1}<mn|svO<~LbJXFMQJV?r(S5BM5U=JQ8NEp%)X6&s8ksf@TZ9s<=r(7zOER5Xn}jc@odUUFoF&zEiZF)uP%RJ&=HfN=!q+IV z?g3x=DY5mls;{K&Ue^Bq1?Q_NDMOzC`!t{h;OXyoNd~C|9suZ1%2wrr<>#yIJo8FQ z#;fgr@u;x&-%C#roz~)OT7nNs(74Kdt>azSYcD_-EkYS zTx`2_n_bC3`QftGVjgL=uC=cL(jXy{dDntv~fgSN@Yzqe$$|GfN| z;jR%e!&eGtoN?BT!jzHcxMJlmq@wqb!2`#gCAA8t7MwOgI{#cOkw}f}hfjv~bjtGB z#LEQvQbEMb&zQ>u$E8BfEs|?)!X~-;ddm1(bU`vcDw|H1_s}L9D@fEP`XxGqpiMN# zCX+Bv{GB%3(3K~O^Uz*bfj%z^ed!btG_`r+I1!(g{X`6FoYh8pns~V=ZV~C?&U2{I zr(ec^i!gUzg{507MX^H6jeSJOuBKISn_WaHz;dpB@=7u049kszZKO59UKBgn`Ig`) zaNUfvVjNRliE*;)tT>0ufsb-vpG&nmls9J7Hmvz>g6|mn5YyfzKDA9y>r?uNVf9H_yF6p|>8Lerws+&# z*n~FQ2bMK9+K#*~Jt2>KQr-X(`=ngAN1pJ5T!;+l)6gm`u`c}*yLv`FD@|4;siBID zeWL(ToJvmL%L!_3HwqigkbE|n<(-zq9Tr)3ePmAf)V$ys+|7L4EIV9pnls)t zPZnaudJ9w^tIUbln&p)y*JhK-;#v&ruAwZhG|Sm|LA1VI=0Jt&fjajeE2i8o71j4i zC7^StG&4q=X_;)BiZzd?NhvU|F-e8^q6AL3K?*MusH8PbOwOL(hf3@F^AhTHPpIt?Fw7xFkh=C0fhLd%Wb_&g?g%;?0tky40_EE8uo$-#LE(kaA zHwwK{d&JM$fojaHnRWs+PZ?mZ!&2PPqR6ZP<4L z`v6{^QvV8lAy5j?pVU(=PsML{t!Hh!)PA!xd>mRu=jxkhOgnQXTmDYRqo#fGUK39C ztJ@0&>~`x*(o^y!Ps>xDl1rbK2ar2sJmMsAzS%0rV`kC~EeQ&P;QYvP$>)|SyDX(2 zSY$J%Ffl`*tepFiDutj)8Gxn^N;Kdcbzx~Olo~e)^n8aJQ%TIZ_z+UA5KoWhdr87L zY6^DG3{cWTUQzR*4e1-E-XV{`{u=NWz~lz;57-kfM_+Ylh2lyIs#KMKe9aX#)3L8J zFvZ)5hGMI5pY*mMY!!wmX3pD!11Y^nCXdEsoAbqfVybwGNwyc*2Z!}@giHL zwbr0XXqCQ}f{=Q)TZeunF8x}xekD3C6Y1b+hlqCJ9d!^s8WZB7DS=FHsyUkGhur%VmAo<@hQ|t&CTic8FJ4tk+qF zLZly$A$s8(7WK*Prr8@UsL~rOvNhoaOO_&0VsFHHi~NuUo44+^WUjS{_vZGyB{$*L zT*n?yw&G%~X_as{{PnogBHv+2Y_-TgT4)P%yL6Ly2jzD&_@N%sbrv@$pH9cZSV08!TBQV6L>k}~uFEfwC?c$7H?&Pf8-lK45w0T6&xcF4tXbgL((~)$W+E8(+ zvH}tnSKc9FbHqD>GSZ&y?B_^zVTVTilwNXAN3YDDVq(99ghU4xxL8wG3F&ERGPX>} znP>&FZW4R<%cdgNJM8^miMq;O;yT!$00vyu`Tk#keH+kwx%&Q>?@;UUFXgJ-#ovDi ztIv<`4SWB5ysa)p$S)-RK>j#oOoJYyL*5XxQx-bpHv3LRoBxXVj5Oj|>2C1}x%f$W zxwuCz{XtH^$XXpcPer@GLcQ5_K%KHj9;pnnnZxC@f4N%VLvMC>2JKv+)lmoOPYUE^ zX|nhyzEM1)O*qXwP8yRs)#V79iuaq)P-C}j>E%MgUj+vr3NM)YcRCcl&Lo?!z=DI) zYX!*ip@DKm>237sO{QM)vVCNZwU6T!)(>2RGsk}xt$#o}Hr_w4(drLzTs9*}l|=_d z`G6SzgDCfo$*75&NEIryulTH|kS9)5dx#3D9vu?O%^YDgx`bMkpRtG`<`#M-<)#i7 z`L+q%ee)b`Fa8lYYLZ&C9zzu8m#eM4g^`c_+R<(EfLr?q=}^Yxk9#VaC(p<6(8rMdY|Kw1B5g*o@IWj?;TWqUY&tx4tPtguGlpizmYZ ziKySw`vkII9)OZ*zEUvvct=WoOS)XxNXKIvpxBX8UzM&G*3-+ibYpD{vcZG{SHKNl ztHt`61_(04rBZRI1vKV7%VP6vT2$T?vE=r0LF$KMpDJH3U<8eT=&$VyHD6vudU?Kl z3Hz_WN!N7d%f+xS2c7`vZ}7|Nd!F-T*ZzZ~FnwKOSo;wkcRN)JP-L=&=C?L^QJq%G~C$d-@bdCs#^9vT50DRZ4=GG;&iYs%Yg>TkZ< z^qpzM52mLDbF7$Var{?SdxkBjxSKI->ahtzxZ^W+_aPjr@D+BjnDQRPv-ow`!JjG& zMpkCgtfUq9e%9PqNRu%7!FYF)SSIceOq0xSs;&J9^EkBn19}choFuNqg8zh))F!&H z{{~Ua6CO5UY5raTnq{kHxnN7P_OQe1ctmvN#O3u!jSroNL%vh_be>RrwbC5*M@ap&|TRw$@{bS*MZxBW}p=42ON+y$veOUz+ZuLfWd$R_^*^no&f#^)B@vy z6ySSPCV2+95vT>m0V%*fv-s>c@DS@ zXaObzS-{UhoTUWZ4g3X|4deqd&~X@Rser!$b-*|v3Ha$x_yN`c&A>z;3Hb4k4Dt$a zC$I>Z4D<$mIg~-(0B!|pfnp#L`1T;yX#qC_jle{}4IDUtGpB%CfJR_4a1wCv_YCqb zupYP)I1d;ESb&dz!(IpA5@0HD5^#7w)_Ma^05=28z%*bW5CeSiYX*4+_y^zzMgSJz z{a>Ji2d)9UKrxU6{P;8C1*`=w0%igpzyW;!6J%uIKHv)Ad|)`x3)u5x26-EJ2)GWY z0>%Kzz|Z^e4A=l%4wM3Y0Rnuo7kkQpYk>-2Fkk~d`vGg-fQx`>KyTouJqRDT74QQS zfOO!8?~%vAO+XVc703fj!292U4{!-k3iJd1_!jX2?g9P+Oa;<`-@k#56j%k61A~CQ zUt??yECOZ#g+MIu^;ZZJ_y z0etx>`~ZIks(=xI75MZMq#tMjMgmUYgO5?SfB-NNNCWnKgm(hW2hxELw3*ZBa02z4mJ@5dkfzdz`uzMTo z3a|>O0EPiEz|MD(hrqSKX@Cv*^c~b6U^!3&OagKNGw{V$gbCaMECFT#9>5ChcpLEq zRs#!waX=RE=UYfGa6fPrFb^mKY{0i~;>-fzZs0QDT%Zt$0e1Zdc@C@ws(?{IPhj^O zxDTube84OqAFu+uUdNsq;8tKEFcruKet!+)Utle85ikYl0~~l2^$u7GOb0B$Q?H<| z0Y$)`mr)*oG9U?f_a)RTpf~W+i%2Ih2H5`s+yQfebl~TIqs;_v1sZ`dKoao9^Vk~z zTnU^BBm-ZyXOPE$)xi0{aKH?F^c>0v@K>M$7!C9SzJC^H1p(Iq^MO+U8}P|9Xj_3+ z;9Ot`5C`1qew5%3=9T-dj$M}#lUFb_lGmc zW}pf10DJxgUce$?JP_Q3wg}h+TmhU539A6MgaTnN7{h%fH>gg4QRK3VZit6QNMxdfDL$j9qIxw5^w??_n{sD z%Yk`7K41d2+>81Iqyul?gZB+g17d+U)*^p_Nx+|XqYMLo1x^J-;Q71Io&eK;Y+&C% zQ3im&1C_uqAQsrshPnaV1dIm`tU($9FOUqpbtmc>PzZF~fjzB2Igkx}c01}da1M|P ze0UrB0ALo70K9)I`cz;H@Z&A0OF$Wr2)uPO@(wr^_~RdVzd$3<7uaH#npNC9F1 z&q6QuTsAa&)B%*AcxwFhK2OX1mWJjQPqp9ExS-lo@2~V|$$lje|BCVpJOS*O&96Ro z|H|5u<6%$*8MLeghieTTd_ve9HytgGztJarv)M4Gsj6-~9j zY02^MfG$e$-V$i?)YZ;M7n)a?UpT}QXsYmZJXs$L22q~%l|?u~ z61o@K{}!PCWjC7qRaH?&KN@x_*C-h_wFCl9{y@Mpzs_5+psK01(z_%S!$MD>-rIz2 z1`sx)Ehh#HYa8e;pc50hn&$dw+gyWK`2x{)Nd}CNT?;Dxi=ypM47fG;D^E~X&9C!U zEQm7K12rv86@GtqohRVMMonz5t}OF5RA7TzSp(Kdlq@V%3oqRTLMuUcwT8YL4|3Iu zop1VX8k%dH)ymr)pDqWcdHl-hE}9IxI;IVauPI8$^k7}Jgd4{KE880D(CS3V+PB!J z7A|AXiahPHM@@~GwCj=V~#q+HeXYN zx6X4xz~A7R?+rxVo^+Fnqw4Itc@K~l0{*(=h(T4Yudebq-h;290xfD&8O@Ai&r{z* zU(_R^ZgRof)Z~p;TIt4n*EdSH03Jt%L@>kP_t>+gE3cy({u(B8{*q>v%YAwTDi5=h6$@(U5^LIg4nl9T&|BBy zD?{V4(AR|4EXoM!aXgw@W5h@4txrg#>H;*8*y3~oQW$8iZ!W9$7Y;i?yiNd{CjX*n zTPnRgZ}J6N>Y^QS>M>HZl6;TSm;4l2H7Uc6a>6DgQ^zoZGKBjZ6$4K zF{txUyJlbwzGhENb91AoO1pavxq-;v5NPDn3*E&%q7nMvj)OYP`LN{O> z$tFJ-LItm_*a<~IiEC9 z-DuWymKj}lO}-0T@X-_FnAQ|U{U)JeSWv49C%R!*)Zw%f&5$9BSNfY!kn6k+)%pIW zYR_U%m)vb^@-JTEQA|xHArB!L7UE`e1;mY`@KRCJ(y+j%U4QgqTI8u|gskPMX{oA$ zicmAusCZCL$HhT899O%zMH2}Fp;nKD(#TY|GzUD?IHrVjvY<6WPuZark=n`zFDj8v z`PBDGj~5OULd#ol)Co|6Zft2-5|Z7!s~-BnBjS*nk1c2#ej&6t_-l=#x(i%UzO^*q zJm5;7haSv$^x4-P3v?rN=g9F!CGN+jQTAa1`9eWyS)Rc;G zwAHgq`BAx?kTR{XLhq<8g=kiGRc=yix=}TyuN5OVwCPK08_}>gfN17ojW3jZmI9`#h)&p|NFm(TH%)x_+nt zn8xNP{LHUyE;DG8B0{UIt!OS&)kE5i2VHxDHAdLyrI}{MNob8qcTui~Qqfb7A$u)G zea#+&XhygW`O}O*ga_IR(GjM;`wLtA&Av+ID=@SYg%6!HlR$MwW$53uCr*^8P-psq z*ZV4Ky~fV4!VBLU)P6ZiXsVc1xyaw7Eq7AkmbM9fu7k zjI}A=CTz~fqy%0UJ>iGy`xL1ouc)A?ps=8zuyAP6;G!XU!@S-~Z`nXw*JPluz3Ju-n5ab)zJd@ z10MP=JavuwHIcicj-z*}nW*py$nFwB8yq^d<_TPgIr9s(iE7m-P6w!f}`OO%i;43HI0G2qMh)5mFs|qX$c(C@AqHOW7 zp;}|{?n+CfSFLPiDPKIKU^t}XdOA9#qJ(CO!`(yN(UXkBokFKm7!}VZzrR^ucnoJt zT}Q>XaY02uot4+=j@3+n%jlv?oktZoozf_4MQCGTbDyscube<63*D`dcJ4UCWQD)FZF4jDT%owc!dBy43Ug~51?D)4$kLC?RQSrUEW1Tpw?R`ylSCtG95{Cw9nUBkMj690 z%opko*p;MU*02gA%_w-%No?bHV>s2_prdA>F;do#7>85u)m}?X+0>yXwHS+g&8h&#~Mn&bU|tr)&ob8^-3p5-Rjo)8h=1%nvjNBRcV~DYW^D+ZSXR7 zq~@+}tScJ=$SW-NGL9N`sw^is3a7eZhu*2Ks}{8p({;mI8C^HDJ*EXn*AY~r`W=Z{ zuyuJTN<>{3G{Us_6{J)6+esv=j0`Hq;m5!yQ+6>*}bI zQG58@DKkP)tn*dX)mGP_)egJRj8%7X8%;2Z9tBfmco@}WLkE-4GGru zdNUY1bn&N|6qYq`j3v%6jw)V9dNGcZ>c=QPO_<&QjGWa#1n^M9$YG@qLpDY47LCi! z4PrX4vk9YuMjb(h2?fvHGymqpMx#QKb&4YyHOJ z2-oxJtfR2}xe3e&Ab5-F`R5O^GY}q?QJckq( z(dHK9s2bevy%{&Uo&JpC7ODZX9Z;kaZZY!dtEM)Y!JhT z{vi}6ZqeipL8b<4rK&yNrrPEjeQ}~&w^}bDQ%@YUVu`Rjbqw?W*2jo z#ty+UI)~sG6*pr?N-pXz6C>P^97tik8YqdQ5|Wfjy`VIp4Lzvdku75 z+>yR!kGax|30+rI)#R@?P7=*PJx-0aSeRuPA3EIAm+=s3>PRmrMM-SPrP|lLNV`fR z7$ZFyoan2g;Xu(|>T9ByE6oIj`aP5^s$Edl*7&d@gsM+LF#}WO<$LWk3c3;vU5z_p zp)A8OjBC`3bu<&i(}9g@_{1u!Ev#SD>Z@qV1W(K-? zf>Kyn*Shd%l5eDyJ|YPH8r0P}3T=b`C}Ts*RT)z`Mw!7#Llu?~Rv482u5qiXXlSl0 zW0f`PfTDI-R^I|8WR%V;{EbV>ltv6vSG7?;qG)cHV$~?!=%AmwU7gb2PBTV|Q`)=h zwxg=6I-gH_Rcn_p^@F2M^<5aqL7`X58(kWC?E7g@26uXc^6q`2)1-2*p+L~R@1uK- zAs%#>pV8t@o<2IdQ(7whff9GVlv%p?(dvY_r(^P?BhrQV(rIhf4jPC`+zYh-NbQ0s zT{B;rC5m%3?&G;jM)m6icdW40??ysSA=l{EGwzT_AUd|_xb+sJhDw|tD^MX$7!4sp z?lIh;hqQK{)Y_dKrnp#2kFdfzn1J5SsVI4M9zdxXoWWR;sXDySL04B8%yNafjaG$1 z`LZ>o@S<7l!Z=5#38#Fc(u7loKp7He6uKHRT~VgraAE`Xp07p2qHKSyti{R8ni0r>%k#b6op{q*B57^7Eqcmn&&fXA| zXgGYJp}9;~VBoJwS^BRT2jvhaqrFzDBmMK9m>cYs*V*~X--W?ucJ8ds*Q~dXmOI0) zti~F4esOn21rsz>AEB8Cc&_Ye({rFx^{K3_=!=w{Lz+w!XHARr1!2{Zx*S2D6J0Qf z^B-EYlcn&|U00~M@HgnG18Lf#h;qGGTLM->L7CMSwxI!|ieXPxvtN6Hl4+)K1OZ(l zt(`fH7nVFIhb5qOwu4=3G`@}GZIDoZF>Cmd65nlB7!h{@0x8Tr79N_FQ-BN`GB!;<8D+t{Q zVw4s|FxB$Ozof0@3#A4pSnCMbY%!*v*Y#);6mo2|kA}wq(A2p+OZR~EoGNne1B16aZ^lUQiMxN(EeE?_32XvOta)-*nWptgZ zamci!M&SxOa51^%uP7Tj7`;t29;j>Gj)_OMz7fnTu+dSw8|>m#iHC6v6jiiwp6U+C zLg7|dU~&)ZkE)}HBix3LeC=*@!x^WS# zt5T|4l+71&ZJ6>}-F-DY)%5J6C`yd}=$A9YFd1jj8^%l-8XCKT9$$Y#lm}bso8MAR z*EbmQR9qUmqRlSWxmH1YXXqB&Wuxk);EsNAfsZMTWGY^E%J_$4^crjGxU5QdPLyXA zb}bvfAM9LFzG-ckcr;73I&0lRfr202PIP@fy5(qElfaUC==}_1SdRmXw3SIbf{bdf z?u~G0+=l5cY2w^yK1Wk(>HbxS#NG6}<5YvKasDluAI(rf9iOARDjF9< zuKMb3CbCmU3veXAAvZ-=W9Z6Tr@e|iq$i?!XE?;kZqSa<3;n@!_x_I3kD{zUN{KS{ z=lT&IF(5|Cplx2Dwt^nz&kx5b#$h&oCE_^gta5bsrx=F9M^|8U%0jnp4YQvyraAF{ zzyHgD{||E@=sw-=R+foleb$H=(m4B3C6${PEq{Jfx2gW%yNi|kyBXcjsQG*Kej=lN z86C>#1V(2wTE*x>Mz3V_CPwdNbPJ;|Gx`CeyBXcjsCf?ypV7XI4rO!#qq7;UVss&+ zS2B7NqxUkph0&K8{eaQkjCPwzxGAv*ZOwmEY-V=du~@y1W!K4!%8a%yRG-_}^>k6S zSlD%Wqxw9CU334|h-(wOcDJkVF@arkA}~uoM)~zA?D}vpsI+}<7XKi-X3-)0*mZkA zjZZJ;kH?d{D_{{xV$T<|__Iq*67<+cyKSQc{A?E!nG}2TJr0}0<{{_yJGb|_=N31P zCdJK_Kn*FbDFljuLBL>O2r!i7=X=QB=bj~*!Hiz_PuxNXMs`GBu}G|e9h8VBS>kmf(7^d7ZSnwOVn zqr54vb6W}uh$FRMzIs0|545khzHuaIaiy*gAE-K5f*LPm=i9mMEhMu#vuRHaBD_+7O3ncsGRVr8Nag!IiBG?>O0 zbhtuU{E0oIaN>L=9!f%xmr4SP>4FY0LcY2^pnd7ID?B0(mFueJ#t|M5A)L}2r1?hk zYS1`FPg7~pP(<2E z6DciCUzV=8TeoN(rDWYeg|=G$Wl^s08L066$@8;^UfbOcH>IbUPBT$@yXk3@8+0@7 ztVY4kb>}M2`@8!qv>2QCX~K(@^%IoR!(Azz@2^}kn$UywX~OB+SxV`*ahm-jLifR% z1O<~NE^Ev($z(E#Cc%_Jl1MU1Atk5A(Z__mek*+igx4#6A7zLh%ZqZY1`4VKYC|EZ zT{zlC!ww)&PKz(I$Yt(m9iTtn4pDnB z=%PfS1)#%~D?*lm^6PBaF9E#3Qos$+>#@K&fLgmK5A{bjUAHeyxnH7~?eqVoP~|BY zb4rLBw(_u*c?Ma{6H3aJn_B7T=@k?Pgz)^ZvR^UXA4D^fJ!kKf7H{R(($p}5ej;WB zySAZm$jdwJtnqI3hI)PKX{S#*OSx05ETvP{DR7p|XE3N*1fO zktUGRw2NUXzZ!uIyP)%Vd)m)<@<&>bKTl0d6-Z`k7EI;WYZ>awrsQ9f)$2ANZUy-B zR;xhP0Q|Zfsjmb0b-U#t+J-+ZLA8Y-tv0v^`14Z4eQ8+y3DVjxEWPD$yDZE-u?u7( z!2OpaUdsS}-H!M0JHW3C*3Ur7%hs2%^tUB`i`e~;_&ZGH7n29y1u>&p`dZh$v`!#z zt@{Wj{gvWfEem^JyO3AEgymNO;yO1hzNHBJuCVZ1)1E^~X-|JeX*t0#$&R+wnbb>Z zVZlG8K1CpnDa|nHuXV$}HwfgD4Lf1dpWqzf6iA749!%xecI{e$tX}&vOy!5LP7e!@ zkSwHq8Ck<}0AayKq5k%He)J#(o|xC@u8Y;Rv!M#_sq`(x331A@ewse@6eBmLRPgO}UnNmb z@ZU*|*pbG4Yt*i2QTNW=Q1%*X}1`io}%CO;MPaQY@vJl70$G^2qc_FsGzX<)u30pku>+l-&0F_aNkb_a8VSeBxSz($um3 zFgTZQSjI|u$U7-dszc3aNNR-HloVjKr1N<}UUi;8{CSNqm*p*k+4Wa|-ccZ9$pn~Q zQU$Z??=f->TYc)C_x8~oHFOb{ve}+js@3UIg`JA|1z0%zUC4Ce#-R*`w>?t9Z_~h)o!K21c znOWY@vg9w9Ex+y`;m^-4A5eDDU*?^c+dpSuUViq7i~AOjru{tsNiinL5+lc$V+2W% z1+z&unFZ0ryJ1o~eUf{Cd*aNQr5=1Kh)Urzrcjgnr{$4e*n#kJ$)9t1V=Qijm-FtP zPrywtP{kjD^6?9ob1r2f1$Q!-S-3nQ{GmGF&k2bme+J{&dv(Dc)|XR9u>u(a(DyzZ zbPTg||64B*$R2<`-v|0Dv-9T#=zA+z|LX^(pl(CIc!3NC=)Iw!6PP{pe8bQUXs0_gLzLCct(KQBjqG_gEc47w~Le@bvYi`|<8S{CuV6}9(yfbysP0!3Kr z2-zf%%6|d4=>x3>!k@P$eTF*Sk@OABeMtwXJtY`k3wsD89-w}Df{tc(9{)|a{+8X_ z2YMjlxf?k;9H9QkfKr5q-)G``KAx<2ALL4KyO zJk0{7puG66H6CRGp!c?d?qv4R^8|rx2I%w0K|f=5{(LLS)7Pv_?E^i)?EE>gI3P*J zSti4rVwnoFJmv!AG5@vVeLT+INjvCk5$~rRa>$PW^$`T6==2TwtCv7t0qDK0pdT@N zh(Gf44wk2DKq#nH7CrC#1 zuEGJr4frfgp!aAS>j4Vo*=X_R%FAJ{kXOnka-F;i_MV554k@un$wX;&rwoKykTMcx zNy_*XiJX=)RVl^GrK?e5o=yGCon_wP?m+xLcYg`9@!lpyH{eb^AJS@h>cudZrCtql zb?PlJ+fpBdNysE2U>+bZidzyVlDm`EqJ6l)?T7#VIRkR&^Qoq?c$c$0{?VkF=FLey z_okT2!nR=XTl<2)2kG-NGREDJRpOrM?nMS29t^2;%HgRn#{|bH&z~YG?(?%U+#}o> z#FQ`Ri^P`i$TyLU{Oo+HYjl$~(h7|SabslTwjpGXyUdMVuYeR#{zv}Y=_SL;`?NN; z)wNITm`QfI|19~a{H50G+g7%(@7U~0vB#QvNpV^6R&$zDNX$%*=|SX-Bu7s%)s~RX z)qEt9cbi!qkMG&`>$#~FJ?4CH`)_ycIIx4O4R|zM=to<63wd#gA8*^Fz&OyA?z z|NE}^#HCMNd%opP>*ueo8+1GO$K$Ce%)qgcap!`I%&s#S%`tc$Z}2=*<2lU}`r~@$ z$ng1dE--05=kklzbDmzU=RCby&w0Vodd}r5t>?T+(0b1EPtA>pa)O&BhYa5T$NJAp zzU}%vG90(3gx^vqFDLwFN4#1;_;uuS@@rTAkfgq~PUYb; z1}`Wb|78PImo)+?0pvL_-wXRwHTOkpy@bWlIDF9W5B3^$TPi6D^c0P3y{l)Q`HX)DS*=xKnF z{|?9=KQS5a7tmjs{Sau7*(1XYzZNz@d-yLkoKn!~%uaqn$beD!1f$WRkb+8Z%~UjF850P24O=!eWsH49%dyPkgn`Eo9kG3SAv2hcY8e9$s>U(bIl zbfFIeH2ikZpP2n1Xpq@;{bPK0JsacQ0(v{MuLs@8?0Wvmc7a^;u^Rquplg`@pP(C= zUC)09-Z5#!`+_3@^tY>2USv7avVz%l7#ykZaRpN#>17A|!WkoQ)| z=H!cexZLq>fh6T0D)>^YIP3^+Aiki_z<-HzJT$ZI&KF^BPI(M({K=Hf8xF1Bvf+sh zCi2vVr#H|yqx~!RQ6Q55>Z%m9mf0IYuV!{_--O&xlHK)LY3_8yJj*@Yol3-f2~w>! z-wvrZJ>RWJwb~qxly}wfgpt%6;`og~&H$A7gU)64^Fhm)J><3<%ceHx;cc&78N4%j z6+3u;`21gQVdlp*n5#ksc%Na5;+K4)w1QatMFUDK8yd9=x1CU^F}8I`Y!UfA2^S z5!Xikxtty0Ep*A#&E-`tpPv+R5uJs{gHtZwbPPLPmjN9QyYasQ|2F(v6RA98!_0Dj zNIb zpU(PEmW%w6^#|-4zqr%m(1XR*!Mrf;A24r;dm84masPq&R@{3qKZ^SpX7|4z68Awv zSDt-Ywm`1PUJlbzkX?{UdKdIZiiQ=8fVunLy^8LrI}YXo3tVJ}xh8`*PeEvB?Og-& zK>b1Jo(_Hlv-@ATK8>~>9SOAc;99k4dcoE)gt*5N^6IG=@r@@X=@Url3#XGl&68Sh zNzf1m5 z+i|yRNu-x9*OC5t`@{7ZC1W+F9oe*_9K{}uHFL8u)@9%W{v@B zZv<^-_EykwCowk>4BA{TxuEfPz3DQOt*(Qx|KSS4T%G4j0TGj`1)jI3~iJ?3fC3rlSI8m17yqBYwLCaxy@l6o8Im_7c!Z z%zhT=RA!I#PxTkg0QEl>a}jhc5bZPH0S(RlApJaFBK;Gr4G|R(Vgk(qD6dw~AhYii zV2|ru)6s=TSZnJ%7>9N?{$S68BI$K7>tHO&K9~c0pM(7lnn>=!{+g|D=ejtaL+vSQ zyT$cHZn?Zmu%QbP1x!ZgNh$qd^#+k8lS1jWEJ}DT$?QjOb@yYwm9*36T)$Y(+RyeZ zs@Xg8C&GtBL^lG3ZB9BUO3xg-MEbf-xCX`2-8ptDA5%pB=n>nV1=yqLtMk;?dz!Iw z1>4r?!G2{mE!Ft2P_@rfL(Q_r`bzA%TI!4BpspvOiyEo^aWOy_3~UD81AYeLk*Nj1 zSwKC{VBDv_>*ZIFd0XXgVeXZGfthh=C}w(w9V&)7`cTOsk&Hbw4(9kn6JVZxXsS97 zqorcpJ5OC36>bE zE!OUE#<}8q^z4<8n3SB7nwFlCndQ#zozrLLyy^uDmtK0=<*$FR{o_w}bbS8h?ytZ3 zc3&6+)yn%1&YRCq@P3fbM-c0zBmB}Ek^XtP3$=qxUtQjMMz{WXKgl0*|GfQf->dcm zp?ENdmbj#>ex6gtn4O84ee;KomK_NhC*=(pCE0tW3)c9Q?EZzrMO%;5-UEt8m|}aT zJ$E)Q||6q{YR!VlCM$tk#*o`{quR1V~sx_R~)N< zx+W(4ceL@390q9l=kf>7Z(cs5eScd1IUk;eqYa1{`QyXOWA)GDYviA&pQo4m z;k&u^~(X?Lme zOnFL#f6NidY@R3v)CSNp84Egu-9H6%7_%3HjsbXh1m*JrfQH)$dNH%Nf-YzF)u6X9 z`|Y4>m|aOghd|5#^&bP8%i@{~jxt8%VrWD@k_sMSGGgS2 zytRiwt^{cK%RyHFH2v3ru4MODf!@LFYe3gCI}M*Kz&HV*{!2mUGW$GGFSA#GE@Jk- zfUaV8>it>kht?D_I`-_?6!L8BhcN$bf8XAdd|)5#s3B)N-gYb|I~_yPW;^We{DR&t zPr;o*|J!SVYl1XHLPld9>Dd6)H*LZo=NW+Z5gW0t$@dQC(4N9RAkZd&`fx+0UdH5V zT$0|TALPRO$x~Qio$StXWAz%=rU>NJe0#y`f}A}qE&zR zc(P>5Z0=Q|NBC#;me*I_{E0Y&_ym@?hJ68Ux+K;%8^GCy`zsT@; zc)T6s`M}>_+peztSfpI%PF35xNdFyf^*-mLhJNJODAHd$Ykzsa66rrO{_TfZ`_KKc za9anlaiwh`4_cv-@sCV@s2ykPnhe8d<&>8GIoaj5#l*W4(7q<+r(%9|ME=Npft2K@7knxd6topIlETA9Xl3Ud zt~eY^96<+bWwpb25KF~oV4XeP6_9TJ%=|porhXo?O>id1y8pD;-E@WK0A(F#xw{hf zDrGfe@bH1)L2?i)@{Hn71f!oQ(7&?TQ8Y=2hs6y2hinn87CFXbv+y^ood)`ZyNY~& z+sR;JJFsGFlO?3kfaeeOr{H)`M7da3P8E9u(KXoV9QelaTazvlim*ekLWeRj0`IbWml$=w|*fA0T(6@Q*jTKswX zxI3=l<@b5Nz$qU;a`)Wb|BAm+{_ya5e2zB$+~5By{=B^YulV!#^1tHG^YeejpO>fq z6@SR4y1(NsKXk4^?{}>Hxj#;|!r;$&Ip*(+-{<9nQ=Z=ij3IaTzv6FHe|fk(emp+q znW~)CesGiGgv-Nx9K!qIl9=fF-$?#kKI8Ib2kYngx)m;OmX~%NC-L}3Ql9VpJ|F)P zE-y!n=OX1{?oW$G#-Ha0m#?{;%JYSf6UiGjS}eQVkBmPbKe*3Q-$MzLf4N-D^NEMo zg;_TjN5r4y3(sdBU;ZAroZh-)ayQ&MZ}IXHS^jywuLixI+5ZlDGqbmWu4i`b$RCG( z<9FylFn`JOxVtb)pi3IZ^htmBcdl8c>sskvKI-=i&{K9{9{55+hJo@qUb=3Qubu3U zH@$b6j;+_Mj8+yJ(8K7`P{$v}hA% z(Cy&8lkqPH?`4dCZ}1*xz(4%_gYf;hZP20K&Ge`N==PQ}{T@AQf$I3^dbDiNa9v+_ zdG!1TIPC$T_3#jA!iTDT2WV$FUnO4oJv)iD2NUk7u8 z{Sla(?N7jb+WsQUm+h~@eBJ&g%=hgdz}#W)u#04u{R`N4+xNo!(N0&!ANkYL56p01 zyNhmTw=Z3a_-5-CxIIH%s6ub?GHtm9z;J?A$-RcT>nj+hOexJ+V{Q4ATv9YY+ z{y2@yA?}Yq=lvwVuf?CsJ=~qvI!yljX!(y~5#|0ljf_9Pj*K_&-+8&`{duUovi_O( z?`>l%Ije5z5&R?km*+5fj;(9q{XCZ|$l5@+eEH2I__KbN>-Ts%c)jBB4%PEVyXyNm zUrvwU&*I7bbG>b3zt6*QZ})Ll-O@<@ zf9-?x$v-rH;pLsJqvG|9 zmm3~_xqlXC)h&(WADKVoPc@!|>6wW`t#7=ZkvZL$pH}N%(GFi>y%y#w>uQ*Px2}V^ z!MX|N!`3ZU3H#fgfcsv5Cw0>%p$!FG`R?LZ6zrdZ{*26Hn{yNWN zIyPM~PU-s{d$}|;#+TauYD4^OD`Bp(T@Ul`wskN!*fzm@*!Bp_&9*0CK5csm<}0?> zV7_5{6XsT1hs{NH*}i~%x9vxm2W&x@U4HSwc&tD#@L&eAu<*JCIr%Ib3NRA(3hi3JzRhn}3!m)Ubc zM=*Oa=qP3%13HD-OF_?L_Eyk?%-+cxHjF>aYzz_uYGw9V(4ove0<;*Qec~w4G0a{9 zdOASk_W#&>4}d6=Zf&@_dvZijGR%yakknunn8BPCb=5Uvc8!=LBVx{ZjjOo2#9ea^ zh}m5eW>;Os92YT1nE$DnZWdAZ-TS@we)s z8R!c2N;OMXsn@6_WUV>{G*z93_;u=?pc(1|phwilKz~!81kD11K~C_u9%@?#Z6r*~;Eyyz3AtAIb-kFuoG%_z(O_(2smRXo*Bk z(i`dN)O)FJBsT4jwEpBp+RHSor?ABqsl{+xC0tJ&_Z8%eXnz&@OR@hI`~QOXDfBmq z_rJ5BDQb;_3#YaFhNZIjT;lIZ5uC92UZO<3lW={pzY*`tsQYx@A(l_^{Ni~E?oZHX zh~pCR{fhmWIPNOwZ@xjrDRg{xexZC4F@K8KTZxcg;`PM)7Vjs1=IBCacleR}Bto>i z7O4l~dBpP;;G4z#631IYc^1!E^!|yEPJHfy^9gouvD_EbFX284?w{*zKQ*J`(Bnu| zDp0-33DiYZ4z#?A2MtqI0Ij5|3|duH4KzY!R040)4MiaG4-$z+D8n1h|_Zdw^FGk!pz_>uPCrLkGB~@(l+vKU_U2=!?Zd}Lo&go*A7vJ@lxTsGQ(-ySb zMcQXkKTQOjjM&Z_h4Ng`z6+nT&wq*o`s~`VUtSYVePsRR8|%MT$4Bo;oG%xB{&=xm z3*+hd2f{d1c>e6K^kW72B=ieWbN+t(TlD$`?UOjZ7W1>nIJ=;}iS=6K!hMVFapr|@ zKFj*T5#sw7+xMc^7wdn~cCq06McNm!J&yU-fy0Su_9-5x@U^M@+4BqcU-Y`-^$OZ0 zp?t;*xKS}b#P+G+KI;Gd{IBTs#q)^mso3s|_amNPoL7nUAYN$i#rzZRGjVl`qL1wO z%nig!o=K9)&~rADwF1f{R@Pj3lWn2=1~ZKfo*5XYWqF_VX2^N(Y|vZY4?zF)e(5bI zZ@u3^&h&YQcHx7M4bZ@>QHi zM+yC8jDU9)pHIx^M4_CD&z~uLj;Yqn{=C_~4qlKMG6|kpCR2lIWuBnkGNTOmQkeyE z4Ov6b*0O=1gJjo0ugh$p(eiQdeUhly0;KFV#a_^JikqOf71Sq7^oRXe!*cFJWFF+a zIiU0Nj)Laq<$=cL4@E8v&;R(z{aCvHpqmwCMH3=Md|!G2!p^apF61yz(!~u?InNtSD&U9uk$tjNs;^Xr~mTP zKe_(TMncBp;-291@$!Aa%ODsLObAENAn@QZ_^ujw3-DQlkT`JaGHNb@JK{YMyb*j5 zN+P%*6i2v*-HI^ykAKBW2nj@ew`gZ|7ve=f2cjKTbm-?Kd|#~S1>n4wlVJIx zOeU8r6iTH^rB-V+TCGl}*E=~CE9Q*D1xIllC2+XnaKqt_!vlw>aCixaw{Z9fhi{R? zP~<3jKSe(KLqk%L#F21)hAvXS!nL!!uF-0&N@g3Ceq&fQyfzxhcV&l_Wyz7U_sg2d z!?I5yPxN2n4=bhrRnRG(!u`0@LIzc!`guoyceji9rbR`bI-p*Dg*n@lKT1W z@|{VC{7#USwn$q=(h)d#uE-q~{iwkBnL0v?BGDuUeg)%ECKJg9l1Z{iHpxXJY-FOC z7$%-cWHOm-s5YY5cs7$ITs%i4@mM_?FC{Wea1}%uuOgawO}rMTbRi{(d%Q=yC-K7R zOGB|#F1|c2Q;Af?rRv8w#N}F%nD{>SOBTJU&pvB~7_rEU?NyY3{}9K`MqxZDw!h-| zq9{(pDE70$Jg%U83-dy;U5>vR``NvGDdMa5S7iJz&L>5jM8S21dM4J-n9`2^BfjXk zP|*K~_0+iJpVdRLe<<2M6!bU3eHM&A#QsObg^S~uuZ$DLaf~?5F_!9S&!%`!2mex@ z(YQ`1bHFi^^(a7F$POYQ2iU`yznx`ofZnxxbN`clibc!0tPL^4xQU#eOyc$$s(SA; z>^H!huHiK3ABG#Ce;S^G<{9!q1@Gj^uE`8BCYwN~B~J%!pT6lso9NBu}sL@zQHR#k?qCdr_wSUDByX=1j^rL@Cw1TCA%b^vl8LZ%sFe=^&tzdE96V!*d zmR~8~msH8Gh4!yrep|GK?ejZAR@lOA6-awqXLxk&YU}#RyDr+Vi2aY4*`i)Y)ZdBo zJkj1Fj{8OXzSs|oc3-jHiLJ2MPKfoeXt@&o`-s;UxtLD;oml^)gmz49N5pbhv_6XU zyr5kZ>UZYd4facj`ryZ##i^7UcQh3k2Ez zU;+{IU3@PE`6c+F5}!L$cwTYb6ED=)%p&WJisrMJuVOxm`iuA&hyErz+QAcBIJof} z2dDQ>53w9|cYGudgaYGK))A37*^yG*k1789C5|s{zH$^jVc#ZlNBxbxj64`#-#)f- z`sw@wnlf>4Q+HZ;qeeTuppFpYkF2%Mq8Hge@cCpv@aY{#*6D_ebWh&AEgRYOV4B45 z=yZu#0g!?o@El=3q+f}beI=d|Gv4uY!U^YL#EMfiUaVk68khJP>W*()Y+9CXkn zlc!*mG9!5(=z-)Tpl_0GpnQ5eXreo$cL(j6-V2m`kU>vlvQ@XC=eD&2{rVx!o5bo< zfU7Ao-WA8yMe#Ud`zW@XV*exRF~oVKIBv-n+EKCJA%eb3Y&YY@@#T-h95=mt=+|!^ z--z=;F_DNnj%nr?S7n!UaB*BF-iNrZT)gjie@D7_aa>izt|YeOF>*(`#A*&M`tugo zN5+eG%*&4cGqb8Aop^sD*O0O#oS4ZRW+kUoR93WByiiC~#Z~UAy-t;iWn+3e#d(_Z zZ0D8E5*L4$P?w4>)m_>Z?@(NceS}iHr2#*etPTT9nVI!=U-?dJDnR}8|N*2#P6q0{c#kIgq(QD5nVL?*wHV? zzyIxv@lh*IeK9`z;F&MRmwI{rOY!K`FBgq>uI~(lv2zp9Xy?|T-#WL0)3El=9U;d! zJA49APXKeB>E17!oVSARaLxeDbUuKT2b~W?KIVK8^s+PU^PF8ufV#W11MO(3_1dOEa((yR?tPz%R$#cnRFT2B4xS`v{U16d=;TF4aXLgV4L3&A1lA?pR%3G%1=Cb=R#J!o2bi0xXo zxDG|o?-p$*#dbz)FU9snY%j&}qS)?cmKWL)p?wkCv4VM}&|Zn{sM!9B^KP--P896S znF5b*GuCnAU!mgjvBLLadn)Qt#CBEeH$)tVczyA?#C$1uE^(eAwExDIj^`8G^Tuuc9!MYKlmOgqRiGm1US7%7#vNWsRJ=y0~$@TzaEc3~;UFc9c|hI@q09RYii)xHaCgfY?x3%s!bXmD^uFucg|wzYXf zbxHap%z&`Ntb`1=?-P3}5EQ#(+Lc>9WoXkA+!>bQLkxl!+rkz|xp11GdFPD?=@ds8{Q$k(;+ zYLn&hJN$ab7uu~R+N&+{V}sXD&?x&}c+uW%kvGO9e>{DbWB15ZWh!L*PI|ei?;tz& z19R+(=kQXFH4%23{t+N_#BtUWHV4|Ep|J1Eun$j^>^`|GyNBm~DSw6MelPb_7)dEb zIlR$9%DTD>k|Vm0PLzsCkR@BjmYLV`0t!T*HwKk!}2 zO8z6?iR1|=ptoypOWv)`TZ`F6(zv7{0*b&b*?>`7Z1U7(ck(v*9pulELLKbrf>_&1 zTQK>{XNvf4DR+2zD^epD}Vgcms?=n?tXVJ=U`WP5S;q{ih*=f$<%4kcS7 zyLa>$5;MNpwUXuMPOY1>qxkhs>Z{pd)m05-r8*oq_jA{Ii|&lNf2>OXSr?k7_G|s} zQIFB<6uU;24)#vka^%4uLk*4g&X^u+-rBQ1_gdGbQk$KV_xr58`F2UM?|z@XaJlc4 zho@SVyD@Y1H0{27HG9`s8l>Ow&2HBg%03tP%0E@z7qw>P_!pNxjJ;hgVR?vcO2yf= zY9G4(@c1`l7KSAq@Qci8yhvU1{lTV+;MG0L{a$L*@b*78=s)93>A`Pbcc0vQiR<~b z9Zq!Yv2A|8*!t0Vf9|}}sYKm2$+Lc#IQMconYrhj>dl>Rr8_p)k!1z?&iT!vXZNq) z%P}s~`&OS?q^1tC{&DPquq$_#>slRQb`+jm}PO%f>(H9MgPUs>jxb+Mv6m zS1tH;#nZ&>@FwfZzuIu@@$QHTEtht23y@fMjks9m=6A)rwK8eqNPJYLF)=3FX>N2D zzachPF}41&aw)wZxG(wka@8Fp-%2LdK2&OL_dCTGesjL!mZ2{-GaH`>{JGx~?-d=c zMeG^-kxi(1pyZETZ#mCzaW-tz;ODC84Sy@2*5{GuvUXRh?;8DHHo5MRGD$t}xqjdJ zV&!eaU+ZQ!Jr%reK#uRqPS?#D)_i7sjs2$8U2Ybe*ZfTA#zD`N(;EEhpZeWHkELyY zueNj4JL#l4hfDv|<8FyXtu9pBI_#BpR+E!Kzx4mp=ZB7eMDC5t<0jNP=(ncZZI=Zt z&sEqw_m~gzDfNz)P40EyZE>4RRkn|Kqo334bjbRFPYtU& z->~c(_t&`m|EKkzws0`D!7fr@WP`3yjr~WpSUsnQp{*Ek#_YH_%x$j1UhdT7=1qKv zFIL$ZiHVdXexwwkCb+Vs9PuaRNdO5XK^Pf@z#tb&!l0qA2*X@uQU%7jYOp4RlL#Qb z%@{XDLHE&>bR*rdpI%SWi}WVcqgG$ikMt)4$UqqH29d#J2pLL-k>O+n{HuY`l`$Zfq7~}q>34U%uLc&x8kxeW(5I+^)B!2a<|72?BR}Y+bs=d_x9dV*p!F8ri z%}$s~s5o)r)J$A^Vs-*<=*qt>cSN|{5Y+Owy$59}7%Ty|jR;Ey^R0_31CX>O9F(^Ic3cXCD(gGInr9&ZCXkD~A znO+LlMD!<8xtvlNyaR#IL$B6py^FgRFHR8ycFcgAJ75EFz)SH1aKwu^ch?g58lT); zUELAo27m!%H#f(RT-`i9i@P8tg&BBxdAfVJl%U+py+jF5Pj5gDio3(5ZwdNy4-a>D zPd865nh*#55-9^v6N}mFA~33`5!8;i5pBUGSnrJFH7Aofv2vLL6FPxPMgj`K5u#n`IX65 zk|9PvlOL|=w;Yza6@Hr`U-P>Oy1n%N(kk+{w5>Gue=3t%wmGxE?4hz)H&ga5Xl_{> zDDhA52XfB;66iJm51{$}w(`(m2GkC~oG73%sQ-tq`PI1Y`8_eOQQN$2#fXp17kYvY zwocFx+_F8yT>p{n31a@)F*k4auyebTEbcD9zJJ|qo8``^D>J{HnQ57M@k2Gw%)^!? zPqqYgI{lkP)0^Gty63c|x?GnouYJ}s-Roqdk;~3ou4{fcX}WdMqH;4#+vWAUB{8Au zo5%I8TAUt*Ww-40hb2pPy!WKhH!MljFB0kgY>O^yk#Fd}o0hYqE%mq^w=BI(iSIY3 z-?q%KpYM*P;txy8Buu<(IWe`~Y|`hRpk+&vc)Mn z@AnH2E$55JzfvSWvRnykHD}?_$Cg%!H-B7Veq#AvQKr8|`lqGeD@*#)V}DwP^y#|! z$kZH5E7|E8*>#=@dO1-)_jz9ZLS}HYxF0!QI#>FK%%bS5*os}bCn{5w8(`MHQ*)sG6vnt(vb&Rc%ylQ5{kJhHgfp_E(3hE2=xGC#vVEm#cqJ|Ekt%ifQ~c zbuHfXH3v%JTmZOWq3V~ox11|27TX;X@!h7%1z5J>n(Dd0@hyPGmi%fVNK{|Y9Y}0<=`s>xd!sG zrjVa+V2yc;K+_c7%kCBOvI_E$YFMg**G8Z={3Qb%eqrTJ;fM7Ke8(ct-%`(vN{q*} zz*CgN$SMS*rv@0wbi$aaH%3Z>F&-LACX%USCYcRn)l#7M)&i07GuZ~KUM4w4j+2u> zTAf9U{(!uMRp}$i$DohJ_VhAF!KfJ>4faE&H zoMO&1SD5R}6Xq53mifTs;WeNcS8_HH>BiLGOUA6(+ zh;7QYX4|n{*zRl}wjVo?9mEc0hqEKu(d<}u96O$!z)oVPunXChY$E$3o5H5CKeOxU zOa97HQiyltDfFu%?n$t}i|Z+}eFXiVpa&G!6J&ck;zhlqsIN2%b|_IV8(+kpPjVgh z8_^#!x#ajP;>h9!Jeg=`N)&KBBHpZE{epla6ZM&*UQ*PH5^){Fd*OP*I)o@;J#oB< z{}KFh62bl?>O-^LL}b91Gq7m>iN6=~L(~_4?fFFa0FW1Yl7E9pfHC9;7x@1 zrr^yG=(U=Iw-DrSz}q0u^S1@>fj68iRo zQpCx;WP0+c-d!a}izu6+I)(lhqSZU2uW9Ji z2t7*|K@B^c@8a zy#?+6*EIAsf*S{`Nz>qF!N^;ls7X3e-Ux11@%z4u82n!?KYW$T}QNU z5urYc^*Bbbhl%Z^h{F|e0r8?;NNDH8_9;`qOD1X@?YX!v*eKfTg?2yDE!Lh-iCO*Z z{Od#q{fHN|AC;iph=2y7HG1a0&|3_HwqhKaOs0`}WD)x1<>;A{fbrO9@0kyhBj}ee zkl)Ewas#XHA7XVo?T=}1?93ELf9#GP*&BUwNv0H27X5NCQ-O&lLAdf+$AyMjJgf?im`>eymz z3DymLa4EJt8^%^;&1`M9KHHFOjQ%$oJ#bsLy}b|a#|}a7JBl5{TG`3$baozk-ev3x z^uDXvHEfd5|E^;~?l1dxXtm&$8#)%j_TQLs(Sauz4)Y$v8Eq=L}pB7tS@{ znsLp!mRxJD3)f9RYki)}jgXfuq5tU%oz4*Gaki2b++lJTdA8E`E(tL449|q(ZyeJS zd*K|$-x2&BVvZsQj$v1vUm^X5J@$?xH%>r$#yrR03;aD}>TpZB`P?RM5x13F!tKDP zo!l~R7q^1j!zFT=zzgl?)^G>7B<={8%KeIzSxET`DbFBfhG7?%ig~0^)jSdjhH1k%OcKM5+*4wsVMQ4m?b7j4M|B!O-Xr4 zL&U~=SCowRHcJw`Ye*(|*OW~3ZYY`T-Br>Je^b2Uy`AxGaeV8GZ%z1i68=geMR|PN zRWjAPVN#557yK>9UyScc-y0;uPzQIE%9S@YLarSq4U-z-Z>o1ElEn?iU7qFWepR`g z7kP&J2;;GWPRyv&^YWVoe6rR^ij~? zN*@P3UHTm8tQ0&DsG^FX`6qcux<=SSGi6SK`+pm%WCS3gJL{k_H2fzkWi?&?78-Ls?i9M)F{ zcF$phbs+e9+7WzTvcytMUtvqE1K;P^A<|)$bzuEIwawC7WudiU?4d$P=4HI|uEW)c&nRGYs)EM;h%2{8nmDDbe~t zwqs3fQe`!qL8k0Q&t^|Amj_T;Mb~7?R12uAo(5{iqvXr%>E-tHGBq~3APReWxuP^= zJ5J@J+@3}u1K^g(?P-)!iXm6n)7bGVZxxR7s6y$l?BB{&{xrRkqT=khmRyC>i~h=< zM(s$WT}{(ym(zHAN;~Q$TcH_<7^OYE+Hqaoe#B_dsvxC3z1n_tJ2ECyt?)s-Tw8^v zky1RX!k%7hPcPRsq49EhCwBBqJcL5WzvF+<^E}!f1iTp16#N@Ora1Fnf}8-pT#&bc z?-b=TM2S3_*6lj4n9Ya z)4&f1@*(hF1^GBQ_2by47$JV(Mg2LhLcfxQK+9GejKd8|V7wp~*8Ai_|MN=F0aaH+ z|Aj!`$x`qYf?QNL)I`t+{kys$suQBuqIw~!6Z#P!s9vZE0*!AEPW3~*@PX=x4kOU` ze@9QW8v3Fi3+aq%2|A-e;8brk8HI}Kj_4kmBQdcX1TOBS`FFacYJDB|Ms-Sy2QZ{1 zDqLZ`(iG^FrWfdy@DTKXe)9j=Kj?YvyMsXUqat_{LGBGcUXWLSuSB5rWEJ>N2(%qY z0#6m>H1MAh=y}$GZx!S$@P~pdL4A-S&@^&zl^|=uiwm+VxVIoz1+ON^;oxRLjsmYO z$aTRRBG9D+jliRY_Y`td7WAq+QEzq$b*BRKq!kfpnjzqs2(-2R4g7=ux<-MnMZoQm zudO+#SsXs85NQ4$06&O8ueGlPu7yBliz~)(2-JJtcJKfE17C{Jg8mlur6zRMXkJKd@Hz>jBmRAW*rmZ?5`ymkkkUJUy~E&A0Og zD&J3dt`J|*Xpbf$uWE7V_xjNag+t_tE9W^PoTBV}%VPnLS>$u#0mtuS8f>wr6FDip z)$x0Q^R0H+KSv2XWv1h^$P*sSv44N&hJ%w_$Ms@j9nX^+KiB@f_?#k79P9Xgn6My8 ze?1lN??3-aQFzzwNet*1=;ax#mD~xs8*3zCX|P*acM5A!AEGWl zv9CfM%2_!TR-sOzYgk|}9xG_N46-PhiTokErI51Sl|7W`D|Hw2-!m8Wmr=hzI6ZdF zl@``tvF;q|kktL0_bJIu?}y$B^2qxMYE6#!3(!~IZ$RIB6Qhh{jA|o%C)?+EJ&m!D zhuUX)@%EWsqTMSlF_p$hy{yR}G*FniHG$krSm`qZ@?29H^j1-&8iK}@a#(AJ1VFD9 zun2TXfMcB})qv%Od~t7V>lJJj5Z}bs1n0D`#+_pfLtjsm%-EMBl zbILzMisgGid&UboG-@U8o!=MKme0UG%-W2Yp@n1hF5aE32dIdl%(m4aH|@TFAJ}pb z|IU^N`t?K3V2p`C(+mS2Dag~oX9)65y!Q_XG>s9BnI8g`zr}c|GXj-IfsYg9WbpNZ z`~jT$RH12HQ69VysJsV!uOMfFpBCh^;5I>yQo+WCK-0&8TLt+C@Ku8R7@XpIX_{}< z)Q_P<9tl29kW<0e3-Wuc6tf}F^f9`>bl*NGpJfned~@(`1i1zJq8)d*Cs zj(#8r12nV|C2IDdI`5uABXP*Tc8iC3&=aByhR6hCu zd4fRYK~GRC5vcqN_Lfu z!YZ_KqU0np6|0cyG-^Ggl}VHeJxA0GtC6a7az>@a3{)YP=yjZ0%4+0V9ft@NgCD3h z5}aQxh02|i$XN}e!ue%NqEX9npPWw0DkNAu4;OD*g;Ij#a;O+J5=O34%M?;h4@8+# zr$&q%_X}@xSg<6Mu#8%z1(rssk;});GsqxZL#dKTIb1}qWU=Z~%Sm*|6&0RM zu2Eu}Ev=5IIfYWE$7MB~oJeq4wM@dX_*Sn{>lnEVvrUPb)#<@GR7yM_=AjJDVNNc=-9q86 zWRalqIfxq$MG!x+S1^70X3!MT@&fsAYS_ zV9O7k11#s4l(S5{QQC4p%+JznzR}{H=VRGB*vqo zzQ)pcv&v$QRajP6lvyehi6!$aW6^$VGbg)!Fh9Bd+8np%xtU#^V=g!EkvVbZJ+pDa zEpyD;>t^4~D`xL|7tLO-=gfDToi^88aNL}K{q9z? zZ?8?}`MK-Nt_kVp>0T-36MNR0_w`t9uI;+gT;tR-^O9MM&0*gxG`}*<3 zbn`b`rXgVsYga|C z-v51M_}f{LZ|hHrT-JM3*_ha4m zgMFki!ycO&jM!Ux*~o)+=8o!ks?q3GU2lv|N@zXiNXm{eCGV@o-aipNw*8ZtV=o5o zAA2@GXRO-l7B^vPcwE0z?c=)tF*0s)xdm};bm?*YADMAeFP@K^6na1Ik@wrUHJNhj zd$X%`%2q#XULM2155U^$~=Zz-ItBAzW*h`+O5Mh zYonj%SU;>;WG&TTx%FvcqE)-$C+oq{Y1S8#_15w&Hd}w{vEACX=5A}=qYSI6>jCSb z)Wg;qhmKi$Y(8%NbL1)OAlEb2uCvZroo`&QZqZz}c5u65m6B`L*+>7d?i+B!TJLqX zHKWrlYii1E>yYzztix~JwZ1xg&uUD%Z*}VMz5(_CuH*9$KfW9$FRsA6WbReBb)r(RCgr9zrPoZCoig{6J1p3^&8|NNneb@Y&MYskzp z)>jstwPV_|xWO||$N73D#eGN|9Je@|k4xY3`&dtX%-Cz{)G@R75{8lYwT=#Y%y9W;q+1_AM$Pn3_kiI7thrGG4DkP?1a>%$k>q36Nx+O&Ivpb|# ziTxp!P8|t3*z82ecZp|079749a{l1e5Z6`NAzRzs4M}|WFr=q7C#3%4=OOiKz6lAO z^dV&4R$Iu>M+|Ry&hZDHOZjO}H_#OTw_#Gcz z`Hm;t`6o*}`L$iW`Gfwx{G=QMZ`^3&PYm+ock`urLrxjKTv|C^)}uUs$2X9Fb}5K& zH8+HhtRKpsv4!#B+bi-v4yeqBl&Q+Au2}*Zx+eFS6KKZ2c!6g<7)ENB5L!q z-`C*>?W)IX$2Q<+SsL-)(kA@<6HR&V#nJquZY}tP3g7Uqvex{St8IA6*0%iddF}Z= zgFEuRZ94O1qq^`%g1YfFjXn5#ZoT-t;@|OC-TLwujQ#nB{6KzL-9da%_aXeSX~X#Y z+eYxxKS%N329D*253};)y2tZp7L4chzb5dBl8OAAx|8@hb0_o9Z%*NNhEL;Xteno5 zQP1Q%B+TMHrE~a!3+M7PL+A5n{#d|YShk3d?X#Fa(R3+4y5VwucFYg_frYF1b&pr` zn!aoK0MBH8E}O=`;eX-D_6@x2x-IEf4dudZ+kXEwAwa7!y_)=M~n4 z4U|G>hH={i%;Ts|%z|JdyV-?ny zMPaNZVyT8g9)>{2F%(yIOpqzA>M8>D5Ap{%#a7WY6kipIK;@#kvu46PpX$y!BG9So z5%B$(^M8O$^=XAMR<$tir{{?Q?R_Bm)5L;Py&9DZW3p@*H&XrDASfsPseUcIuj4#L^=r$7vF5FT4h+}c#)ME^+YAJH z-A)fNZbqQ;XUG4RkN+(ne^)+W8}b0Upe%4O6|iEWAyzeX#EOZ2SP3zi%pePa_S*!% z9lrrbaF+n{LR3sK;06MiYQPE9VHz?`m}sU2(~0?x8N`fW;(*SWR@~N0XeW0 z_VO&|9CH;&y~nVNzhUwj8SBcH1S%kytqwe13n1}21BDj{+}%ueF1r*~|71JfZZjD636WHToxFymo}{Uy zrKF9dqok{(uOwD7ToNalD48LdBUva}ELkB*k*t$!mh6%okQ|j9mt2+HlH8L#l01{V zki3?>ljKQcQkB$M>M8Y=mXZcZt4X7z4Wuol?WDb>!=wq)8PYk@RnlbXCh2bJe(A5$ zv(i7L+0rM{SJJoAJSi*F!w0ILEJ()7s>^E0n#np~W$XxBwHa{ zBTJTTl5LgkknNW3mmQUzlx4}z$u44l;%wPn*;Cnb*=sBaC?+o<_mum}{p4lkf$~c7 zD0y9ZV|fetxAHFX?($ypzVdK zr^X4$mz10;keF3eH!wk!p$aGaRGE-}RUL=l?-MFkU72udvpSqasq4YleSLK!HAfm_ zrvo+o;qODwwEq*Y_$Lr^NzG`=6IO%C9I0X7*DOIZ0dj^W6T3w1x9=f!RMT@3%$KI3bd}aK4?R2W6(C*1Z@DBfIZh5W6!lSh`DUvZSA@C1!S|{0?ooWeSp)? zB+x0;NlL0Zg*$O1!l|{BCu!r<)k#gdIdw;G*vqLu=me*Upi`V?g3fVThHsZUt$@7E zEyGPg_PHH~4&tcWF;G8`GU$W+Jpw>^k5JGG9#ui<(hKPSJ==J)q>E=a(C(i7J(XmD z=U~K3yefHxl5np`FP50SYGA!`Ew9?330|wc3?#`b8FHG}eXmIJ$mm-;(#rSoLt` zh59Yt_ zKj?n^44@_b%7F&>jRB4GTL!wt?<(jmzrVDhE9`#Yi5+LM!;UjKQ2Gr83)s;kv1NJz zH?pwoK2TE5Rt_{EBmg#zfV!Yf0*dNT?*={!1jZnEX)v^;!7o5b$b}G5t1980c^z?q zK2=L9^HiT|w(C>phRzFxZmf_F)v=pQ`LKFnun~pDggKEeVdEfA58D8m8I})93h7qo zu066AzBf_=P~Ix1Q~|A)G9t~)j!YYcQk0i>$9A82VEYp&i5%NU_`b;d7y40xx6IOj zfl&hZ283Y98^Jdr;Ob;M_zpo1#Q3ugf(PR3f+rwEK;8_#6@jMT2TpBSR1S2;`4Ffa z23|pssa-1@fnqjpg5MJ2S=h8he_m$D76kg+8sPuRCl~d{)fIu_E2uxNN*ME=gXSp)cY$f^QPycY^Ompn0+fJVS`z5568_=N!au0N;o}&$$D9w;*SL z^B6-9MtmrEm@u9W2agbBGx*O41CVAN_G~kv1YQP#;)(+?{tg!OuT{a% zpdK#8h0lVYL!kF?4g7{6-vO^i+eE~-0sj_(rdbRgg0^f4KA6E%5$w+nz6F8Wg{Q-I zC+<5+_ZnS@K!5u^_$yzGqw(Q2c=LhK%tyoDG58b&nx@AY_?$qXa$!3jK7RH1)IaEU z+`|s|gm}wrWSerR5nLP4j$BvJq1-Ug(eN%tEi)4&`DCJGiUd|h={#vwGGDqHGrBd> zy`cLra$v~;=^@ZYvKCl7*GBd&=neG#u-4kWNj#E0fy~Pr${CV`9vz-I?EWR(6&?!e zCp{lDLfJ%Fj5N16;JZ*J4QudzoBB}q`HYF9(yvL z)D$D(+6eT9O|3g?( z(9g4b(BEs=XJAR@-+E?!8u$!tV@Pm${sR+W-#Ke3zk;W`>I_U8GHh&8ToUdrX+@F` z`5`GcsU>-qM3R9mDr7Zs_|rI)lAYpBZl*j#D;JO&j6Sqw>gLoKl9_rpwKN%%7KavZ zU)llC*J<- zt^zt3Gh-$RDU+F0(4U!gpzE29pqqdjM!U&uhrEN?!$|BHTJ*=j&H@1m)GYkpVrIaS z>p&5+Fm^mg%uD7qOzz`6t+F^j!YVz~e^ zm>bSX;GJ(WboEm|@yj<4R?hi?cfNGgvD4gTApEazIf#FWxd2DraqkhI$AzH2#Yo~M zcxp)|=zhsb&@4$XYFenYHR@iX(_xg+qfRHCWF*n$6-wt^?_DTod%gF865oZsERe(b zpbCQ?Isk{4zjB5U$W;v%P=~KShnK(JhJlEgYM2AM#IOo9#jqZ9hhY!sF~bSa%XW`{ z4-8KrzcqXWEn_Ngk^p5JhEd1_EMS0bEif1*GA6KVAWOOhc8A<6us`Ghfw7PW2M!D5 zh&3=CazfxO#6J%F6ZCmtR8rGXHIw=!u_Qie8EDDm;AE7I-)%`{Xpx^yGEP=v|VxfNo39K+J*UL!gI~smDKuPd{1=9i9ib0#99@WD7nAZ778d zR)MjwTvh?C&eR8OgxMoY==v`fIP7@5`w76Gvt%kW6Xj+;R)TTp6_z1>C9@jx8q6-? zc^T`$q+|ough|LYAk^VylG%rNiql380-udN>wwJ0=#ROE_&=DZpcJ8vdJn|9gnVEO zC_{d11<(lA!g3^vtqHjv+YB_C{RZW%HQNT1qSaZ_fsJ9Y{(NeIel7wKe_ z(JZI;D5H5!Hqf^&c_^dt-cwOJNBPA0NNDfpJA`n)i%>?F`>yttku~t`h_Qe{jJybS_Z%W{i4ZRQ(YxhmL!LSi>h9S$~OwJgx4SI6ZaMvIqO-&O` zl?fl%38k$|V2?lv=^5A$rEN&yaFoLFfwv()3Csa~0VfW?bOx0U!fXSMfMldbQh$`f z@kz@;{gOjao+=~{1Rb3`7IYjuOR{8U^1NgzS&+OC9#8$|)vhuqB)rL!(;qUo?zuW`{7+Z6KCHU6{)_epT37SP zl5Tre>{;98%{QW!IK3Ec4yzq0g4@Kilxx%KhIhzjM2E zv#OKpWjx(_Vdjh@KkV1{Sn>0S6RS0)hOq%{2L?V2ns6s+K-$Ei4bOk5vG-c)h7Ma> z7MpNo>bv3TH+tr@Xl`EO6t*R(|B=uXi|NCXV`aGwcXUrPj*e@)@4%dt$QSDDBc+p7 z3+l~FzA!L>%WmH7H(BfeovN3=fANnYXX{Q2XqBU{tF-% zv59@0j%A(Q;ktjVN6YjY%@zHs_%3}s`$ppt^~5$vqMl}0vMf){7HeGx%Fu3=}O;0=KB+MBa6XI5DV7H$}4o=TqU3}!RiBGC^ z_f)V)XO{Er=J(!~G56u#gzJy{MXxO}>d2-yYu;r9v>bf<`N%;>R&*)z=#;taZ}J0~ zrlUJ1pYeU1eez=5l}97b49FSREbi5=fo_A-dgaQJmK^)>a9lUlI_+y)96B}S z7yaLU-p`&zi*=4WkJjt*|Cru(hhaC`tz(Acpr7`w9}weMTcS2;O=t^E&Y+*JFez_h z(enl&o;OthH4CdsCYa2a$ox<3g$oS`i;Cwz#idg`EuG&`OfALJQu#;le}b$1C)oA> z2*#FT+W${6wiE*|V%H1fWQ*e1Ma*nb9J`30EsA3ov9v{T>>{qVD2|=t*J&P}0vG)m zkQ~fK5orAXuG4koL*aG0$d-b`MEsCX)II^P;LzN#b`J$=VXbZy{4{x!)z~FMhEUFJM6iSsxiz z0?PU$M)a?#l>;p=e+SBcvcfl3sU~^K;i^%vosQ5X|I#$Q;F2R z^0&Btl70}$(x1nS_jmmj(1uRUFe3Zl6ywr^{reTcm))*l#!Kugg5~Zi=tLYVf_*&= znCm8bCV{4S{sP+0D+Y7jk6t#=aPPlX2WNU8#fa~mx2v%h=Vmk-mBeIp?2}Q|h?K%^ z8MPqSGd2fpX>4U=No)Jg8Ser=V(gY5XakLfo2|K+GRlIbL>{aNhW05~7t9fTuq$Nu zU=L8QU|+--g3Dv|aR8h-BFBQOfkp&dKx+io!k!nkg;mLP?~O>{Ei*n@n>-}RnxrPr z;Z+oRkU}ez-=@6B-1uWkaB3JljCM}t$^O)nm?vLPy#d<6zGnG)+6~N*MQ@`{`K4PF z%Xh`B*dw3McO!M|yLvRpZ;Y72E1Em!cR@_={66_C0WQ<_JusP^4f-gX98?8Qr7Y27 zy|Y?a?`*V1*t~^R&pqsjO-B(G%O~{Eu%Ly2QtxBX>k$Vow2)$ja^n6jw5-+<%4#Rbg;$Cy>}xOmu^ScMDR!g!SF0tzRKgwQ z*0FY;mQn@ws)EMfzDpI)M?%x>zzG*#Q~#e=Zq0cuzla>BrLARS{7<*=5t`2=5=I7ie^YdKH&#^{Wn4i;`c}2Dw zX3<}opD#fCBKT$j*5b2y^<3^}&YS%559ZY$IbKqcg#PX9Iz~DbwexGUYvL0CU81;W z!ZNB?w$HJ@_x-^aW2t{I$9C-hP8oI+KLxv2I#ILQK_6Qml!N#2B`&eNFV&=Iw(5hhVNg z6tj2eECR=%PjJlP?*u*xRFIs&7lF{>|Lq)}PY%Ugy+ZOJ%+d!ZkA(c`e110K=Or%y zU6{NG-#TXX>yx)8%gAM|JyP2g?)zoQ#}2D#IIOk%>BRXV@$;BQy*g@ zUZ46H6Y=_A^D$N!$4^SZE`>nf-Pe4N{YSC>g~#?^^Ho-OJwRcSl%&)w67TY8TNN*?BO{Cb~5`Oc8+yJ|7<6_*W4f?TJGndeU#a~ zIjUt?7Vrfte!ko$$Jl;TPy2JPmTkOA(9_x%DgAPTLm!;@vhPw)YA5kJr5u=G1@ z{GVI&9hUpTUMfHLPx+r?zjv&M`p>fBlh5`p6z%$S&D03HMZbNz|A$k}HraPG{Ak~w zxoduRjLCc0J!^iKFR?BE#J{G)*5&ZBnO$g|)W@&j0IHzPw;<5^J_>xAAkPG!Ey#1h z>9~caNd(VA;31y@|0u+dfW34i0!?#Fg>zzlHUf_IE}&n%hCt(QfJx?t7&w_jpoPF+y zcLEO=G6aF9j|aaj z$l2hJ1)03X>=%KiaRRR=$kE_!1vv(MiXhJg|3#3=JNy=b{#FY8mM)nc{6I z)`rH%gHy~6l_}44FckknWr~5hgg|BL0jDtbh4EzI9UZ>bz*Pt|O(k%OiJ@{hxLJ^!gSQvt z>EIM2L(@>4Ol1TrR|Thd87j{QPZnf~pV=nJp_tE9rgIvksSX~2K=DJ7;1)rS0xd${G zcql)VTW}cyjaPuX3$houNs#@(O9^rr@bZFO?;+ayXYd1o!W0VL0D+z-8vHv!rft$R zLB>~~9RD}^m;b4K%KnR3yMf?|9QgoV;*vwI1l~-L2ZB!%kJwT6nsx|Np=nLP1zmL`?3e1f69uZ?>c4 z>uTz2pyALo25qHj4cb-H4RpMKo<9Wnh=7|v0oj3@zooei8SAAn5-@9PgMNyh@2Uj? z9vFH<0fzoO(p=PD*0SXHLP+{hT}3SYuB5B31CCI)QP-Vp(d~r1Tek=Fq;8OYEg7sI zj|x0XzZi6>eh+AXQ)P@YM9jSza%-oypq-pzK)V(~-VbsbikO5?Foi!L#@Wrq4Py_t z3ZO0AT7nLC8v?q`j>4bhJ{di5s=Lu+1ThI%e2T)S8yip*ekaf_oORoBss3rTN%^$OX+Zp^HBM6s znESDI)Zm7+O=&FIoVEquZcp0*ngJ|+5P9+m7XMK?#S!{`z_WbyFk;V!)xavzl{6E% z*3!1p>C#!Kds)&)(x1T^SEiLa0cqMqzEPeje=pCI2Pt?(dqpQjA4Q^Kui~6y zrgDyQu5y8LlX9DKCy=Ixl(eQCSDsQ{!rwLJZRI^>K5B|erB^wrTvXLm5vVJ)rZiGD zQ8mY3TU94jPt|bMc-2mzQ%|b0@RyC6aZmM3^-cv(18OI=i@KUR0(FDdj7C7L(pu30 zb)qNs8ctA8SI<^2QZH4nQm;|3Ri~)a)a%qcQCAMAkEl zD@{913~B`RbT~q@Q$vxjv<_TH4Y;RyjxB7OX}{5S)ArPkLwWyRyG*-Ro2fmb{SAMo zQPQao#OKVM3=$`bCp_tt<X`^D;|9+RY{}EeD;ldJ;=NvYee|m09gbb`ngCTs(T{s! zYs~V7pBJAJ28ws2dm2-eV>EDL@z6+L#BRmUu}iTNERZ}bjIqW+@YT4&NOdV$##|#Y z%`j2FF4U__OsOFO)R)VWfER)90x2piD2Na06*Mp?HfS97_e>0G795ZD2y``q-NOt2 zJ-?K<@zT&;q10l5`CU(~oEDYVB#rLn*8$kut7)&(=nkQDe^9zRs1e%dSRioe zE`Q|bHJ3JAI&$gwr4N_LyZ-NE-~axeYNV+?+6Nkqa~~{u(Rp+7=I0&F%Y|-(z$c6m zR-D-UkFYpq*bdl^+J3X0ww<+IvR$!Vvt`5M3)N#3u8fnyXEJ-Am9o7peUOQ{R)uVD zY_=V&8A-w#uVrqoZER~CxyClkY#`0&McIbe(2*a9T(rri2a~b)UfNXC=fJPk0kY#| zV={h@Ka=s&#Wp5ww5{v-+GNy)1*CNkU)$zRRB#&{chMR#TY1(Ukop;De|rA#WFZ7iLbJWrcE zFu!iIhmSVJ^OUa9w_(zSJ2BGKuq`H4%NCGMl<+gzlRHx?_vE6<@ZH0uwA3B^u-uZA z9p+14O8H9TbDY+GUO!SQd&gZH`ZAC7BmSt#>j^J)imr*;%KoZY@*IJO3LUNt+T2yNEhW>U03qq7u}6o#kK3M+UV-k2sfFx|EjL6=OC%( zj{3T@Z8D^VuVzYV$yKFcpWjLC;+O@yzKir?dMA^Kk_Kr$V1LF=*1Mv7eJt!rPa zq|~qUEbYL|S-Pe7=1AqAtku=cT&n9Z%wD%R{-!SWZE;=d%X7LTQmSsr>Y37m!@VTi zq6M@UE`QW@*4)+Z=>9~y>on11b;LAX;d4>id7VE=>isU-6QLfufwqlw^P6nZnVaXA zR=mBYvz+N6RlMe)E0uO$_bf6_NEB!eTbd#4e^bn%l|v(js4O5Eiq=3J#N!)v6|lhYx+a8 z$9!`9@aKJ_I-V2v0E;I6OZ}w({{QpW|35`E3U|zn&(;H-i0N6&{=+Q$A!|Q3Y1X~k zghQ*HT^4mHQT0^c6u%F_ac(KCey*Eu_NadIpGxFmi6xY zysyUe-*>Zh(}iA>_g=o_F|1Xo-uLU)Jbq$UpXmpOj~YGeb6-`fm^*`ame|(#!CvR} z5$}7fb3d`Q>e=!6>)q=*shrD`PUV|K2TyF)<^ITSo2P8(yW2HhtzA&Y`bVo?bp{pC z1kaw<@9u)_-^VsBdTD6NL(}?0TF!QRR63=3537=|wwN7EQ!E*KBz%o^%9?Y;9{_5-MDz7KoCBN$wWVw8BhbE2#_WYRPp$f3PvuJ~b>Q1daPaCGq44rGy z{_x;|mo3e8c|$rC%UE0>`o*5Br)vaFAGz#C&pp+jZ;FVVZspsyz_VL#kB_cxJ8s2` zoYlvjC_Lr6_(~>+huPG5Q^sfApj?F;4QbOaL+7Ar;X5^F;VT;gN>uTx)a^!2TaScx zy)J~dU0T(xaiXPTnqe&2ehi>On=KbGzp{I%RyZdQ-m# z?7o=G=f{TrD@q-!uUJ;-lXA=L*GI1#pl#t5=!13v&M7Q_M5Bstq*)0SG8=8 zd80y>p6=MEORWi^2XoK-`h3UrIUT-L@&2;$(4@U#?U#?6f4+B;*Oh^(PQ}mp#jHEN zc6@%v0bOICkKOHe%Vw14na}xK4=~xj!vGm!l zvy}$cyKJ-k<<)VUN^RV-x?H)Ufu)rT)*Rj6V`at4>5YaZoSc-o>HM7gd9SZ6UvIJb zhi^AuS91tCGg0w%uJg>R4M#NG^sYu|!{pQV%oja=ly+fR!H7N$cDR*2FtJJZTIMf1 z#I>&&WioWww4dJ9>@wOE>Y3W~;;nh*TR6VRUHfYPsLt1xjIm!)c~+k9t*86ve)YCQ z(%Fh9I&XjT#Pxyy=GtRg-@6zaIP_!l(P#7I->A(3fYvHEWx!>6m9=z`gKWHAksG zZ9J(u+-qmq3dMXo2CX_cdkeej4+sA$;GYcsmf+t6{C|Ld0QheJ|61Um2L2Y{KM?$N;9m^TntJ>VY! z{=VRU3;b(?{|oRxA@~RX$>3iH{MUhhBk<1v{}$k182n3se>d>=0RIc%Ulsf}gMUu& zzX<-zz`rs0uLS?z;GYltZNdKu_}2jcLf~%){_DYiC-@%+|Eb{L0Q`4@zXtr5gMT{s z9|8Xg;6DQVZ-aj?@b3!#C&Awl{DZ*X9sI|G|99}u1^)fPzdrbX0{_?Ge;)k%fqxA6 z{{;Uc;C~qWUBLf3_`d`HW8i-P{I%d;3H;B2e@XE71pn3G{}}vpfd5kP?*je@!T&k< zcL4t{;J+9A$ANzm_@{!uANa2Y{{i4X7W{3%|1|3L5` z4gUMU|0Vdh1^+zY9}fO%@K=Fc3EATf1|DWLBAN)sve*^Hp2L7kOzb5#v0{=GPUl9Bq zz<)pZ4+sDE;2#bCKfu2u_{W0(81Qcm{*%Gq1N?h~|8el24*sLTUj_by!M`#1uLu8i z;9nK|>w$kc@GlSk6TyEZ_-_FJc z16+fDIQYK-|3TnC1pIa2?+gA{z`qLk-vIvv@DBxlZ}9I4{?EXF5%|vq|0Ce<4*vDQ z-v#`4fq!Z6zYqS-;GYBhkAZ(8_$PsX0r0m1|2yE{3H&|5{|os0f&T>XF9rTb!9NrH zKY;&j@Sh6)MZiB7_!k5J0Pyz#{}te`0DmR;HwFJk;GYltcY=Q%@b3oxQQ#j4{$;^` z4)`|${|n&168!DJUk(0u!G9L`?*soh@Sg|%r@_A#_~!=y9pL{B{5OJs82HZze=qQN z0{)f`173e+B=m;J*p{8-l+%_@{w?1o*pwe-rS33H}wqe;D{z z1OGzce-Zp!fPZc9?+pI-;GYNl{lUKk_@4m(C*Z#s{O^JPQ1Cws{*}T14EQI5|3L7+ z1^%DF|1kJh0RJHHKLq~8!M`r}F9d%R@J|K*$KZbe{HK8bHt=r;{w=}(Gx%2ne`oM7 z0sblA9|!(F!T%BXE5UyR_@4%UQ}BNR{-wdcBKUU!|IOgP3;bV#zZU!z;NJlJYk~hI z@ShF-^T5A9_%{Xrz2H9#{O^PR3GhD%{ z{Lg{^Xz;fJ|K8w#6Z|c}zXJFtfWJ5Re**s*;Qtl;Cxd?{@E;8R1Hj({{O^Fj3jCjf ze<=7L27gQN4*~zh;J*j_gTVg=_&*2#>EK@g{EvhGIPk9y{!_&7Klq1%e;M%41^#Wo z-vRumg8v5a_X7W%;NK4X+k$^3@V5p3mf-&u{Fj3Ne(=8y{v*NP3H;}Se?0gX0sn&F zZv+03;J*$03xod$@E;ETG2lND{7t}r2>AB^{|xZ20sck7{|)#*2LE#4-xd6$!T%`u z*8~6E;Qs^sOM(9~@ZSRd;oyG&{8Pby5cn?u{})>An{11VDd+6^T{4aoiAMh^={@ua91NcXQ|1|Ko1OJ}je+&E_!T&1w zUjzRY;Qt-`bAx{p_;&_>SMaY5{ujajBlzb5|6}0)4g59W9{~O}!G9z8_X7W7;J*s| z3xWSb@b?G*9N?b_{`TO%75rO(|0(eI0snsBzhh0;u1BYg8}~hCHMPG%-1rb#_}o*iGNOp5|q?Xxb|uBAOqNolxb#E93yCr{3f zn>5KS=){TX)5eegnfK?C%D_3@Wxp8CgzS!8J z-&U_~wrI$Z$8GoTUwUx+_TjD?&4Uf|=Lhuh^lZ?kQl);?0s|+Qe*5;N@#)i(+f}I0 z>+_m5O)5Nja>4q;had9?4}LiG`t_7%<;ryl%$v8gy}7w{(__aLb}w4AR)<4};!5Vv zuUxTcQRSQ#7Pe_imMCnjtQ;yiITc$P7FICe;lq2LuU?($U7|#zGh4PyTt0mG%YrsG zPRk}wE;Ouc*;d{zE)~2A7Ob>oz<}Ft+qBu55g*_7YtyEy8~XW;=zR6+_Oq#}fupc>FDV0 zqSHNb`1DEB|Ki1c4bGfV=driXUom%XPsQukhaPn7xOZiAbj8y(YerOYcJ^+0`0)IX zYu7fPG+{z!?SOzW>$-J2{;YlbU1bUs@L4!?=+pR4o%SzQs|#Fc+<4i<+qYNsC{m>6 zgwmx$Q|8R^n;a2gcd}Nks8Vm=9=o(-M_>Ouc}kAHdw1=_4js}9+1ceAaqHH4zx(%Z zwea!j)~IULVTEmN^W3Uicb=`KrG39*#p*n6*KSAqg9n!nxO8dHyLIboBy{O=IC1XW zDytVRELZvQ<4cjFM}PEr_UuZH2~GH=8KIQ#okX!s=Kvc zzw19+v{+~L`SYjZFJGS9*uDG7S!2e0ez9Rg?fll(j#qZ=8W=HZ)cb+Oi`N_C?cJ`J ztE=yagoKvIwr(BTk{+@r_o>vihPu`n~UH2M1Vo0Efs>#EM34|c3rvCof|EjOIsxwH4& z@Nnl=2M)xPC{(EG&SlF=_&#`WBlq|3uTmE;E>k{Nt|Gg8_r5sj#*L)uvu0VXU9h11 zwxvtGiWDyFKX2N!yzVbvoTz*D>@;2T=E8{~7!%fWI~P&j(Wr0e^Gw zzX$#Yz<)UScLjeX_`86=1Nip`{|4Zn2mC97zXJRpfd5MHKMnp>z`rHhi1^zFz({~YkI4*rM1KN2_(-vs=7f`1wC*Mk2w@J|8%5#WCk{3n6`3Gg2e{y)LLHTc&C|M%d( z9{ewZ|4Q)R2>!9)zZ(39fd78*-wys7@ShL{GGr*4E!I0|10n>0sdRSe>nKt zfd6FhFAM%I;9n5@2Y`PY@Q(-orr_@f{#U_275qzq|0D2Mfd3TmZwCGs^!y(v`v&~o z!9NrHH-mp$@b3fur@((N_*Vh{df?vx{9A$lIq*LZ{_ns)8T>QAzXtd>1po5jKNb9g z!T$&Nn}YvD@DBlhfAC)p{)yn<1pHTle-QZ32LE~B9|!)v;Qs~u)4_im_}>J75Ab&c ze;xRL0{@HPe+K;R!9O?nzXtz~;2#bCHNoE*{11cwTJWC${sG|M4gA}Ke*y3x3jUqI zUk(0^!T&b+7Xkm$;6DfaBf!5F_`e1J9pIk_{O^K)2k^H8|6AaHAN+m5zbg3Kf`47` zw*>!U;NK4X4}$+C@Lvc1UBG`X_%8(i$KXF2{GWmUaqzDU{{6v!Ciq_g|3=`S1pfWN zzXkYz2LG4f-yQtNfd2;Yw+8=R;6Dodi-W&6_`8CC0{CwQ|D)jl9Q^Nq|1j`(1ONKq zzX$vy!G8w$4+4J`_@{w?5Ad%8{$}9+75p8*zcct(1pk)czZ3k!!T$jG7Xtrf;Qs*p zzk~l`@XrPQy}|zm_|F3W1>nCF{0oEsH1K}`{%66zIrx_Z|9jvc1^z3*e>(W*0RMg9 z{}lYIgMTmZuLk}x;O_|4HC~0{q8=|4;C54gR&k|2_Dx2mj09zY_d6f`2Ud zuLl1i;J+XIw}Zb1{O5zeC-_$a|3L8n2L7kPzXJHL0skl9{{j35ga38#F9-g4!QUMG zkAZ(t@IM6p`N4k?_*;Pg67aVIe<$z{1OJEM{|fv|fd3Zo9}fOD;6EAs%YwfP_!k8K z0pQ;T{NusDDfs(=|5fl$1^-gu{|NjQ;6DZYn}NTcfAD_;{_fzP3I3bGzb*Lp0sm9r zzZd+gfPX#kZvg(S!2cZhp9lYU;GYcs8Q@<7{2PLQdGMbK{=wk?1N=?Fe>e=+#y0{`CNe*^qy zf&T*VUkd((!G9X~zX1QU;NKklOM?GB@Q(uj72rP|{BwZ+KJb4E{?)<17x-5L{}}N1 z0{@)gzX|+9!GAaS=VQfEprZ7k!17aj1e%xZ7&ywhb>Nk>W`R3DHwv7!qF$iunp%N1 z-&GH6GAuA~cp3k|TREx(y5y@Acqy=a;DBk$z*jH40uOg96L{fE$-qiWJp)@cD;9Xh zwMby=uWo@4?-mN|f2Clc%Z&noZZDhzkDBKT>|G{L;NW&nfi0pP17mJE1RgD!BXH;h zyFk@#o4}LRtOE~kunfFi#3JxyoLOLPH`Bm;Yo);Vy|t=-b2C(x4}Vv^H2JD3UGI}B zHuk;h_4hZb(;Z%^-ko`_%315F>e&e`R0kg1Rn;GRTNUSeQ#I+xHPx}GZ z-8{Hmb>nQB>hrx+mDAVFssgz-sXiz-s3tdGr`kCoSv785qH4$81Xb(2YgBbZR;fnL zUZFaFak;9x%QDrj?y;&;X-iZStYcJ39ivrCb}dxZD6l}aVC+0q*n>H$@AYP@-ff?$ zYE?2yRU&qVs!{IgszWoUsYcm_tGYx@QH^q(tXdbXCLdIoYF>x&D!++iROi=>R0Ule zrW$EJMAgNApz6cueyUu1daJfr^i&;e)=gD@ZD-XdT?f_Y_U%-j2U@GflxnGZxF}Q= zV$)Q$J*=_HO4C49aY8*+dGk7|FSCMG0}9tr&EFEFYEfUM+VI>@HETu{)hlIX)$Usr zRG((}s3KHK)u69ls+C*HsB#Z2rP^J&gle`_Torz%h-&dNcUAh(!m1YyTvg7cT~q;f z`BkCs^QuB`S91oRs8AmGyJI{~?sHv-h zwE@?juL>x>J3e5~{J4OaAxi?bwu}z2sj(nneucRK4a?37s8BjGpr&_3z-YQy&()a} z5WaUpz?yHP0>1bU4Hz=3e?b1%Jp&#!?-Fp~RJ(w?4O<4--fj{QI-)_qZr9oY)y`E5 zcskQRU|p-q0gDtq0ZC3?0nbfJ2DH``2{5%N9Pm1S0S0aJ1gz?wGhpg6+ko?rECS|N zGzr*N;)nk*%{%|eub=wceYxXb+4+jUZ?n_>HIfeb&n~drzuoFB{%`6g`_Hmn=|A^r zjDO?1bN$PFobErT$Rz)F_lEhONbBx@=~xT@X3ll|x38(-nqOZaQfYG&l4Z_`Ec)f%H5Bzo+s3Ot7+u^e%7s}A9lC8 z{IR~{%}=BH@Ay1p{m3r?&kKE>vVG&%p=*@inv7WWZChL0?{WEies5fG_xCPytbUvv z)bPjnsF^=z%sc+0t+VdOqL^|&zfbM-vrE;epN*{3ejcxM>!+i+?&r${_l&KLgEE?w z>X7lG^_Yw(vxOPEYHrB5?t3(2-uK%X$|WB&?v=C3d~?Ai(=^g6vthr$%ypAPGB4ce zl-VVGP^M$V#LTU)XJ&TUvov#@X;S8)Lur}atPf_|UOSWdsN#*xO{E`ap565})9=;y z%=rDLn%05#nz4QJYOdBPtkK={)HE%w(0nUhS@YzrO7m=JEzPp@hMK9TLNy;(w$&62 z?xM-NwU_4RhXI<}(r}IYgE1PXMPZt)?%|rLlOi==(&uP8Tv({FJFrAkY36c`b>-EX zW&08}mL=C~stw+(sTG^1(IoBE)LObn^L5Yx&Gj-zH0w?u*La1V*4Q07r+HW4l4eZn ztD4|RH#AQc+}1pudrxzA^h1qX!zY@i_RlqUx4+a>2zjH~d*z+xQJs$(%jD0RDL=ky zwpIS2`P?l-GgGb6oS&)FSkExgju~O5?bpmg>*`^pJ#o)kn=;#0o1wJVzCM&gySYwI z?TCGj+TJCdv|A_V){eW7NBb;SK5f2WXKm@e`L(Jk1+;_axM;7=E~uS8$yK|wS0Sx? z^}<@uoNn6mb8gx%Xy> zw(#>}+Q>Y`wZkeD*B+}~Tsx>yac!GA#kGAZ7T1R7Ev}u-^W8QS(>~}?OnWa^F>Qs7 zMYUU1MYU!37t!`DUqtJ>*hAaut-Cg=qPuope>bhiqQctu8wzRHZFkk0ZYro<9POgb zF`$4}4)xuWJ&2T+(F5ozslSbxO0i$x%&e+XI@1Wz#j52e)bVI&ao=EuEs#d|9mt z8nH|>CLvlgPd!KT(PX-2l80I|>&7U}?jnOUZ$9_X_|$H%iOtzeQ>I28&5|qrno2vB zn$>rUYL->Vr&+buO0(4CduH3|&obQ)Udg^p$(ah-b80!emXJp)9LP+U0T-7 zJgz96S>UZz=7HP~Gh8foWQ>xgXROH;nqeX3&zMx<($5j|Cja#D^Ze=Rwf)D+70Mq) z-%tO3ab(&z`|hz{-z)Qe@pm}&vHj+EZ_f?5`LufpyK=7&Px6|VcGYWrjJuH zV!p~r@hvp3;!3ytis%6a70ZVdR@55op?K7%nBq{!5{hymr4>W`y%a}_DHVNf$}5(> ztf+`O?yDHQ%1<$FtV(e!q?%%9(VB`HpKB?W9jvSPIIDr8TkFP(d_|fn%Df0w9Nf@S zkvXJ|;%mkB3Pwy6aXY#w)T6p97F6z~c>kr3A|e35p;6!W8dMsue#Rz@f$o6Jz`>lCSIxIao!*?pE` zYS?T=;Db4e0oCUzTvpFlG_YK#uygj;1F~~b! zv2W1|#S`61#q-{)6&EhAQ6vVfRSa97s8AJ5RyfQ^QM~%GPI0f#2F3f^8xbo9Nj9774 zQN83*#g0wK6!)v1P#isaQZXd-wBp^PGm6^7&nc!@Ur=m`yQsKX;j+T&>=i|D?`w+X zmNyjc2{#p&Yu{EJdv!-)G4GyYN!1661y3F-bn_l7o>zaWX!qrrqEGS*g_SFCRLQ8B*AXGQB*Ulet=e^ay#|DjmZDnn7pTcdEd(kaphOUkvKO_c4M znJS;wGE+A4H&@1&w@^MQYpE<#(n>kDxV7?8F&pJTPg`Z#(ss%cN_%C0-yF&uH5`c2DeX(TDJOPzS1wxSq3n0Bh_X%YI)^La`%<;tE3!oxU%wXvntAGFRCgRh5IZ0ORJP_R|1vO zr&UuPuU13pZ62)5bFP;1+R8f0n-l9PBYHGYHf`BRxut1{vUKaF%36Ixm9f)XD9>(f zrHp*jMmf7ed*#|09hF<&cUG?K*-bg|eGg^dsNTv|{{56wO$RFH-y5tPePx(3_tTNe z;km~uKX#m;%=auz8MSGW(qYyVWt2Kx88kCOc_?*;vhepP<&*Zal^tHpRjyyYKcCkyE5N=82WSEU{tOaI7R&3M-BAaS$Czl0*X4vY0g{ zVtsjGBe7A~Xlx8N7SnRBOkSkn+!{B0KL4k>oYF;Ip!5uVj$OjPj9tNQa9>)H zj@4_<3nv^aC&v(lg1;ydRwjpO*BNJZ`wv#B8?R5lmu-Fe zOUpN`QqN_jv~rWm{gSoi6D!s=R{5+8NY47)Ex)c<_hOZLYu4Hl>(%-JmxPHm1@(R^k4~iuE)2*KD#@tZQuw5(`GGSogP8k(Dj*mv!q6ssH`D z^>lK%am-&n(F+U(bFrM{Vg`j(@zKy|zOm z*067O*vbm`H2r$^-7)Tyo)S5+1I>x`X%Ge-rv^0i@ZBTwbAF@F|2|Y>))TUPFSpe z7dd#&yI!Qc{JR`H`}KKuGW2=KJ6=9h~7vX1^pB5^SF`E_)Oo=GL75=mZ3Vl} zK2@K)?(eUr-x898DPm2%Aw!R(eQ0W;&8an!-1T{S_zC&_Cbu6V z)6@Sy+7DA&f~q$Dv=G&7>fkrDX<}n8HT|U(Nw@Kn4(QvDGqf3biE@$EW|%^`zil;J z3rVcrw~)m8eX=*QhTpi&m_$vw?E&^VD7P6Rs@fUbhqfHe_NT40l%dZ&Wx|SnKU#}w zv>Da)ZH8FaZ}VHMi~V*g@={6mty&^`#h4UsK=-zucrlX24isJ)eRn$`TY zPjWO16Ijn`1FGp`S~P_Fyu`6MnWLw4Q6|2eLtCkWK3>IN;d^k-3B;=i`%}KX9D7RM zj!Gg@_&8Rk%*uxqpeMdnwwM-%I2KdMG8s-Z3o9wkv3>6Sa&D|Yr#ubrw>$5lJnDSH z*-Se554bM^@g)kBr1U9JrGS-`m1)b_B|oeebZOz@N1ini{i2gg7jjT`cj<>e^p{pa z>et%IoAZ(i)Mc~kLEL2=X&@2wX1a|kB(H;(;Qi#u4gKhsnOk$5=~_57z+| z@#31YzsS9KNUk@I8@K;Y;>HEZ@!>?wxb3npV#Srg#QPPo;;Lf8H;xr|ABIJ}$dmX- zvEs_Z=^w?4t4+O#7iY_Bo1)p{#>Mbh9Md>%oX9vJ>i?^8ur7|4KZ$xOC$2U}Arf=dm{?{Ooxi$5J;*uAhoV z%6>AsUiPbBB<>(4UMtq%`}!e&Rbj$E|C~5XnDA@9Ao3F?e96Q?dWY$K@i^P|Kh?gN&~AvBl9joZDrolh zMJ=}}spvM@---TNTT>=i*8B8A4egF`TeFSlDsryv=w5mM>}`#L_N1KLrieI{l`zp~ zBHEwqu_zz$SoA|YMzgm;CUP5*y$uSYPP8eapW@Hj6kpnnK)LOxj@FcY(awlAM)cvN zqC4dJUFbec^nvV04`SjvMfqV&b089uL#FjcXzA(;Cg*-Yt^r7o&!@Pf1&sJ!WQr z-1dn!P_%!|IM;mC5I-{+e-kFIVGEin`)TOkjjNfxeH3kBU5+W@XfDLW{U+K&(H6Gj zWS>#d7G~Bb4&r~(CW?5PLA-VjYKW_uy&dd%;Xm5J|BY=QAI<**8`)mTYic1W%UN1k z``DDPz|Xr~#Y*;4W#1|!RyE@({l>F4l5Ot4`Hubkr5pjOz@Tc?Yt#%3u2oyAQ@39I z22#UDjYFC=Z5G|?)IURF3 z<<65gpELc$efmoM`VYuoV4&z1{!cd1Wh}NKbQp{7Ui~)QrJ(BwxifdP=*az7ZDYrc z7l)d_pS)q}Ns}dM%2XbPPm7p7BQk2{|0Oc(U-D@bD(vR&QKV?G*>mRdhv57L3pwhd z=*2OTv}CD#f><<8TK4CsTTWOb!?P<4&PtqBtEDxQq=dDSl$eyvR(&{||F3k5dzL6!s&tvMV$9^PY<9%VCwQnRh=1sqOE}p!r)>WJBd&?~Q;Cg+ zmL@i9gvr*kTj^|E%W-{7}~JTx@4Max#9 zH0AAZ+1iJOb_ne#_6rT|)LCEErE4e;yD`|;qi3()eW>f(PwKB9)*Cc7d`D92{oOn(Z)W?mTI} zc;SN3g^Q4AY4Pt56T`LyTN)D+8z&yHEfdA^n3(t#F)=Ge`?6K(w@Eg#%4cH|5=;y`wNkzgTHG=4j(!CM?3!Kb;o}H z^zjo?=t=IAQ@=cQ`pnsL=M6O%E?zRYzvyKi$=7w|mnY;0d{S3qu89v!-r^l%9InU2 z@I~AZH8(?V-M({|o$rbJ^q1{^XrIsr4<9|2o;-c_JoLrOKRW!YKdSs2b-#PPjjedK zohXPO>ma%SvTu&rAK?F&AHQa^u&{U|^q<-O(e>p1b%(!uy}!Qt4<04+>*px#Hra1t zAssL|L~rK;)AJVoW*3M*%zv{MA1U1}0~tH5W?7GNfaO5S!IqaOuUNjdEFitJ{Ap=N zSk_Exbe4`*xg;km1!JlWt=3R(wmNB5Kssgh5?`{;%LrI2M9O5FO_UTd~`VteFkzU(& zlpSn`*yfRj+782?YCDs1w(VTX`L>Ca$+nv)x7emqrV%ZfvlA;hw{+k30saqL3x0wv z?d%y%6)}@Jqg_c#rVUa0*p;WONE~I}DY28?$obNdk(W+(ov9gYH`&ffno1;P`sM8A zP|hQwGXEdjEuoCHTgILuy0W<>;wzg=NyJzt6AV$783VMlu&*dt+S@bc;$ZJc>11D$ zvNW-lIhTDa`+`zy;w|&Z+jpi6v!7~jA%)vV;m@+4Lpjerkuupnl`@TJ%)BomF?&e& z?H}NOARaTl9y#pkuXf1cNa>WLDrG>9Mmcgxp*dRMcg)e5atzU$`P_)tY$7emv6(V8 z$8JgyyV*=SK>TL@V|P$8TI=Ibo-)WGm{RR9iE@fVxPz%Q&0z+9l*1~@H4ba3Np#qR zzr`VyQp9-XI1Y#LMU-dmtDHeOgQRLXYtwgLmzd8cQbf)uO3AS_y;gyaA^aKC#IZSk z8^`vPogKS7noB(#2RK?w104rZGu%=1dChW^at)D8a@C=4sh(3q$`Gd(l&zfFIGIXq zi5$(R=M+IXg9y^(4RqQ>xrIp5^ba}hrQGjykUb9(SDFmiPFE?fJ2}z=<&--wrHC`l zC!c#ZrAeNC^xO>0H<41EZwY05zL%6Ah&j!bJKNJ|txO@00;l+*GjQi_<=meOY8Qgh4#h3V4?Atp6Z{E14< zp2Vf52ZhMgJnNE=o)~wRP)dt}ubuluAeBsxPGPl=~~gl&%Ba{uLs#bh%U{!h%L=~^h%&i^x8nV$!iO_yE`i+R32v076UtByNQh7Hkya!; z!Jkb8>t0gETEZ|(W{DP*R*ANh_K8I(gA#{QPD)Ir{FwNSG9yuwXewzFZIkRJyQJbt zyx*i+l%0ro&HbFTh4OyVW6Ebq?*-zALQ|VUnZT$PmFDX9~K|7~pN)&B!DH2DUc`iiK z=G#p1q%4`@O}RhiA*G0@%_mPxZL%K_Rht^(YI8pjS(|>u47UtZ$vvZF2A@nuB}(56 ze@azG5cSnEYEah9s6|;jV>9)aG6FQ*QAFUT^&kc}pFeYwIhuADWAYA0wF@Ggy6&1z2Fp+~Y1QU6h!mvZwaqJ2vF>fFaGvuV) zgntRUj%8pHzmFv_CDs)iD4S$SUnakKL0EIF6V?-xtl?_;2B;+JtdmX4jnE@C2|l*lDj5i^WEigCv}SP0e%>x3=G_G4lk@)hZSJPDGDJ2pHYuz<))f=u2x3gqFdiw< z-`^1n!;WF+u^ZyC-1jeXR*5`S60LbnECg$fb;3l;ABOF~_G2e7iMHGYGmJqh@kI_i ziI&?Bi^oJxPMvJ=wApL1WK8tiiyT&BJW`_l7Jcv`*h*|a_8pUGm4h)uKF>k;voX=H zF8a!)Lfo&I$UP;-2o1SDJK-uP$7YU)HO0DNJ+VPp#ulDWH<9{agRpT}7#4xe z!D29xS8zEdo#*;6Lry`FPjCn(`t6@%B1d2_W{B-AUCN5<-3b*jy+u6l<=9Hu)}RSk z5@vgaF?p;ORu5ZqmETBA#Kso6Pa|*AwqauIQjA-Q{97XTmdLRsJs>YSCdMQKFhhRS zM))GH;Qoj3gNb}j!ldUW(qZfvCdMkI7u@?;2-X_wfOW)vi^2Vd<6_cV<}6?(Fh8ui zY_-u)tR>bB>xnJJR$#j^kuS=SD@x=tG~_Wf!54W64LJ!#K0*;^+mL_IkbBUOchHb?(2#G?kZaJ8XV8#i(2!rykXumX z6%_dc4Y>mic>_04CvpZF@&$JKnUyQhkSEZPBQT75ksr{Ie@V;)znVao71@|8Y&VsH zutGabC3kGaS#o$|_h0e+cj|wl^4Rg8{W^*{)nc>C68&6k;b!%DWzE$VkL5v#->Dee z65D@O&9V-oAD8TR|Hp5OR; zHe8QE4afgSYPg=itH$T~v)4noelu3Xbs1h~{QRHQ8}|EGfB%N#8uTBX|9|@Z8*^{? zJPc~MzCTmL=l{>D;kbs^8$PeUs{ick497QqpN8WZ)Ue;5squb>@7tB8U z#;-Fx{;!_@-~4B_E5_f0@#kvz9*xy_Kg0K8P{aO)$Hr=WUc>&zYWzCG;K+ui?0c>oag-xL*wIhT-oA8~I53?sI&74?3zJPDJ%1il_r7Hbcj{p+nj*&}bN7 zH1u2N$=Y4NW=^>IJuS^Z@7mo-3Em8^lX2FY4Y*6Om>khP|)!LrtpwYIEvWUVV}Jz49^+CbKZ zvNn>nv8*AoHj%Zdtj%OK3=}q(AGeUTrL3)FZ7pjXS+gdzWUY%7Rm^44ZyjXqC~GHK zJImTd)~>R4leN37J!I`EYcE-Q%i2fQzOwd{wZE)luvKgWvqZm^Qn-WV$3tWtD(f&= zhs!!bRxxEpY@@P7zYbEkW8}wUWgRE0eliUH1o`nqS;J&i%Q{Kc$+Aw7b*il4vQCpV zLe}ZB&X6@y)+kwL$~sHd*|N@&b*`-QWSuYT0$CTzx=7Y&Sr^O7WETClMAoIUiis^^ zi_4PVrnoGZAIHnOLe`bCu99`NtZQUVkaexBiLxfink;LItm|Z5FY5+bH_Ez6*3GhR zku_D;t+J-cx=q&YvhI*|r>whV-7Rantb1hLE9*X4_se=f)`PMhlJ&5xM`S%J>oHl6 z%X&iAld_(Y^|Y*KWIZeEIa$xkdO_BUvR;z)vaDBRy(;T9S+C1_L)M$J-jemUtaoI+ zE9*U3@5}l?)`zk_lJ&8yPh@>6>oZxO%lbmrm$JT+^|h>TWPK~^J6Ye$`a#x@vVM~F zv#eia{VMA>S-;ErL)M?NX2_Z;t43C>tU6igAmFz;YYuD<)Km6LqE%(z9}STGKveXn zoA7!=pSti3{pxz(G^@{C_=bLS;fp?VH%$1V-#ke6tD|*gU-Y97mwnNf9w+&E^_|cWvLA+uK6$bKee|R3e?~>$yr`FK$jgoi-yIcw z^ujmv&kKJqb^GLc$(Ft>VbteBb7Nwkyr{G67eGZHzNi=d_2Xne42_k2(SLtT_C+r1 z$Fgte#}}^+vdikr*B=L8^ydpd3>AI)!Z-Be>wWs|i(+~|r-|f>3E$9fZ-ZZsx?s89 z(1$PfF>_>IIVSwc=tkK$^yBOI$;JJN3BM*P`tt4Zo1jDFdeOH(8OuTa6m*MRe*ir! z`!~_snD~B0zke=F95XjsM)t)xK)CE@jo4=GA4#1UyA}J-M&o3EIhrW@V(j3q>|5l{ z8aEL88^#Xw=c2wOCj8QP2EPh{xEt(_Qkk^7;g~29cpv~CXRC*y(Rl%{6T?3jKUHV zzBOti`*x^O_G_VHT*HNZI-}j?`krVn+3$l+mwhq*A;vxGaL@URh%x!WZKp9Wdb=#z6G@^yT})gx?hvV<5sejDzU+8JIN&BK#11 zG3Fuu4lzN+*oS_fh^+Asy}v1I3`D>G=B#lL;Ty(6gl`xR(eIz0H7263#~0%w!Z(bK z2;VS1qTio!1~CR9d@=4I#vF$5-~jpx)9>>Lcx^v)i0ltTN6P+abe!x@K*KQcS~WUdu8%~;n20!z7#CS3`)kl7*-t^m zc!}7@Fh-#F<#7Vx8^#KRZx}BSzG2KjzrQ?gp!az$!qn9|{>tp1{sglT8&&#k#6H!^ zi4QjHYH3`*uDQ}ZBVXhYqxzQ>&$h2Oz52N*qk1pD?#+`o^osQzBCOMuxqm!}7e z>Q5eBa?DvfDCM?MedF8ddowqW)O|Iox4+uA+Pk?e_U0lOO!n*d&zv}Cz1#VjWsT}5 zFKJ$`WV%|->yt$TXIBBT12XScN< z?c_gcvr&EJ78wmIEpR+|%Ba5a;yS(qUoM*S)TsVg)k(!qO&xYkBH&8)_w_k?U-X%C z;qe8H>YuDD?Y!8w!n=w_^|Q8AES=HQcT0#-yC#UG8r3)V+%-39YNJJMjOzDHC^e|q z$RGEH8P(6;@npyHJ@*o38`UouKP=*0$E5E`M)l{GCeEHesOHYYM)iI3=G^!Fz4eTH zM)ljumFwQT_@*;IjOy1-9DDxg!@!t4S#S3@?kxSvls@d(?yRRHOQ=)*vYM z>>t!SuDSf2SUvhpZZ?oE>yEq{wO+KF*|#Y+3%-x45~wbmy%Ik)uhogMXQTJ~8r2VK z_j2r6p9!;@8P)d+dy%r*_u`fQM)ko(mNX#H6T>VF(_JB0wlk_9ng7n{6^}!=jx?%2cw}kYdEsrt z=NZ)pv|n`mNVAH^*BRBP#z+5bx3%=bV@CClI>yKBSzhZdKg+*t#iOd+s&H`WnoOhm zcFPy98nWc**L>nfEZcT=?~Bl8`)+S9XH@^NT9J>=pE4sF7}c*C)$Y>Lv+k#R7}eiv z<}vnDn|zDaM)g&dZqc{X;vX(Gs$Z1)s9m!QHxjoQ)%(|6yZXksr5Wdq>K$vYUfZC- zh}~icJ^SVo8?^1+tdvMgqZ_yEl$HsO@7&J28`YO8H}}Pp)s9R3jOyd;V^?G>b$r&` zsJ_%^zb-f2ORgJaR6q6H+A$sK*9?m^s`t9H&MfG$$G$a2^|N&~J1Am%&f0HOUu{QJ zY@BoX%QubclL|iCI`3A2WnYZyZ;ZURYrwumuX37a{}h;B^Y;BxKhtR&OB>byy4Qa^ zswTDJZ<5aROY;8$DTD5#fBu&l>A#0KzddiG+>%TE0{j=2TUp!wu-qA@~;dsQ6z97dlj+V@t z{E46G_}?*uJwEpr`Y^ud)`*^rf6;9AYtFZwEhP7U(LDCGE*1qlNS1$NHv4#2{)2Ok zpq%5ni*k?aKFU9u-!2uR#VE9oo&l+FUgkjNcPm8c?iNg0$E_h{h+9j_)@~i$Sl{6` z9zV=&E9Ew~bjrPM2i?r2LvDxhkGh?vJnwdi@~Yb{%KL7wDL=Y>ru^!bNjcbkqPv4M z$351a|1UkPJS-$zkK7*gJ$e-IFqd3B3gH*?DD7b?mGSVxS9nyR^!Es)4ECr?*~nwE zM@4Chhg6pS#IoLH=?^K}fwEKC$CR(izNP$F_7kP#Rosi7ST8RxOL_MGK(8>bT$1{q zn!~T~woqO*u~e2*mXUmv6_xzY&a8gc6X|F5$0?WNuh7rx&s6I0E&kH1{;c`@n$=wG z8v6wLQ?t$I4@ww7&BTN_%6R>(e&@ArYw2598$lVlb|2-zwMQwx{Y&ThpG&%*luLS` zpY8wjAI$b2k<4%6Uz+EChdmAR{6FKH{HOE$l__mfh*FW#lXB2snDfuVEoSoP&Tyvu zKQaH`LswPDcdctf+2fy_|NnpU_=c2ie8aGIK>W#VSU(_q!x{qN|9jjcs}JnIbKJwQ zhCzJ(hII`3@8#d)A^deu|Kh*LL-e0R_VEz09^>A>$42yPL;femMuHd@sUeSz{Mk53 z&;b4l!1OE|jhW2naSiV61*lj*BK8q$NFK<(VJ%4^>i@^aRW7r(0u$HP0*#gZWvEz7 zBI?C@60w#<{QGZMPa^ze>ek8oY(RI){%&*+Cb~!VqKD-ABdEodtYccCVoi!TzF3!% zNA~lfuCiYkts?t=s7m&O(3-MuSkEH58tPLQBG)%X#TplJe6h}Dzw94GZ_55{^r!4+ zqIs@m?Vk^=Ap4cjMzSA*4v_spXoT#~Koey@89gZbhf%SFTenK;9XsYi!;@O^ zE1YX@+$6cfdQ~ruvlJ0H$qFpRPn{~oPoG|+&WstI>qJJLtP~aXu+q$# zqwCC?rQg5ue5SAEWZ%h?rTA&nr1;24DSqb6#L9E#Y^^+Z?#jyZ=S%U67PeL)+j>mQQsOLl% zP%mEWqK=6vs9v(9rtiv?^?X;YYT>(jb%^hpHSK&85~TRGOjmP|2Ka`BiRR`%WMAu3cX0-RVB+J$t;=d-wXN_wBP_MUjs>EX+r(R(lb^ z#Y;V9s+T%^nwL6ax{rFs46$N}<8Ss-Z`tCbPUU!e=xP^ri(-7#OSz6UYkky7oSQYa z>KLZ;S@Ah0Cf-sdB}ws{Ha${p-YmtZroK{b-D(<>o^DN_qPcqJOdqb(OFeI%k9z)m zAN7I-Zo!L}i}6rXb$YrKvv;qx{y5X7ttcBACH9wMqN6(o$Hol|UcTIiYZohzqJvXY zmj`d#CSGIBW3k`J;FzVegZXZ_9{sUU{p&?m3clN&Wg{XsmyL`(P&P_F?xN_m!LhL$ zgX7}#tC{$`Pn2D-KwN`i0$;h$6y5#spnY7v`8 zzW(@q`$T-A7iIY#W5jnD9{!>1v}r#$cQeI|8MnM5BeUL9)@#i9PO_iR#B0WkTm1 zuBTYGy5jmebH1m=F<$DlH13J*Wz;*FR^lkt_MAMqk>|{r%{}MMYv&mq-PJQTHpnw6 z$&>r21mFF%da0=;xtHeDOH13%XD&_JzI|WtjvdmZojXgYcX3bHNhP?CV(O{YKHNv< z-2Xn@M}s}*&mZL(7dO#!<;rl+wQFa3u3Oi~Q=C)WFX7?(6>ITp)_8bv|Ho|DV9GU_ zt3?nV@jTx+m>UHaU)az5ldCb+xDOvYGLV`K$i8}FqWd7%waD86tg>bWEsh2vI z`#3({M7nKmsU5T^n}nsHoUk+hR%R zO#zW6ErNn{klw_ObuFxIS$mh=b?pUr{h#yXi6VFJ-Ftuc-uJz~_kSm!lganwOnGL` zoS8XK@(evrq5q&hLKTUWK>Y}<=bBL0_ym@8JC2hSmNa!Ij61LcyLPdpd-A{r$Z3H(|@79UUBI@yjLO`@IYCq<>DTEIUq+PdwE z2zBby_p0;JZia;17lnorS!CqTqNpgei7!O4u|$@e+bP<;N6888RZ|li>KouB+m+;$ zliT7{SfT>g9HW9ym;-Od@Qe(Mt)yk}vsr8(Y+uAOh*im#V7n}(MMXkr!>^zXETs5M zCS{TXx|TDtx+Z6211+C3GA5osxmNw6=VTLkT@6zVL6u?}rPKt~DPx3DS#JtR`F z;Zm^gbYGW2n^b|0V%a!WFfM26ggS`&g8C|>c|)Z|v^`S++k~=8VD~Uq!F-|K*uI}^ zYi%9aBd90plvJuBg|R`)qCHk4zpe>GN){=WLW{%a<*H4Z~63IQVHOG?5 z$i4D%a;!qZk0JM8;LfFBhLc|iZ;x}XNG0cEAJ z^0*poi3;dcR(8(F+}xazd-8Hd7D75z-~~tIjEs)U8M!?rXXMTd0`n@Sk&k(dp^X7s zNoyLu1bMVaR?@gQD_DE5z?R7M8T)R4TBW8wIRb*=v;mzhUSj-5wt?g)?FyUoE)vZVP10Oxh zZ55dcc6gVSG#A!kd9=N=0^5snL#6TbS^(C7QSv@QeMDRG-|(N@0yCiRlwdr}1bYG6 z4G-0w4Hg`38iTHI$Q&}4{*^0g+dMdrO%o*OM>In0Pp6Zo`gDbgv^f9kePaJxJ}sh8 zg42St;NU60a0=XV_~+6}__xy1kB{+P1Lw1?ga09IfbzeEbJ*yoHWGMjBaxC|0t1?a zk}wiZB1q(Ck8h0roArf7CIm3G5MZ%JV8W$C9Em3h@QFw8;f*^;63tD9hd08*w}>vZ zhd%sQ&=Ahgpy)?f7e(BJU^oQRf7m;EzkPL!Ks%IIGM!;%G_-U|y92>2|^8d6K@XjD%>@fMc5_$*6;kIp>+zvsZm-2NB+ zD_i0S-P->dZ&c_FceGZ<{)%m;h;zo{b;!9M}u>9hnMPfJ{h8~8(qlM%#&z(dB# zQFu)4NHVHh<&QDA72@OFzWe*2{C{RA|3diGUvN${oHGRfb9NTA7k(*E&ePlrq>X&f ze18#siFBF%e1%*kKftGUpzi-0lM7#m$CTbAx8#6+M|pX;O`q|jK>ywto{9SJE#M!@ z{r=x2{eV0q_%FG~pGkL+pUBVT7dZFgSMroRBhSeTGMc=E--W$~--Uhsne6`ryk!!7 z-d^wi!K^w#Uhz-5znydp84Lf5`^zvMPDuS7*b~Tyzx?t)nBzkxl1YRnRqI$%5SW?hPS@h5l}3WrN7R%9(Ce zRX?(-t9Q87)eRu^_1UDM!MJ}@lV1Pk=IZ|Y_jmUJE7N|y?b{Pbd3g_3MMVU;@2bO zeqtq$f4`rYrwqO@up;FDNB&|Z*ju`j;^On9q+|psE%kc0aJOiKL>!j8xSdfnD}jX>1fhnL6;~ z{7fiM3G8P-B8f60(b4`YF)<<%8>>UMZVMst@i8PJA)9R9UO^HQRaiTAB#@+}6q1~_ zi|pK)Ptr3odVt>(%Ke}CzS*#A59xrX!IKl6D9!t?k3#OJ*m{d4H|&KX=>QZTrz z%-9tDWPjZUj=qE`L=p$rThI^YG}O&3v)I^$@SFL#o>f&td*ER!3O~6L?2((AmO+0Z zPwq2UB3o3-$`pPzx!)Y_xFNNN~D4 z$;j{|S=nPrPR=AaDP<-pDE23%)gh#&ek*BgOd!p49w|f;vWtYpxr0rEbvp1U@Iy&L z+~M3VZxRtbiEQ2BPm(i2NLH;ojCF6)2=j+3oarYColU~S{Yi9u2-#i)a~7Rn0Fi`+ zknn7GxZw~&8i1!v;)o>Co$M$HA*HlTK9NMYlgM2mB&X4xG|@7nh$Jb5B$v69@)oc< zaIaG$nXMv~HnM_u`l_U-x3V%a&apBxFM%z1$I8y`fVKTY)~;QFtekz3tjfwfR#n4n zn6DaHO^{xbw6Y{Sd{vU*e|5>p=U6EzmsqK(?^tPR9ju)@AF|TZ16dixk*t!EJXUGd zY?bQjMpg}-?kY>N%~vHZu9X!Ze~y)qaEY~j`#V-*Vh7A=4_Qe`fvn`bNLGG+9&2yu zY_R2xEUd34>0n7BeO02OT3ON2=U6c@msqi}?^s*6cCfZ>d&r853uML1BB8!{tem~G zRSF6kS%sjhc<=EayZ3*1c;wpH|qiwo*eSh%@I zQIWI<+!qAf;?g5N-V|*A0#k{^%v35Bng$1ZnudgqHw_DSHjRkzHI0m1!Hti9Kqa7i z_rKvks0H9D+8^&jsKFD&w!pm$33K;C3S%7(%7I@Op|3xb-nEC(o_M*(AIBZuzrcGI z@)zFqz)u`HC3@rgfCs+czv+6T;Eq5viGe!_pWhe2`vJY-&cZe*9q$0dK@ITE0^U{V zPv2J|Bs&k5+e7dSI~a+Pd;?? zpVQ|+b@83!JpvQ(pqs+W4Cu&b_X&=|odQd84DMsJg5NY9UMJ}HbaImM%OdaZd7UDs zLGBFDciCZpScqt_xV01f8^zr`*{D=HSU_<8=~<3{?Cm|1B})G z7hdm3CwQ=bgL^Z-g9qA+xjQo&rsl7S+-E%=jzpON>#5KEy-F-)RyI*#sj||E8jH=s zwxC$*EDnpy(qL(_wBTDFOPi&`(xtysw7(uppT-6(LwF<0|;Uq#Ecx@A%aJ0_ zc`Uy!dCmX3)BjPPU3uAL0c+vk$W6Yc@MkSzEoLoY1%MS;3ew9UUQVZ)P5*ucf43t4 z&5A$4UyOYEwMh3h$qM*0%&NapUe~(e=hcjCAnThhsn&G)+&#tTxE~8&YgsGEy1$ZU zJ!=De^DXl&{2)Nz=g;U=8M*DO_PSmn5AzQnw2OOqcjl&oun+F*&i3=0 z`!;*YB(+m+7cM^(pFci$x$K>ewek3T?@8nG`g^bYdDV=ek7pmto{_o8oi);-sM&>U zy?yaC;k%VeOY*)O)2Uq=eZDECpeg%xu_X7aiqo%CdYV5!x#MyItL)1=1Ffx=xR~p+ zjJKb5JGMyjd~9}j|6P3@%1m@}EA}L39%_EOo9!DuNNVPkGGxiE3E#eM7w%q?bs}1{ zyx)$HjcLB)j}}PU7r#FB{K>P!${PJX{9<{xI`5^AS(>%aaji$^wQpGIFZs&5;{3F< zd3T$~Z?Io$KRV6duQYwq-0uyv*2ykBI+EPd{JifEOTy=kEpJI__b~h+zTdY=R(z+3 zRpUm*|Iov6oM@P7SnIPrk#~ga9-iw_%x&Gj`q!RsRxTby!uO7Sr5}C%_2|IwtJc3= zzI$7~iRF~17VGmm(l2s)4kQ-7kBp~zAN|2$=;+92?~>-uw0=_FY@RW>&+XW84s&9- zjTdYtu3T&IPqCsI5KlQro2jTqRHb4J*^38p!C65wbnhkr?&h79wdX_f9XZz}bbseK; zO-~tdzrp;lXokW1$3HE9SUkmNmf0Apv1IJdCGjyQ{D!|;LXG?F{iPni6^AL8WILX} zwdec$jTy@6wZd%m_cqftLI?I=fA#Da7e_SNk1-lH=K92Ji#I-6Pbwog?=){MPH_+V z>8ko;opGa2w;s+KfANmq5nh@n_qOkMXE)zuyX^1Zqqgqcg*|gyHy$^B+-JnKOHW^4 zpEd5d>Z4v{+vVk*4U1k3x8-PCWai%X9do8SvGaNH# z#nEf8-c^(r-5u|0QlJ0bL(k-&uFu`SiSL%b)=6ugB~pJYM#c<&nacdCNWTSB;tQL1R_yo;3TcJ(C{}+{q7_60N?D)Vu@1?!hX!DTZm~q$NSS)JI)_$qs za&*Sil3s0trv`}CRaYN;m)xX1VHJ7Qnb>A)^ScUrWM}E=<>@ny^$_phE@&-_c^tL= za$5DT?DVPc>byDE`M>f)POKVZWN|IbKSxr#vTFaliUD(L^ZND=IeKPb^OJ-DgDVG* zs}1Trr((Rbte5k-eve9<)ZhNPhr6v;=Fb}oJ>J&L`!*~8%6om6hfS(yk2oH&lToq_ zM@O8~-7sNWq>tDrDBEH!Ri~3nU@(B9i==^AjdF}hyuyR{}oevMPoo1Ep zIn>J`>F|poYu9$HVE=aHe&B(+f{XYG#j1~{CMe0ji~`6wmvZnU831EZ^kT#M|A~bzVW(M z8ag9b{|Vpv_A19(t3D$Sm`*rivf>xsuDcg*ioP1-LCv`p{KCI+fJxxMhpyE+yvt=n zHfg-wRQiR(m-@*sZcY5g?cJbPTH@Y%S>I_dOjb+#;q<}F*3(Ax@4NknS_e1R`;#Y` zyh@1Au4;}po$nlw5G-4v*1GH5gzR0v#&#}UIAnfEiC=l|)XdnCn`@@d&AH)sg1aCbBzTJnY-^>Vei8@obYHO?W|h*WL^b3_Birq|9z` zjpeJD5by94bEj@G5&UATc4Lx$@le(NZAp(x*WV3SY5R7>`R_&=_xHD|TWIs8Dm&G) zUbZ=ZoR8ApwhY7fhc0uR8{1b*ZZJ+QK43HPXkjrsuIZG$c4YbNz=r)xCsn&Y`#$@D zNRX}Xw`SlfWB2QYuSOj@RWoJJh0&|8FFv?taZ^8Q=~|5!v9C{N8|aRlcg^YPFinS^ zD(3<(sy!?bU5$CX_}Sbu>z&JDrku!Jw8gQ2FSMDkzq!}A{Gmj*B0YBaGT$o8XA4g* zT%df{uJ-n$@mY0e7p3=_7~!$CoZ5_Me zw-=l48QfU(YrLxZfvrU)sU>&ScJGh|S4?ts`R2p-CIXM|Mo>R3(ix!E=5Y8xxOXb6 zbh=jRWZTrar@v5jF4NstRrBqwr7K51EnF#fyq%%riYP%5UMpORHpwP2Vl~uzmdFr3Dfz@wWq~FO&HAEc1_7*-TDb`54WU<=k+$<{{0ufcA7mN6Eh?u>78*aw_D623Yux|N@S~p>hp^4=FW%UAw zGuM7P*~52{iT>BM3EM6#&B&POsHwG~^I(EbP0zaKHvi2!w_inRzE5uJ-{D=}S-g3~ z#jh8~xow1D=2@rx`4;7;vx08r*BS>OGQ0YnvAMaX z<@{N8@zbv~Z|bjOxI}pB%*r- zN_=fkuXj$eiVs;UOA7T7Epcc&5EYjfc_p&f-gG}{{)}}QTgzM$O*6;4XdZcdWYdk- zE0({r+AnRgSC%YOPT}-l^tzu~EZc z*M_y;h{(;$ssDLH=c}VXetUlM#nxQYfVb)us-uD*y+PitO?ciM(!;OTA?W;ppo+&u-!}x&{+0!3fR!LZ3s=4dl?y~!JZ%%a# zj2|%QxkR{8kDBc^tAMYQYt_eGBA&~g-#ojQ`O|Ywo0IeF8@4=*(pGk*hBZ`n_Iw)9 z)=RVG@D|bGX|;>4zS!5IbV1d7@|w`mA@6MG4dL#+dB-v23;()3;al&;b(luZ*r# zv({)^$h!h9HP+40_osbh$K@CMy;X4ul@;dhY3q2n`?dGmr8iuEyB-kHe`D5t_Sn?} z4_4((cYdbn*-{t(GOe#os+seGq^U3BPwSk2V_LLJbCPnqbx-pdr`^_*(Bd7o>9y3C z%io4_FRpHhe@S(`*|2iQYDzV@U+vPx`4?@APxwZDe=Ftp4b_o#OMknoGC@0d$2^u@ zy&<*XiPPi-diw`|o8N4@_nN+5cwFaM-%{ne+8j!3X?3no!SS|XKl#_m;3(dE*AmO$ z9(;9oeF(SdrvPeO>-!Z4QpT^zdZY5p`WIFIfujc-UJPEdz12z2W6kcMTa~w~3TO1x zamk%E$X(xXn{!Tv^xW`D)2Civ_bqbh?05oN7HzW`4e7IQY8RKrpB!?RS6(wQ z^VAvMxAybn9;(}VvS#<4eq`wJx|(f2FKR6w@SD5l_Ce3)Z;(b$FKHfBG@VudB%^7w zbKv9MaS!@9?e4!}&G|5`JNp%UckV#GrvSCDLW1ADHt`NB+Kvdya{&NJRnpQv;tmYJFq3zMsb21eNIxK`S4PIbgkS;9=df z(r+$3yrF+xm%aC(v+CUi^Hk4xBoDYf^Y<{{pHGmJ`bmQO)(4s&**q?&n6YQ{S-yVp z%&8&odO!HK=6=huRD;kY$&%|ow^4KRzL9vw9$^jkHtl@jU03{I!OlL$OK0ci-t7DA z@bJx=hQ%o+ZbuBhEYX>Maeu+q;tAKk+}~`b(P?jZZn;;^)Nwg+*Nh(pjXTTlH}Ztq zvU}b&@9UrS*st{9$+dIZxyx2Ada$L(rK(K7j?nLyJ=(j=aN3*T-<%~m9;4@99?to~ zabDqs>KOi@FLavw9^)4cdzbcD``VbX7lcbs+b$U7u+0A9F7wW;HJj(GnzMAHb7Pk3dV zM>JK1AM(Di{CLH}+s;Sb)E{p1Ojhk#djG4Kxos=fJ)SwtkxjXjty+NG}7xX>xw&#TLvsPU; z|281p)pkRy)0&iT?=Bd*zDMy76FOc8Zsh8;v1`qaG`W}rcY>ee^AE-V3 zO|SJz3p|H&4^~vpoVl&GPl0IOs}_R=8NcQ>nl4%ORA6y}z4pcBlFSI}_3S*jYc{xF5j}Z!VcpIZw^$Kd z>y|HE6mDw$-GkMW*q0I}Rs?VDn7F^=i~I$L*0HmOt+;m0(>r|9h8cdpZ=IL^*5!ML zVgCL5zl?2NMs4<6n6Wu~;T^kQ>>rlx_H4Yoy}9;w`;DS3?}Q1v z?oQMCX3bX4`Gk#El1^@M^x1g2y&^pDb>~9e=ELVb8W!$c^u@~RffcJRjr#gPX%^=< z_b(?cxOLaaQh(}zVlUmsqf@90~KR>iJ;DbdGCw10+x59D$GaOz#Pf8yBc#CPY+beG)Tf?Ls{j$@xUtD+M z&FsWiR~L>yQqapiE=T!g{!iNb#~;pZ8~-TT)KGGB!^?rDeSPg8Ro~M%Rz7WHpN3BM zzCyF93$E||%08xbtIG9Tmv4M~I(fR!H=1*Y$sviaDc1D1{)nZrNpH~Xj4%ixDD zGCCLer@#JAXvxpN?^^ul-~1)|v-Dl_`ak}8wZ%G%7>|t>J3X>Jh^)Zlp2cH}Qjgy) zRteS$f&_;=;Q1MrL*R+Cr#;Sl+yq2qHI|K*2P}0x?X9q~o2^2udU-}#nR^O6iOkXS zsnshh7f+5bSQsIU75aF>AxYLEYq9lA&v~A!01??~>+{x^taCkmY~a)w8>z)st9Xxo zp4k>fR+SblR&^FfteP!OSRJzX&g!(q6}pT{kKvv#EQs)}g{sgDL4q($kSvTAqzg*~^};yjFUYlm!@^U7A3^4#;J)yN;8)=f zf(|+brc$!hv+im6rL~S_e`~~qT=MYuw6+{!J zlv-c6d|`dZ@~w50Fe;= z(bH-kT|boR9<0(nzN8;N$$9WRB;Y70O$h&~lD~vfb9fGu;$`lD!n?-XDy`A`n~}CqmOM}SH~5*r??&;ZD9cJJuZ5M=T51C?#k*YU zkKPs6yOO2&1ToXkc3oKTJQtgvY`!BLTSHq@TS7d5rrNHxvjw69@F33+Yhskx6`*@YWJ(%db{^_o9&eBL+vQ~h?QtNEjs?JkiQ9r(i$Mw0(lRA z1^UpnuNB&X@h7>jq?UkGrD{^Pl#;4TIa01vL#ip&f)`J!E!6=n&}B5CO9f=`S!O6T zlJccJq+dvTN_$Cr(<1}5`BSNilDee)!;gLbvV=dB``gT}C4Z`e;uAa&y~}kgWaX_Q zzY@8mLP{Zl+%DzH%!U$~C6EJhA!vjpVlhq}Q+g9xs53HWqOC8_ZM>_TrmpZ$&P~sRze$rtI$UgOV#`C; zKU*r>ytmY~d2Oj_Q{yl~)a)=?#IiE7QM2l0!?o&Xqir?V#@Ncn<_mcI{Xi=h8#Aln zHeXqJ+l;pIw^?8nX7i2J4x0^DX*QdL1=fx>Yhf<={^NYI+r!2)++w>`kUhuo8+&EP zD0{wRu)U7sLHluzDfWXMpwt-n4$;uvT=_VTI_WL#XH%hfSjQ4lyF3)fgLJ zt00@nR;z5>tY&_!JE|kwOVg{Lw}ZEtx2w0Mx3_nRXQgL?SAkcWSB2LuuX?W@UdCRH z&ZnFYJGVKbe7$FjXNOa#({m?f8e4kVd5!m8>^;qUmG@ll4cz3Df zuSZ_qUSqv_4igR=FibSee3<*N^PZPIdpZl92RMtI&7IwyCwcjLjT+`V%x9S2FwFm& z=N->c&c4n*&VF?M{$9(xLWZReiy4+XEOA&7$UK5oTmXC*2o&VJ#VgE9>Ye5t<-N;0 z!MniwiRVktr(WvbZ@qN9S>8RoJ3afk@31v-ALKREJmJtx*D}|1*Lv4>r#nt<;-}(k;!g1cv8ro_^K<9JuD4vzxOTW+c75r}b=Gs{ zxej&h;cDkPz}40D@UTulFeu93FEwu#Q$ zY}28(dR~>zZ)~2~+_!n^_PY&{wz1%+?$Sb=c4wLG9r)hatJ7J<_O+X~?Qd?qY`2Tj zL^4sG?LJ%OVGo95UH9AS4YRiW%C^6)GSod^R4S?#{bKvjHelG3;pc2G*}kw18aCf{ zmhA*vp8IIqESnBDxp}J`_Rh8uS}w+`ZJ5|r-F>BPPxpF}QGQUvdnE{*Vh*yU7LIfeXRjg>!2 zE2k-^(Q9>C+eaRgO!hIk><*OC0rVWG6G&N1WLzLUpq@YjfXsn}Kq4`ulexpEQ9wS3 z6hY=I_5)skI1t{0#3Aq=BTj_(baAd&A@nz5nWE*}>ADt)E5(iC!}64}QxLDy>(pq| z%hsEs)uDESGB-3g`U572Sf4bnelFiNjgUBqqIPS^RMCDcyQd!T<PY)PAnxspqTjOYzi=I2PO%jUX)- z?G*hWgR=$?4D6`q8o8PsdYzoh+~?d7jR37$Eq`8&wuMfjPP<-!uCo3XeI6yE2B_z2 zJ%=)Sa$~q-HOe$EYY*Uw*jv>7IIWx%t_#nX7oZcP`ej@r z_BysdCxTN3S8-VD6mOlbk^TU~I~uwALdrr@q{-9r)EdRh=b318bwmbbY-No~eSdZl z$3p9do#k|RYt%QQK2Rx~HVtFV*_zKaJ$a41DLSupmG!3RU)B$yMrkRlw`v9O zB6M5yiwxQf>@-cZ4(rrvAJ%@LZN$sg^3xNt&2@WHT#Cyz*Lbe&!%^0HqVYiQfo_rh z06i0K1lv~~^{NcsZ_tl0dCfNF-Qjs^`)T`Y2WfZecWAHB!84)>@zFy`w!TmwV?xFn z_!vwvXf!a_Z`Gf|4r0f#M^RI#6l#>ZzxoRGBK2DJL{2&yYDcZi;v$X@Y*J-C~mua7Z9B zSIY-#83Ogx)4rjl$1~>5<^}Pxd53we&^ixzuX$W;wzi#iCDitgc26A(ol#I*3*9NY zr*!r7jG?wJdOmvYQ0FOnW1-#wQ13f>ditaE1N3X5_MQ4X17iboxP}-5bHi%Ga>Lt( zY&8otq1t?-1S566k)eqpUe~`WbgDg9Q)YV_da|dmec68O0QL&#u|RgEs;^-jJCU8j zu4IoktX0isx3DK0o@HNV+p%x3@31ZTAJ}ZFMb(%ZK$%k()L~Um3VYd)@}~l*Kq?XX zJDVz_YNnwGdT48vH>Y$!eJ=NLjdg?~%#_A^O=ITOqk-7`KJk`gl`>4-W z4*^YyP!CWKROeD>)l<~d)w9(T)$`SJ)f?5nF}$qau6{@TiTZ1GJRy79nE}xUj*=|_INil5X)N)!l zXE|-0Hq|?v4$c$KbIxVe48wL+9#@ZR#5Lyn@$I;IhIxjUbv(JA+#9MV4AYIKaA$Mx zs0MOFxDnhqZZ@|=^?_ZRg(LJ^)R6!tLaK;2LQdYglNw zYcv?TXb3e%Y4~XPYqS`y(D2jn)p(-1PGgHkghr6YbJY}$Y>gv^r!+ojv}=4&eW1~) z`dalcyOpi1*`ZOSwHH$!d8#P}UZZ+&QY}X`44>X@>-q7sSbfeiuA9%{z%Gz9Qp&C!yPEDk?*l2*ZIgBJ1wdF>>+JQ#v zje@ijwcXXywX?NzwKo|B!>du-Q!T>itah9BD79FlC)#7xK4`zzX6qyy8SD6{nd=C3 z+;yg?$&B_G`KlEf`RbGz`RfG2c&sw2H;U8QZ*bZd1hbz5{B!4@3WZPh)i zyG8A?Zo6)XT8Hiv-Iqq4x*v2S)VO+$s_%?G7{#c?so8;z2+#}CQ{l(yCF&LF)#_>S z=NKN=YtcKUx4`f+S`I#+KS19?-%h_bU!?D^AE>`he}#UCeuRF1exiP&TDraoKSixg z|1hUR|4Y6tKV2A(QD$MtzxhF2vsg-Gq~M zk%e(J#7Ga|(Z(1sJ^=A01Q7bt?{IDu#Mhb63f@h?uYkJd_XhCq-FR3S(gq+5bj%{e z0*JtWeia0Vun{B-4on>_0VDAljIkA9J6i5jS_i;h-FPDbuj|HJ4;TqU1e50mz?-|t zZ2^q*J)EzMvKUJMBOOKt1dOGCkuY44M_fxrvz(c~ID0c^7 zBn)Te5lI4!G?&2`Cj&;p&?b)vJdlSbgy$JvD)7>1xliTp1dNo+$Y7iS7zu+Szf8bL z7%s{qk_8wkn86rl14hE2&|w+iJv8rAxp{z*FeskIm=AbwH@O19NL}TM0he@>D+P?y zRjv$hc{jNV!21}vt8#Ht3Am=4TrFTE4ES7040V9(yU8^HZtliw0gR+@$YOj1FcJnF z>iF>}ydl9b_`rNcj=={cPQUxT&Z!o?N_(?apr+|?#DC+zSFjCiYp94nfTJ8(LFM+z2 z`wB1;hHR$n*MN~Q9A|iM02^b0UB+}@z(^Q2Gjja_3%kiV0!G4MK13di06TS)a|Vor zVJ4H`Fu+I{OqsMUfW<&A3JkF;U^iOs(^zl^jD*3N$!|DdBn%_u5g7p(2?O48#NYvV zEY16r-#EZX#!R^wj|Ys@i{W8B0WeY@hKI2aU?e=J9`na|B48vdhKKQFz*B&P3=iXJ zfRSt&9>%_ak!%YCpwz(~I_ zX)#_67|EO|7vnX6k%lllj5h&BdcdT`SOOSnFe8U?FkmEirY;zV07mL67XcV)2qTAa zBw(bja#4Vh8ksUMjs_e9WWw+;js=WV&+stb4j5?=!^1ceFp>dNF2-4ak#rdz#v_#F zG*RBn@UM0P=ZS+?7O3l(UJH0#H(n6nO+dj+T8y^_#n_t zhKKPXz(}_k9>#|OpXw%e8ZgoUMh@dMfWHGe$nY>e3m6FlC6CBCz(|J}IgHN(Mrvhv z7+(a8be`d1do2f6x z&jBOFGIAKd0E~p;2*ZB~7)i!pj9&wOL(6@#oo@jnVR*~Pz60FZP3||q9~e2soI+IK z39&#J6weaY25iCb`qJOP8XNFfpqoto7>@%y8>s8`&H*e1>e^P(fLprp4gf~Nz>`Ph zAmH!1$(;p^^pq(V<8y#5R58(~Hn0RdndU)Vnf&Gg4hK5Nq{UdK4eJ*m40C$P<5s{( z7!58315By%4B#LhXY31&G0ae0F2bm@Gy=9 zjC3eUehnDM18xAqAbugQV>95EZoCb>Vb2Dn5-pd-coSfx`}oLZ2pDezjAYNq(QzNx zF93Dz-*mu8N149FI14Zm2G`s2y2t>3pn0Ek10K3W*g%SQ$Cv`l1Hzzae{H}9-Q@Uy zdv}xT3)rNaoGD<7ZgK*^cHQLc0lRdQ69XR6P0kDO*WKjC14in49X^1Oy7DFgM(WC& z0vM?)ZyI2vu5I887^y37CSatlyxD+}y7J}#_6O=(#v;Hgy2-5syb5TfJUJb&2D}Dn zGQ-1oE#P%P^BEq->j7^7k}y1szXcoww36XrybqV+r60psw|c1RMv1L7@ln zfK$53r2@|GCMN@&-%V~W;IeLV<$x=?$yEYIQkNG^$8~_~fiNiQ(g3)T;ngs@-vk&5 z!)u1OA23q;oev+7kMUu^NL|Y~4)_!h2F2WU8ZZ(DMSkZ1p9kvNR#yODW#km~`vGt} z!^>dmaue_^pss1}0Y>UN4?YC^2nd5BzsG<(7+yS+-%o&%Fev7?=YWyAUfUbMZy7lW zllC3pPKFoE@ZJMP!l1a`!Tn%e1%x4&u_Z$QBVlm8A#Yc6z(^Px7@ieiBn*rhBx3+0 z-DS$ecn;tdj2uiEbXW;^3(&8O9L6DlLut8BHmqhK%!5D}zK}=c9NAmkua!VlFRx5M#As~OuiV;2aJTF2P3xtFw(_~ zav6*l0!F&R@G$lVycDRbZC?f$3Bz4Rb~#`q49ZLyD*z*5xW&k=1dN2?Hp5#DIFROj zy0$fd*LLG=2E2vl!8(&kD*=3`n_LIrU%K(00e;?%2M6ttK|nHQ3}S2o7^y37C}5`y74vu-q?+|2{00d_iN<+v>9+jH@O(VvE6uZ ziXTD3aAmqYZ9L$_ZgM*SBYo68kWU6&&`quoFcO9)Cch%UNEj*@UNK-K4Al&;1TYeY z7KT>}7zu+SzjDAx7=B^oDgYy4xWw@G0Y<{m!SE^pBVmv+YvwAzEi~_wUL68_xEt>X zU?dERF?SR&5{71`jAMY2FdS$4!>0)5bD%lz;Cn_vr0))YokuwE67U&9N4=^qV+}2I*3gFIeauN*|*$0F{Ouxf=81OTo z15ADx~Vh~N~_TasixNl8(?m6~M?&wr=jNA!F+Q-dOPEqI131NN})? za!AP6%AuhPl;I5Io8jRUiHOj=85v2Ds3^^w(a{u%iP5|n8%vR`TQzTP+eVQ%$b*Y{ zlzA(amv4e+k55zDw{NjhW#!lK?C~V!>gwiOH8mTQYHOpE>gp&`5ArNR2_+D#z+O)h zr+OZAQrZOESK;piG&hLgZWfQ2qg>+<5O@w!iWeliHxJD_(Cx$ zDy68hDlw@Bc{qWDBZ;6iBGV|&PQY4NM>>B>W9wFmvn`d8miYh6gG)hY;P8rs3AH}J}>#L?XHFcCmJ=U*TOt2gk&}GSX zN|?AyOy%TI!d&1{c+J6-0NRU$)>FcU21?M_ND1+r=H_Nffa!QBBcTM+U@-{^qXglR zV$cmr5FJa2wr&-ZZE+Ozg_tBHQ-YKfF-hG+k-RcKDaY%C^qPwF*e9VB35ykjE>UC~ zrb(d4_Pt_KP(YEwdNFB`mn&r2MJSO{g5VG_`NMP#lnC1KV?DWeeO={IpVBC@6Z>Ny zMJgM`@Z5fsm$wH!4#LGGB8n14W4~{s1aS$JaQhB1oN)mCf$38yL29-b4ivy^62qAd zlwe;apHwkAq>g1uC^*!V5`u0(IZvs|N=jH&Pl@P$-p{A|3{P2=pe}CXlQ?WI)bVsN z$$<9J!0$rEBrJ@A=WauNA}C>`qFvL(AU9V%BO^^cGn0bnenVX#jeHFAiIg6n&M-b< zOr%i4RK?gS;FH2CN?46LS3?PFYx!`V4kfIw=aUAeU)A75KS>xR3=ij%2)a-BWZOWtDF+#mr$e>+p3D9#~|ni_L(pkbug3?hJ{mth*(NU>j(A+-By%f zX9gwA%;bN(e!AVmXR6X;J9eh(M?Gxd!!yuTZ_)h>bz|W*Lac&umY8JA_sV^L@WA3t`j6RRSb3tuTczj>SC+9%a@Dc+zrr+ zYB8xn`^$`BJgZV75yRoPV1vWNBpmIVB5x>L6~9-Ek0ddHHYKSFKK2o|M>Zc0wGfkL z;Dh~v^DFqESD-IUeP|wC?ueUp&2pWmuUP{63Tf~JCP^`$l$64g;pw~?UEaxu{KX`* zYd(d1QdCZniaIfE&*`>NjAI-#iIivujvGvax>8Ars;c;;x<*WDA#bpSL_*7n$qtk$ z7Q;Cxs0(!afV>!)Hv@3KSP~V}g!}&@P5quIEeLxta=vR5WDdE_HC*>IApoapOKZRu#ls&D7jeMqW;Cgc@!EVf>k`jPU z3TtY`qz-Kip zz*#=>ITrLpC_z1MpoCaA+I~ljxC!eXu*qmYM%+xzWX47Qh?{#+$7;bYfUGk1QygCy zk7E%qh4sO?9@hk+d=eIUa9|YLce*dY#()g1w?$6}78jchDkj!$-#NO1i^>o0AG_g+&kDELji5N!(NMcbr2G0CUv0_kE7n$q(bU^<`5 z&01AeL(HnHv$Se(9jlN_cQwn%;FC-RMjOJ|c4aJ|zHYD!bUwJoe5o9t-K-@m6~h@y z^6^WL?>%CYN9(be>@B9iE-_;e($VvFKJZJ|+$_Uuz&6@XiE!Sd+Zb&&+B2+wJ_T}2 z-e~u_*n8Uk$>(+wxlJrz11Rjh!e%J01MM->UUb{wT9>{CxF)nyXrHrQYGvd2Q>58v zz^kNfXkVD~Yx6YkrG#laZ=0p3bB2KZ zB$-*3L+HF^Fn7~wU>xjUJVZX%Lwa1p;aaHNW=KWFq#^tEtr=2TnKY!TYR!=9>gpji zH5Z4}*1jB4S8rh6&}d`c)NEjm>p8kla>Zmf-Sz?)KRH7rnbQSX^!4Wm7~2c`3doz5 zhjMX_iIeLbtvlF;sME}NqsJh<9{?WONk~V}oqPDOUZaHN6@0P}$1L#W>qE_(u+{+G zehGUXGrh2|O~dImR}BUIrQWx$E^0Zfnd$k0Uf0OiCb;eu(R-t4N*EJEkywSDq-_b< zKP(&j49B3n4oZ>=ibbzGalOdY0mdoXI1-HZ1mvI$0oXDj89PspA)FgaDFN(3gs=w@!2U?6 znEUCqYA_|F%Z#Cfu$L5kT(4oi>3oubV+hAKZPPyPK?JY|q3tT{Q$R1EKXDC$^C+$# zab1i1%Vvt+FVORw1onUQz837)PD(yEV&1rxmCpqN+7@BE%I6_b4USdl3k}8CKpn&V zFOJiA3idUBWsE}_dJN%sOwSyikx@N7Gjo+$R@Snc^qw2-M%kI+sLLwk=i!n#%@Oh0 zLr2KUG)EwhUN0#0NHK5WdI9ZX5hW-trbHiY5u;c1dVn57UF<2=H=0jk=rsk7^{+?Z z_*W)he=XUeIVvgX=dsDO4H!B}&L28c61sJASlF4F;lPL4D?k$W^|JW*t;-UMb}cV1 z{&^)`E^W&+SCxZ|D$xv-lq_3NT6$)ES@qTxH8mw0YFkX9jD8y>!MB1!Lee&dw%Eg7 zu_99vGA%1KEFmjA;zU*?+Aew>8ztMGvTIjrhBPO$(lxWHsv@(xIx4@Waq-@!rnmXc z%~$uLjiB2T=NX(+^B9|ob6g`unwnt^%j`L5-ACQTv62b%8le>I>o~eTm>2d9 z&LNPN)>#}wsE;^*;vN(0ORs(8^ERC>6Y7s+6!jF+(PiMC7xx5MADqX4uSQ1Pl0+Q6 z6&W|;R(y(POltO^7#Yqv^mi~mFdxi=UiS`)DX-OxLA&=^IzgQwimrw=aScV);ySvKqMDlM zbtHvz2wWfdJ0Oo_J_U0p1>>H=@dtTR^d2pbPvtXZtKpbLnSEk78y0;ikZ%K@!Zi-q z9mt1IC1XFz+mDjrIN8sq%c1i_yW7a8z^>807MO!53F>45pV|(2fX$HmjIh1wbv}$G zv{C3U!nWE$QAsJ_(@3SLG%Op}U8rNs8UXZ(lF(~9J{6C9Lab{OpTcz=v@Ipc;8U5* zo}I>+M;)KuL&4fY-VP~zDphffwR{Tu4E$x3e7=Sl^9P<1xuz_^c2MMvpV6NL7<5ft zBB40aU6cl_;W@dvlyJAaUn#|$M6dI*`IPLF-ImuckD~JBvYc}CZBxAWY5S8!3Bj%iU_UC%Euch&ioI*s z`2^=drmtW;;QB-a{U-!}jDYTY^a%aH30k>HvF=X?3s6VxfzD2(2K&Ov@)W6`Fi zQ^E{v3rPD}Jrw>bT-z||XdS_Hv<`spO;Ik|aP&<;9`yPi_nZnH!hIsHL8>WxxlaxJ zIgFoxwvo6Ogmj?GM6y%iqk*=^eKpSSsE_oXl=cUJzJf0feW@Tv>r6Z)NWlFh+ORZAxHFv+z*-CU`{0-3!x@4U z0b3#1y<1H7KpOg*aSWqhQNBI|KMt(r??c$jKp*0| zneIRE5n$ipIvm$`kT2aAw7mx(71}slUlmZoLWNHg*S}rXs-KNB>`!b5T+?Ftbf3^` z5m+bFYZ3as1EfL!hA5Qw-+sK`pjg9$@0i(NqMc&;hW2}+4_&?&6voT#fgo`QB?SMB zAUXN}WA9A>qAK_P@mWAN5l7_4urDg2f)0p?W-`yg42_D6ywoitQ$s}~FBzK4u&=_1 zsE8XPA|T)ZBeI&>a?!-lNYUFu$yS+hd%b3x|Ic&I9A-cew7U0w|G(zz=Q+>v-Jb9B zoabzKj^Rw)pW*H^^2c=aM{_(B?5ATx_gi{iqlp`PeMRqeYsQC-DcxTo$6h2Z&3$DCr@eZ@z0=QLe&Si5LvuhTT=7wk0$cYeV7MB`t~UN?IlrT6{l{t7wv z{DK`n?z)|wU+ldue#|uMNscc!7wPeuUgJQP^^cAno7Zo}c>sHU#9kBA{R{Qj|M8t< z_TIfGd#{+zcdmX4drxJHp=TOhXEp1SK6gGL*xxhvajwJLqPF?G<~YmxK>LmQo_=oi z6=dWZ3N*)8_F9$Y(>)jD*I9bZf^5&6=GPf|%|q{1vga*ti`nx=);5kyJ+VWco&(c$ z9c?27DDSCK@7*grwr`*RvHkm1$8vI{l?M(eDsywYDi0pCoRW9Q(lei~4M!|-|ISxj zUTw+V55luCJmNXM``@OVv4*74ff*5h?`{#Et$sw#SH6APr( z?7l_kEXwTsQCNCL(PJIm-&h{H=3u>~=hr;Xc6zOZx}G_SeS^+X&3!4XU7qYcXS&a6 z?$xmC2>WaZy6L$;y?4nTGYvg+H0Ocraf3Vlc_!?{c^2j2=8R`9UAyRYFpW9_i6gk-&esLqSuC{nsW%v{sVqP_PI=)r6KM)c(TtYp&#qD zG4^-Qm?T4!Wa=+HSET#d9!o?1`3$>WAGI{3_tP+64LA4P&qd5OTN)}fKC$Oy?0UsL zGi3K+{$2uP+3Og39!g_M_s^dDYpfoDg6<#bmI9?_&amT@)^d`q(;UZGU+Dc1=ra=t z|FL;<;6D_KoPR_`b^jweTHX*7qil$cZEc8)lbwl=SDfMQQ8t}PPwzgXr0Wv3P1i8U zv*%rz^!y)n{93EI=3@6t-MK)=iSp6@K$h(r8!zsD4%;{On2$1zALRuf+Xud$t|_{* z^c)LiR+g@HWtM_+c5UX4rEEPtrW~{s!Zwv`2IO#o9xe&QCh0cwY88(*a}9^y%hAuPsSQlFyTO z2W{E2C-95C2O_rQ=5~K^u*h`Nkt5w-7MEsjIeIkkn(CN(OJ$X$t-9JGt)@oSR(rw$ z_lwmTn>VYzQ^YlA#3xCvBqxV=r)&?}wqr-rzjo$EY&&=;@PG0Po3|AeHT}<#BR97d z7kB?pNvTEVQI+gwS-r0V-M`su-OavY1wDt}=PRah$NhRSz2?B*IIz#^MRcp#{{;X2 z-+lol`r~o~r~PrTf|_JtNv>rud0k!q@%;Y_?@xR|`jxc*_a~NGd)v^E>%Kp65nJ=0 zd4FPl&v{n#a6e(<+X43<8%Kv-`^N-`xl3&9ReKg?&07ye9!xT zcj5o#_cw+LRtQ$(%j`D=KM2hAVg-r#7P=n3<7+HTLs)j;`?a47_Tfzr59wL!<>1TZ zMFMBNiNa$1eQLe%r0^Z#kHTMs8G=027h_*NiM@^13>?wnqJi6n-tvvy%^_gAL)W%%v#8~FW|Iev%zO8o|Yf8|=gjeZJtN$dapl`oJq%;KxopIOfpEW|ng z*VZ?zyTJdvO`0H(B_-ZivH%p!zIyisLAsQx3&FRPm#{=j2c&1Mi%=hd{Bf+ibh}W{ zOy;m}Zb%l)7X+Z@d92|UB1@6A$WD}ja<+9R*6K*%e&J$06xp9YyY^=YGWiO2D|4FV z)a@j7+T%3VDc7mS>3ye(PUoDwolZJkbZT)rVY5K+oHXBhu{2CjBE4um-*%}qTu?2& zWE~9q{;-=N=#X05d?zgyEU|jhW|8gpQkCFUs|7Y$Siuu^;+xo)|;%Ot>dket#4WVWOc_%Y;9m|Vr^z^ZvBw8wY9yqv$c!$MC-}cp4Q&h zGp%P^GuCsh=UYE%y}){*_4C$@fX+&Pc1}Kt8NNUeBsekb%V9T$-5F+JU~XV-;A}A2 zAle|?pva)c;H<$_gP#oU7>EtM4Hp@%He74C-Z0)U*D&AkgyCt!vxZj<*BT`ooi+N* zsKe-Mqo42|O2+s}xHaOZ5oTr+%{|laj@f)snT6O_FFyykwswS8_t~zT~{* zqU1A4hvaL?4aqIZPm=6WrK7$a)q(e#S(`hXPc-*5Uv0hyZ!+6t9&KJ~UT$7ve%}0| z`3>`1=0BO+kDfhx!RUpf7mZFHoi=*k=+mRmkN#}*t3{kAV?QHpM1c3lR*viOm}d6(l>U#rKlX!h z%fu7Klf|Cm^m_3)8a+q#o`X}*Ww%E@5Q&oKZ);%SBS-W270T- zYs72Co5a!LcyY2gO`IX#A6X%Na#YN&$akkR?4qI<@eptt_i^DR^3=FOgJ3Z`#U}mAi3l57NLLF8+ zL^!N>P&mXpq&j3cWI60}$a8RY@o<^x;^z|J@~q1umsefFU7}r5Tryquy5zg8b19wp z-2{Q4$lF?>NkL40jB6 zyzX$`LErHq#{kD>hqaEk9TquWby(uK!f~>Z*d%{A|3(RIXWq9iusmW}YFTEfwmfcG zV_9!`((;VuIm-_%FIs+V*T=Y%be$*!6`06q;IY7 ztnZ>fQ6Ha{(f8J$sXtqv(Vwe7U;jz{1^Ns1pVwcczgT~n{%ZZT`kVBl_2c!E_0#k- z^mpiI>+jRg)yET@iAApV6Qjp>xTlWaF@ECY$|QOT#vZlb{#h1 zM&{UgeNC@PI_j7)YZ&&_T-QWk4|!ST{O|nb^Qcy*Mtd1lUGexJu!1a>o}{)a@YB; z2POo$E^-ZZ-88XuLg%>PNg3{G6Kf~@!+nkG2G@oOH^zNDVc*1mk6Sq@-nH7M(dHAI zZ)|?DF}9W3j<@x;ebhG7G2gMu@m1mY^iAnw((kQ(3F&p|O6h8;q16a0iIu0#XsdBnGi~PDWJsU0 zJz>3Fy2$FVv{KsU^qJE;HWzKau<5oD*qYhe*}B<2Y@6tq?O5!1!tt!*CC4uvZ#e$s zXyC*+J?Zqk(=w+uPMe%!osylDPCJ|moJyU{ZT@_J_Mh+1{`39W|L?v(`?B<^^i!#c zjhW5MRxev=kGs8dZ`y|1uCm==yGG~!bOvLg(D!o_S>T%1Q8Y&6BAOtYBJvbHB>0}W z&D`?4<2RoPWEL_nFrmz9CW28g@eJOFz+^G|m^?;>d?irFoJQKfoJV?&zWy zJ+qi8Vw#yK+bmI*=zxauL>v`~INB%T=&*>R-6D?8i#WO<;z%Xps6@n3g@~g{5l3%} z-WJt?PJ^mJ4WRcx%^JEY;;2=`(WfGgu8FRRIy4mS7w&gm#L-tGjts?y;uU@?{Cdao zK}wfJ9Q|ALZ_)Rl+n}c%?uhP)z61RX(id}-DoPa}YSOSDZ(@FPs;} z@ZdB1Yq0$-YkiU51c%A6>ciTl(K-b?Z9UN{?TJ+@bZcXz8=;b3F}gcKG&(~d~ZM1;SmReIc9Snn)5$) zx9v>rN7*~tkF#HE_m}J|I*}2#~Y4?m>o!u$B&+Xdm6n1fT$A9r5h{G9U!(0=DEXSH*_bBS{k z=#2Aa5Eh^@c4OL|+nqmi{vXg!ps`~-K{G(|F^`RTa?G=!7sjj@6FKJfF`Ge|V|I@@ zH|FY?kH#DaeF5qMnUA#^>p1q-82z#1$4(wg_tsy>M2}4x`_kBDW9NgO9{bkVh_U-X zrp`{z*3NUBA8~%wIm9{Hd9`zlbFH({nCs4y#=JM?yD>Y*D#sogJ8Nw2m{nslVd<4I zsxf|Jr;R-^rf|#&O6dG{Fq_Rh49X9%xULf*4~4x5Ch3PG-k_~Q!Z65H(Wk( zIqTBy@`KCBF`FjcnedUzKoY&{rGjX^cs(zHH~!vgHr_~hT5plHE{FE14fiCc@3-XE zSTcIE^)?AM;k|?Af=WS+pi-|!uSl;{FGFvKUb0@AUbfyoypziL?G{Rv6xKuE5m)>N}!f_ z`JE&){oW&Tzq4en--jf?Z<&f@^&g)&$ z`%JGxZ-xFE{q_3x`lb4R)D>*L{`z?e+;=e-KO}x+p0(I--n@DCVrSe7cxv9W^ZqvP z<$15pTQ%?A_jkA$=-%a_HM)0tXj%7e4=r@eP#HX9>#$^eJ!{o&$#q*5C=kgN(>0f`73l)(GFgbA`8rn}kuq zSYd)NS-3^06mApl5bhH0!SjY()EyEQ;BPugg=IoDeO8PUf#>z!$Ftav@$C3({FTQr zk&%cha}?Ztcl-CQqx;xn*kc5OBY&Y2^Bo7vP=KoILloX!=HG?T^ZHKTaNRKtv5^pT33q zO_W|@d7ePJ6rl3gfV=H;{tE=X{qJp`^B?aIga*F<@z`7W{!{zC|h16Q=D0N|2SY=!7v&yx~w<@wKwJNu&w5qW> zVRhQ-eXFxp=dCVUU9!4r^_kU|RvlJfTivkw-pbKsE@t(^(hBJ5@0r6wJW+dI@Pc3w zo~0cf_wG0`?n4dqGaEkF_@d$abF2++7)>_6W8`9RX?W>~(%~+KD@;xpR1P;cd`N=3 z|K=BsOpF$fupT~l_=#a>hnbANYTPmGhT+WN<>uzYSC1?jdCQO)VPa}vy2EUu;X)&3 zG;iI9@h6aX427H0r8sH0MM#jc^dV0pjGbR{|hlz&` z6ZQOt8M8lrA-j9~obfzx++EMt&~_8P@St1P(>{Ld_F@=yAO6D;V`CHA8Ig!)jYTvW z(7k)O&3Zam`{3Q_G0nQsli>d>_WE(w_^LjQs2=_w{!xE=T*v>f)RcdFJXlRbO?sKr zL|~}01Mxv8ribvp;wc;se)ga60e|}J&)C>hPtSCiX|M4#z0Y62o^$$Xs;93Hg~RVt z>4ULrT+!D|PnaFfW=&0dmJiVYng?;P-%fIs11S7Gs&%aPQFkB1-lIe)r28`@hYuu8 zS+z5f=D5;8{QcBZ8T|UVYuXRzJ*)c451~w**ZJw|_1 z8ZY{l?c*TtJvE2lVD|3i4fUB-!!z~R@5#^(=@xiSo$VmaI-LDwdr9{y^vBKCaR*~P z?ifTxh9=gZJ{my!V*|+-`gFPf{&Pd7bM?FBMB#N$|9djJ{Mu&S z+Pm|-FdK`zn}4qwn4vD?M>X=ZnX4J9tG~q;t^HosI0t@<;@+~ol=p6;6BaaM!i))h z8-`HkBc*Ye-S9I{r;QJ?PSxGn9&`NI_f-5b8R~(?G1ldK1c_^+{C$3`4&6F-K)H&( z%zewcVnfOIV}39(&ANciV4Ph05ZMM)W;9@nVBPY8+wRdaSf>YXzlU-(-94oD=D)iY z8t5#*k8fk=09U20)$8xT(06>aWqN}AfS1EsM|g+s-uC!u? z5qB?*igGzTG7@mt@M5v?t|U#K)^O!s{-VIzyN_hhA?RZO5u*A*2lR(e3=ei{Xu(t~bzZ3D^= zwjT5S7?Y%YiE$O<_w~Z_7a`evY^3xVPUtz3~oK3%?E`9218+h~(+`47ww(bS;++~sed?hUp0`*ih>k00HMbe@?G7N4Hg_V?)@ zwDi?Dcz}my`mTW-ni%y+1ODo$A3-=x{nTygd5WQ1ImCQlU43JstA-9(@1Jh#J$&xX zrcu$WQn#RELbrPGJOl@4>dgTGYVPj2(3?%Cbl)a)Wce)DL(XV#pL?|TWO$iDw)e|V z)oE@4Xgt8?K9p$Pz7HYJC}-wA#RR$!XvOz@Oxn{pgy*BbBMbg+pwCbDZ(sV$_hN@M zH!ZZOc0J+-U)#dvwC(-ph8YhN7}Gxu;?Co7EsFaBSUT{BH|+Y+%l>aKm|$UIZ)o7W z>5usPq#wV+*;wzDkBiGtQ}$*H7E4XE1U+NIR^X0JuT^!ZTD5!{z1#-Xx@F4s8`WzK z^_LsM{xn>BIeb^HKb1qPW9K5v+Oy;RF&X+-8u$Le75WE2^hdqAH?y`?!#Xtnp{vyz z7%ZomC_~u=X}@?r?cWn|HG`Sa^7oq?O#H5P-jCi}>Y?V>yWy@Z$&n zGqA#)gK&C#b)`R3l!14)zxE+cAB_rLS-+a@uTtw2?b1N~7^wdKZ1v&hOl!`l=%nh? z{s$MSdCmmw_`QtZv+=>br|8!0Nm&EY}$2WQ~S za~cQaW-V%l=3e8&D&REIy=*{zeHye9S{bddPHP{zL5iH9CcO`L22tCmS*xGs`ZTgd z4M(3%&oV?k>tC=xWp~$cdQs1+(OBVj=DV5izlL>!s@pC85=s+F^jDMP znfu>Trn_6%zqtPmUF?}wy)$H11PGXZrM+M}3O? zNVEF;THzb{t;UJIY;>w>o4715^1xfFu#d1_-|Hcb(Z1F9EI)`0udwe08ec!uZD0Ap z%6;#c_1xIEM#j zAFO}9+M!0L%qrC>_s(ie>Q?sWMc;Pa^8Ms_-C7y0Mq9pj?r!=1_<1hvR+mdRIJ)(} zZ=SUR2ZpSbXB|w2=Nq!(ff%*X>8p|}({{+RZ2Xp&;dJt)KYGib$~^d1v-S>o)7R2w z!5>BJcR=aa17WG^2pL8|ct$*+ut^6N7!h`Ae75p|b##a<|$qFSOw&rcIrC=VL z3&mTBk_7Zya`=*}ph3`rT$ey9^cPwROg}gLLlG%7#G8`>dTba9Y+-R0K(|@KETL|B z1L~@Tf22=Kdf9J*cN=;x3e_c z4LaqajT^o@kGB@qiKySrct7Cp9b0|zQ2o&M)=0$mN9-!%>iGGq<>;N`+v#=}-j8@i z^rOg1JOlIFQcx@o7PkmP#qY8uEhqgVBE?|ziTD$-q32C;q}Wu?(6cYSeOFCij={=& zyvOQsaiV+s0mO{bDe){le(z5bTo4Df2I;xA&T4gQ^~JZ*eFb59v5>m=>bOU54|LG9 zH}#I-y-Q}88~xL}pR4uG>D7IH^YdRm-}AW|e0+a~x^2^oZB1>(OxF7ZBShzKb1RiK z6j;Q^xC2NaW6j1l%8?6^tAN67w_-g<~!j2ZOcaK_qO++*5@m@DZUr^8fuB% zR}FhoHm4mK-JGSNuZb_dLu+YhXl!U`I9x-<*m5w&mXfxGuj#X8HR#q+4aT~*2Fb&G z-yeDa&or7nH9i=+>ILdW>gDLM`n480d%U08t!`gX3vI)Dd*X}u^s1q5U>@t#Qf=7N z?jp`Q8}x+wKKkMM1z47)!vcq~v1Ox9 zk2iNy|FymNJgFV@qz_TwX&t}q+dfdauO?dN;=oI=8a+|s`l%TUwwCn-5?uc^a-1~R z&gT<{ogC&XxZTr#wzW?WczgXFgW2P=Co4tm_vt@xg=)B$X5NDCw!i+%3}|gAn`$(| zwzmI0_TWp8Pcyk%u7>CE733JS7)T7M7Jtk?UDEZUCr^9F#RYq&E;pZN=U<=xa}s<{ z_-&8Q0pi?Sis$B}b#+k7tXI9ppZ2rI7k2zvzJ7h?eb;E^qt=gKbnLa zl$~|Fzx-Ci%S!WgL)q%~l=5?B-C9;NH~*>a9#6o-dctiDHRG@Ko_flc6oyrF_2G!^ zM^A~bWw&(y*PZ{oyzUlyHD2=l<660rRI?Us`PFCAqz`dq)c zNT8mJNT2@G+TQwmY}}*&ye)348woBCuUHT6UMID-w4M(o$IJER)vaay){Z}o0z2-U zC*b7b(zpLCk8XeXG2kMjYe8$9tJRh2orf(h+CA%$#y&m%=iVyn*Y@RJxxN~7coAt=BfgjHb|#y`B5$Y3i09{P4Dml>_&Rw{F0v=5 z>dEn3{I*Ne!pE7~ax^jY3^q}kG?++-2kM?jA^JI7c_wBc9f_XEuqDmdwDicpKTVy) zl+XG1Dc>S^FD&65nUq;1-C~SnMuuLX8t0%S+FS3ELa?3 z7H(#Udq4`aEVEEifmxMVq{v6mVAg7;*6$iY`EQ#^M!Jrafh*7~#7sW2MG!ploc=le zEHlcj)(;<MYYouyq!^qZ=w@1pMr^+mGB(0H1XwAUAa+M6+HuM&`Bv=wEQAk`z zW=RSpCnYVCZi&$-$5FrN`-}=4wV^e1lwwrYDAlNjQLUqHkCK?Xn#;|D&BM(T%`JxI zm{*xMnYWtXHkXWc9sQra9{Mfz`1rUD3t-Q?1ZQ8;8lP z_71BbHqR>3s=%triniaj`ea!6=$pe5M;BO4uzt=u$(rj`l{Fs=DsO6#1K%%Li)=h> zxb_gu))3K|^Z8hVrqKbPud}%zF0`R?RW@BV5?iX@$99%M)o2U-uF+jK&lSs zrERNSA-=-E^W42{>CY@KJC{v?Y_8|ZDiIW2o>MP3dXNqS!ON50#+-xhU)s+ZLeDzJUMmu2STz`Ze_mi!&wH4H(0 zlewiJQnL*-Q#y2w2$opFcO$bbiLW38eJF4^>CocP4ULw9HbWyv6;?|lM|~sS$J^}M zOIK;Wh93VN9RnQ|__|n^qZHp1i^Ny6TAYlWeVjw_4T=Wm+s<+`$1%1>K4TI`29B9! z6goyZCRAK7<^?18EV(@bt2f#=XxjJmwAb~t^R{|#b;eRVs{Q8f9zEeW={VUq|8XJX zBF8Dm6^uJMu4P>JI6LD4b*_nOw5 zero!wsojW2M!Yz}M?gn~9^tYieUFGOnsKEwiyq4&MWNzEQIq+D$jI+w4sz?qgQmV% z9k9B(HjhYjr8;z<544&oMm3npalEBG+;ar2onTSo$Q;)y*S^Pt2QBx$%3<&CQ>*N` z64j|4*WNSD!o|xa$Rz?_byT}FyWDiK81FScXne%@Ond{ddHl`s7Oq~dL9P+5nXYPA zt&ZO>M_0%uSLzcNo8I0%ScyN=vz!8gW}e2jS_+z7Z@OAcpw_tBpotL^GbgGiHc!0C zlEoyBye3f!niK)boRB$z-A>BvRSrVC8q^HJebz}9ZeE}uw+OdPH?>={+f6qMcQ5xK zP=tFXNbTN?+)a0j$v3g}njADaVshqW^<)dTo0FO+-<)hQ#cN8?l!z&rQ`A$Mr`()k zA>-CpFIkW*LY660%bI03WfmS@9zh-vvIvh%54DGOM%_~F_V8}@xancx z>4keFy(!2u#5>Yk>0RJ`(!0gG+uLZm<8+_tfzv~$E2d{nS51%b%=A=yHhbO#Sxohs z8ZqCdd&=)88I_+X67jM%;uRlXIeb$^>EO`5f7`KGaptz?Bf&Y z6Y8Vz$?{S8H2Ado-1d>oaGfEa5j-P&M&gW|84WYKXGmwtXNJyH&a9f*I@9Q3pNB&q z&U(1{Vb#MoAGVm~HfzqT7iL9(UYM0StJxrLR^6=TS=VRjKVtic*CPRsEO}(ZBbkpB zgWd&w0=oH#>FlwyXUz@*g|QSnd(Uk3>~lcd>|bWb&bIJ%W64+0@UU!F)-2&8fsa%@ zVl+E=cFSzJuhO^GS0>Mr3rQf!AuYs}QR0hgGC!4{|D1+7(nqr%mCjYnZJO)&SomWt zj|Kam^q0&FnWvi9HP3N=@Or>RgVh;LIb)2e4Yq@qTmVFCzVf1o(g%Y^(kp! zXkb;K(bK_C7d$Ol5V9a^LDvGGXA+<3enuXo!~?L9XB(asE>tY6TIl%K#J{%u)ipRW zxHVY%T!{569CVfHvLg))wFEqSx`vun*gI`R1 zvFgRH7p05*7ey{ASk$t}=%qQnJ}(8nw8S^`CB;iwFU9)C`l?=Pcu5%I6A~U$5F&Zm z_2oQY`OCpChrgWoa?Z=|`c}Q%`tt3U+kD%6C5v4b%NOg*$I9o(m&jw~!HdHeCoaxe zT(vk)-n6)DvGA35+d=>$2O+B(J)@8vJVFtEyL9UKNJAh6aX4 zhUSDegm#BIE)QI;Sgu;$x?HkCz9M`@&WffL!j-a>AuE+DPp<4<=@=FmrU+AowT6xL zd(icMC~|cDr_a+sJy*^A+DNx_6&#A5{&;dmNLI1BH0$>LtUkKDy;qyTO1hFG63^zhloGmuPznd!^3%d_VngUFACRq(r{TaTKopR)w$f z8MUNeTdeLSZc74|ge^&3QoQ8clIu%Mm%1$tSQ@r8b!qWZYV#b@>uk<+ncK2}Wns%w zmlZENx9s{d(^uVIH53E@VF3Ql;niYx3z=81ziJxl78(#57MdDb9C|MFdZ_7gx8(uL z!2S2ZX6R`YE(s_x)*7&Rm zS(CU%wWehauDAd0`uD)UNB%wM?^yeR?!QYTu>M4ZB2`9IMYKj3y&-!e_zlGy1#dLH zar+I&wT^3jG!(d&qtLY+Db{k7wU#5*T8zf?O-{dIx&EPk~ zL5ZLoP!*^N)TJSOP>LmKBuBDHj{GD2BSSP4iJwwK1(6(`j650H0_y+irnYvVsB7!H zBRMi!XSB`{qRz9R8LxrFDJQTk@396rT;sl#mUH8>&)TQ(QM1r3P+{ z+$c*8PHotzNbTMz-INpE5G{`hjZwzPVuNE9u?4Z57X_(4@lC0>Q(Y4rw@~YfB>yep zTdESXwoto{Z~4Dl z+Z?=EvAJM#)8^ZoL(&`-{)%u#mg1zMOW_k05|tRGifV}xM!QA_Mn^_>M@wU>Vp?O2 zVyQ1pv0O|N)A)$+(NLw)ShS?^kr1X+PpA)^b=UN!&4KBW={f0CkAIwNf-0>lQJCJ4 zE>!v`!p@49-+!KBye8ClLNfqPS>qb&@EbsDjLbOnU6l&awV;O_@g9jke3TM{ZYb@7nIT zBN(LI(Xd0f(`RS+&H_;DPHC1jD-e{JbrN(tOSVh4D-@Kos|6&9E3&JyyFsqI zLw0BFZrW|M$7qjyPvjo|0!2YpLC)c-?8L0@0@uR+y?F52kix9Orb1~^NKrvicahH# z#gT?1qT*2{$`Y>cZ+qUGu9T!=z5S}>l=#OI^{=lZO^y5|%9<$VR$H<)h3XTwVA z{iX?}GfLey25b!5=(aI+V=-`UkCPi$JXY0IXPO;pCJ^h(LO zO|fK!((2Mv_P&BMrB_P7D*dsPkJyOKMn~N@ z8yY^b*;lY?v+U@W&A~@YHY<)cZZ0_b?dGPVw~tCx5|t}R4hjZ^Yba5bsLIh$m8weB zq@gYqNBDdmOVToqWMv%rm-&~4fFeOkP=SU{mT}Zl#!+_}M@HowIhJ$eQ_fLfIY*)8 z94X2<$|~nbRnAdEIY+JK9NjMGNKzrGpsS&41xNAtx5}R9z=dZsgrs} zsfXHB;ihPE4NwFq!W5~BV#PVdb%kk^TU0<)SX63Man!k}>rtlBZqWhJVbQ74#nI=Y zuSc84xWxp-Tvvp}q{ak96~~;5xgKL0>lPaj8y1@yYZ_Y&oC944nZ^agy2S;=xy6Mc zO$8N$&Vd5r0umbtv(*!pV-3BCtC8Q=4C!9;T&XQ>& zN0je6q$vf&yT!XDQax^LO+X?vCb}htVN2Un6N?jz6Sx{$K9_hs@mwM&D^~}r!_^~_ z64g0swT6+F=hyL9T{(gu3J13emG&DjDPyAo_81>@k zYB_H92_0|E(pW>#r%QahcFX>~5dU{#ygs_s{3Q|1X}M{R*VnsNTe2LkA?ib)zYx)K z@i}(Q;+{U^99EYo2QBkk-L`w>IVmj%uccQFy)VZ<=cZ4)Sr5Revo)N~S>`=uOFe)8 z(8~%hrFk;St;3w0c1_mRqs>3a-uK^;!CA7-6V%`He({6#$5%k_Wz$H?*k>X1=`JmW z<5>fJM&T~oEQWd7)T<}?QQdosO%HxPqP9r8ou0MI)*VoA7jvIIaS#BypiOO@t$zv}@J7*t9BiN_%ltYG;m-mnK z72Iu~^B?#2dimemKIcEq6yQDI|J&u-{!{zC-Agf%jz%G|Eqq~36B#`pLqSm))QqX zE}Zz+iBWHRy#4gsufM(Z?XtI<-o{^tiNcpkPSXDidH?IY>q*rT`APZcVBA2Yp4=}f zPZpd!d6J9JV6xpOT~CFa(nhmyPH`&d)X7t=r-Y{+Ps>k-oK~F9IeqeU>uKRTj_=6d z33*5HPR=_g-)VhE_^#u-@^?etRlJ+??#Xvs-xa>+_@4Z|koOet<-B+Dz1H`H?>oLP ze?R1X#rrw$pM1adecq1btn6&y*`Va`v&yrovrT8a&q_X!eGvFT_y-Zm$`4c@G=0$h zL1wb#oa~(Z9d&Zxx$tw!bEjopos^Rn}S=flq{ z&#TTio$o#``B3&@AWJus!#`AhsQS<%rRl@&4<${qrog7~CM9laG&OZMNiN7P1cJgZ zC@-ikG+pSvAh{^J7#5;a8MbR9Bj=bYGENm0b__@~NGEw-pWRkby>b+<{{W$l6OUTNX& z%63(IQ+s#2q$4Oz))Ckdk(QYj-l6PJbu@K!cSt&Aoq?UD^yNq)WceOqYGFPH#@X znI8Cc_}9v>EtFo$AZ3J7^>q`d`)kQJvTp*v3I8Tjsr*LuP185s->8+6>&?oWO4;?m z>*3dx*Hzb>u3K#N+8VUg*P;7*#8%0-vTp;w&DpGkbEcmF7UgcjEIcz?=mx#-!=Wa``?mVvRi?-f-=KzDQ~H6 zHQnmI6_J^lDfvP6L*NhLKPZ1t{h-cl`l0&=$!*!~=FFR!fw#kND{otDSKV&99q_8x z_U_w~A7wuVZ4dl0{72=Fsvnzv?EX>mQ^a=JPk}#$|CG61`IG9Wrk}ch3R|zhFmHis{YtWAH zUn6#8?ojVg{;K-5>DTUGC3j?Z0`G+1Y2Lx_L%(G!kUPj7hc_TrMYaM(&5F z)Q`&N${&;a%je1G%O95q$e)luDSt{HD1TbMK#m9O@@M4><$sk2%b$}!FaMkT1^J8e zMe>*AA@Y~yi{-E28TV58GWo0WQ2BEC3i(QTn0%Fdwfr@Cxcqhb8u{Pl5%M?WYw^e? zQoat4HQthMkZ&ZX4vlgLQYkZ({beVQS#0q@=iW^M@8OEChumD_qLGt z(#d;8I%!anhOMMw8)?WS4Oyfin>6eu4f{w#4r$0G4SA&D5NS9}8VX25 zA!#Tg4aKCPlr$VA4Jy)5MjFaVLj`G2lZNA@p^7xrkTVK$CW@SiCT9}KnIv*1nVd-> zXVS?TB{{Q|oXI3-_K-9C$eAkAs3eVBNn-|S+(sHRN#l0XxPvtAB#l|5aTjUKCXKsE z;~vtumo)Apjr&Ps4r$CKjR#3%9%(#88uLlxVbWMY8dapRj5L;$#tPD?CXL5PVkxrAIUC6|wq%PMlYj9e}!mn+C+HMx9@T&^USkCV$)QIM7>(h^Nt zVn|CYX^A5(@uVezv?P+2B+^nsT1rWainLUb7BJV4mU_~1f?U~5u0)e7G2}`dxe`yV zB#>Qctcb$kkYK zHI7`3Csz~5)kJbNnOsdFSJTPWt<2}O$xLhXZl*P6GoS#XfM_5Fhy~(+cpw2t1d;&A z#H0YJz!o44NC%X_R$v>D3G4!>?mfU>U_X!p8~}2Ge4qd*0*(O1KnYL^lmW+>)>!xz z3;VIK7rO)431k7#6$c;V;A0$oi-YYr*p7qkIM|AVop{K_qb(k?@$fS~1AvZr$j8H{ z_uCPzStZEL<8wS2C$9!BC3G-A`WHP{sOkY zh(mn>_!8m))Fq&98_L`FGhggTX1+KCJ%{3$FVs2Amzxzp6p#QU0!ctJkOHIvTYxkm z9Z&*Wfo(u0upQXJd>I8jQ3rrr;2@9(90Kxz!$1L02owRuKq;UC$^kV{2~+_!Kpk*` z`7#=QMMncMKpX)7qTyRKe2a!}(eN!AzD2{gX!sNjf1-B+*}z_4AFv;QZ_)5A8oout zw`lkl4d0^STQq!&hHugEEgHT>9|7QBH2jM`3c$zcG5~%?!?);K=F6?ny%oN0ML7dD zw!v<834pe}@OeLEb6_tA<=l7xb`S0bs+lhjgRcNKir~i)^yf$x^JOt&Ub2Vz@+f>) zp+6O{bqsN>24D4Y=F3|2=R_@Y4dZid^G=|Oxu%F?u0?HUuAxuYFh{Rptgj{HGuM*# zGuKiSKm~IxHH*2HmI+{+p3GcR7BSbh0hxP%902(pI{@h0sb;S2s%Nfc$1~RsK_>qY zbFH8RIL2HnEMu-6DQB*gZegyKQxpTonQLm;KfW1&z2nh9B9H{Y*6~yTc8^2P@xuUQ zjziCJ=sOPC8|HS~<|3v|QO&ePWif4-FKyAgfPAJcrVs!R z_U*RVSRfVH1{?(P0Pw^f1_}V^h%E*xnYK9a$3br#__4pW#bpB9fgJ#B#6dm|{BcJB zH2|6TL?8v&4eSN>187Ttu7qeH9)Ntp9su?d;BP_=)0P+qz)oTr06j_2lavkQ0_8vj z)0Pa`WYj0amt^QphW%vdPObu=Ck1^=fu0ojnF1Rr=zq#_0QytV|5ODK1As3TdQz1D z;*biyRQQ_;e^QSEu$KzGsdWJ4wjc&ujxlX%@INg9NCMEuw0!{lOG6CP5Qnr{_XI2 zJNmvIG2ae7+hJ=vY-|Vr4#af_^zDF-ov@jOK4n2~7UGqS7-S=c*|51A|2IbIOff%cyQw@7+^i_>^buH6&41GOT z&9qg*&&nJCIw~Pw34bafUy1Rngr8Uk+OW^JRiV#Skf}m^s~}ee9aXSj4gPAxt{Q$; zWBh6`4{H#&nyo+v0Q)tFM-Aj^psyBj!#P7+E!t{fvkrdMAzud{>S!5u>fmP`^kGeC ztH->oM_lSL2K8vGFJ{_Kpg$+jzZ0lGfw-QiV%l+hYu~(^X;-M3_9z9gjcJdL2X+EE zOgqj$+GAq?x=jJL0Q-T%KsD1Ihcb@E?N|fa<4S=trXBlxJC5z`@yD2U?ChA+vrKt0o*0-sXqnD$iYNd<2z?5EZ+ z?OUP&__L*eX-|WnX|Vu&Nk^a34>0XY^iPR4WhDSTTXzAlmjT{w@OK-0!G6}Bxg9tH z9B10GkF{^#0b~Ktwf!i7SnYuB9k7LctbIo=kjJ#|gr7T6mj%DEueI+&Uw0`1*x!Y6 zb{qitY?_De-H69-@azUZ_QUqwh{j1D`nMN0_kwRWY zM}Gf4ppa?LLEjEw%npF(0A#VBwjbQawC6=J?T6rBK6nlze;7Io(N=_Q5$qj7-;coO zBd}Wx{$li}82rT;v*JnseJz2F5{y*|?3Tc834AHVc$KCCkST>8>{IPWkw03&wBuOb zuF7ZH%h0#76HI$K=1Mtu%Mqh;*v2_tdj(`F(60*cS70pE@Li3uQNvF)?5YtTob$C; z!cHZ|sS@>-@Z~u2$1zq_@TVH_t43^bY;3Q=IMsl+2JxKgN z_@5{O>Y0ws*}z_)is?`!0Y$)3pc1HKI&i$~!1-DS&b>OKk;gf62ab~+v57zl(-F6u z>A?AHM|?Wdf%)8l`P`9k7$^jwD?tq$18RU1Oh;lo0KP=%N(67>0RXm>HUpW!5ulXm zNJbg+ydwpAaJ=bAt!6s5!~l@r0^3`Xfjpp$=}3eBIHq)@qaO3SLkS)w{K6dXP^y@Y zt?+j%+P3awIx^75476n&Vmh`#W*h3Z?E?-1@OvBjz70CJl>>;uHu${_ams|hnXs3M z_U*8}eJj&}Ip47pec8DOfR3yv05-GG-z>y#7vi%Eea?nW9BVqVbAfyS^_cq|yA=R@ z$8o1)5Bj?Yd^q-W>;=z0@b80u%<+yK#5V`AJdgq?nT}lWLC2Y zaik*;ea(YkdD{W_mIoVo@I4Q{9D>b5sQ`2yN(11_q2mB#^Pv~VoQ{0hI*b?<1Y(<1m;1lb~t9rl}!67ZCQzZ5Yoh5e(@hvP|y3jV9$XBpyEhPrZW z%i&i!Vo{EMVn67>xnxHLWGmov1^Qfp@mC|3YQ#VdzGKjH46>CN*Gl+t9Bnw)>^P3` z!MS5c6~?y~F|P$r9pY1m@vDPhb?~H!%ZX~H6W2VQn`40#U@K70 zbmE-4Q?VZ?1&#vszzL=^DiKHqih(kw6W64jF=&h53FHEYfkFW7@z4`r%XB6{e?klZ z{R!a1e$kn*kLgS-0iYk}a-E6Cfhwjm3A&S%z#c#aKu1y?)0vzH9AY|CP@e)jDaV-3 zRQQ^j0ie7E_O^g84gTT0s}tv4ooTy)d;m6ZzSWtIb|u=CJDAQ4*v>#*2J~mZ$8Fo0 z&dh^M=Z;hWF~K=f=T7L~3B5bf?<~}3r328J1)Z4NomoXpC)V@MUC3u2U^;hi0nqD-SPU|sISc~fT&e9J*>bKrMw6p#kMr`){&cykepT*w`Sy*$X} z!LK~T7}uzshY+7b@HHR43HrYj|e=}Lvov?``6J(KCm ztYo^jZw8WqVgTixyMYp>D+_g)4_#Sm0J^fEXBX`3g73SacNchfp?(*1Wk&-C0JLQv z2GE|3_T4D&M&I@%0PqXP^e!CJyY_&05Bj|iHn8q>?b{E)KGwdjeJ7YM%%iUTu>jil z!|(mz-Cx6W;XJ$x$M>#W$l(0DD;IilO9A+H5cN2>?!vt3I)u0!$_I`xUHMS}Vwhjg zbQK_GI0xt|g3cnyAK41z0nl*-ejb6I;#~mBD#S&#gXzMtyQ>`WS3}=1#I>@T={gQS zst^~P!**4Jry9Dd!B-6*YY>YX#H|M7hhuVA4dPP+d7Qs?)uKeDxTMdg!j-&wRBzpZRJ}Df88lO6IHLt$yF_k2!#3+isfV zA<0Zeva=e={um^=dyyPIf}}Xt@4K9ggGhEAMv`5KBtHSkk)22?AeK`}TPqLweRm)( znkM^@q^OW&Z$pv;eFt)qkQ_-tQi_J$%_!z39Y>N1=G-kAG=VR#`k)gq~Yf4MbEBy}`bzs2vngNa~1m|TKnOA(TtaPr`8HIn^s!cK$s3C#v@6FzC$VXNHS={Hu!gFJ8j(y1&8*+wL?ds?+|tE zP&qn%NDZZjY7zEB^$1gbG~CTkL#*<%Ad!Cneap{-L-}RUl3#9D%e@4JFn_*@W=5EmrCgMxIZD%iRm$#$45IC9kQyTWLMt}q6Cg$Xp5 zgqRj?fxg0>n~9?%ahy%u-N}?mWQseHO(HU+Q;1APWFAE3Nn}%r%!|mT5t%oUO(!xR z;xUPMxD$^l#6w0rJcx%U@t8_Hyokp%;^9p^rV|ez;yHQ1IkAyZ{!st1|sNv2LEQ@zO4X=JK5nL3?J^&wu9h?hI@ znnI>cBGcT7_ax%&PNq*H)7^>BB;w;vX3ZkA9m(w3L>@#)IAQ#V-#p^?9PwL6<`ObD zl05bldF)y8*fR3iI^yq7{O1w>AmaZl@&7BCPssfFWPSj7d>(mxISC*nfFS{XB;aWh z@GJ>fNS>HSo>)$vSWTXKj6C%;c`AfFy@o9CBMSn^f(2y38|0Y)@~l5u$dHB4k%cdl zzxtEKi^<|w$l@^a3L&qAkXKfcSCC&vmOM+AE+tDt$RGaCAz8JYtO_Hm){xcDlGo(qwMWTo$SfqUEhew6A!}YI zYyM8&2qkZZGUH{g%ye0sg=|U_S6T z5CA*@JPAAn1OiV33xH>UAmCYGA@Elq7kK5X?$=Jk+Q zm^UEvCUEK~13b!{sa?f1*2;lLfoFk*z%pPZ5C*LA8!wyUibO^e51M$=WGYR(Xfll^ z-ZYs`6CWfVkeJdDRplr90&A`eSN%wKC-})|od+#?(4q$|deWjNEqc=8R9c)$i&JUQ zix$0T(Tf(R(c&~(oJNb@wCGKX-n2NK7N^tVbXxSGMIT!9p+&^q-!H^H%-wGVLhSb% z%^@D4e$9CwNBK2ZH0RNzh$h7}Ip)_~kK|JNrQLp)^IIzXE+4*pgeJu_DWQpqCS^!k zVrddblX#jW(j>|6av_x{q!NWxqL4}yQi(z;QAi~UsYD@_D5MfaRHBIT6;Zw-%2!1B ziYQ+ZaAVDjVM4F&AiZ(>ix+q!~MeCw3=hFmY(X=(1ibc1?&;)$ZlrNg{#ZbN&$`?cV zVkln>Wr;xykbp0S^2JiVSjrbm`C=(wEai)(EU}a&j<&|p);QW4hd>}HLqhrDFw#h% zG>%HdQHgje5l>s=XX(wn^J9f*hAB* zJv6PRvUpTO)0$kG)+*SP%HpvJn-YAZsch=SrqkHe zn@y*)sm~k_)^iWmXAjnA57t``)>{wOPY>2l57ti))=v-CPY>2l57t8u);AB* z@8mWm?52gX(B1Pr8=>pl~`7(AuOz1iM5W! zm96A-qC_3U>iYlj_2mIQHQ)bdP}Z``z6;qZ`>wPSNuqizMWLdy6j|F#i?pjq+S6Xr z9$7*nq0)jvmTcJxbAB`TzWIK?|NK55&g<-RX70T+cjkSc=i%XOeibF5SvI{sWR)@C zN;a|b)C{<14xerWXb9jSxUh+pD>*P1HpB8bHkI-?1`>(e*j&mbHkD za$rghUK)HVWlBDoa>=>Ql{L$!QI?tmw{c)LHfM5)1C!Wn$$HCyD>(>C-c2U)as?2S zLJO{9P6phQ4Ha*JLz)0Qj!B}8k1lr?mAunTVk?~hqLL3eFNg!1b9pib+{S^m&OxvV zS=fv$Durb3ERMT~;cV{ZsTuHaHtX^@Htz}`QHq{kbwLjkn2iAo;J}*Y!1}-;Q-mx5 zgk*I=b?&0%A%dp}taTP3wh+`Hx-^I`4WdiK3BjcySfeT5w<#}6qp2XdsUS>*vl>l> zzD)&8S?t(UlEc8csU(+yj|l!EgoqF!K*IXrdDVt*Lqus<44 zJ(JlV25Ib%#xtkFXHMQ4&zwD_--jmi%n?TbRa+lgD3V6*5R|3GmTmZ?k;9&}3hl=(8_P zwD^kF>~?k{XPo~sE`J%fzl{4|#^Wz@>@VZ}m+|>uOq!7J_+O3RU*^PL#{Vx9ATW}$ zl(ilvu-6IphR7LL_J0p8_7Q0|Tew{r)8NCkTKr+=51$qzuIzeyXo)xV!*s1WPv*>? z1#NM#8D$(FZ}vx99CAh($48z0(Prbo)?79c0$1Q8;aa>;|K-yDTG?vXmyTepS7^BU$ohmJM3Dq?`{|u&pz2<0^`^fW0yBys zT6F$h560;HyAX`g5nNdhgW1PUOh^|x0MC$Qm&6a-#7@1`g)A2y&o zCRK>>{wuh|{j;PC78Vx8+*n~OhSlrKgdE;4uHxlf6x{ge>#!d3k>PHxOb~}obmR5L z2o_cWA3#XSha#qYMl*7BlyD17R zR49V=l)bFA6!|?b3TN+SE7{Y_p2H;eZC3pb!E6!xrGj>;r_c_bY?+@1JT=p>- zW0-vQ@5SVk7?W8?*r2j8&Wf^$@LIatVrH23*wjYuj+pd z7*qX^T^P1(LB$$$F64o{#?;k9hlC5uwEWrhWU2t657F#WiQ~;wVu3EaC(f)V$xOv3 zTp?T>53XWk!c*|4mY0@oD!y)v{5yw@{5w&N{5wBMHzjy9#g`BP6G9oLdNieQnPF;( z1i3yfpedbWx_eU^w=)!F4^hs#*pwa2^x5wJgFHURz3DvnXPCp|b38;jH>@f543Tbj zjB9pGW0>OBoRY)QLxRU7=q*8C3HnR0K!QaQERo&18QQ1TO0@=j+kt z?^D8cobwIkTtGhioAHk;_{SAB*~=8 zCwCcLv8iVFT%iw1ggvM1Ze|k=d(`+{FT)hCjO(ac;c=6Zm>fnjb2={Nu}dqQIbnvSJWP#qY9hz{)G89<7)j+E zi%{oHs`D^)-lTekJ0~ncy~2|d*0?&4Rp*VX^TySAyXriFdPPP~M-A?*$rEVub~Sm; znmmi9Xkf9Lyh$x?)#5rWo>_}0)#8O~iPp57j@rByZJtV-J8ScTw0S;lo==;{YV(4$ zd8{@sNSha=&9i9psGg~=7dG)@=A4irK`Bh zDxPXJ=T?gxb6L$@R&$rtysFhasXp(AK6lZti02|Rtl{Dso^%Z_V+}W~5e=-CH9X9K z*J8la8}I}MydVQ!kU@nPC#)(1p1^=NX~3H_;HeBMVmV=hXu!K=z(;Z|@A+D8UB{EI z<4M=?r0aMY>-c3{$E}83G~}WY_cP*tM%>ScCotky(1^Pji7u=bBOYeV{fxPvF}L!` zhY`WR`eDjbnQ|9Xp2d`BG3EU*<$k8TdQ)D#DSsABdEuLRmd!lDW_&3TwPZ_=C(ojH#%=Y^Z|#?5)GId9yYH*U_`HRlP; z`G9TZ&f9o`ZM@xWyyk5@%Qn%#Vz==ow{z=uuG`KtZ|6z3^TM}_R`x7x=LK2tf-HDG z3m##?tFqupEqGE3UXTTkwcrI=@PaIO77JdL1uw{whgotLE1tzlc?T$J@2zsqFYu zVaJElj`z)ukEuQHzCE|@<4O1Nr2BZ%eY}=^yq0}D=|1kVpX>H>odb7v;LZ-**@0(q z;MdZDyEuq0tRM#->%e0VaOVTu`2cs}3o;{uAv34b<-`ida!#E%z3p7y>54lG;MA4T zlL8+vp!bb4IY95v*q`f|w>w(8LLvIKe+o@Q)MxlLTv$U`-P8X9_-< zf={NP$q{^V1fN_%lP_rU1x>!-pD+05-*#p#UKFesxwX=%B2b{Ll1ewOXO!#Rcod_o z*OhKOs`41mTp1u(19)wB-35Ar``kSt@PXWVR~R2gxjtCXhX{O#z=sO-Bya2PN!|*h z-1DTM4;L~-3Ve)UjS;Ld5{)O zVBs8mI0(&h5NtxTTw;CTz?wA`yUBsMaA4i!z+6nl%fUggxr;X8a+p>K{WBHYGp!IV zhpE^H4s1NPh?m1$Y=HyI!hyBGfwf>Rju{8RCJceOcsVS@9_GZV%|Qqd%C!;8wGqdKgJ2WJg-b#*VO(s)uGol|W4Bnu9`SPQ5iiFc zrev4HPHf$dHN={=V=b`D!GWdbz)Ir4E{C1iEC<0RG|MH{EC+TunoHPpRxSW@VG^76 zrJ6662ucQAd9j2m**|c{HWUm3Lcf2hPi zOybXx`!i(2&QXlxDC&a*{TYcrN3K63@#DzlIEl~&ymZyl03PRJh_r~awU0kC3*6N8&>)bM;^yes4HKRpQET3 z`=2jqhoh($`+rg5&ynjdO8hu7nJvEj!4Od*^LChCxGn;UdZ8UA3*#)Hs2AF)JSO&w zp)fuSh4Eo1TxYhh3**xio7fav)K$7EHba8hB4)ZbWrny(3;T1g-qO#U_}Q=wLvIQC zO3+V&{t^t7AV2Kl{-F|Nhh>7yPc6Crv;^5%r69BO3<25EsetU%S3q{2AfRvp!QvQ( z^Y|=@o}nP;NMweBoF|bP3Nk;h<8chR>?~=YpP_TTcwoYipZIcF(w=Y%$7OaZBIqUU zhzB4{&kj2Ty(GVLu|(zvjV#W^NrJ8tbeEuTHp1+Z@#Dzr63*5oD9Dm=L2&l%I+*d z$L0Wum2;w%&kKL8e`DDk@z441;KY*?FHXEU3FIV*lY}@% z5;@7vSvr!vtux|Qw{&Ekw-F;g8yN95W#qUyBYqZ)oY={T|1L%X?3SwVq{V zoQBJ&3EL<&c6T`4RAXxZr+k~E#^!TQ`4&d)4&SzL>d7eIq^R9tvoNE4Gor?3R8ILe zM2*dEjIz0jQ#KRbWFKCw6k`zl+lV zJGD9w!IN(R)as52Jl_bYHSjHfS_9t#sIzO&mI48s1YipRli0l|f}mt~FqhaZD1xA5 zHy@MgeCqsnGw>DRxCnkCoDjjkF5`gu)8y0aT|h&!wrQ~I#n(8ES~e23PAf%pUai3@ z=Q%j?Bpi7bji%UOzGg5Ckzl9>vAOlY)yncVRm>PMD7E+TvW;p6#X!+p*=i=yEIDm~p~f zba@uG`{E=bl@Y-uG0uhUta4oR*(1w{4<}4yJF1(EurLE2VZbBU9_S_`tRS}4;e;93 zHir`y!S*(BE^HHXlMxnX&UI|x66eBpEjL}*o+ZbHZBA}7!ZNcx2q!EH+kbGv(zE>q zcVPvwy+w}89$paJS8&2awx_ts2n%Cd2TmeVUHQs(lhYtZ*;}kYInP!ofpVU`r3#eu z>`hdloM&&HjI#Aepqyv#nF4hZDA%*qg;O@+y0RIMYuLLaqwGympxl$KECLnuf+u@l zWE%FCC{V6vZ-4@2Jb&T@$~27U8utDsP?nAHT+h}NPFXg0arG`2(Md%1$a9%b1R{!h zJ{7q0z6*MeT+dLV7yX5mTF{IBYy<>#~S~KVOM? zJsi0|LqX5RN6>TRdWI6c=+D<&Lkm8SH@yEC;#H4XH$LgTY!Wgt$Gy?Se zm!Q3hEJW|AL20k4aOlMs?B?YMGX`IVye~T7Sr@3|-E6QQe3*9ZT!SB#SD?YIYk1$I z9S13zVPW-o+S||q^s6F?)Sk`IDQy8wo^6eFIlZx8(K!e>wUw@`NyFU-BFO8>=iq3U z52WU8E2K*`fRg1xlvcfq9W1R;ueC4MT3y1Wq3@xu!VYXP*hQ4pJg~QAS5V$Ya1}`; zV@?joUWxf+j?pVLzxNv^wrfJgIbD>U`TmIQ8%@ zxl$R6A*wo<-rNg)S8XOb4WFPadN)k1KM4_&KGKM&?U>c|H#M_<0J2gE@cKgx4h=m` z%mcb1jntr;Kc9o)aAi=N7ljuGZYNHvPIyV#0J~&+p?v#I*t04MTwd&g!-j!itacs# z=tRO$w1-{o6jYZ_g%|acpk;pn2~wK`o`>F(LaS@IX684XzL(BB5C*T@U>VGVjlnZf$w`|0*W zC6JT32-_zbz)5EXV)FYNgoZArAKVtf$T_EAN{{MROT&8L1w|8-l^G0&8Oei~u}* zuoev#jKE}>&QSl=kp3RC0A|-jp_};}Xd2m<1}(8ah4XNIEY|1;_ z5!4Bcb>HFm#Vc^;>Pm8afIXVO>O*N+gO^8k0;-j0jy!r+9uB8+)%1D8gXqHRqH-E%S&2*I^5p9llO^eCb?S0_Ix8W!kqz=}GGw9PCBfLA!nC?Ei3PdC< z9N28Q0}sBtk#M&vjCk1%maLSA13%5k@V=(F=)a%jhuaFcwm6aQ?Y0{7-tM9gZe7NO zX20oJsdC)+Ul=;+4ufsmlIgoz8+rwaP|n=iY>?%k1Fvqeh4^ z9}CxZkAv+g@wEE|06ncDG%f0d>eAiddZ0gc>AHug)TCowY89+H8V}!FI?;sOLufp- zf~L=J!|oG*kR21ISkB_AhvhUrRs0u$p3Y>@Wycg-64RMk~^Kdl}A( z--dg&y2A5~({T8WK{%$y2p4Afp#IYsT<)9#j`F=>)3K9Sd*}?&-2f1@`ZT@b{0k?P z>)@{=O>lb4B3jwG8BNr#fKS0`bm{vN6&xwdXjn+M-qHf!&fT#fwi-qp*avQ({Ne8J zSNLD`1@!CGPJh-egSg=~B)RJoXgN5Vtal!aDYjm_Qu&-50fd zJK;7fgzv_kVCG~6=rFtuI?Z1UhAYRyu7vYYqFIX9BV%!1mlRkrst2en?g(KkMqvuw zN}fOJgzJV=5-@QsdR2U&JA8X%;U^EcclRi$O_8PACW}CC!9YAGJr+Cu91LTscftUt zS=fI}Gqx_AgY)X7!S9eEygi}{Rr0ww`n@!!`EP|y1Mb7so?&$AnpbEyI)J=vUxq{6 z&B*$~D|oKV7WNNSfGUlpFidX*3`;a4NvTm_HO3AWnaoGM9_qy7LkTwi?gKH6^YGZQ zBGliy6HKIap!M-R%ul;b7DQH~%*Pr?aq@u;+dsjjQ@>!6-Ee5ui-e=E>f!0oTkv{F zDB4Yu!cVf(a8y5OIQDBY9Wvr8=GGjh6C8q}faNgi@(k1sf!^|HhMQ@(AW$<8`Z!Fa z8>Lo4Q%xItF3!U*kC!CI>jB*RxtAI`6++)GN_3Iz2|S~7A8MwrhMjiL>9!}gaQW|^ zBwVu*nsBU*x(3sbQoC%PSGwghL|$?6{t?$ibG~iq;I#Z!-)rz$c){Sz|MUFM7R%zmzDp~ zF|P7#d=JyH&)m?y<1QQ~a|lkFEF*>icQD~%BaQB3h!@P{V1(Ta(3@&WdUWUv>Z@*& zs&6UKL(!Ks-8Y22PtU-C&Ca0mwv{wL?TVF84-%uA`C#i|NQ}SSLZ$BV#A!zX7}@qD z=2I*1XvGw&i;r-+b{{;dV2&UAmqXOE&S0VZ7IbG^!|mmF$@!kW!2H@u+`QrzOw9jC z#*`j{F%fcDIr9>9KO94TM&@HD7e(m5!3GyPyd>&3#$vu=KH9wRkK>gG;g;GMT=!r& z#s*5Eqx2ZE8C-5VRGpPdaFw&cx3biTg_^`Ge3iLS~?hH{i>3%5f9|K!r_dvIP_HgRK zV;a@D4tIamA$Ic*LdNB_w7O>oth`f6tDWY<>t6$i$?TK(7JK3~qcyl8*bUO6c0ucw zBs{s-5KhGEf*N<3uO6#Gx>K4X+e@t;q9CRxBOpa!2!nk)7 z%?jE;YOW^TrlpNv`smS?d#?Ci`aE`TNyBH4E|K0!Lm{U2D(!0D38%Md!Y-$ssMGcm ziZYMFVa>^8aQbK1m^6qEc`*!ndha8N=L#_8nhyS6UIfPuT?MTHCO9SZCu#IIgC&>F z;QkL!!6_>M;L>4urU7^#eb_QZGIF z&dCA$Rfdp>*L$IKKSwxYF9kWh{IF5JC&(=cq}qdJVBPb%q~eexOd9wE8n#@A0n7Vg z-yuif^rBj@9`*vV6OhdMT>_6PF3{{q71TT35iZxCfpbp)n!1lf`D>dYF?Jlt8FnVI z1=e6P)*V_uwLrT=U-(3AvE#Ijr0WN1)N>D`HGdLu>7ge0x@j2<9X^?oosF1fWCXv* z>cGIVsi?EU2Haacas0+%kgwH>8f>5Oa%*S0;-wEHOq)l7OzpAPkY*Bp#}BQuYVf>E zH2C>86PfyzxI1hV*}T01(s$dDggeWj{Hi{gw`3`JO|v0azdPcwrY0I`TM6N2NZiL+ zL7Ag29<7SN7@AKP$bNZ!rwChdd{{_UEE%%ue#auRET3nMlyl80N~(qee5j z;h!#SEAM{~yEz$0D?KXlXSX3F=kROTlp;gVRiDN2Z@qBShILrtxCsvTyaVweXW&`S zNW2rd9~FEZ!8~g}IknRT>SZU>?}>Ntf$dPR?fo2$dyOO49`68JsgGTaKjY^wDKOY@ z58mE;04}~Lh4W_JN!>UT3}1N)-n5RuJ6Ty6eCZclX!n3`11#WB=}zo$FBp@0p93(l zM!T+Ei1NV+SX#UVMn8)J#Z?;g{`9vn<-k`uIjsw%j(d%fD#>s_y%vpEbwC$O6Z&ko z2i#WLM>7^J!PV1y0SwOC zqdWw|scG+_YkWLezBvb#mkk2dp3}g)`3&fEJdU#O^Pqd>Ow7GhK$1tM;YFQC z7?h$+>$<*%TSwF|cfBGUO~@rGN)&o6K1yi+bL_qDC>@}<7>BHMrW39VgLYS2I_B01 z*q)RKm4Alg_6hQI<8wo})5{Sb%szlm2DHMHYe(TvP7d+kY7cM2#^QrIE$k;hka{gz z1Sfqu!HdQ%Ncum(pqnq@%fYS`lJCLvdE=0vEPl!Lr|>Br1}t+0hldkk)0N%SZD1&# zdioN-ORd0fL3?QPdOdu+>Ki?G&Yr5@w0xX#@fhfd%#QDpg(?g$&@#^|4boN<` zzD9FMV3`!&`VmP_MEgSbl&9dV&>u;HAD$RH78MO+Y2vv?*sNIt|J~JvGded(yh07C zBcQb7VJsm&5TIX=wePjaful2_cyAo8>7t4?9q(ebwHsVXoJ=BLszdaW8}z?<+u_2z zLi+Q$C){$^p<5okhUK(|u2K(&B`1H-x1QyA+9Hewr}u*aWBTGDhu2WhUPfJ8yy4iY zG>ki}1}Dmc=$j8ez$4F|?MHj#0WA%x?i>I|AO9fgN9}R=>RMW~tS`1CT&8w|R8ggM z4y_(=3PR^^pi_np!Ga^PI8yop9NoQzG|cFLTTJ5VqLL%H<8dNc{xl6^H%RdWB!djZdE9>126` z$s9zd*mp$P$8w~qL=Wu`z9KT!=6G=SK{|w;6DV)dL7$qDXsWUl2lf~WX=_)KCEraT z%3~?0&i;&n6(%@XdK7M+u1~_M+1Xa!EF4td9h8=SqkV$4P-c)8DCpLJLMM4L#QOvW z7s^4OSXcPECXenf+m1)-O<;~)E6NP3p=#5*VM4eoJtxAUuj#%dR*vRji$MlaOKq-x@m1VR4Lx3_EjITZT}NeJ?sp` z91TR7`VC+(P>pKzTZP3^K4i5i#VuPdqU#zrw5s2Wwg;wyMemVtIchPgJswXU{#g&1 z7Pm3@<`qzA?v4|p_dwUqdJtFN0Otn)9TxB#11g)zc@Gm95n~IP-5b!YM=71OQW;cs zIgt5v(rCSLAhAucfzCZmV64GG&{dF{SeX$;*kB`S%;~}uw)Ce}} z-Us8;ZLq2F2-%wP6vhs3qGLuJg_p1P5CeT@xTpOUr}^zhHI-{HUuzNOx~0+$4)!=d zQx99!Q}MQACNzGT4uik2bE(HGFs6$M4GF#vk@Oasb<`WqHqD1lI|txng(-M9U@N{o zUI@y!zhbv#MpRMvI6T&I#^Ldc;cX8YRLc7Tg}b&wz4;#qn|pzd3{ikVWdY>f$2s7m zCI$b+rr=8d0=jg@ZD^=;B0Kl=hFN~|NdF)EF&l?N>04DcCw9g^mlUzDdL8Ll1GvOI zfIK-o2H$9Bv1qOK8yvo^6|+EIlQ#q5_*>H z!Mc+_AUSJ4K6jr98M8`B`>&Z$-OY@QzHSMjBfij-rgbR$P7ePV49275_hQO+Gx#N| zfe-F_;h+2Mkf9U@J#`M^xcVqu@6n5NJF*)!q?+*iPhU{$w}5=SSBzKc3Q1_kT@boH z4PGwF!iL;tkdqsQw{up(+`J#S^gKc1gBx%>M5D=%8MsE(2Sy)#j+ebUlX&YQ=-)en zgqM_I&8qp>r?Veu4SPzi*ZqW7XEjN1$xWzB-$Zu>E=4cD>uirgVAutHa_2<^7E~;N zZifxCy z0KoxAW?=D5CA1yn2l7W}<5Tk(de?0s9#c;vCHDTfU~>c3$$Z6j&u*}WK82ej4wJU& zzi@<$Gj{M&#@gCU@}$Rm{2e?85B;ZxT1U>&eP{;G9M)>vntc4}W5b@e*FGd=;ODZB7SgeQG&{0NRIEhDjyYO$(V3N7BwMuibhq!i!6 z)Vc-WWmt$$SHDF)^-w%s(u>HrcY&h^6j5)A?u-Jz^z{pOQ)k9FOC0s6ngsZsGG zJg5B|g618Dv1-$)f_f$<>a0b1vkH`-IR?`EnnLsYKd7h2&UA(#eWPs!T`Kp$>Z6{p zW=nrs+1C@yV$EUI-4*Cs`3`$_?1`h|t>M%r100ucLqaUaV0@?!>|W=AXV`xesKOglnOeFV1c-UpU7Gcm4Y4>|c?Ga6b>#=8sI^DyHOTpV%= zt!KR@vcuzX{LxC%a(yTiJujkWr{BQ0`dnf)pcL;!Z33MSflztY4yMO#^ zc(Sw^TsL~6`Nb+2Y1M*dlg<;fd-kwz12B&zAMHZK(@e&z{kM{oB#M8i`Y(1~%-Q1*C5(ObH1hKlHWW*6Sp4+-eSd z|0z$Vd|HLWUvC4G)fymo(+2d$v)_wySK{T?QKv!R;q>A@;=vTH|pF z!V5pqqJAzoc4r!#vHb{A*1=?KTs_1O>r1kxYQu&}Bk7Ogcd#|W7jAC2f*-yAU{9OP zxI-m|M(CEHc3LU@HbD`s47<~EQv>`{(Fb>ZQU&X%%k*xa0?_Nf0E)IJ;GuX^de7}R z%=vVTPPB0bo6vd0rrH;CQ$C`+^i|C7d=RI0{{~+z_K}_)&SIB4Q?O6@GSok`hdl5- z4f`@edjVJuRD!QR8(~0d0@!!If_;y?1M4&6aQ^CPaK3OY6b0QSX5-wjvu!Cj6`jI0 zmQLu_Tn@5tZE!+Ab4W>1qSI$B!(aJ%xFMnm&;M*9FWBB&Ywj$XwI~nv-PVHq4}WlB z&w9AJbPl$TOeRwsp2OW8t=MnzD~y|Yowz-h!Wp-%VA*>g3^~<6Kcr;gui__kudF(HZs034a09M(!Mg6 z@XP2~)TdVjwB<5f*!Xg8SnK zLf`VUBy~su-ec#k6RV=}0zHE%duPCvZI9_~_4(}lZz8EzybP=EeS&*Ml~8#0EPcrK zFP_c2pgnIfJQ=>3u1JiA`7_g@@6B%L-E42wIP!s6}E zFlb&6FkaAvYaaXt-P%O>@#s3)<3M0+%6=FbU5In7lrT`CKUkJcB6F9mgCn=}(8+_H zv5&}vN8XpAUulFUH%U1@LTCFw%@~BNjb=5MdNpNE_wFFdZI$|@quz2@eNplk)P_Jf9zqX z_C7$CYOaG@ky#{k{UkVPBZcETe!x0x$Bf0Axa{i+(w^1_M=L$UZ&QC^_!vcU`1C@w z9J>-t--dx_;eOoZo{W8~>*&LcRrp(KA#Che4jl*Q;*yJ#aJXE5aPnP&TlaS+mvwxg zD18R@)(e7HYiE;Y#T0N?P{8-f(;%r2g?@qTJ><#RY9qK1pH2mgd*J+UtB}P8ve&Ty+}Nq=55l zm*T!FzBu;01Ff9a1=H?5VP~Fdh^u```?1$xy4H>KTBQ!IE@xox1~ZuA>kTJUPNVUp z|3EWrDva-Ci*=r+*jP4*X3kWC$A<3c*mD6?UP{7Cd#liL<$ZFaIu|?lI6xoYT7aXh zuhUwNh1_W(Ao+WmO&n^x3Jh%Z;<+<=Kwj0g=un6An??X2q+=7WWFVm^5HE_{Q z3AZHegOb8AWKaJo_|ruTY^4ukjZzMce?1!3U)xImJim<{=iej>o2NpC z>O=17>7e1=OL*k76~6b~kJlYq@mP=fIJWU124ydxZ9Rs8DwvY7!{u=g*?=b7UgEXL z`Q%b;8F-Xj?SRZoX6phy&K3OS9?gMmx%NmeY8%0 zNq!Bz08T$Q&^39Epp&x*(j5!HY^EpOIqWGsdOeTy+p-K`Koq#k^oOo9W@B$7A4nfP zAJ)X}!=|UL*jXzc5AH#BUwwo2&YGmIF&`d`(Lwpc=}>HX4|czwj1!BJ=#+wR&|5zM zT%$_C;qGRX(VL97POS#bd2G+J`yy4A4}|cCSHOHwA6U>{i?3bZ;I{PRq?hb^@Eu`I z-+!EfVRavfY2q~eY0!%dO<#m_=`48s@(9FFv7>#8P4KynGTivG5z0>PCu*bBv4=_& zHScQ-3D@?c+N^w>SYHIY5*5MFqL>U`JreUb#FFQ2?@;x^Oh~@A9<92*CfE0$z?Dkf zaOXJ-H10G4hkn_G%jR{5MUCIkA^ZkCaW4yx%ylA@+GWw)W+<7q_9HIT2*H`!QaF5Y zBvcJv3tsP*LF}d?IKE&Qjo$kM3)7<@v`;UnvQeS8+{dHg4kdIMtcj0?)Y9!18*s$i z5!3_jqu)_$@<6)LJIhXStS5AA6Ywl%WwUsxFm+uZ*{X;;@SsiY#h@i*a`h%uT z3Vo_DAG4vBjB@-4t-obRo^BVg9wfYQttvi4Z-W%hIF^A|NpIG=+JDIv^SfTl|RdjvQY^YUGg5Bp0 zaMY_IB&B)~EXp`YN4gEf=8KD9gGLzSC~PMKE;iwf8$S56bsPlG^@S^Q?jm*8z>%qK zIIQtHSexxZ*Os5~{PhzY_ppj)WmaMClsMXCx*Lv}CSvpB6o`{iBKO`@;O|aDsYX;N zyegSN^?z)@MYStPdU`fSDXW5m;$2vrRE2Qw1~e5;Cfd=fp=*X8_SGrHVe=Enz)9gS zw4?-li=5!qn_JilJ8_oE8CYQW3LXB0(BH3Ju*`ZQ(O;p7HwMh7mVZ8B{~ne2PPG_} z%gRY}%2D>dQVw%oxB^+53p+~`;Imp9+4(#iZe$)HW1?~~Q_%p8>yz-u$(2w}twDOm zBsjiV1sD4d01e9x@XE`ISZ6Q9`%d-bQ`LGHV_Zir_*vp!bN1U)`y))PkcI^zF`%6? z89!LB!go4h^sLWM2!7Q9ZX+gOTI@d9HN6I$o_C;2Mt=sYXJ0VQ@&>L6(;&)YdO`cL zhxBc+Du(@(ffrew@X^HgVE@bkH?H%cbl-Z={_u_3t#gFF_x&+*>ThWI?;BBf{Ep=- zW|1!4yJDD0Jk-^sqWh%|1S$cF)2xV(yBRxQ|4c4yO@=q&)8V+(L0ph%MCxSOxlxT2 zxt~B`vUEA!--rD=wkr?wo_NEA%zgC2L1T9QmI2>aT!ta7`{5B zSb7RH7!;BheX>yN!b#{@mWPhEO~mx<3!q!%ao(A05USb$^VR(@-}Wc$W6$tGXIDtn z+=RO_CXiS8sZhDWj;Nj9fm@{lsgmg^FjBFh--pNGp}I$8Yx_eeOIuFo&h7)xq~4O2 z>QuP7vz{ou{s6;gN0LEVmJnf6L^dArf!Y!8$%?jKQ2l8Tk=IOw+XWk_(tRIfCt2uj z9|uu^?r2>w2FpIPdnI5WxW9=Zoj!VC>C{rX?Yu5FcVfTuusv~rM+RxN84l{I)pVlC zL2%fpfLRx>;8bS?uv^mwUn|wn_Xl=@w5Abxd;cJOkx``kf@ER$(6zYy-5~UD=|!E) zW}tf%z+u>LTSxrn{0t9FaD=DR2GTKON5aX1TvRXb z2Upy#z~kxPAt`wy$%QCfa=Q;XrO$pNo%fUeGO|YHZiaOFq=&HiQyKXhx(kxo_fOYG zT*nqYc`|W+Klro#1)SUknMkTz3aJF8Bye`KfecMR~O*c&Mv6%Sh zmcjP@d(ipUcj)~f0T&%~#qVat5b@+B_+1+X5%VWw$c8p@wBtJ5q*+N4*n3dX$-UT3 zP7aP;*+6D(Hib4w#Gs<1;BFNPI)9SE*!2Jg4pWu;Ql*+j+?265$>zucEWqyIOROLPB;tH z#+J-lUI*DP9+5X2FQVLMMN;m63&-0lpq=j@pzs(b{ZfWmNv&j++Fb~`^_lv2_5f)L z@H_t~Z0_QO@?*5{{@6w`VXzS-F1bcKKK_6m^lRz)GZrv!&t)jsYl$u28_>zV6w1?b zN&HzYxM>wg-+3;B*DoxI1)JNKGz}-Dr6X(&O~=dcpCfI50EO=yVCgRta&35b$lX){ zx7l}tOCF6Q_+&U{Dc;AAH;gf%$^_>+KZ2>dZ{Wf_RhWHYAho{0&gDy9;qHwipwFW& zFzqD;wABaup0$`dXac=dIs)JK55VM7_Ptr_OzQH$7K|=gkuGQ--eBK^` z6J4)k58WIZc%dBo?!16X51L_F%q>WN9R!1Ir$cFJSM2HW6GEOjLj^RDfuC-}`@42H z;EOE`ZW#^ZRu!TAlsmM|xEY6aoQ4WbI#BRA0Yn^*5IzV0hks_s!kDC`RB5;&W)B&Q zaXz^)uQ(J8M*u8OQUz7>>p1Y?b>eV!Fgv@uMcbb0;l`oeP`|VdD@t8(W6!lvKjAb? zFRcRjlnGn&zeD8Cy&&(weg{bphDKF_YZttNK_=s{`9L(OElkA7(^Fwiax}bZGbaZI zbcN|%`{T?(@3C(9D|$F72xMMug`lqEAkNg6Ja?H0k9&Sb+NU>2FL*~R*?H=G*J4<2 z%g&$fR)gZ2Y%FwmNryb#iP4X&sP=~|*gj$|274G`zT6(tG3GS(v2BMza|`jTNi0n3 z^%;8j{-R4;^KfFHvyd)rf>9|m=)jCzEGso2ha8S!sPb-XWc!cG4^jl=t#J1Hja1|0 zMyPIFLH74nM}ycByw$1)@=D{_d&VI+wo-{EepiG|vA@A0$pij8cZVhM%Rx`OA6DM$ z2CbGMxN)>G(lc{uquUnn4m?i>%H_kvxx=y8R0mWFpI}qUI2^rb6M1=}4OUnlp-WEw z2g5c;z|&tdvGu+bp1AcGV>2F6sh0yG`@n6sk9Ehx8llwm`ET~ElpIwwuELO})ntwP zaQJw2Ds0=;ic`%U=!8S<=rQFJ?fNqjeRjB@UraJ6r(Q?H$H(!;rbsw`DF!>+d4SEK z0qmS!g{sM`;IlLeU%tvioAO;eySN<|RDFO0WBP*Zs28-unO$Jn?b zPN31|a5CD^8xo>ziHh|#_&6>JROh&0x10LZ?N1?Q+D@TYe*s)=55@PdYA~egHVkdE z2TdJ)@^;IApwR9@O!}~IwjYmyT{XAxz`i_G?K>7Kmi&YOBX4kDlf+g$0q0{5i9f#eRyFx|g^%>L?u;cIh<*}QMK^ZEi@UEz+kN-u~{%O!kV`U~|u zdt!EQG|G)1hMhMprx*1e!$&=1v{Jf>U+$lV$3DTR(lCb>e>29c`KO6u!wlFwx)z4^ zF2wYtfv{fpEWW*QnFRW^LQ8XJaxGC7-b|lK{_IbKYto5WZkG%({qMkT17qwtu^V3Q zy9HEcKcvwCdqMfm16&d?3?n05h*_L7rpP(r;hF4tFYQAHD|N@5w421^rYxHE4Tgrz zx}dPdkD7oYUi+y}w+HLu8*5Lx+Ho?fOdLxM?{|YjheFuwejSpFvgy6lAD}@4o#B2*}4(Ywk9H6BlcZr^mVtS$?lHFqZ~k7VPbNk?E?&=_#)5=Xzb1;N7U$3gF| zJM=uZ2k-BDi)X(6zz*)7_`-W0VM{U_J6}|azknw+))0dWonh+C4eU4kvpAdWSEl3@ zLBP<-G|5;A_Gf)0vImc1=a1J(v8pnb`I*z=&mQPIa3Ec7x(@7rg_C!)*g1YcCAwVw zhMr4yk#ueL5sPp+Qu>Ig%Xe@8Rtv~O>TG>~4VPzG3XpRL-nUT0(yEn4`LJjEC z9LRlZOB9#5;KzrbQKj@L-1%rm&D`VhUHLVbEu#k`4{gN>&ojY3X9Rf}<&OIb%Ykhk z(NVq_<8OvQ<^I8BRv!Ca?NbGHQ+)xWNW;jj{$ysB4$jz-Nc10D z!Tqs2!G7Kx=rU{oyob zGj5(FO%tyw!Jj_=krUbuurOdeS-gE9=Ed34BX2gts_M;naKSoU?emp9*`JC%%iPJG z1s~vL>v|mZzAwh@I!-e7$wSnnc>3W#3$R*vnGEWr#=gs%4zaG5IC}3?nz)I*7e9~# ztyA;hn5{Yuu?WYvH&27ygK%X3Rzmu8{v+HJdIU1dEpt5~*A@Hs@ z39Vm*rwqH0dwH9&U;07Zs^0>QDtk#tdna6fyb(u#SH+6vY#OQ;3Yrt%qSxX@Ag$Ym zx%oL@uRH`6Kf446x6C8TO%d3;`3Y@oT7j~Y4-#L;bojx}kwWI@VxZ|T2&u?{KjSpP z-(Vkv-zb0%jz3X<;31G*=Ld z;6P7|m^YWqQZ9q!`@_)6D*;ojuH(+LrC_A>3GeJN!ghL`W|nV6JtJl4Y@v#SDAHR? z99N+Pt2tW?sFBpD?{N<$JYNy&&v z3H?b(Div9wEu@Lk`FpPC{r%>?Ki7So$MGBA^Y3zM-=|4!u}A1*`!HdGd`Ik|c&|tt*4~Ms!hgYSO&Xnl9|}>CgV0PH5B&@32s&_{+lkyKkxNFj zruGd*Z!BcPFE6CJHM8+DV-e=$>9bfDHTcGTpN4H0ym_eVQzTrr;vP4{5$e{SSE z@FsogsGx(BX7c!{(eyWRtUeBT(7t6>)ubg(Se9I?~%0-W}4ziESAa|T5EH}uIX|EH$ zOfSa6nwO}MN+ajP_Dr==m$>8$IOorY@1s6$zHJUQ-@ec87<-^BBNgg9Z^3^}Dn%q| zAx?52V(K;`BGi!^v`mBD^to_N`A)G$oFq;N^UHQK-kA_ei*GLCH~fn*+;B2ISw9_7 z_BRkqMTp|N@MU8%0&0HK2~u<5 zNA>_ruufr(GmqhZ;|J1JH-Nk3dAea8OJf_I$kA#)6>T_;G_xJ_cjZMkuB8#tqtD;17pX zkh7C5kClp{FS#EmtaXMkCy(Mw6eeKk0$V;-r-*JC{$V+T+v(EgGg!6y2!cMnVfpDn zf{*M-8Xo$nY3L(g*JCtA#-AV3Xr;rJr|Dqh8H_f~qe0zsko&%!jVP=~`*RoCw(B{a zYc-=$;x`e`+Nk)90qy=>0gaC#!ag#XDb5t9)e8lWPsmXRc+G_AiJ_>l$>fo8OQ_~^ zKi}~S*?-M3m(?op59+WA5!iYcFY=FdOoG<)? zgp$qJ%Adf@a3I~x+{_#z`mj(Uo(6=b!fRj?b-ZCDqc{$#iDsl1)x-K0U8mwnA~1J5 ziT@I%S-s98_^Y4hhX-S0_Oj#jMqgC#jw7ehHfk7O45P@+~leq@T``U8M1 zJjiPc8fkNX6CxCMQt!BxOoL>|f8#kOyKW-hRV@_yK7pifn~uSOIV62=81E155&X$n ze8o?~9~%=me?Crk#lgf<%IM{xB4$h2T#%?%%N&|1X2 z6MoRJ1v!{GUzh{z3-IOT4X6$}iA?88bmc|TxY=h>?Ao2j0|z_KD(bePQa6g2mPYFzWOqxKs;sf8!RO+;fR+e|zz?&tvgn zfG*1@wIu&~X^h$9OTLnk?3Vf$Y%R{^sRKM|<*G_ji@O2&$9~-6d=71tDquq*pOAf9 zEF@c((7Fg;WCmB0{-MW|_$7!Y9Nfe0i)YierV7jxKLgdvAF=PpNh*4+$!B(Np_D(C z6l+q3Ti!Rw#4MLOO(#-R{409$IFWU0x6yFh9)A3n1|F|hLXwI<9VkA}vQ})t)%3~y zEz5xCY-u`LUr27zI(&Q21(LaIhUk^QaLzo5NIMD-^HR&%xM9eOO$r z#VX1+(dsL9bb6{beRgl+ietO*_33~7r*tFL)THqA^B?Km%p~^oRWe+6C$V7;dGOI+ zL3c02QhC!7K0W0g?wy^@KW5}0>Eu;vRewZc$EBEk$^uAzyhQJxNYUYOGqB0n8~Uy5 z`P9`{X-R)b1HIvYrncf9A`o|#y23%o-&8y_cUa-Ad% ztPufreY}tS(e+$v{RS)<59()WN9Yvhs8NWh*-!Yrd6L+n z+RTbYhvD{-h5TcE2K)`1Slzd5Ouk>qyYl{0!BBH{a08>-BUefMQxO&gno`cmPf2g*X}%$VQFGgW5dd_l}%QS$`$DV%RupGrED@Zu978!3}2BeF+5`zY($K zF0Kxnf`~U;>16F5iYh*b%2%hE)a@F&I?)PHyiLBfiLAKR4kn`a@Nn^3Xr{+fxPm`) zIJC1>o}IZo5C38?TA_!4J&ark>ERM`yY{_zaik~VEmx|St@w6Us zlc!PLwdFLb?LRu!V9!=O)F5wvD^~aAAO#rC$AF>?;z~pL>3gMg23=63OEfFsiJU z;&VFaoL?@jiO=G3LcieNnr2oz&ky6|){|mDAg&CG;weK8;@snLl<07j#1zM|B;`I5 znb`rV$tT^cudK9X0O{r(<^K1@@yt6A_moPgY4lq%U$u>7f`f48g%O3V4&Z-Mi%}gO zLNRkML8aB2M$L>9a#zt%JbMcP*T*qcr}22Zw-GHDhmdaW5dLeI7zT+;aRqBv+FA61 z21ib%px}D!yEhe{PKWuW!*b+oJcGnLe&VjS&^Zrsf!4%M9$8x~%)Qw>B>Wk|lyz8) zejPS0i)Sw~^T<3#9d@BjP5eS*_;0B1iDFy+%qD%~v3zv)W7;S7j?de%n(ns53hH6Wb?#CBHlah+Jyti?3v2+}oRwd-jj|-8!i_K$mttbb!|GpQPf~ ziDx>Ks8H~lmJ3+@>ojUJNaX`WI!N}#4Su6UR_H)i@d=XCKo|V@#nGNfGB2j{ zF_IJ)yo$Y@^N(h2IYQe$JfZ%}E4g;I9LWnh6J|PKhkgimdifXg|N3H&-fMW?n#--% z{-CJJl}u-u2CXZKCfo9hWc)G*T^m(tq|$ZT>-CXlr%ho^Y(9oQ2_}*9$q3PVPHCG~ zk$UY4%GDPY5~eT2;4k5^oI zOPMWgdiIyEQ&L2E+;~>^tPcb3r}0g?A~f{CGw_$hdtEi|Vw8=k$?uXz2io(;kUs zhCAu8ODs(rQi8oIlB`$aG`$KqLp@!eFm2*m=25kulK!q^ONs)qT6`39nlcb2OVrqM z_fYz#*F+O%*`s8FCmZ)z*n`{Cxb4o9v^89fT8G?*mPZP$;LnjYEQt(SBcZw=m$wAe zQ0MwIZmKbu;!76MzG{C``RKzUJie2s(+!H*@RY2ccu;OsBF%6}r9astk^lZJpK3Ui zI!}gCLtQB{pDV)q`WINZsj%tY!!UA1gx8r^I_mA@h}aJLRlOSkdo(tY?l zq8rZMZFF~FHhO-{hH_gdE_qzS>ikK_?L8^zAy=u);~Dj+*0Zg5*P~MFF=l(af_tB% znbyN$C3K~&>Pl#h!WwpAMIBujQ^n+CT**5;n}?QLkje!K+I{31)#k)Ax5u{7)o-Ce zY1o?t5c}*dVnQY-k1QSJJCDKQV|^4b z?*NOB&BDzgzwys9fh?X34pgd(J+x{wz@ zr&Kl$0p}VpKJEkC(q=;+*iJkzVq`bqBvYC*gId2P;7VULa#xCzqiF}dPmaPbMIY39 zuICS*iqLjtc|1$>f#U{e_IchGYZ(FIM`6z7_bgG$` zf6|l(i)neXpfx)>LFesdcDdyr6y0TEW2s6C#a7I^KbD^Bn9#|=E6H5q8vFNj1g!T$(Z9%rHRANN!lF`a%; znY|p()*j?D7WR^EWG9bM)x(Cd+BC^h9@(=;@cSlfNHRW~IR*Ptg1bBU#w|nf{1aSl z=upHx9KcsEolY|^=JFXHq7?BaiYZ**N=9R?*mupd$Ue}^OCLSJC7V#LJ++N0SP8#; zRguCv)tHjzP7LoTMBRnGxPI+9H_=^6Q)_joENLF@IN8#0_aQWX@CqCb?15Ux7Bap) z91`W@NhI5k+MC}o|EKGPF6DEUe^BsUHSgUNS@g%v?DbskCZ%5(DOxupsW)~ zv64bxdTld$Ln>LWkPF`UVL6QdM3APtCfUfEQCOzL@m_2VH)!!fC`K$SoU#=MKA&I(ovyrCx4W4GMm>{zo zcYZljoz*EUt@#6&bbA&Q6-7Uf3}JO8id42+fw>I+Mjq==vGBjc=vP7}-{j#-g<)s; zRGl?~emRq-Yp+1UgE+Dl@||L(`*3r;7$2i& z1c^xl@W)Y)6vjue>yZ*<@Ar!3ua~D>JI;5{eMa({CbUOeluj;+p!%37xvHqH79Dd{laxTF2cmClb!uL7eP~`S-W``Ss9OFcJ<4!YSBUz)-?%! ziWRO3>;Mn1_x!DjBo5s>LUxsTv`O8X1X2nN>g=BfUxfh|A*__`61h5*lDPQn|^{f_RoYq{86To^;_5hVJG}vC&spG z6hU8S2RVEGqz@%_Y*`+o4RMocRZXgpcm50U#(umE=_ZALVz7KUh6NAsAmxOK)MWhw z53ZCTR_v27=V{QROA%<6xs7cnwXj8M4C^mWrT=OMLhF7w{LfwFRtM)`)gA*3)0sx2 zrvh}6d2W@Wu7-ReCT9P zLjIg^LzDC6qT2IiYM8QliU=w*Nhg>DU|nx66aaq<)`yx<~e-39dC`zj_Y@8QM9 z<}_j7J8nAWBJK$t&?Qg*(bm-$m~`mrjat zQYx0i`j@q29i)X(_r}wy`U>{@+aELl=R+@&u6I zP7Y!rN{6UaQ;!XIyh9mpL$Enk714dxq;lGxCYPx2nuoF0EMbiZZX2@oT2abZpFP zekP!hBsQ$3%P%%VwW^%8cF&>lieDkV--Nyf*ztJpk=@Xz~j|R=E>8 z_ZCpbrI%FcHH*f%E<)0$95&2%6je?63tn4~JE=u_XwzrS##D7jBx_(2L$G zZu{sy4q`H|%#RV+S0gcUv+%r?UZRGd%MfEKg3DHK=v@90Tnbu7zs!!{=IhmXu{4=K zx|~YGQl9ehD4~T*t=T107Zgtyx+=Tsut=_q8s2V#e47GEv>3u);wDp2%7X9vr%Wxm zfTS)@r%Sp;7^|Q_`%{~!&vg?F8vHQN=a}GcO4HEE9(;q;Wl}EN2E}J@Fm?N8F52cv zQ@4EOJ%MhRpEi#U|2j#764oPVSpw`fL{Q_L2pX(*mCS^_clW*DWVzrFE&kk(SN9iC z+}PDjP3W*&RsMf}UWTAatxRi?Ir_5BVPLj0eM_x6dT!aTR2k?}-`T z22=XA-;moGiiVsi^f1+qcWJ|ka?ieDG;x~^Q+O3d@~?*S zQJS-G?~E5-t9~Kn$w@q=VmgfE9FS8z7~S5XSoEJJEg!Omn+y4N@l+*N?^Z|c(i_=m ztx6KD9fISXx->-a6MODFK-B|dG!8#W?7zo2mZ6RX^S3jp^lH4y(qZeD9YM~K6Le$P zXhh!ChD2Q=_Pp)FvXw1#{^xO~x3Gmw`rfi>ch6$t3lEBj>?fe3_=RhL&Gzo6~E(9+YT}5=E&};4&m}f42^zwllX%`Np)l=2D zqRS}^aV-*l$VlueIL-qyCy>bUfBcQoaC$UGp20W@Zb3fu!uBs63thlfOhoCd z*C$Fe^`}QdCd5b24hBP1S;UR=G&Ax6NeQ$Y$=MT_{yZnR7p`P+)?IXBYB;+p(MK=J zHM#4-RaAd#BV~#8(u6F1nwBx1yqqoI^XNG8t`_lk?mCp~|CatHXpz<}TRaoDhK|}L ze)!xA%xiWdt=AjSPf1MN>JFVgI)mBm?WVQ)87$JKh-mOu8gHLP9~-sU@87Km&C-VA zs1huxt7gp>!k#{>PUx`TMN!{BVZIQwlFf&(^l}~@4hg`qt&aG4_cA)SS|a4_R~!%j zLHVK4%v|RL)}^PRaMfshkiG@!p^pUCNhQKQ$B;(XHQ_h71{vo#hJ`}Dt70xXxR!ky|Gb4DTvN0_VmgOhU+0lgauX)w<@fcYD zoBz8$RNy^K;O$)yxFr(2|I=Ukz;cT% zjQqNo#pj_k%}f=4OVjX1YZQMtQJ5#)^=Y%I3S_M<`M9w|QPlK?cf}P8S+5`T`tUHa zUr-8Z!{2B*j!INd@ce7<$=&G`V&*5|wdN8o6QNBJ zYHRpSi=U7>Vu`Os!n5c&5_^0U;i=h#q=tCBRv1E_Q+A`YGMJv!T!dGU9e?`fJ(k!0 zW&K{a(fnpB>JRoH*L$77E*?y6;|$mjt0M@A2<0W0YX#k@o8q>~(uc}K>@b!`=)2o! zw=BZJy{{2A_$&(kpOx5flOQ#V?{(M)?>Wtwynh=?7GI)ftz&o{IfO>f5u+Ky-|*Sr z)u_NIh;5JjLSt>!`1i2kkTyu954#`I_tWy+{c<{eo;M$XDfwjaZ8`g#5KQGpZmi}~ z4Viqo#PS}Uf|S^LNW45j-VfuMw2lgm+2_hK-pqx4eI0WzmB(5<(pUhaI&%4*k8q z`6+R4+{g;$%YL7t@4^{wIQlo#7U&?cOOz(->acCk*U*8;G7{Y)0n=j(;b^`I)mf9E z8Egu-n1kHiGLtNyF6V#rJTUm}O(uTFo_>xQ%&)xsK>sy_kkg7E_-d`dEZ$GSmx8CP zrq~awvj^kE!cw~V%8K{?n~AL(+*tqp1cX;7;G&RI(!OKEMimz#V$v>F&Ooi(RgtFE zOy?T}PiFjR>isx|Efjj%X8w<9;p1AYm?6#9wj0s&JAbjTvJ&Ex;@A>BbGlZjhcJOx zyXj&VHJ0g+-st7rys(f)h!pdS7k1O(Q@@$JtuE&MkYvH1La;a4mHDd{2poe_Zl1Fg zWj&vnw7xhczEYvEo!0br_-hDDqtA)ou8j8+p+BolPL`{2^@RbC)SQcw)(52Zq>I+8zUE^d_)>mT z94%U%NH&iyvfjEq#zsxK-XxbH|^!?)bHc)9XTl9jK=KHekxuyfn>L7(m{Qq!^zfM zt|^Zmnzhk+mlQm-7|G;sZ^19|Z^ZmlaHNIsyrYBk?{!nV)Kh%+O5hIytnqo>EAmrr zLY=sf(L37)zvddg+{1$Ghv@Rz)!T69gCl$Dq(t%ML%2kx7S#xTTjiBzyd5d%;k_XO z2RM*3A^#nvWWwe=eg-Qq;;k9cG$#2QJrB7>W%k7V<8A4V;c^OX&7qPnK=tto4A?yw zY7EJJMGQ-{k^c zEq9^hYmaFL`$c>1UFSocR^Z3Jb~H!iquAjyl}wmMWyM2j)E5U_i+@bN%l4Du-7Y>L zV>PYaoj`LIs6pnKCxt9|Np|N6$>Ms}izQeCVbhr1+rWYM?X+mOX97}Vx3c}tU*U7U zi|r_uqDLDW$WL@V#ZR>50qw5^9@BZG#*M<=&$8&1kHxN)6XJ$|xiJwA&SsZBrJIBO*x(BzXyfKa1da10BPB`Jd}l7n!2yZW9%26RPc%Hcldx$xEVM%? zR5yw_|GEO%#(J)C%K`=#s^r#PLr((?XlB0_g*|ysuP;2JxqjRDosmJ5?t29mUbx6@8xYi%|bp) zneAG!1GZZ?vG7tkxJAdY`YpNiPD7K2tUQifwu&h63>?R`;MJKZT)a$VeINs(V`aFc z`)`_(7mZ`hQRHoCg!ymt(fVy7AE$c}qldJ!bo~c3@lY6kT=0k86Fct3MPQTd$o(8` zaK&R7jf(n1f9gcY++imze`v~vZ2XGE?MAH3Gf?nd!A%uR@jE-3`<>9INE0CwF*Om1 z`9fAr})m`QEWA9-WxZxg7S{&g2EVF21_6^oqZG|oRNhmuw43Y$6MMn!@^A2UF3bQ_N4`ie}21kfxCr zHeZ{HKigX9(hD(id-9Ina0Pz(Yd=jm`4vyvQW+xpY z=*ZD}avTv&bNd$2373KNd`k?Cm-&O)g2(f`XamiC@`b0{|HYz(M#%pra3N|pF^{$` z3YROSc~f$zYlb|(W|WOspE!0s=^)j3mhh|lS7Fkp@lYCIjRB`RNxbz9iDbku`8VCT zte(Q#BVLhl&@D3R+(qHqOL?E#Fp_h-EO-KDa5J&zS=Qm0RBDgVX=0GcJIfW}0rAth zT=Tp#NqT4qzEuIfb*3}@wQ^)Su!nWLzouWzIg#4m88ZC9;%2J;yMrG+y$*+GUZ?wcbtscPK{c<19^2}B z{CMvK8o!{3g`C?&7fa=MUeaqw<K2au|lpeavA--f0#+% ze~8+XMOZp=IC`~z@ZpjpXw2(fylRIIvLYkkuzwcGJ=)2XlkDi}`M+HLKptLd+~l?? z)1m6y%GTfcfp--QJNR^JlyF88eWmw5tOb_z3o6sJ=cQ%#`2Ao5cl&0BRD(_$xz__d zJ-K|#K!0j+Ze;JR|KaH;S!8#&;C1^qa_@bJwLz|MT&Q9tR}k) z@0p0mH#n`Z;pTH1X;6I=*OsrvE*&2>@-Rb2#dp$aPr$w1G8o*DMYCoQ^Ypv~(Pdxp zazht|)-7j$uIiBKo*wQiB8k;|ig0wV6C&C_vYmyiP}^?8s*BU%SfCE|Em?53S;~D{ zZsCz)3s-2Eh)@5Dgk5e4o(eN}uuB2C|8(SyQyZ!7@|1Sh zl*WDb;t6Ff^vG+!z%)Jr*?A{eiPCH|k5{EuliOIJ6T#C*2)iE}iek;{(5mX@m&+aL z!IHT&Szrw}htFfVruXUI$tb?SI1q_1B5^(D0Ud36!IN`BDRF-vZ<)A=j9x9G@neL)+5z2Bl5A7)Z3Hg7#8QJxXlvSKVku?<*xNLS^7Tc+8Mi!$dIhgH ztjnJrGyXsmV?^1ZG+F#G5_3s*O~yGzjesO5Om+D2&?PRR~ zSA#}Jc zHwgDm9U!}1!d?UXdntI@vDpwk5=sfiC$K9i4!1fpG3NemoSOLoRl#4df1EMp ztA?@;6LH#D0yJHTBsm=!t`l$`v$Eg8ETkPUS)qvsDBSn>5YG;Ki_ zO7E_sG`0Ecq9Q?W$VrrlhvU6n1KT^sgYIwJi3Mc>*D-J|8}PM=W~?ZunAnx5kekV# zWf)_|>n18t{*3O&hDg8t36oz-3m*9sG$_WhQN~ko^J^lV$$mud3ptxwIvY(ky=<|N z2OM>#ovs*Lz;8qoY&=Eiu3;m6d1fVe_DQ(i@qw=9ePmw^uRv#i4f7sahwK@Go)C5p z;UCvfYk3(>mriAUtB=sW?q}TbwLI2TU!piAar{vaq>b-SAhAY;S4PgqBFi)El?bE8 z;0-Kr)n0r)$6z6FMsCKJ;ofKsd=jZ(4bcTyD|88`S%zVmza_VojwWB3v23F38;q4J z!O3`3_k8&0F^%mvQVNs^X*#^PSC6z;}Sr@?!}=zv@fFPS2Pfga-^r*r}p-@LfC z=6v{NY{kX*c4TESj5kdmM5)IE`8wgAse}59_>1vsnE7}w`>UOVqo=m;Hx-`LBzBRf z$4-a4S`VE(6oNxbN}%*rl4xuPz8tH<@0o9Tx5yoG{b$Nb6=vb(oF2R`Qi7kNCTYEW z4wd6UyknX09%%~0boCNiM?=VCuY)`H;r7~J%-d&wAf*_5;}HZCvWi%MrXlQ-v0d;Sp3 zahlC8kE3Xj-Ml8{ALhmz3pzc}yiARal&OTn6Fu(GH;Gi9E@KNaf5Y2L2ldh;$ne+} z*3*!JE}v(7-NXynIXRTAeSME49zEiF3x)6Z(*;TIGRUYf z7ca!}Me-j}C!x>6LR_fqel+vWwxPqaN3loX^NtEK<2m9B>0NR<^+Xn7he|kGb?qgp z2laCG_2ZDDUINv*qBs=%ihFIUCWjNt+4c|N^xwp>EOYfBJk8dEvgHf9;*mv?1Fgxp zYYg)~WJ8l;ui?bm9q0>D|z@>C}6=NckUHCof4;v!5ni=+sk*e zE&Ch=pUVjonnda5!##X>%?s>HFe8uCzsaU|7E8YU2+HawdHDTQ%#8R&qRS7^wDZN> z%CCypuJ1HRt(U@wSkMHa5A>zY7inVmkyn2hZbF9GZbk`9(t1Lx^4q!Y$S~@PP-mr^ zW>dh9GknZ{D=D^rBNICnj@il+XtLl-iaqh6v-z(nW~3MIUZ{mz{ccSCMmZ%qOA8E@ z=M<3rkNMBpkJGvjXzo2flIT1}FNHi{Kzutt6Q7GZnSAVDE_sM&r+vt~B!Ui4bYc$+{b+mQANFJYK7qlYKrb^k;?JzNEKIo( z`-Gfj!sYR({xga^^2D(GNE=M+I?)?xLe+bcC`~*YvI!&T-O6(2xN|kSCS^lcOIPSA zSK#lGa-2;6NeTDQB8zX~PLqaF*osE%Iv80#S4t{EPAk)V zC8yvRx;Lqv+x6FChUY(W-LH#hQw8s_VGO3U$*~x}**F{hha|pb!KY^+FYZ<$p8DDP^=fZOq?@l7UZXYL%e#IvmFOW4CyH z#6H~Z`ArMG*P-8~f^$D5`d@oj9w$|GrEjxHqo~EE5JjGX0Rf>w6qK!0+?TY9k*JBq z#D$C`bOZz=NEuNP7aAqF%acVA5DFAU7I`cRB5KDCgD4Ndh!M1LC5T>x$-E)Si^Vi4Cl0L^(J-q8o z^LC$4)5m@gn$O0MP0wg^eER%pL(D?cIz3|UHR;nfol^ZIC4Z}0^z@{(zv%C#=gxzwzeBS6X7IO@)0?_omcI6k zlhQjn99h(9$Tsud++I~*UHY_Xv3y;6$*G;vhu(g(>A&RY^z$b!H!XU8oZhf=fT{QK zJTtcRvZ7mleO0==RgI$bu6d?*{cTludhN~H#Xqn5dQj)|(m$S6wRFH~=|fKYy6DhX z=9wA4oncPiInW$E;K6jf`uVWw+q3Fdv?^ni5Z6;GJ!zdqKS`u1Pa6YDidFPgHs$ZuNP)H{DgQQH?cnUZ^ssapAP z_w>e94T{E%e#Gn^@?_Px4?i=XA9}6%{rtu0zn68aI%~%E^vBO_PM2FUY$m_qZi!<7>;mE$5=Zg{ zk%yB6{dt0k8$bg?p!X-pfj%$;Ct}v14Fh`4t-+vchY3?0XhVF>pm!hqJdSoy2MOed{u0phfg#Wv4||{w41pf{?X&+tZvyOqJ}?A&>^I}!cuj*H z&l@GqdXK>l=mSHb zHxY864-DBpXwQcp&&>?7VlK2V1Pk>Ia-=KZa-aNDm3^DE=#y!FD zp9MXj4-CnNolpB=&_7l7+4#i^WFAi~&tR6WzLx*t-x-vH_LrwJe1F^M48}758*|lC zq4x)!_#oGxc}!@CO|3e2WIi?oqu*Gu-w*VfTZFC++8doqpMGmD9iJ?89q519Rs1&v z{ptvjSHJq5`8Rcf&}{GbH|EMu9G*+Rn-+gvVSnZzkr#nJWgqDi&X;r35eR5zPoc-d zVPaBlzE0~Q@~a^a8drb0OZ7i*xr}G8xX4@AkboBU7P>EJ>L-_pzF$Y=-9+6y)IsP0 zppzIc)ei2t6$#eZY?@7Yr5LqLZ+ zWc*{GuWKmsMxY67*=$ z0q7Epf5mZq!MB105KI(x%r2GL#|AQrxU+y17+9w?H;7zeV2nIyjhqa~ub-^G~o^;4P zhdhDtu<8!zpzTOW1!yW#oIOL5S?JsuNdk*`0h@W*yWd7PY z?8|UI;Q60O`_OSdOuZ%J`!g62X+IhD$LmKTxi`9;;^r9*l%;#zvZz1 z%3=Sm!+y8JeuKllmSg-wmp$6&yd^TfmpkUiEJyrJ9R4REKGp@1?VDF+{c8dSk@80z z?bma(ZyfT#k-u{t`MJvxp9!RYoVP^wZ;B&7ajefgHxX&S(=mTnJLG*F>+`z~`zsyx zD;@b+>B#?Dhy7}YeT5_b%N_B*V}-&2@uFd4T(-To;Ix`;PP7?ep%)sJ4cI z&d=HEex;Uob<|%9=MS#yM8=oglFEE!zlfAS>lokUYf`_5!GK8n1nP@d7qcG#{Q1>&06U{+a=OZnZtgxLtgHXKj4si>m@$k7bG&ixFdew;Xm&1 zAA4Qe=XC{69R4dE_Ay8QhB^A@IqXXu@}$FmfcvM_Z!l;65!wHEh0G7! zPBui!&EF-zZNMN>o?IvPoxvbd9^$zk>y}7)Df$-!gGhP&GijgC2Z)rHJFZ`^nLj$C z%60FXA+vL~2=8f$pYxtb|7NSS&%6;S5001nQ+&Qbq&$iGV!w!4`B`E=3JfCU2L3Mv zgP4^+DfSDo@DM5YisxlME{B{*x!EZF?*Rsp@?^32Xa0zk_i?Q6GadV9SL{DrH;D8< z%VD2z)K6!}^-(*A{BlSC`a0xW5kHHL$o{o-wEqa?T(^j{U+CEXUU0~#IQ)-y$P=%*p@LXyCelUoX zC;Nze3K&Gni}2zD*S%o!n2d;ds0q0Tg9;_Dq2#pJ{j^<2eEyoO|E!BHPYJEp8O9sO zd!=UrJ>(}2OaPNW+F^K1$7rPMf?K?Uonpr4czwVsWB#xnk4#QfRfPeqJ> zv88=myqaH`Xa9qSmipBVS!i4T-2w(ob4^XD~Kpom^q;lGOpiTd0W%QJpk{yq5Rx=Gaix7k-joIf`E%82%{ zifDDJ<0f66XZ`wD%KWFomka+W@QHdIE6KBdZ1Kk<#&5GvM$JFoW7T;sMZdV75w)JJ z{n9++PeirvBID#CFrtprMhB>Kp3jIjea$aJoXig~$TR=RsQtH$U$R8I??Bzt8*1M6G9|%PsRS9T^)SllsGLuIgV*mw)lb!smWS)OoPQpNbg2&AuX{eZ^07 z{-})De&RsM6W_Zh>O9%#q~-h(`>WJ1*K?xw<6G)a>)B{q{(Y=V+>eObPYL4WxrC_o zY_zTY(un;JEcvswr}?(_%ObWP|6az4&*g}^9UC23_Ai^hKF3z(S^t$k(fyNTp6j0< zasAnl@BT4b=I^ygI5B>O@LPgU)cc67eo}d!pUR`wpXK}&MM>YwWa(N@2j zAO1w^S1RB9=<(a>Vi1vxCvd+mog4uYRKSr!wOB+4>(uT)%weiTxt7zb;JW zX&>ZiUy-N1Eq}2$qt%7YK15tRcM^3zgX-^O{^8##5D^nAaKSQ)YZw)hR^CG$o^I~&lW!4yCUj3i02u9NuKAQ(mdO@)o*#!`fVb4;`?jFR8)J* z{iArq`5##3zpdUhzZU+!k>g6#dNw+dXa3E{vd;1O2GQ1@=9lGJ|F-e3$aDNEquN{g zpNx3@Rxi)-v-LlQ`_ep*5Ow}-_9YSR%kr!rTl-1)Wjzyh{N?*tKl=W2Df-EEpQzih z(LtWiuSzZJrysHZ@tb7*;`Ig5^xN~kp!@Gz*1sU){nsScIo@|8+VZ8_k9U+f`F(Ok z|7zhM14l${AM9iMx{gwL)^DRc=Wo)oek7Vn|LdavM4i{e^K9RQvVL>ih`Jpc9oX{M zRpO7qkf{A6r;6S<2#Dch!tV_}QQMbU=1&lD{S7Sdf0&(;7v9ey>h@Fnxc+GSiahPT za>>&l;h5-`3BMiqME!g<} z;%}FAg3oP@QKbA6WJetw);vNw>^%{|J(j=YNMoqIZDsxt|bq z{>|r-Kkjct#^=KLEqiW1X=%Tfa_E0A?Q>luDm5$VS;o(5padm(+PBMd{5t1pAI~#> zQ?=*(hnDeE=`*I4j6d&h5PzfxhMq^MJm-Hh&+}9AZi#;&;v)vv37`8mk?Tvgf9*t% z-ycrQ%GC$m|KI_!9|1k0H%a(sfKSx>lXsBlaeXG1Tdp7V`s1|`d;Yy3vDC8u>GdOi zhS;}7oWyuN;d9*~>T_J;2GQ#Wdt&gg@J|7ssN)azar|}uyXQIoW?AlkmPP#gncjJ} zU$Kw#NB7@${xF+lUEp{VF&tSqd*SHJXYLz~R|?HK{qhx|xvpQN^vBe_BV*6^c7L^| zaZR)4u#{OP?H|!VZ({ynoXnUArE_0Q{74-Wpj{Wl22G4~_-&z>U@C+F|G`xrm> zS(|^IpMCYueUJHO{79Fff6BM+WBm1zAKGjGoWJ$a;J*5=}u2Sbk-H$Oh%lYmo2sZvfXb<#D>ibX5Ro{=&bhGGpgnpB6q-UA`dvTk8A^Lny@`dt0r%Lo2z~7^azEJrah4Vl2N2~z=z|q~y|Uk>Xx;t*%lK}!jPGE}_zt%C8E)Y-j%kynUVje8IqQUO54!Fqp-VvD zFjD9vLHFArH22}19|_I*GJdqsoPXCT`aAXeRoe%MK7Zdz)2}J{#37>ZAs$VSRPrkn z{nm7;7uJ{Q^GwDQ*HL2U-J-{NkLG@IcvAhIT+9;RqD2y4-Jd+7JvY!vv0F27;J4s` z67&&V@Ilw+h5@>!x}Ymk3sezr7(l;>8viVE# zV3G2##AdYG&#KgZ<>UJg%`;MW319&9<_kXw41w`yh3^B+bFc>nKyLx;fgv#dJnVsH zA?$$x&|3t1pzdFXhja6_=yjnv98*>c&EblFAT;;OljaM}x_xe@(8Xxz1m%CJlAo&V z=cx9~XX20NM;(9dNh0U?zpj2C&sX1jcKx44pXK_VTK|jG?>&C6>Z8RyV$c1_UnDg9 zf5nvCcm}3~E=J(jsQB(t`^z9j&rnElE%y(CHzSQv_d4uS4xoM{8O=CpP{F`TmX8u1=@pMt=heK6-x}G#0b9}!b^_xW0 z^CIfCj;L4S(EC7*Z_lBUFD}RDDY~1Y?^E`kl6O+{1^4BS&&g{2UAH86yr#_)x)_P< zHZIrxW3}F=C*;cY{_0uw*AqXuBhv!UK^!+5de2EatSg(I<~Pan`z*D;d}*DZlKl-plW%_a)xL6u+`o!!x8;C7|9aHA(q>re+OHb@t8C3f{7K(`<^I<{tMecowNmTp zeD!<1J=FSrprRMd&z)zr)%x91oi|<{Ci>hTmZ|H_E7ksVqH3>`+K;v?`5`KvbMsfY-_*E_0)L`vcQp9FQgw9~_@Ar&za0E~7D+rDxAEYAv^KZD4}f3$Md7nf z9s&R5+Hx-7{X8H1-)|LtJ~x~KKF7_5mwhXCywC76wVq9CBkl2d+Y9RX_o_Fp0m{aD^+z-^AoXW-VRoI zU$5p*J2gMrw37bvJ)^jykHq~}<~OC{)z{}ot2(reo7tSoh&Zo_-1Y*?@9c7K_G-+{ z*w;k^M6Mf2i=6vdXpu9Hc+EZg=epxt9OtBI`V~*t0I<2TT0)OGNlt_=~!Jcw4RS$EbB?5km8M{<24+m|2@3SU~oa2ABI`2$Z>)93R{?3xy#GdoGpE{3R zrRe5rzdKL$=OcA~o2>S`o78#jZgrlyV5P*v`LlGM(8b7K7nP3{YQJu#=Fh=Oex8y~ zQ~6q=#Z$y{sO)Fl znydeu%Ewc?L|z~5HCOTcPOVSj7oyMk`NcnT?VG9dz*H6gwW|N4J`{V-uT^S2;Qls7 zol}yPdtR@pkM0=mf8&&W;IOA1_s9Fyb&c7v_x5Q=)b{aD#GdOgk@3@x`|s1sBu|_d zaYy^C58@JKpLEzW9^xuxA2{r}9&ug#=v|4QwE1H1`D5Iyi!b)ko^kV>m)=Kv#!oz0 zT_*=y_dI^OZi-Qe2M22W%%0CXv}gah4qUA4jir8AH$>LUHEQ3ATk=mkw$FWgkg`uY z?3sV=TO*Zy;IL=jW5}O=&m+E0^3VC9k@Me%oM$Zt$+(av&H|s~L4MOAqR%{0-o?T{ zahT{cKI-qMo-@$j0T%v{G2(}LrGCE$h0lDkoqH|(p)*9k7>S|&ZR$P}`Io8tWvq*V z7XFQ^#6RnX`ro}KeAWZ|QAgDU`R~6Y`dlx_PrNUDt_$q2G>Tcnu)%#r3_lAj|BjKO* zb+Uy&OT8yX{p-evpBC`LdR$=P-#bq9TPpq12ZevM;=k<+|L5SduD`SJ-#=3gnr)HD1KNELK*W{)h_uX2a10$+avix2KH$^*Oo zSzP>ZouGc8{F6V$;(wybCx3sj^Pk24QD~6;9j?Zg{1=sf@{9V3AFdxa!{0QWXBezd zbx1#p)%^$Zo2zlD1^;g<{Q&%xi=+bTf+qh}#i##wEc}fY{#FZrhlRh(!mqhFH%`V= z&%$qP;U8h)x3cg{Ec_EK{8KFab1eKm7XGg-{HrbeffoKS3;$*de~g8HpN0R3g+JB8 zpKakkXW<7H{>v8r8y5b17XB8+$29m6oTTpGHSQtvnCF%A{vq|r{kK6ok@NoGQg#1g zclTWT8b=Aub?^jr|KuIDU))+t^toU58Z5Mj$Zu2rTI`nixNnVC_xl=5&O?AIG++XZ@ZrPF) z`p6$uqA;p@i=6x29jd)PXNjEWj`xlin&<9+sC?CVPwIo`moJt6eHV*8&&gX<`x}%$ zPmS-HO8?sRx$PaS-WPnSk+jEi#O-SQbXqP|&l6u&`K7a@SM#Tzs+WdE5)bG5V`~0>Gg0I`r>9iDJ6tGo zj^8&&3(fWBzQ={;dH$Vyg>H#NS6wYM&xgmZ7rF!FbsrL%?Z2@_=#G$owL<8&pr@+m zLBCY<@5HM_pYwh41wu3al&Y`4{x(;CwCdkuimp`i^#ZlNA9A_)Zg*CX51xiY2B z&-K;%_cPW0!n?&kug{jM_xoQtPUM`gYq|@~_2rV;Li2iR)!1Br7r!d>vB-DNJA~%> z^`M4AbAHXOAvEi&m6~s!s`p81{kuW!ht1UU#6exeKl}6Gxk7V&>aYB*SM$G8wg07> z-#t~nJE`a8i`0Coak{k6_4C*2`F)$lB4>V7N)SIT_TXN%`+tqsa zs^>xUH(U8@Zt1VqJ1U~y(Gm4pN7QQ@QLlYOz0MZBGWEU`^QEz)+IJ?ZeU|!hrANNT z>3@^)G7lU^RNAKygOo2J$uwxcn& zL-N9Nf|ir^BI@mqSO+B${lzTx!MKQbz9IeK{Z#TbE*l%I4*vNk(bMab_IH|PJ<)m} ztM>r4obUEeS?H22X6dmpoJzl3Nah7~O;<$B**zR2QK2WwH z^cgV%qt$7{mqd@xg_N5t zxno4MdO3Tj=<&XC)`9wv{95!}>#j2UPm&K*|C3YrRLDRf1BDC}GEm4sAp?aB6f#iA zKp_K#3=}d@$Uq?jg$xujP{=?b1C|V2Y5D#?i~2sZ2d_wFZ1`N0xX+FX2LCTIup8eO z%jXY7Tjye^AL graph.svg -# -# grep funcA input.txt | ./flamegraph.pl [options] > graph.svg -# -# Then open the resulting .svg in a web browser, for interactivity: mouse-over -# frames for info, click to zoom, and ctrl-F to search. -# -# Options are listed in the usage message (--help). -# -# The input is stack frames and sample counts formatted as single lines. Each -# frame in the stack is semicolon separated, with a space and count at the end -# of the line. These can be generated for Linux perf script output using -# stackcollapse-perf.pl, for DTrace using stackcollapse.pl, and for other tools -# using the other stackcollapse programs. Example input: -# -# swapper;start_kernel;rest_init;cpu_idle;default_idle;native_safe_halt 1 -# -# An optional extra column of counts can be provided to generate a differential -# flame graph of the counts, colored red for more, and blue for less. This -# can be useful when using flame graphs for non-regression testing. -# See the header comment in the difffolded.pl program for instructions. -# -# The input functions can optionally have annotations at the end of each -# function name, following a precedent by some tools (Linux perf's _[k]): -# _[k] for kernel -# _[i] for inlined -# _[j] for jit -# _[w] for waker -# Some of the stackcollapse programs support adding these annotations, eg, -# stackcollapse-perf.pl --kernel --jit. They are used merely for colors by -# some palettes, eg, flamegraph.pl --color=java. -# -# The output flame graph shows relative presence of functions in stack samples. -# The ordering on the x-axis has no meaning; since the data is samples, time -# order of events is not known. The order used sorts function names -# alphabetically. -# -# While intended to process stack samples, this can also process stack traces. -# For example, tracing stacks for memory allocation, or resource usage. You -# can use --title to set the title to reflect the content, and --countname -# to change "samples" to "bytes" etc. -# -# There are a few different palettes, selectable using --color. By default, -# the colors are selected at random (except for differentials). Functions -# called "-" will be printed gray, which can be used for stack separators (eg, -# between user and kernel stacks). -# -# HISTORY -# -# This was inspired by Neelakanth Nadgir's excellent function_call_graph.rb -# program, which visualized function entry and return trace events. As Neel -# wrote: "The output displayed is inspired by Roch's CallStackAnalyzer which -# was in turn inspired by the work on vftrace by Jan Boerhout". See: -# https://blogs.oracle.com/realneel/entry/visualizing_callstacks_via_dtrace_and -# -# Copyright 2016 Netflix, Inc. -# Copyright 2011 Joyent, Inc. All rights reserved. -# Copyright 2011 Brendan Gregg. All rights reserved. -# -# CDDL HEADER START -# -# The contents of this file are subject to the terms of the -# Common Development and Distribution License (the "License"). -# You may not use this file except in compliance with the License. -# -# You can obtain a copy of the license at docs/cddl1.txt or -# http://opensource.org/licenses/CDDL-1.0. -# See the License for the specific language governing permissions -# and limitations under the License. -# -# When distributing Covered Code, include this CDDL HEADER in each -# file and include the License file at docs/cddl1.txt. -# If applicable, add the following below this CDDL HEADER, with the -# fields enclosed by brackets "[]" replaced with your own identifying -# information: Portions Copyright [yyyy] [name of copyright owner] -# -# CDDL HEADER END -# -# 11-Oct-2014 Adrien Mahieux Added zoom. -# 21-Nov-2013 Shawn Sterling Added consistent palette file option -# 17-Mar-2013 Tim Bunce Added options and more tunables. -# 15-Dec-2011 Dave Pacheco Support for frames with whitespace. -# 10-Sep-2011 Brendan Gregg Created this. - -use strict; - -use Getopt::Long; - -use open qw(:std :utf8); - -# tunables -my $encoding; -my $fonttype = "Verdana"; -my $imagewidth = 1200; # max width, pixels -my $frameheight = 16; # max height is dynamic -my $fontsize = 12; # base text size -my $fontwidth = 0.59; # avg width relative to fontsize -my $minwidth = 0.1; # min function width, pixels -my $nametype = "Function:"; # what are the names in the data? -my $countname = "samples"; # what are the counts in the data? -my $colors = "hot"; # color theme -my $bgcolors = ""; # background color theme -my $nameattrfile; # file holding function attributes -my $timemax; # (override the) sum of the counts -my $factor = 1; # factor to scale counts by -my $hash = 0; # color by function name -my $palette = 0; # if we use consistent palettes (default off) -my %palette_map; # palette map hash -my $pal_file = "palette.map"; # palette map file name -my $stackreverse = 0; # reverse stack order, switching merge end -my $inverted = 0; # icicle graph -my $flamechart = 0; # produce a flame chart (sort by time, do not merge stacks) -my $negate = 0; # switch differential hues -my $titletext = ""; # centered heading -my $titledefault = "Flame Graph"; # overwritten by --title -my $titleinverted = "Icicle Graph"; # " " -my $searchcolor = "rgb(230,0,230)"; # color for search highlighting -my $notestext = ""; # embedded notes in SVG -my $subtitletext = ""; # second level title (optional) -my $help = 0; - -sub usage { - die < outfile.svg\n - --title TEXT # change title text - --subtitle TEXT # second level title (optional) - --width NUM # width of image (default 1200) - --height NUM # height of each frame (default 16) - --minwidth NUM # omit smaller functions (default 0.1 pixels) - --fonttype FONT # font type (default "Verdana") - --fontsize NUM # font size (default 12) - --countname TEXT # count type label (default "samples") - --nametype TEXT # name type label (default "Function:") - --colors PALETTE # set color palette. choices are: hot (default), mem, - # io, wakeup, chain, java, js, perl, red, green, blue, - # aqua, yellow, purple, orange - --bgcolors COLOR # set background colors. gradient choices are yellow - # (default), blue, green, grey; flat colors use "#rrggbb" - --hash # colors are keyed by function name hash - --cp # use consistent palette (palette.map) - --reverse # generate stack-reversed flame graph - --inverted # icicle graph - --flamechart # produce a flame chart (sort by time, do not merge stacks) - --negate # switch differential hues (blue<->red) - --notes TEXT # add notes comment in SVG (for debugging) - --help # this message - - eg, - $0 --title="Flame Graph: malloc()" trace.txt > graph.svg -USAGE_END -} - -GetOptions( - 'fonttype=s' => \$fonttype, - 'width=i' => \$imagewidth, - 'height=i' => \$frameheight, - 'encoding=s' => \$encoding, - 'fontsize=f' => \$fontsize, - 'fontwidth=f' => \$fontwidth, - 'minwidth=f' => \$minwidth, - 'title=s' => \$titletext, - 'subtitle=s' => \$subtitletext, - 'nametype=s' => \$nametype, - 'countname=s' => \$countname, - 'nameattr=s' => \$nameattrfile, - 'total=s' => \$timemax, - 'factor=f' => \$factor, - 'colors=s' => \$colors, - 'bgcolors=s' => \$bgcolors, - 'hash' => \$hash, - 'cp' => \$palette, - 'reverse' => \$stackreverse, - 'inverted' => \$inverted, - 'flamechart' => \$flamechart, - 'negate' => \$negate, - 'notes=s' => \$notestext, - 'help' => \$help, -) or usage(); -$help && usage(); - -# internals -my $ypad1 = $fontsize * 3; # pad top, include title -my $ypad2 = $fontsize * 2 + 10; # pad bottom, include labels -my $ypad3 = $fontsize * 2; # pad top, include subtitle (optional) -my $xpad = 10; # pad lefm and right -my $framepad = 1; # vertical padding for frames -my $depthmax = 0; -my %Events; -my %nameattr; - -if ($flamechart && $titletext eq "") { - $titletext = "Flame Chart"; -} - -if ($titletext eq "") { - unless ($inverted) { - $titletext = $titledefault; - } else { - $titletext = $titleinverted; - } -} - -if ($nameattrfile) { - # The name-attribute file format is a function name followed by a tab then - # a sequence of tab separated name=value pairs. - open my $attrfh, $nameattrfile or die "Can't read $nameattrfile: $!\n"; - while (<$attrfh>) { - chomp; - my ($funcname, $attrstr) = split /\t/, $_, 2; - die "Invalid format in $nameattrfile" unless defined $attrstr; - $nameattr{$funcname} = { map { split /=/, $_, 2 } split /\t/, $attrstr }; - } -} - -if ($notestext =~ /[<>]/) { - die "Notes string can't contain < or >" -} - -# background colors: -# - yellow gradient: default (hot, java, js, perl) -# - green gradient: mem -# - blue gradient: io, wakeup, chain -# - gray gradient: flat colors (red, green, blue, ...) -if ($bgcolors eq "") { - # choose a default - if ($colors eq "mem") { - $bgcolors = "green"; - } elsif ($colors =~ /^(io|wakeup|chain)$/) { - $bgcolors = "blue"; - } elsif ($colors =~ /^(red|green|blue|aqua|yellow|purple|orange)$/) { - $bgcolors = "grey"; - } else { - $bgcolors = "yellow"; - } -} -my ($bgcolor1, $bgcolor2); -if ($bgcolors eq "yellow") { - $bgcolor1 = "#eeeeee"; # background color gradient start - $bgcolor2 = "#eeeeb0"; # background color gradient stop -} elsif ($bgcolors eq "blue") { - $bgcolor1 = "#eeeeee"; $bgcolor2 = "#e0e0ff"; -} elsif ($bgcolors eq "green") { - $bgcolor1 = "#eef2ee"; $bgcolor2 = "#e0ffe0"; -} elsif ($bgcolors eq "grey") { - $bgcolor1 = "#f8f8f8"; $bgcolor2 = "#e8e8e8"; -} elsif ($bgcolors =~ /^#......$/) { - $bgcolor1 = $bgcolor2 = $bgcolors; -} else { - die "Unrecognized bgcolor option \"$bgcolors\"" -} - -# SVG functions -{ package SVG; - sub new { - my $class = shift; - my $self = {}; - bless ($self, $class); - return $self; - } - - sub header { - my ($self, $w, $h) = @_; - my $enc_attr = ''; - if (defined $encoding) { - $enc_attr = qq{ encoding="$encoding"}; - } - $self->{svg} .= < - - - - -SVG - } - - sub include { - my ($self, $content) = @_; - $self->{svg} .= $content; - } - - sub colorAllocate { - my ($self, $r, $g, $b) = @_; - return "rgb($r,$g,$b)"; - } - - sub group_start { - my ($self, $attr) = @_; - - my @g_attr = map { - exists $attr->{$_} ? sprintf(qq/$_="%s"/, $attr->{$_}) : () - } qw(id class); - push @g_attr, $attr->{g_extra} if $attr->{g_extra}; - if ($attr->{href}) { - my @a_attr; - push @a_attr, sprintf qq/xlink:href="%s"/, $attr->{href} if $attr->{href}; - # default target=_top else links will open within SVG - push @a_attr, sprintf qq/target="%s"/, $attr->{target} || "_top"; - push @a_attr, $attr->{a_extra} if $attr->{a_extra}; - $self->{svg} .= sprintf qq/\n/, join(' ', (@a_attr, @g_attr)); - } else { - $self->{svg} .= sprintf qq/\n/, join(' ', @g_attr); - } - - $self->{svg} .= sprintf qq/%s<\/title>/, $attr->{title} - if $attr->{title}; # should be first element within g container - } - - sub group_end { - my ($self, $attr) = @_; - $self->{svg} .= $attr->{href} ? qq/<\/a>\n/ : qq/<\/g>\n/; - } - - sub filledRectangle { - my ($self, $x1, $y1, $x2, $y2, $fill, $extra) = @_; - $x1 = sprintf "%0.1f", $x1; - $x2 = sprintf "%0.1f", $x2; - my $w = sprintf "%0.1f", $x2 - $x1; - my $h = sprintf "%0.1f", $y2 - $y1; - $extra = defined $extra ? $extra : ""; - $self->{svg} .= qq/\n/; - } - - sub stringTTF { - my ($self, $id, $x, $y, $str, $extra) = @_; - $x = sprintf "%0.2f", $x; - $id = defined $id ? qq/id="$id"/ : ""; - $extra ||= ""; - $self->{svg} .= qq/$str<\/text>\n/; - } - - sub svg { - my $self = shift; - return "$self->{svg}\n"; - } - 1; -} - -sub namehash { - # Generate a vector hash for the name string, weighting early over - # later characters. We want to pick the same colors for function - # names across different flame graphs. - my $name = shift; - my $vector = 0; - my $weight = 1; - my $max = 1; - my $mod = 10; - # if module name present, trunc to 1st char - $name =~ s/.(.*?)`//; - foreach my $c (split //, $name) { - my $i = (ord $c) % $mod; - $vector += ($i / ($mod++ - 1)) * $weight; - $max += 1 * $weight; - $weight *= 0.70; - last if $mod > 12; - } - return (1 - $vector / $max) -} - -sub color { - my ($type, $hash, $name) = @_; - my ($v1, $v2, $v3); - - if ($hash) { - $v1 = namehash($name); - $v2 = $v3 = namehash(scalar reverse $name); - } else { - $v1 = rand(1); - $v2 = rand(1); - $v3 = rand(1); - } - - # theme palettes - if (defined $type and $type eq "hot") { - my $r = 205 + int(50 * $v3); - my $g = 0 + int(230 * $v1); - my $b = 0 + int(55 * $v2); - return "rgb($r,$g,$b)"; - } - if (defined $type and $type eq "mem") { - my $r = 0; - my $g = 190 + int(50 * $v2); - my $b = 0 + int(210 * $v1); - return "rgb($r,$g,$b)"; - } - if (defined $type and $type eq "io") { - my $r = 80 + int(60 * $v1); - my $g = $r; - my $b = 190 + int(55 * $v2); - return "rgb($r,$g,$b)"; - } - - # multi palettes - if (defined $type and $type eq "java") { - # Handle both annotations (_[j], _[i], ...; which are - # accurate), as well as input that lacks any annotations, as - # best as possible. Without annotations, we get a little hacky - # and match on java|org|com, etc. - if ($name =~ m:_\[j\]$:) { # jit annotation - $type = "green"; - } elsif ($name =~ m:_\[i\]$:) { # inline annotation - $type = "aqua"; - } elsif ($name =~ m:^L?(java|javax|jdk|net|org|com|io|sun)/:) { # Java - $type = "green"; - } elsif ($name =~ m:_\[k\]$:) { # kernel annotation - $type = "orange"; - } elsif ($name =~ /::/) { # C++ - $type = "yellow"; - } else { # system - $type = "red"; - } - # fall-through to color palettes - } - if (defined $type and $type eq "perl") { - if ($name =~ /::/) { # C++ - $type = "yellow"; - } elsif ($name =~ m:Perl: or $name =~ m:\.pl:) { # Perl - $type = "green"; - } elsif ($name =~ m:_\[k\]$:) { # kernel - $type = "orange"; - } else { # system - $type = "red"; - } - # fall-through to color palettes - } - if (defined $type and $type eq "js") { - # Handle both annotations (_[j], _[i], ...; which are - # accurate), as well as input that lacks any annotations, as - # best as possible. Without annotations, we get a little hacky, - # and match on a "/" with a ".js", etc. - if ($name =~ m:_\[j\]$:) { # jit annotation - if ($name =~ m:/:) { - $type = "green"; # source - } else { - $type = "aqua"; # builtin - } - } elsif ($name =~ /::/) { # C++ - $type = "yellow"; - } elsif ($name =~ m:/.*\.js:) { # JavaScript (match "/" in path) - $type = "green"; - } elsif ($name =~ m/:/) { # JavaScript (match ":" in builtin) - $type = "aqua"; - } elsif ($name =~ m/^ $/) { # Missing symbol - $type = "green"; - } elsif ($name =~ m:_\[k\]:) { # kernel - $type = "orange"; - } else { # system - $type = "red"; - } - # fall-through to color palettes - } - if (defined $type and $type eq "wakeup") { - $type = "aqua"; - # fall-through to color palettes - } - if (defined $type and $type eq "chain") { - if ($name =~ m:_\[w\]:) { # waker - $type = "aqua" - } else { # off-CPU - $type = "blue"; - } - # fall-through to color palettes - } - - # color palettes - if (defined $type and $type eq "red") { - my $r = 200 + int(55 * $v1); - my $x = 50 + int(80 * $v1); - return "rgb($r,$x,$x)"; - } - if (defined $type and $type eq "green") { - my $g = 200 + int(55 * $v1); - my $x = 50 + int(60 * $v1); - return "rgb($x,$g,$x)"; - } - if (defined $type and $type eq "blue") { - my $b = 205 + int(50 * $v1); - my $x = 80 + int(60 * $v1); - return "rgb($x,$x,$b)"; - } - if (defined $type and $type eq "yellow") { - my $x = 175 + int(55 * $v1); - my $b = 50 + int(20 * $v1); - return "rgb($x,$x,$b)"; - } - if (defined $type and $type eq "purple") { - my $x = 190 + int(65 * $v1); - my $g = 80 + int(60 * $v1); - return "rgb($x,$g,$x)"; - } - if (defined $type and $type eq "aqua") { - my $r = 50 + int(60 * $v1); - my $g = 165 + int(55 * $v1); - my $b = 165 + int(55 * $v1); - return "rgb($r,$g,$b)"; - } - if (defined $type and $type eq "orange") { - my $r = 190 + int(65 * $v1); - my $g = 90 + int(65 * $v1); - return "rgb($r,$g,0)"; - } - - return "rgb(0,0,0)"; -} - -sub color_scale { - my ($value, $max) = @_; - my ($r, $g, $b) = (255, 255, 255); - $value = -$value if $negate; - if ($value > 0) { - $g = $b = int(210 * ($max - $value) / $max); - } elsif ($value < 0) { - $r = $g = int(210 * ($max + $value) / $max); - } - return "rgb($r,$g,$b)"; -} - -sub color_map { - my ($colors, $func) = @_; - if (exists $palette_map{$func}) { - return $palette_map{$func}; - } else { - $palette_map{$func} = color($colors, $hash, $func); - return $palette_map{$func}; - } -} - -sub write_palette { - open(FILE, ">$pal_file"); - foreach my $key (sort keys %palette_map) { - print FILE $key."->".$palette_map{$key}."\n"; - } - close(FILE); -} - -sub read_palette { - if (-e $pal_file) { - open(FILE, $pal_file) or die "can't open file $pal_file: $!"; - while ( my $line = ) { - chomp($line); - (my $key, my $value) = split("->",$line); - $palette_map{$key}=$value; - } - close(FILE) - } -} - -my %Node; # Hash of merged frame data -my %Tmp; - -# flow() merges two stacks, storing the merged frames and value data in %Node. -sub flow { - my ($last, $this, $v, $d) = @_; - - my $len_a = @$last - 1; - my $len_b = @$this - 1; - - my $i = 0; - my $len_same; - for (; $i <= $len_a; $i++) { - last if $i > $len_b; - last if $last->[$i] ne $this->[$i]; - } - $len_same = $i; - - for ($i = $len_a; $i >= $len_same; $i--) { - my $k = "$last->[$i];$i"; - # a unique ID is constructed from "func;depth;etime"; - # func-depth isn't unique, it may be repeated later. - $Node{"$k;$v"}->{stime} = delete $Tmp{$k}->{stime}; - if (defined $Tmp{$k}->{delta}) { - $Node{"$k;$v"}->{delta} = delete $Tmp{$k}->{delta}; - } - delete $Tmp{$k}; - } - - for ($i = $len_same; $i <= $len_b; $i++) { - my $k = "$this->[$i];$i"; - $Tmp{$k}->{stime} = $v; - if (defined $d) { - $Tmp{$k}->{delta} += $i == $len_b ? $d : 0; - } - } - - return $this; -} - -# parse input -my @Data; -my @SortedData; -my $last = []; -my $time = 0; -my $delta = undef; -my $ignored = 0; -my $line; -my $maxdelta = 1; - -# reverse if needed -foreach (<>) { - chomp; - $line = $_; - if ($stackreverse) { - # there may be an extra samples column for differentials - # XXX todo: redo these REs as one. It's repeated below. - my($stack, $samples) = (/^(.*)\s+?(\d+(?:\.\d*)?)$/); - my $samples2 = undef; - if ($stack =~ /^(.*)\s+?(\d+(?:\.\d*)?)$/) { - $samples2 = $samples; - ($stack, $samples) = $stack =~ (/^(.*)\s+?(\d+(?:\.\d*)?)$/); - unshift @Data, join(";", reverse split(";", $stack)) . " $samples $samples2"; - } else { - unshift @Data, join(";", reverse split(";", $stack)) . " $samples"; - } - } else { - unshift @Data, $line; - } -} - -if ($flamechart) { - # In flame chart mode, just reverse the data so time moves from left to right. - @SortedData = reverse @Data; -} else { - @SortedData = sort @Data; -} - -# process and merge frames -foreach (@SortedData) { - chomp; - # process: folded_stack count - # eg: func_a;func_b;func_c 31 - my ($stack, $samples) = (/^(.*)\s+?(\d+(?:\.\d*)?)$/); - unless (defined $samples and defined $stack) { - ++$ignored; - next; - } - - # there may be an extra samples column for differentials: - my $samples2 = undef; - if ($stack =~ /^(.*)\s+?(\d+(?:\.\d*)?)$/) { - $samples2 = $samples; - ($stack, $samples) = $stack =~ (/^(.*)\s+?(\d+(?:\.\d*)?)$/); - } - $delta = undef; - if (defined $samples2) { - $delta = $samples2 - $samples; - $maxdelta = abs($delta) if abs($delta) > $maxdelta; - } - - # for chain graphs, annotate waker frames with "_[w]", for later - # coloring. This is a hack, but has a precedent ("_[k]" from perf). - if ($colors eq "chain") { - my @parts = split ";--;", $stack; - my @newparts = (); - $stack = shift @parts; - $stack .= ";--;"; - foreach my $part (@parts) { - $part =~ s/;/_[w];/g; - $part .= "_[w]"; - push @newparts, $part; - } - $stack .= join ";--;", @parts; - } - - # merge frames and populate %Node: - $last = flow($last, [ '', split ";", $stack ], $time, $delta); - - if (defined $samples2) { - $time += $samples2; - } else { - $time += $samples; - } -} -flow($last, [], $time, $delta); - -warn "Ignored $ignored lines with invalid format\n" if $ignored; -unless ($time) { - warn "ERROR: No stack counts found\n"; - my $im = SVG->new(); - # emit an error message SVG, for tools automating flamegraph use - my $imageheight = $fontsize * 5; - $im->header($imagewidth, $imageheight); - $im->stringTTF(undef, int($imagewidth / 2), $fontsize * 2, - "ERROR: No valid input provided to flamegraph.pl."); - print $im->svg; - exit 2; -} -if ($timemax and $timemax < $time) { - warn "Specified --total $timemax is less than actual total $time, so ignored\n" - if $timemax/$time > 0.02; # only warn is significant (e.g., not rounding etc) - undef $timemax; -} -$timemax ||= $time; - -my $widthpertime = ($imagewidth - 2 * $xpad) / $timemax; -my $minwidth_time = $minwidth / $widthpertime; - -# prune blocks that are too narrow and determine max depth -while (my ($id, $node) = each %Node) { - my ($func, $depth, $etime) = split ";", $id; - my $stime = $node->{stime}; - die "missing start for $id" if not defined $stime; - - if (($etime-$stime) < $minwidth_time) { - delete $Node{$id}; - next; - } - $depthmax = $depth if $depth > $depthmax; -} - -# draw canvas, and embed interactive JavaScript program -my $imageheight = (($depthmax + 1) * $frameheight) + $ypad1 + $ypad2; -$imageheight += $ypad3 if $subtitletext ne ""; -my $titlesize = $fontsize + 5; -my $im = SVG->new(); -my ($black, $vdgrey, $dgrey) = ( - $im->colorAllocate(0, 0, 0), - $im->colorAllocate(160, 160, 160), - $im->colorAllocate(200, 200, 200), - ); -$im->header($imagewidth, $imageheight); -my $inc = < - - - - - - - -INC -$im->include($inc); -$im->filledRectangle(0, 0, $imagewidth, $imageheight, 'url(#background)'); -$im->stringTTF("title", int($imagewidth / 2), $fontsize * 2, $titletext); -$im->stringTTF("subtitle", int($imagewidth / 2), $fontsize * 4, $subtitletext) if $subtitletext ne ""; -$im->stringTTF("details", $xpad, $imageheight - ($ypad2 / 2), " "); -$im->stringTTF("unzoom", $xpad, $fontsize * 2, "Reset Zoom", 'class="hide"'); -$im->stringTTF("search", $imagewidth - $xpad - 100, $fontsize * 2, "Search"); -$im->stringTTF("ignorecase", $imagewidth - $xpad - 16, $fontsize * 2, "ic"); -$im->stringTTF("matched", $imagewidth - $xpad - 100, $imageheight - ($ypad2 / 2), " "); - -if ($palette) { - read_palette(); -} - -# draw frames -$im->group_start({id => "frames"}); -while (my ($id, $node) = each %Node) { - my ($func, $depth, $etime) = split ";", $id; - my $stime = $node->{stime}; - my $delta = $node->{delta}; - - $etime = $timemax if $func eq "" and $depth == 0; - - my $x1 = $xpad + $stime * $widthpertime; - my $x2 = $xpad + $etime * $widthpertime; - my ($y1, $y2); - unless ($inverted) { - $y1 = $imageheight - $ypad2 - ($depth + 1) * $frameheight + $framepad; - $y2 = $imageheight - $ypad2 - $depth * $frameheight; - } else { - $y1 = $ypad1 + $depth * $frameheight; - $y2 = $ypad1 + ($depth + 1) * $frameheight - $framepad; - } - - my $samples = sprintf "%.0f", ($etime - $stime) * $factor; - (my $samples_txt = $samples) # add commas per perlfaq5 - =~ s/(^[-+]?\d+?(?=(?>(?:\d{3})+)(?!\d))|\G\d{3}(?=\d))/$1,/g; - - my $info; - if ($func eq "" and $depth == 0) { - $info = "all ($samples_txt $countname, 100%)"; - } else { - my $pct = sprintf "%.2f", ((100 * $samples) / ($timemax * $factor)); - my $escaped_func = $func; - # clean up SVG breaking characters: - $escaped_func =~ s/&/&/g; - $escaped_func =~ s//>/g; - $escaped_func =~ s/"/"/g; - $escaped_func =~ s/_\[[kwij]\]$//; # strip any annotation - unless (defined $delta) { - $info = "$escaped_func ($samples_txt $countname, $pct%)"; - } else { - my $d = $negate ? -$delta : $delta; - my $deltapct = sprintf "%.2f", ((100 * $d) / ($timemax * $factor)); - $deltapct = $d > 0 ? "+$deltapct" : $deltapct; - $info = "$escaped_func ($samples_txt $countname, $pct%; $deltapct%)"; - } - } - - my $nameattr = { %{ $nameattr{$func}||{} } }; # shallow clone - $nameattr->{title} ||= $info; - $im->group_start($nameattr); - - my $color; - if ($func eq "--") { - $color = $vdgrey; - } elsif ($func eq "-") { - $color = $dgrey; - } elsif (defined $delta) { - $color = color_scale($delta, $maxdelta); - } elsif ($palette) { - $color = color_map($colors, $func); - } else { - $color = color($colors, $hash, $func); - } - $im->filledRectangle($x1, $y1, $x2, $y2, $color, 'rx="2" ry="2"'); - - my $chars = int( ($x2 - $x1) / ($fontsize * $fontwidth)); - my $text = ""; - if ($chars >= 3) { # room for one char plus two dots - $func =~ s/_\[[kwij]\]$//; # strip any annotation - $text = substr $func, 0, $chars; - substr($text, -2, 2) = ".." if $chars < length $func; - $text =~ s/&/&/g; - $text =~ s//>/g; - } - $im->stringTTF(undef, $x1 + 3, 3 + ($y1 + $y2) / 2, $text); - - $im->group_end($nameattr); -} -$im->group_end(); - -print $im->svg; - -if ($palette) { - write_palette(); -} - -# vim: ts=8 sts=8 sw=8 noexpandtab diff --git a/ee/clickhouse/materialized_columns/__init__.py b/ee/clickhouse/materialized_columns/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/clickhouse/materialized_columns/analyze.py b/ee/clickhouse/materialized_columns/analyze.py deleted file mode 100644 index d19a282117e..00000000000 --- a/ee/clickhouse/materialized_columns/analyze.py +++ /dev/null @@ -1,205 +0,0 @@ -import re -from datetime import timedelta -from typing import Optional -from collections.abc import Generator - -import structlog - -from ee.clickhouse.materialized_columns.columns import ( - DEFAULT_TABLE_COLUMN, - backfill_materialized_columns, - get_materialized_columns, - materialize, -) -from ee.settings import ( - MATERIALIZE_COLUMNS_ANALYSIS_PERIOD_HOURS, - MATERIALIZE_COLUMNS_BACKFILL_PERIOD_DAYS, - MATERIALIZE_COLUMNS_MAX_AT_ONCE, - MATERIALIZE_COLUMNS_MINIMUM_QUERY_TIME, -) -from posthog.cache_utils import instance_memoize -from posthog.client import sync_execute -from posthog.models.filters.mixins.utils import cached_property -from posthog.models.person.sql import ( - GET_EVENT_PROPERTIES_COUNT, - GET_PERSON_PROPERTIES_COUNT, -) -from posthog.models.property import PropertyName, TableColumn, TableWithProperties -from posthog.models.property_definition import PropertyDefinition -from posthog.models.team import Team - -Suggestion = tuple[TableWithProperties, TableColumn, PropertyName] - -logger = structlog.get_logger(__name__) - - -class TeamManager: - @instance_memoize - def person_properties(self, team_id: str) -> set[str]: - return self._get_properties(GET_PERSON_PROPERTIES_COUNT, team_id) - - @instance_memoize - def event_properties(self, team_id: str) -> set[str]: - return set( - PropertyDefinition.objects.filter(team_id=team_id, type=PropertyDefinition.Type.EVENT).values_list( - "name", flat=True - ) - ) - - @instance_memoize - def person_on_events_properties(self, team_id: str) -> set[str]: - return self._get_properties(GET_EVENT_PROPERTIES_COUNT.format(column_name="person_properties"), team_id) - - def _get_properties(self, query, team_id) -> set[str]: - rows = sync_execute(query, {"team_id": team_id}) - return {name for name, _ in rows} - - -class Query: - def __init__( - self, - query_string: str, - query_time_ms: float, - min_query_time=MATERIALIZE_COLUMNS_MINIMUM_QUERY_TIME, - ): - self.query_string = query_string - self.query_time_ms = query_time_ms - self.min_query_time = min_query_time - - @property - def cost(self) -> int: - return int((self.query_time_ms - self.min_query_time) / 1000) + 1 - - @cached_property - def is_valid(self): - return self.team_id is not None and Team.objects.filter(pk=self.team_id).exists() - - @cached_property - def team_id(self) -> Optional[str]: - matches = re.findall(r"team_id = (\d+)", self.query_string) - return matches[0] if matches else None - - @cached_property - def _all_properties(self) -> list[tuple[str, PropertyName]]: - return re.findall(r"JSONExtract\w+\((\S+), '([^']+)'\)", self.query_string) - - def properties( - self, team_manager: TeamManager - ) -> Generator[tuple[TableWithProperties, TableColumn, PropertyName], None, None]: - # Reverse-engineer whether a property is an "event" or "person" property by getting their event definitions. - # :KLUDGE: Note that the same property will be found on both tables if both are used. - # We try to hone in on the right column by looking at the column from which the property is extracted. - person_props = team_manager.person_properties(self.team_id) - event_props = team_manager.event_properties(self.team_id) - person_on_events_props = team_manager.person_on_events_properties(self.team_id) - - for table_column, property in self._all_properties: - if property in event_props: - yield "events", DEFAULT_TABLE_COLUMN, property - if property in person_props: - yield "person", DEFAULT_TABLE_COLUMN, property - - if property in person_on_events_props and "person_properties" in table_column: - yield "events", "person_properties", property - - -def _analyze(since_hours_ago: int, min_query_time: int, team_id: Optional[int] = None) -> list[Suggestion]: - "Finds columns that should be materialized" - - raw_queries = sync_execute( - """ -WITH - {min_query_time} as slow_query_minimum, - ( - 159, -- TIMEOUT EXCEEDED - 160, -- TOO SLOW (estimated query execution time) - ) as exception_codes, - 20 * 1000 * 1000 * 1000 as min_bytes_read, - 5000000 as min_read_rows -SELECT - arrayJoin( - extractAll(query, 'JSONExtract[a-zA-Z0-9]*?\\((?:[a-zA-Z0-9\\`_-]+\\.)?(.*?), .*?\\)') - ) as column, - arrayJoin(extractAll(query, 'JSONExtract[a-zA-Z0-9]*?\\(.*?, \\'([a-zA-Z0-9_\\-\\.\\$\\/\\ ]*?)\\'\\)')) as prop_to_materialize - --,groupUniqArrayIf(JSONExtractInt(log_comment, 'team_id'), type > 2), - --count(), - --countIf(type > 2) as failures, - --countIf(query_duration_ms > 3000) as slow_query, - --formatReadableSize(avg(read_bytes)), - --formatReadableSize(max(read_bytes)) -FROM - clusterAllReplicas(posthog, system, query_log) -WHERE - query_start_time > now() - toIntervalHour({since}) - and query LIKE '%JSONExtract%' - and query not LIKE '%JSONExtractKeysAndValuesRaw(group_properties)%' - and type > 1 - and is_initial_query - and JSONExtractString(log_comment, 'access_method') != 'personal_api_key' -- API requests failing is less painful than queries in the interface - and JSONExtractString(log_comment, 'kind') != 'celery' - and JSONExtractInt(log_comment, 'team_id') != 0 - and query not like '%person_distinct_id2%' -- Old style person properties that are joined, no need to optimize those queries - and column IN ('properties', 'person_properties', 'group0_properties', 'group1_properties', 'group2_properties', 'group3_properties', 'group4_properties') - and read_bytes > min_bytes_read - and (exception_code IN exception_codes OR query_duration_ms > slow_query_minimum) - and read_rows > min_read_rows - {team_id_filter} -GROUP BY - 1, 2 -HAVING - countIf(exception_code IN exception_codes) > 0 OR countIf(query_duration_ms > slow_query_minimum) > 9 -ORDER BY - countIf(exception_code IN exception_codes) DESC, - countIf(query_duration_ms > slow_query_minimum) DESC -LIMIT 100 -- Make sure we don't add 100s of columns in one run - """.format( - since=since_hours_ago, - min_query_time=min_query_time, - team_id_filter=f"and JSONExtractInt(log_comment, 'team_id') = {team_id}" if team_id else "", - ), - ) - - return [("events", table_column, property_name) for (table_column, property_name) in raw_queries] - - -def materialize_properties_task( - columns_to_materialize: Optional[list[Suggestion]] = None, - time_to_analyze_hours: int = MATERIALIZE_COLUMNS_ANALYSIS_PERIOD_HOURS, - maximum: int = MATERIALIZE_COLUMNS_MAX_AT_ONCE, - min_query_time: int = MATERIALIZE_COLUMNS_MINIMUM_QUERY_TIME, - backfill_period_days: int = MATERIALIZE_COLUMNS_BACKFILL_PERIOD_DAYS, - dry_run: bool = False, - team_id_to_analyze: Optional[int] = None, -) -> None: - """ - Creates materialized columns for event and person properties based off of slow queries - """ - - if columns_to_materialize is None: - columns_to_materialize = _analyze(time_to_analyze_hours, min_query_time, team_id_to_analyze) - result = [] - for suggestion in columns_to_materialize: - table, table_column, property_name = suggestion - if (property_name, table_column) not in get_materialized_columns(table): - result.append(suggestion) - - if len(result) > 0: - logger.info(f"Calculated columns that could be materialized. count={len(result)}") - else: - logger.info("Found no columns to materialize.") - - properties: dict[TableWithProperties, list[tuple[PropertyName, TableColumn]]] = { - "events": [], - "person": [], - } - for table, table_column, property_name in result[:maximum]: - logger.info(f"Materializing column. table={table}, property_name={property_name}") - - if not dry_run: - materialize(table, property_name, table_column=table_column) - properties[table].append((property_name, table_column)) - - if backfill_period_days > 0 and not dry_run: - logger.info(f"Starting backfill for new materialized columns. period_days={backfill_period_days}") - backfill_materialized_columns("events", properties["events"], timedelta(days=backfill_period_days)) - backfill_materialized_columns("person", properties["person"], timedelta(days=backfill_period_days)) diff --git a/ee/clickhouse/materialized_columns/columns.py b/ee/clickhouse/materialized_columns/columns.py deleted file mode 100644 index 135f27b3921..00000000000 --- a/ee/clickhouse/materialized_columns/columns.py +++ /dev/null @@ -1,227 +0,0 @@ -import re -from datetime import timedelta -from typing import Literal, Union, cast - -from clickhouse_driver.errors import ServerException -from django.utils.timezone import now - -from posthog.cache_utils import cache_for -from posthog.clickhouse.kafka_engine import trim_quotes_expr -from posthog.client import sync_execute -from posthog.models.instance_setting import get_instance_setting -from posthog.models.property import PropertyName, TableColumn, TableWithProperties -from posthog.models.utils import generate_random_short_suffix -from posthog.settings import CLICKHOUSE_CLUSTER, CLICKHOUSE_DATABASE, TEST - -ColumnName = str -DEFAULT_TABLE_COLUMN: Literal["properties"] = "properties" - - -TablesWithMaterializedColumns = Union[TableWithProperties] - -TRIM_AND_EXTRACT_PROPERTY = trim_quotes_expr("JSONExtractRaw({table_column}, %(property)s)") - -SHORT_TABLE_COLUMN_NAME = { - "properties": "p", - "group_properties": "gp", - "person_properties": "pp", - "group0_properties": "gp0", - "group1_properties": "gp1", - "group2_properties": "gp2", - "group3_properties": "gp3", - "group4_properties": "gp4", -} - - -@cache_for(timedelta(minutes=15)) -def get_materialized_columns( - table: TablesWithMaterializedColumns, -) -> dict[tuple[PropertyName, TableColumn], ColumnName]: - rows = sync_execute( - """ - SELECT comment, name - FROM system.columns - WHERE database = %(database)s - AND table = %(table)s - AND comment LIKE '%%column_materializer::%%' - AND comment not LIKE '%%column_materializer::elements_chain::%%' - """, - {"database": CLICKHOUSE_DATABASE, "table": table}, - ) - if rows and get_instance_setting("MATERIALIZED_COLUMNS_ENABLED"): - return {_extract_property(comment): column_name for comment, column_name in rows} - else: - return {} - - -def materialize( - table: TableWithProperties, - property: PropertyName, - column_name=None, - table_column: TableColumn = DEFAULT_TABLE_COLUMN, - create_minmax_index=not TEST, -) -> None: - if (property, table_column) in get_materialized_columns(table, use_cache=False): - if TEST: - return - - raise ValueError(f"Property already materialized. table={table}, property={property}, column={table_column}") - - if table_column not in SHORT_TABLE_COLUMN_NAME: - raise ValueError(f"Invalid table_column={table_column} for materialisation") - - column_name = column_name or _materialized_column_name(table, property, table_column) - # :TRICKY: On cloud, we ON CLUSTER updates to events/sharded_events but not to persons. Why? ¯\_(ツ)_/¯ - execute_on_cluster = f"ON CLUSTER '{CLICKHOUSE_CLUSTER}'" if table == "events" else "" - - if table == "events": - sync_execute( - f""" - ALTER TABLE sharded_{table} - {execute_on_cluster} - ADD COLUMN IF NOT EXISTS - {column_name} VARCHAR MATERIALIZED {TRIM_AND_EXTRACT_PROPERTY.format(table_column=table_column)} - """, - {"property": property}, - settings={"alter_sync": 2 if TEST else 1}, - ) - sync_execute( - f""" - ALTER TABLE {table} - {execute_on_cluster} - ADD COLUMN IF NOT EXISTS - {column_name} VARCHAR - """, - settings={"alter_sync": 2 if TEST else 1}, - ) - else: - sync_execute( - f""" - ALTER TABLE {table} - {execute_on_cluster} - ADD COLUMN IF NOT EXISTS - {column_name} VARCHAR MATERIALIZED {TRIM_AND_EXTRACT_PROPERTY.format(table_column=table_column)} - """, - {"property": property}, - settings={"alter_sync": 2 if TEST else 1}, - ) - - sync_execute( - f"ALTER TABLE {table} {execute_on_cluster} COMMENT COLUMN {column_name} %(comment)s", - {"comment": f"column_materializer::{table_column}::{property}"}, - settings={"alter_sync": 2 if TEST else 1}, - ) - - if create_minmax_index: - add_minmax_index(table, column_name) - - -def add_minmax_index(table: TablesWithMaterializedColumns, column_name: str): - # Note: This will be populated on backfill - execute_on_cluster = f"ON CLUSTER '{CLICKHOUSE_CLUSTER}'" if table == "events" else "" - - updated_table = "sharded_events" if table == "events" else table - index_name = f"minmax_{column_name}" - - try: - sync_execute( - f""" - ALTER TABLE {updated_table} - {execute_on_cluster} - ADD INDEX {index_name} {column_name} - TYPE minmax GRANULARITY 1 - """, - settings={"alter_sync": 2 if TEST else 1}, - ) - except ServerException as err: - if "index with this name already exists" not in str(err): - raise - - return index_name - - -def backfill_materialized_columns( - table: TableWithProperties, - properties: list[tuple[PropertyName, TableColumn]], - backfill_period: timedelta, - test_settings=None, -) -> None: - """ - Backfills the materialized column after its creation. - - This will require reading and writing a lot of data on clickhouse disk. - """ - - if len(properties) == 0: - return - - updated_table = "sharded_events" if table == "events" else table - # :TRICKY: On cloud, we ON CLUSTER updates to events/sharded_events but not to persons. Why? ¯\_(ツ)_/¯ - execute_on_cluster = f"ON CLUSTER '{CLICKHOUSE_CLUSTER}'" if table == "events" else "" - - materialized_columns = get_materialized_columns(table, use_cache=False) - - # Hack from https://github.com/ClickHouse/ClickHouse/issues/19785 - # Note that for this to work all inserts should list columns explicitly - # Improve this if https://github.com/ClickHouse/ClickHouse/issues/27730 ever gets resolved - for property, table_column in properties: - sync_execute( - f""" - ALTER TABLE {updated_table} - {execute_on_cluster} - MODIFY COLUMN - {materialized_columns[(property, table_column)]} VARCHAR DEFAULT {TRIM_AND_EXTRACT_PROPERTY.format(table_column=table_column)} - """, - {"property": property}, - settings=test_settings, - ) - - # Kick off mutations which will update clickhouse partitions in the background. This will return immediately - assignments = ", ".join( - f"{materialized_columns[property_and_column]} = {materialized_columns[property_and_column]}" - for property_and_column in properties - ) - - sync_execute( - f""" - ALTER TABLE {updated_table} - {execute_on_cluster} - UPDATE {assignments} - WHERE {"timestamp > %(cutoff)s" if table == "events" else "1 = 1"} - """, - {"cutoff": (now() - backfill_period).strftime("%Y-%m-%d")}, - settings=test_settings, - ) - - -def _materialized_column_name( - table: TableWithProperties, - property: PropertyName, - table_column: TableColumn = DEFAULT_TABLE_COLUMN, -) -> str: - "Returns a sanitized and unique column name to use for materialized column" - - prefix = "pmat_" if table == "person" else "mat_" - - if table_column != DEFAULT_TABLE_COLUMN: - prefix += f"{SHORT_TABLE_COLUMN_NAME[table_column]}_" - property_str = re.sub("[^0-9a-zA-Z$]", "_", property) - - existing_materialized_columns = set(get_materialized_columns(table, use_cache=False).values()) - suffix = "" - - while f"{prefix}{property_str}{suffix}" in existing_materialized_columns: - suffix = "_" + generate_random_short_suffix() - - return f"{prefix}{property_str}{suffix}" - - -def _extract_property(comment: str) -> tuple[PropertyName, TableColumn]: - # Old style comments have the format "column_materializer::property", dealing with the default table column. - # Otherwise, it's "column_materializer::table_column::property" - split_column = comment.split("::", 2) - - if len(split_column) == 2: - return split_column[1], DEFAULT_TABLE_COLUMN - - return split_column[2], cast(TableColumn, split_column[1]) diff --git a/ee/clickhouse/materialized_columns/test/__init__.py b/ee/clickhouse/materialized_columns/test/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/clickhouse/materialized_columns/test/test_analyze.py b/ee/clickhouse/materialized_columns/test/test_analyze.py deleted file mode 100644 index 6fdb0fb05cb..00000000000 --- a/ee/clickhouse/materialized_columns/test/test_analyze.py +++ /dev/null @@ -1,57 +0,0 @@ -from posthog.test.base import BaseTest, ClickhouseTestMixin -from posthog.client import sync_execute -from ee.clickhouse.materialized_columns.analyze import materialize_properties_task - -from unittest.mock import patch, call - - -class TestMaterializedColumnsAnalyze(ClickhouseTestMixin, BaseTest): - @patch("ee.clickhouse.materialized_columns.analyze.materialize") - @patch("ee.clickhouse.materialized_columns.analyze.backfill_materialized_columns") - def test_mat_columns(self, patch_backfill, patch_materialize): - sync_execute("SYSTEM FLUSH LOGS") - sync_execute("TRUNCATE TABLE system.query_log") - - queries_to_insert = [ - "SELECT * FROM events WHERE JSONExtractRaw(properties, \\'materialize_me\\')", - "SELECT * FROM events WHERE JSONExtractRaw(properties, \\'materialize_me\\')", - "SELECT * FROM events WHERE JSONExtractRaw(properties, \\'materialize_me2\\')", - "SELECT * FROM events WHERE JSONExtractRaw(`e`.properties, \\'materialize_me3\\')", - "SELECT * FROM events WHERE JSONExtractRaw(person_properties, \\'materialize_person_prop\\')", - "SELECT * FROM groups WHERE JSONExtractRaw(group.group_properties, \\'materialize_person_prop\\')", # this should not appear - "SELECT * FROM groups WHERE JSONExtractRaw(group.group_properties, \\'nested\\', \\'property\\')", # this should not appear - ] - - for query in queries_to_insert: - sync_execute( - """ - INSERT INTO system.query_log ( - query, - query_start_time, - type, - is_initial_query, - log_comment, - exception_code, - read_bytes, - read_rows - ) VALUES ( - '{query}', - now(), - 3, - 1, - '{log_comment}', - 159, - 40000000000, - 10000000 - ) - """.format(query=query, log_comment='{"team_id": 2}') - ) - materialize_properties_task() - patch_materialize.assert_has_calls( - [ - call("events", "materialize_me", table_column="properties"), - call("events", "materialize_me2", table_column="properties"), - call("events", "materialize_person_prop", table_column="person_properties"), - call("events", "materialize_me3", table_column="properties"), - ] - ) diff --git a/ee/clickhouse/materialized_columns/test/test_columns.py b/ee/clickhouse/materialized_columns/test/test_columns.py deleted file mode 100644 index 472b46b1e80..00000000000 --- a/ee/clickhouse/materialized_columns/test/test_columns.py +++ /dev/null @@ -1,240 +0,0 @@ -from datetime import timedelta -from time import sleep -from unittest.mock import patch - -from freezegun import freeze_time - -from ee.clickhouse.materialized_columns.columns import ( - backfill_materialized_columns, - get_materialized_columns, - materialize, -) -from posthog.client import sync_execute -from posthog.conftest import create_clickhouse_tables -from posthog.constants import GROUP_TYPES_LIMIT -from posthog.models.event.sql import EVENTS_DATA_TABLE -from posthog.settings import CLICKHOUSE_DATABASE -from posthog.test.base import BaseTest, ClickhouseTestMixin, _create_event - -EVENTS_TABLE_DEFAULT_MATERIALIZED_COLUMNS = [f"$group_{i}" for i in range(GROUP_TYPES_LIMIT)] + [ - "$session_id", - "$window_id", -] - - -class TestMaterializedColumns(ClickhouseTestMixin, BaseTest): - def setUp(self): - self.recreate_database() - return super().setUp() - - def tearDown(self): - self.recreate_database() - super().tearDown() - - def recreate_database(self): - sync_execute(f"DROP DATABASE {CLICKHOUSE_DATABASE} SYNC") - sync_execute(f"CREATE DATABASE {CLICKHOUSE_DATABASE}") - create_clickhouse_tables(0) - - def test_get_columns_default(self): - self.assertCountEqual( - [property_name for property_name, _ in get_materialized_columns("events")], - EVENTS_TABLE_DEFAULT_MATERIALIZED_COLUMNS, - ) - self.assertCountEqual(get_materialized_columns("person"), []) - - def test_caching_and_materializing(self): - with freeze_time("2020-01-04T13:01:01Z"): - materialize("events", "$foo", create_minmax_index=True) - materialize("events", "$bar", create_minmax_index=True) - materialize("person", "$zeta", create_minmax_index=True) - - self.assertCountEqual( - [property_name for property_name, _ in get_materialized_columns("events", use_cache=True).keys()], - ["$foo", "$bar", *EVENTS_TABLE_DEFAULT_MATERIALIZED_COLUMNS], - ) - self.assertCountEqual( - get_materialized_columns("person", use_cache=True).keys(), - [("$zeta", "properties")], - ) - - materialize("events", "abc", create_minmax_index=True) - - self.assertCountEqual( - [property_name for property_name, _ in get_materialized_columns("events", use_cache=True).keys()], - ["$foo", "$bar", *EVENTS_TABLE_DEFAULT_MATERIALIZED_COLUMNS], - ) - - with freeze_time("2020-01-04T14:00:01Z"): - self.assertCountEqual( - [property_name for property_name, _ in get_materialized_columns("events", use_cache=True).keys()], - ["$foo", "$bar", "abc", *EVENTS_TABLE_DEFAULT_MATERIALIZED_COLUMNS], - ) - - @patch("secrets.choice", return_value="X") - def test_materialized_column_naming(self, mock_choice): - materialize("events", "$foO();--sqlinject", create_minmax_index=True) - mock_choice.return_value = "Y" - materialize("events", "$foO();ääsqlinject", create_minmax_index=True) - mock_choice.return_value = "Z" - materialize("events", "$foO_____sqlinject", create_minmax_index=True) - materialize("person", "SoMePrOp", create_minmax_index=True) - - self.assertDictContainsSubset( - { - ("$foO();--sqlinject", "properties"): "mat_$foO_____sqlinject", - ("$foO();ääsqlinject", "properties"): "mat_$foO_____sqlinject_YYYY", - ("$foO_____sqlinject", "properties"): "mat_$foO_____sqlinject_ZZZZ", - }, - get_materialized_columns("events"), - ) - - self.assertEqual( - get_materialized_columns("person"), - {("SoMePrOp", "properties"): "pmat_SoMePrOp"}, - ) - - def test_backfilling_data(self): - sync_execute("ALTER TABLE events DROP COLUMN IF EXISTS mat_prop") - sync_execute("ALTER TABLE events DROP COLUMN IF EXISTS mat_another") - - _create_event( - event="some_event", - distinct_id="1", - team=self.team, - timestamp="2020-01-01 00:00:00", - properties={"prop": 1}, - ) - _create_event( - event="some_event", - distinct_id="1", - team=self.team, - timestamp="2021-05-02 00:00:00", - properties={"prop": 2, "another": 5}, - ) - _create_event( - event="some_event", - distinct_id="1", - team=self.team, - timestamp="2021-05-03 00:00:00", - properties={"prop": 3}, - ) - _create_event( - event="another_event", - distinct_id="1", - team=self.team, - timestamp="2021-05-04 00:00:00", - ) - _create_event( - event="third_event", - distinct_id="1", - team=self.team, - timestamp="2021-05-05 00:00:00", - properties={"prop": 4}, - ) - _create_event( - event="fourth_event", - distinct_id="1", - team=self.team, - timestamp="2021-05-06 00:00:00", - properties={"another": 6}, - ) - - materialize("events", "prop", create_minmax_index=True) - materialize("events", "another", create_minmax_index=True) - - self.assertEqual(self._count_materialized_rows("mat_prop"), 0) - self.assertEqual(self._count_materialized_rows("mat_another"), 0) - - with freeze_time("2021-05-10T14:00:01Z"): - backfill_materialized_columns( - "events", - [("prop", "properties"), ("another", "properties")], - timedelta(days=50), - test_settings={"mutations_sync": "0"}, - ) - - _create_event( - event="fifth_event", - distinct_id="1", - team=self.team, - timestamp="2021-05-07 00:00:00", - properties={"another": 7}, - ) - - iterations = 0 - while self._get_count_of_mutations_running() > 0 and iterations < 100: - sleep(0.1) - iterations += 1 - - self.assertGreaterEqual(self._count_materialized_rows("mat_prop"), 4) - self.assertGreaterEqual(self._count_materialized_rows("mat_another"), 4) - - self.assertEqual( - sync_execute("SELECT mat_prop, mat_another FROM events ORDER BY timestamp"), - [ - ("1", ""), - ("2", "5"), - ("3", ""), - ("", ""), - ("4", ""), - ("", "6"), - ("", "7"), - ], - ) - - def test_column_types(self): - materialize("events", "myprop", create_minmax_index=True) - - expr = "replaceRegexpAll(JSONExtractRaw(properties, 'myprop'), '^\"|\"$', '')" - self.assertEqual(("MATERIALIZED", expr), self._get_column_types("mat_myprop")) - - backfill_materialized_columns("events", [("myprop", "properties")], timedelta(days=50)) - self.assertEqual(("DEFAULT", expr), self._get_column_types("mat_myprop")) - - try: - from ee.tasks.materialized_columns import mark_all_materialized - except ImportError: - pass - else: - mark_all_materialized() - self.assertEqual(("MATERIALIZED", expr), self._get_column_types("mat_myprop")) - - def _count_materialized_rows(self, column): - return sync_execute( - """ - SELECT sum(rows) - FROM system.parts_columns - WHERE database = %(database)s - AND table = %(table)s - AND column = %(column)s - """, - { - "database": CLICKHOUSE_DATABASE, - "table": EVENTS_DATA_TABLE(), - "column": column, - }, - )[0][0] - - def _get_count_of_mutations_running(self) -> int: - return sync_execute( - """ - SELECT count(*) - FROM system.mutations - WHERE is_done = 0 - """ - )[0][0] - - def _get_column_types(self, column: str): - return sync_execute( - """ - SELECT default_kind, default_expression - FROM system.columns - WHERE database = %(database)s AND table = %(table)s AND name = %(column)s - """, - { - "database": CLICKHOUSE_DATABASE, - "table": EVENTS_DATA_TABLE(), - "column": column, - }, - )[0] diff --git a/ee/clickhouse/materialized_columns/test/test_query.py b/ee/clickhouse/materialized_columns/test/test_query.py deleted file mode 100644 index 3a55a0614f1..00000000000 --- a/ee/clickhouse/materialized_columns/test/test_query.py +++ /dev/null @@ -1,24 +0,0 @@ -from posthog.test.base import APIBaseTest, ClickhouseTestMixin - - -class TestQuery(ClickhouseTestMixin, APIBaseTest): - def test_get_queries_detects(self): - # some random - with self.capture_select_queries() as queries: - self.client.post( - f"/api/projects/{self.team.id}/insights/funnel/", - { - "events": [{"id": "step one", "type": "events", "order": 0}], - "funnel_window_days": 14, - "funnel_order_type": "unordered", - "insight": "funnels", - }, - ).json() - - self.assertTrue(len(queries)) - - # make sure that the queries start with a discoverable prefix. - # If this changes, also update ee/clickhouse/materialized_columns/analyze.py::_get_queries to - # filter on the right queries - for q in queries: - self.assertTrue(q.startswith("/* user_id")) diff --git a/ee/clickhouse/materialized_columns/util.py b/ee/clickhouse/materialized_columns/util.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/clickhouse/models/__init__.py b/ee/clickhouse/models/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/clickhouse/models/group.py b/ee/clickhouse/models/group.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/clickhouse/models/test/__init__.py b/ee/clickhouse/models/test/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/clickhouse/models/test/__snapshots__/test_cohort.ambr b/ee/clickhouse/models/test/__snapshots__/test_cohort.ambr deleted file mode 100644 index 692074cd291..00000000000 --- a/ee/clickhouse/models/test/__snapshots__/test_cohort.ambr +++ /dev/null @@ -1,300 +0,0 @@ -# serializer version: 1 -# name: TestCohort.test_cohortpeople_basic - ''' - /* cohort_calculation: */ - INSERT INTO cohortpeople - SELECT id, - 2 as cohort_id, - 2 as team_id, - 1 AS sign, - 0 AS version - FROM - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND ((((has(['something'], replaceRegexpAll(JSONExtractRaw(properties, '$some_prop'), '^"|"$', '')))) - AND ((has(['something'], replaceRegexpAll(JSONExtractRaw(properties, '$another_prop'), '^"|"$', '')))))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND ((((has(['something'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), '$some_prop'), '^"|"$', '')))) - AND ((has(['something'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), '$another_prop'), '^"|"$', '')))))) SETTINGS optimize_aggregation_in_order = 1) as person - UNION ALL - SELECT person_id, - cohort_id, - team_id, - -1, - version - FROM cohortpeople - WHERE team_id = 2 - AND cohort_id = 2 - AND version < 0 - AND sign = 1 SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohort.test_cohortpeople_with_not_in_cohort_operator - ''' - /* cohort_calculation: */ - INSERT INTO cohortpeople - SELECT id, - 2 as cohort_id, - 2 as team_id, - 1 AS sign, - 0 AS version - FROM - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND ((has(['something1'], replaceRegexpAll(JSONExtractRaw(properties, '$some_prop'), '^"|"$', '')))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND ((has(['something1'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), '$some_prop'), '^"|"$', '')))) SETTINGS optimize_aggregation_in_order = 1) as person - UNION ALL - SELECT person_id, - cohort_id, - team_id, - -1, - version - FROM cohortpeople - WHERE team_id = 2 - AND cohort_id = 2 - AND version < 0 - AND sign = 1 SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohort.test_cohortpeople_with_not_in_cohort_operator.1 - ''' - /* cohort_calculation: */ - INSERT INTO cohortpeople - SELECT id, - 2 as cohort_id, - 2 as team_id, - 1 AS sign, - 0 AS version - FROM - (SELECT person.person_id AS id - FROM - (SELECT pdi.person_id AS person_id, - countIf(timestamp > now() - INTERVAL 2 year - AND timestamp < now() - AND event = '$pageview' - AND 1=1) > 0 AS performed_event_condition_X_level_level_0_level_0_level_0_0 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview'] - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 2 year - GROUP BY person_id) behavior_query - INNER JOIN - (SELECT *, - id AS person_id - FROM - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (((((NOT has(['something1'], replaceRegexpAll(JSONExtractRaw(properties, '$some_prop'), '^"|"$', ''))))))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (((((NOT has(['something1'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), '$some_prop'), '^"|"$', ''))))))) SETTINGS optimize_aggregation_in_order = 1)) person ON person.person_id = behavior_query.person_id - WHERE 1 = 1 - AND ((((performed_event_condition_X_level_level_0_level_0_level_0_0)))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' ) as person - UNION ALL - SELECT person_id, - cohort_id, - team_id, - -1, - version - FROM cohortpeople - WHERE team_id = 2 - AND cohort_id = 2 - AND version < 0 - AND sign = 1 SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohort.test_cohortpeople_with_not_in_cohort_operator_and_no_precalculation - ''' - SELECT uuid, - distinct_id - FROM events - WHERE team_id = 2 - AND (distinct_id IN - (SELECT distinct_id - FROM - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) - WHERE person_id IN - (SELECT person.person_id AS id - FROM - (SELECT pdi.person_id AS person_id, - countIf(timestamp > now() - INTERVAL 2 year - AND timestamp < now() - AND event = '$pageview' - AND 1=1) > 0 AS performed_event_condition_X_level_level_0_level_0_level_0_0 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview'] - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 2 year - GROUP BY person_id) behavior_query - INNER JOIN - (SELECT *, - id AS person_id - FROM - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (((((NOT has(['something1'], replaceRegexpAll(JSONExtractRaw(properties, '$some_prop'), '^"|"$', ''))))))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (((((NOT has(['something1'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), '$some_prop'), '^"|"$', ''))))))) SETTINGS optimize_aggregation_in_order = 1)) person ON person.person_id = behavior_query.person_id - WHERE 1 = 1 - AND ((((performed_event_condition_X_level_level_0_level_0_level_0_0)))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' ) )) - ''' -# --- -# name: TestCohort.test_cohortpeople_with_not_in_cohort_operator_for_behavioural_cohorts - ''' - /* cohort_calculation: */ - INSERT INTO cohortpeople - SELECT id, - 2 as cohort_id, - 2 as team_id, - 1 AS sign, - 0 AS version - FROM - (SELECT behavior_query.person_id AS id - FROM - (SELECT pdi.person_id AS person_id, - minIf(timestamp, event = 'signup') >= now() - INTERVAL 15 day - AND minIf(timestamp, event = 'signup') < now() as first_time_condition_X_level_level_0_level_0_0 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['signup'] - GROUP BY person_id) behavior_query - WHERE 1 = 1 - AND (((first_time_condition_X_level_level_0_level_0_0))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' ) as person - UNION ALL - SELECT person_id, - cohort_id, - team_id, - -1, - version - FROM cohortpeople - WHERE team_id = 2 - AND cohort_id = 2 - AND version < 0 - AND sign = 1 SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohort.test_cohortpeople_with_not_in_cohort_operator_for_behavioural_cohorts.1 - ''' - /* cohort_calculation: */ - INSERT INTO cohortpeople - SELECT id, - 2 as cohort_id, - 2 as team_id, - 1 AS sign, - 0 AS version - FROM - (SELECT behavior_query.person_id AS id - FROM - (SELECT pdi.person_id AS person_id, - countIf(timestamp > now() - INTERVAL 2 year - AND timestamp < now() - AND event = '$pageview' - AND 1=1) > 0 AS performed_event_condition_X_level_level_0_level_0_level_0_0, - minIf(timestamp, event = 'signup') >= now() - INTERVAL 15 day - AND minIf(timestamp, event = 'signup') < now() as first_time_condition_X_level_level_0_level_1_level_0_level_0_level_0_0 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview', 'signup'] - GROUP BY person_id) behavior_query - WHERE 1 = 1 - AND ((((performed_event_condition_X_level_level_0_level_0_level_0_0)) - AND ((((NOT first_time_condition_X_level_level_0_level_1_level_0_level_0_level_0_0)))))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' ) as person - UNION ALL - SELECT person_id, - cohort_id, - team_id, - -1, - version - FROM cohortpeople - WHERE team_id = 2 - AND cohort_id = 2 - AND version < 0 - AND sign = 1 SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohort.test_static_cohort_precalculated - ''' - - SELECT distinct_id - FROM - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = %(team_id)s - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) - WHERE person_id IN - (SELECT person_id as id - FROM person_static_cohort - WHERE cohort_id = %(_cohort_id_0)s - AND team_id = %(team_id)s) - ''' -# --- diff --git a/ee/clickhouse/models/test/__snapshots__/test_property.ambr b/ee/clickhouse/models/test/__snapshots__/test_property.ambr deleted file mode 100644 index 5b100cbd303..00000000000 --- a/ee/clickhouse/models/test/__snapshots__/test_property.ambr +++ /dev/null @@ -1,155 +0,0 @@ -# serializer version: 1 -# name: TestPropFormat.test_parse_groups - ''' - SELECT uuid - FROM events - WHERE team_id = 2 - AND ((has(['val_1'], replaceRegexpAll(JSONExtractRaw(properties, 'attr_1'), '^"|"$', '')) - AND has(['val_2'], replaceRegexpAll(JSONExtractRaw(properties, 'attr_2'), '^"|"$', ''))) - OR (has(['val_2'], replaceRegexpAll(JSONExtractRaw(properties, 'attr_1'), '^"|"$', '')))) - ''' -# --- -# name: TestPropFormat.test_parse_groups_persons - ''' - SELECT uuid - FROM events - WHERE team_id = 2 - AND ((distinct_id IN - (SELECT distinct_id - FROM - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) - WHERE person_id IN - (SELECT id - FROM - (SELECT id, - argMax(properties, person._timestamp) as properties, - max(is_deleted) as is_deleted - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING is_deleted = 0) - WHERE has(['1@posthog.com'], replaceRegexpAll(JSONExtractRaw(properties, 'email'), '^"|"$', '')) ) )) - OR (distinct_id IN - (SELECT distinct_id - FROM - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) - WHERE person_id IN - (SELECT id - FROM - (SELECT id, - argMax(properties, person._timestamp) as properties, - max(is_deleted) as is_deleted - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING is_deleted = 0) - WHERE has(['2@posthog.com'], replaceRegexpAll(JSONExtractRaw(properties, 'email'), '^"|"$', '')) ) ))) - ''' -# --- -# name: test_parse_groups_persons_edge_case_with_single_filter - tuple( - 'AND ( has(%(vglobalperson_0)s, "pmat_email"))', - dict({ - 'kglobalperson_0': 'email', - 'vglobalperson_0': list([ - '1@posthog.com', - ]), - }), - ) -# --- -# name: test_parse_prop_clauses_defaults - tuple( - ''' - AND ( has(%(vglobal_0)s, replaceRegexpAll(JSONExtractRaw(properties, %(kglobal_0)s), '^"|"$', '')) AND distinct_id IN ( - SELECT distinct_id - FROM ( - - SELECT distinct_id, argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = %(team_id)s - - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0 - - ) - WHERE person_id IN - ( - SELECT id - FROM ( - SELECT id, argMax(properties, person._timestamp) as properties, max(is_deleted) as is_deleted - FROM person - WHERE team_id = %(team_id)s - GROUP BY id - HAVING is_deleted = 0 - ) - WHERE replaceRegexpAll(JSONExtractRaw(properties, %(kglobalperson_1)s), '^"|"$', '') ILIKE %(vglobalperson_1)s - ) - )) - ''', - dict({ - 'kglobal_0': 'event_prop', - 'kglobalperson_1': 'email', - 'vglobal_0': list([ - 'value', - ]), - 'vglobalperson_1': '%posthog%', - }), - ) -# --- -# name: test_parse_prop_clauses_defaults.1 - tuple( - 'AND ( has(%(vglobal_0)s, replaceRegexpAll(JSONExtractRaw(properties, %(kglobal_0)s), \'^"|"$\', \'\')) AND replaceRegexpAll(JSONExtractRaw(person_props, %(kglobalperson_1)s), \'^"|"$\', \'\') ILIKE %(vglobalperson_1)s)', - dict({ - 'kglobal_0': 'event_prop', - 'kglobalperson_1': 'email', - 'vglobal_0': list([ - 'value', - ]), - 'vglobalperson_1': '%posthog%', - }), - ) -# --- -# name: test_parse_prop_clauses_defaults.2 - tuple( - 'AND ( has(%(vglobal_0)s, replaceRegexpAll(JSONExtractRaw(properties, %(kglobal_0)s), \'^"|"$\', \'\')) AND argMax(person."pmat_email", version) ILIKE %(vpersonquery_global_1)s)', - dict({ - 'kglobal_0': 'event_prop', - 'kpersonquery_global_1': 'email', - 'vglobal_0': list([ - 'value', - ]), - 'vpersonquery_global_1': '%posthog%', - }), - ) -# --- -# name: test_parse_prop_clauses_funnel_step_element_prepend_regression - tuple( - 'AND ( (match(elements_chain, %(PREPEND__text_0_attributes_regex)s)))', - dict({ - 'PREPEND__text_0_attributes_regex': '(text="Insights1")', - }), - ) -# --- -# name: test_parse_prop_clauses_precalculated_cohort - tuple( - ''' - AND ( pdi.person_id IN ( - SELECT DISTINCT person_id FROM cohortpeople WHERE team_id = %(team_id)s AND cohort_id = %(global_cohort_id_0)s AND version = %(global_version_0)s - )) - ''', - dict({ - 'global_cohort_id_0': 42, - 'global_version_0': None, - }), - ) -# --- diff --git a/ee/clickhouse/models/test/test_action.py b/ee/clickhouse/models/test/test_action.py deleted file mode 100644 index 1c32ece5e1e..00000000000 --- a/ee/clickhouse/models/test/test_action.py +++ /dev/null @@ -1,317 +0,0 @@ -import dataclasses - -from posthog.client import sync_execute -from posthog.hogql.bytecode import create_bytecode -from posthog.hogql.hogql import HogQLContext -from posthog.hogql.property import action_to_expr -from posthog.models.action import Action -from posthog.models.action.util import filter_event, format_action_filter -from posthog.models.test.test_event_model import filter_by_actions_factory -from posthog.test.base import ( - BaseTest, - ClickhouseTestMixin, - _create_event, - _create_person, -) -from hogvm.python.operation import Operation as op, HOGQL_BYTECODE_IDENTIFIER as _H - - -@dataclasses.dataclass -class MockEvent: - uuid: str - distinct_id: str - - -def _get_events_for_action(action: Action) -> list[MockEvent]: - hogql_context = HogQLContext(team_id=action.team_id) - formatted_query, params = format_action_filter( - team_id=action.team_id, action=action, prepend="", hogql_context=hogql_context - ) - query = f""" - SELECT - events.uuid, - events.distinct_id - FROM events - WHERE {formatted_query} - AND events.team_id = %(team_id)s - ORDER BY events.timestamp DESC - """ - events = sync_execute( - query, - {"team_id": action.team_id, **params, **hogql_context.values}, - team_id=action.team_id, - ) - return [MockEvent(str(uuid), distinct_id) for uuid, distinct_id in events] - - -EVENT_UUID_QUERY = "SELECT uuid FROM events WHERE {} AND team_id = %(team_id)s" - - -class TestActions( - ClickhouseTestMixin, - filter_by_actions_factory(_create_event, _create_person, _get_events_for_action), # type: ignore -): - pass - - -class TestActionFormat(ClickhouseTestMixin, BaseTest): - def test_filter_event_exact_url(self): - event_target_uuid = _create_event( - event="$autocapture", - team=self.team, - distinct_id="whatever", - properties={"$current_url": "https://posthog.com/feedback/123"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"$current_url": "https://posthog.com/feedback/123"}, - ) - - _create_event( - event="$autocapture", - team=self.team, - distinct_id="whatever", - properties={"$current_url": "https://posthog.com/feedback/1234"}, - ) - - action1 = Action.objects.create( - team=self.team, - name="action1", - steps_json=[ - { - "event": "$autocapture", - "url": "https://posthog.com/feedback/123", - "url_matching": "exact", - } - ], - ) - query, params = filter_event(action1.steps[0]) - - full_query = EVENT_UUID_QUERY.format(" AND ".join(query)) - result = sync_execute(full_query, {**params, "team_id": self.team.pk}, team_id=self.team.pk) - - self.assertEqual(len(result), 1) - self.assertCountEqual( - [str(r[0]) for r in result], - [event_target_uuid], - ) - - def test_filter_event_exact_url_with_query_params(self): - first_match_uuid = _create_event( - event="$autocapture", - team=self.team, - distinct_id="whatever", - properties={"$current_url": "https://posthog.com/feedback/123?vip=1"}, - ) - - second_match_uuid = _create_event( - event="$autocapture", - team=self.team, - distinct_id="whatever", - properties={"$current_url": "https://posthog.com/feedback/123?vip=1"}, - ) - - _create_event( - event="$autocapture", - team=self.team, - distinct_id="whatever", - properties={"$current_url": "https://posthog.com/feedback/123?vip=0"}, - ) - - action1 = Action.objects.create( - team=self.team, - name="action1", - steps_json=[ - { - "event": "$autocapture", - "url": "https://posthog.com/feedback/123?vip=1", - "url_matching": "exact", - } - ], - ) - query, params = filter_event(action1.steps[0]) - - full_query = EVENT_UUID_QUERY.format(" AND ".join(query)) - result = sync_execute(full_query, {**params, "team_id": self.team.pk}, team_id=self.team.pk) - - self.assertEqual(len(result), 2) - self.assertCountEqual( - [str(r[0]) for r in result], - [first_match_uuid, second_match_uuid], - ) - - def test_filter_event_contains_url(self): - _create_event( - event="$autocapture", - team=self.team, - distinct_id="whatever", - properties={"$current_url": "https://posthog.com/feedback/123"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"$current_url": "https://posthog.com/feedback/123"}, - ) - - _create_event( - event="$autocapture", - team=self.team, - distinct_id="whatever", - properties={"$current_url": "https://posthog.com/feedback/1234"}, - ) - - action1 = Action.objects.create( - team=self.team, - name="action1", - steps_json=[{"event": "$autocapture", "url": "https://posthog.com/feedback/123"}], - ) - query, params = filter_event(action1.steps[0]) - - full_query = EVENT_UUID_QUERY.format(" AND ".join(query)) - result = sync_execute(full_query, {**params, "team_id": self.team.pk}, team_id=self.team.pk) - self.assertEqual(len(result), 2) - - def test_filter_event_regex_url(self): - _create_event( - event="$autocapture", - team=self.team, - distinct_id="whatever", - properties={"$current_url": "https://posthog.com/feedback/123"}, - ) - - _create_event( - event="$autocapture", - team=self.team, - distinct_id="whatever", - properties={"$current_url": "https://test.com/feedback"}, - ) - - _create_event( - event="$autocapture", - team=self.team, - distinct_id="whatever", - properties={"$current_url": "https://posthog.com/feedback/1234"}, - ) - - action1 = Action.objects.create( - team=self.team, - name="action1", - steps_json=[ - { - "event": "$autocapture", - "url": "/123", - "url_matching": "regex", - } - ], - ) - query, params = filter_event(action1.steps[0]) - - full_query = EVENT_UUID_QUERY.format(" AND ".join(query)) - result = sync_execute(full_query, {**params, "team_id": self.team.pk}, team_id=self.team.pk) - self.assertEqual(len(result), 2) - - def test_double(self): - # Tests a regression where the second step properties would override those of the first step, causing issues - _create_event( - event="insight viewed", - team=self.team, - distinct_id="whatever", - properties={"filters_count": 2}, - ) - - action1 = Action.objects.create( - team=self.team, - name="action1", - steps_json=[ - { - "event": "insight viewed", - "properties": [ - { - "key": "insight", - "type": "event", - "value": ["RETENTION"], - "operator": "exact", - } - ], - }, - { - "event": "insight viewed", - "properties": [ - { - "key": "filters_count", - "type": "event", - "value": "1", - "operator": "gt", - } - ], - }, - ], - ) - - events = _get_events_for_action(action1) - self.assertEqual(len(events), 1) - - def test_filter_with_hogql(self): - _create_event( - event="insight viewed", - team=self.team, - distinct_id="first", - properties={"filters_count": 20}, - ) - _create_event( - event="insight viewed", - team=self.team, - distinct_id="second", - properties={"filters_count": 1}, - ) - - action1 = Action.objects.create( - team=self.team, - name="action1", - steps_json=[ - { - "event": "insight viewed", - "properties": [{"key": "toInt(properties.filters_count) > 10", "type": "hogql"}], - } - ], - ) - - events = _get_events_for_action(action1) - self.assertEqual(len(events), 1) - - self.assertEqual(action1.bytecode, create_bytecode(action_to_expr(action1))) - self.assertEqual( - action1.bytecode, - [ - _H, - # toInt(properties.filters_count) > 10 - op.INTEGER, - 10, - op.STRING, - "filters_count", - op.STRING, - "properties", - op.GET_GLOBAL, - 2, - op.CALL, - "toInt", - 1, - op.GT, - # event = 'insight viewed' - op.STRING, - "insight viewed", - op.STRING, - "event", - op.GET_GLOBAL, - 1, - op.EQ, - # and - op.AND, - 2, - ], - ) diff --git a/ee/clickhouse/models/test/test_cohort.py b/ee/clickhouse/models/test/test_cohort.py deleted file mode 100644 index 8af41154c48..00000000000 --- a/ee/clickhouse/models/test/test_cohort.py +++ /dev/null @@ -1,1372 +0,0 @@ -from datetime import datetime, timedelta - -from django.utils import timezone -from freezegun import freeze_time - -from posthog.client import sync_execute -from posthog.hogql.hogql import HogQLContext -from posthog.models.action import Action -from posthog.models.cohort import Cohort -from posthog.models.cohort.sql import GET_COHORTPEOPLE_BY_COHORT_ID -from posthog.models.cohort.util import format_filter_query, get_person_ids_by_cohort_id -from posthog.models.filters import Filter -from posthog.models.organization import Organization -from posthog.models.person import Person -from posthog.models.property.util import parse_prop_grouped_clauses -from posthog.models.team import Team -from posthog.queries.util import PersonPropertiesMode -from posthog.schema import PersonsOnEventsMode -from posthog.test.base import ( - BaseTest, - ClickhouseTestMixin, - _create_event, - _create_person, - flush_persons_and_events, - snapshot_clickhouse_insert_cohortpeople_queries, - snapshot_clickhouse_queries, -) - - -def _create_action(**kwargs): - team = kwargs.pop("team") - name = kwargs.pop("name") - action = Action.objects.create(team=team, name=name, steps_json=[{"event": name}]) - return action - - -class TestCohort(ClickhouseTestMixin, BaseTest): - def _get_cohortpeople(self, cohort: Cohort): - return sync_execute( - GET_COHORTPEOPLE_BY_COHORT_ID, - { - "team_id": self.team.pk, - "cohort_id": cohort.pk, - "version": cohort.version, - }, - ) - - def test_prop_cohort_basic(self): - _create_person( - distinct_ids=["some_other_id"], - team_id=self.team.pk, - properties={"$some_prop": "something"}, - ) - - _create_person( - distinct_ids=["some_id"], - team_id=self.team.pk, - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - _create_person(distinct_ids=["no_match"], team_id=self.team.pk) - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_id", - properties={"attr": "some_val"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_other_id", - properties={"attr": "some_val"}, - ) - - cohort1 = Cohort.objects.create( - team=self.team, - groups=[ - { - "properties": [ - {"key": "$some_prop", "value": "something", "type": "person"}, - { - "key": "$another_prop", - "value": "something", - "type": "person", - }, - ] - } - ], - name="cohort1", - ) - - filter = Filter(data={"properties": [{"key": "id", "value": cohort1.pk, "type": "cohort"}]}) - query, params = parse_prop_grouped_clauses( - team_id=self.team.pk, - property_group=filter.property_groups, - hogql_context=filter.hogql_context, - ) - final_query = "SELECT uuid FROM events WHERE team_id = %(team_id)s {}".format(query) - result = sync_execute( - final_query, - {**params, **filter.hogql_context.values, "team_id": self.team.pk}, - ) - self.assertEqual(len(result), 1) - - def test_prop_cohort_basic_action(self): - _create_person( - distinct_ids=["some_other_id"], - team_id=self.team.pk, - properties={"$some_prop": "something"}, - ) - - _create_person( - distinct_ids=["some_id"], - team_id=self.team.pk, - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - _create_person(distinct_ids=["no_match"], team_id=self.team.pk) - - action = _create_action(team=self.team, name="$pageview") - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_id", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=1), - ) - - _create_event( - event="$not_pageview", - team=self.team, - distinct_id="some_other_id", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=2), - ) - - cohort1 = Cohort.objects.create(team=self.team, groups=[{"action_id": action.pk, "days": 3}], name="cohort1") - - filter = Filter( - data={"properties": [{"key": "id", "value": cohort1.pk, "type": "cohort"}]}, - team=self.team, - ) - query, params = parse_prop_grouped_clauses( - team_id=self.team.pk, - property_group=filter.property_groups, - person_properties_mode=( - PersonPropertiesMode.USING_SUBQUERY - if self.team.person_on_events_mode == PersonsOnEventsMode.DISABLED - else PersonPropertiesMode.DIRECT_ON_EVENTS - ), - hogql_context=filter.hogql_context, - ) - final_query = "SELECT uuid FROM events WHERE team_id = %(team_id)s {}".format(query) - result = sync_execute( - final_query, - {**params, **filter.hogql_context.values, "team_id": self.team.pk}, - ) - - self.assertEqual(len(result), 1) - - def test_prop_cohort_basic_event_days(self): - _create_person( - distinct_ids=["some_other_id"], - team_id=self.team.pk, - properties={"$some_prop": "something"}, - ) - - _create_person( - distinct_ids=["some_id"], - team_id=self.team.pk, - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_id", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=0, hours=12), - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_other_id", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=4, hours=12), - ) - - cohort1 = Cohort.objects.create( - team=self.team, - groups=[{"event_id": "$pageview", "days": 1}], - name="cohort1", - ) - - filter = Filter( - data={"properties": [{"key": "id", "value": cohort1.pk, "type": "cohort"}]}, - team=self.team, - ) - query, params = parse_prop_grouped_clauses( - team_id=self.team.pk, - property_group=filter.property_groups, - person_properties_mode=( - PersonPropertiesMode.USING_SUBQUERY - if self.team.person_on_events_mode == PersonsOnEventsMode.DISABLED - else PersonPropertiesMode.DIRECT_ON_EVENTS - ), - hogql_context=filter.hogql_context, - ) - final_query = "SELECT uuid FROM events WHERE team_id = %(team_id)s {}".format(query) - result = sync_execute( - final_query, - {**params, **filter.hogql_context.values, "team_id": self.team.pk}, - ) - self.assertEqual(len(result), 1) - - cohort2 = Cohort.objects.create( - team=self.team, - groups=[{"event_id": "$pageview", "days": 7}], - name="cohort2", - ) - - filter = Filter( - data={"properties": [{"key": "id", "value": cohort2.pk, "type": "cohort"}]}, - team=self.team, - ) - query, params = parse_prop_grouped_clauses( - team_id=self.team.pk, - property_group=filter.property_groups, - person_properties_mode=( - PersonPropertiesMode.USING_SUBQUERY - if self.team.person_on_events_mode == PersonsOnEventsMode.DISABLED - else PersonPropertiesMode.DIRECT_ON_EVENTS - ), - hogql_context=filter.hogql_context, - ) - final_query = "SELECT uuid FROM events WHERE team_id = %(team_id)s {}".format(query) - result = sync_execute( - final_query, - {**params, **filter.hogql_context.values, "team_id": self.team.pk}, - ) - self.assertEqual(len(result), 2) - - def test_prop_cohort_basic_action_days(self): - _create_person( - distinct_ids=["some_other_id"], - team_id=self.team.pk, - properties={"$some_prop": "something"}, - ) - - _create_person( - distinct_ids=["some_id"], - team_id=self.team.pk, - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - - action = _create_action(team=self.team, name="$pageview") - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_id", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(hours=22), - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_other_id", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=5), - ) - - cohort1 = Cohort.objects.create(team=self.team, groups=[{"action_id": action.pk, "days": 1}], name="cohort1") - - filter = Filter( - data={"properties": [{"key": "id", "value": cohort1.pk, "type": "cohort"}]}, - team=self.team, - ) - query, params = parse_prop_grouped_clauses( - team_id=self.team.pk, - property_group=filter.property_groups, - person_properties_mode=( - PersonPropertiesMode.USING_SUBQUERY - if self.team.person_on_events_mode == PersonsOnEventsMode.DISABLED - else PersonPropertiesMode.DIRECT_ON_EVENTS - ), - hogql_context=filter.hogql_context, - ) - final_query = "SELECT uuid FROM events WHERE team_id = %(team_id)s {}".format(query) - result = sync_execute( - final_query, - {**params, **filter.hogql_context.values, "team_id": self.team.pk}, - ) - self.assertEqual(len(result), 1) - - cohort2 = Cohort.objects.create(team=self.team, groups=[{"action_id": action.pk, "days": 7}], name="cohort2") - - filter = Filter( - data={"properties": [{"key": "id", "value": cohort2.pk, "type": "cohort"}]}, - team=self.team, - ) - query, params = parse_prop_grouped_clauses( - team_id=self.team.pk, - property_group=filter.property_groups, - person_properties_mode=( - PersonPropertiesMode.USING_SUBQUERY - if self.team.person_on_events_mode == PersonsOnEventsMode.DISABLED - else PersonPropertiesMode.DIRECT_ON_EVENTS - ), - hogql_context=filter.hogql_context, - ) - final_query = "SELECT uuid FROM events WHERE team_id = %(team_id)s {}".format(query) - result = sync_execute( - final_query, - {**params, **filter.hogql_context.values, "team_id": self.team.pk}, - ) - self.assertEqual(len(result), 2) - - def test_prop_cohort_multiple_groups(self): - _create_person( - distinct_ids=["some_other_id"], - team_id=self.team.pk, - properties={"$some_prop": "something"}, - ) - - _create_person( - distinct_ids=["some_id"], - team_id=self.team.pk, - properties={"$another_prop": "something"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_id", - properties={"attr": "some_val"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_other_id", - properties={"attr": "some_val"}, - ) - - cohort1 = Cohort.objects.create( - team=self.team, - groups=[ - {"properties": [{"key": "$some_prop", "value": "something", "type": "person"}]}, - {"properties": [{"key": "$another_prop", "value": "something", "type": "person"}]}, - ], - name="cohort1", - ) - - filter = Filter( - data={"properties": [{"key": "id", "value": cohort1.pk, "type": "cohort"}]}, - team=self.team, - ) - query, params = parse_prop_grouped_clauses( - team_id=self.team.pk, - property_group=filter.property_groups, - hogql_context=filter.hogql_context, - ) - final_query = "SELECT uuid FROM events WHERE team_id = %(team_id)s {}".format(query) - result = sync_execute( - final_query, - {**params, **filter.hogql_context.values, "team_id": self.team.pk}, - ) - self.assertEqual(len(result), 2) - - def test_prop_cohort_with_negation(self): - team2 = Organization.objects.bootstrap(None)[2] - - _create_person( - distinct_ids=["some_other_id"], - team_id=self.team.pk, - properties={"$some_prop": "something"}, - ) - - _create_person( - distinct_ids=["some_id"], - team_id=team2.pk, - properties={"$another_prop": "something"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_id", - properties={"attr": "some_val"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_other_id", - properties={"attr": "some_val"}, - ) - - cohort1 = Cohort.objects.create( - team=self.team, - groups=[ - { - "properties": [ - { - "type": "person", - "key": "$some_prop", - "operator": "is_not", - "value": "something", - } - ] - } - ], - name="cohort1", - ) - - filter = Filter( - data={"properties": [{"key": "id", "value": cohort1.pk, "type": "cohort"}]}, - team=self.team, - ) - query, params = parse_prop_grouped_clauses( - team_id=self.team.pk, - property_group=filter.property_groups, - hogql_context=filter.hogql_context, - ) - final_query = "SELECT uuid FROM events WHERE team_id = %(team_id)s {}".format(query) - self.assertIn("\nFROM person_distinct_id2\n", final_query) - - result = sync_execute( - final_query, - {**params, **filter.hogql_context.values, "team_id": self.team.pk}, - ) - self.assertEqual(len(result), 0) - - def test_cohort_get_person_ids_by_cohort_id(self): - user1 = _create_person( - distinct_ids=["user1"], - team_id=self.team.pk, - properties={"$some_prop": "something"}, - ) - _create_person( - distinct_ids=["user2"], - team_id=self.team.pk, - properties={"$some_prop": "another"}, - ) - user3 = _create_person( - distinct_ids=["user3"], - team_id=self.team.pk, - properties={"$some_prop": "something"}, - ) - cohort = Cohort.objects.create( - team=self.team, - groups=[{"properties": [{"key": "$some_prop", "value": "something", "type": "person"}]}], - name="cohort1", - ) - - results = get_person_ids_by_cohort_id(self.team, cohort.id) - self.assertEqual(len(results), 2) - self.assertIn(str(user1.uuid), results) - self.assertIn(str(user3.uuid), results) - - def test_insert_by_distinct_id_or_email(self): - Person.objects.create(team_id=self.team.pk, distinct_ids=["1"]) - Person.objects.create(team_id=self.team.pk, distinct_ids=["123"]) - Person.objects.create(team_id=self.team.pk, distinct_ids=["2"]) - # Team leakage - team2 = Team.objects.create(organization=self.organization) - Person.objects.create(team=team2, distinct_ids=["1"]) - - cohort = Cohort.objects.create(team=self.team, groups=[], is_static=True) - cohort.insert_users_by_list(["1", "123"]) - cohort = Cohort.objects.get() - results = get_person_ids_by_cohort_id(self.team, cohort.id) - self.assertEqual(len(results), 2) - self.assertEqual(cohort.is_calculating, False) - - # test SQLi - Person.objects.create(team_id=self.team.pk, distinct_ids=["'); truncate person_static_cohort; --"]) - cohort.insert_users_by_list(["'); truncate person_static_cohort; --", "123"]) - results = sync_execute( - "select count(1) from person_static_cohort where team_id = %(team_id)s", - {"team_id": self.team.pk}, - )[0][0] - self.assertEqual(results, 3) - - #  If we accidentally call calculate_people it shouldn't erase people - cohort.calculate_people_ch(pending_version=0) - results = get_person_ids_by_cohort_id(self.team, cohort.id) - self.assertEqual(len(results), 3) - - # if we add people again, don't increase the number of people in cohort - cohort.insert_users_by_list(["123"]) - results = get_person_ids_by_cohort_id(self.team, cohort.id) - self.assertEqual(len(results), 3) - - @snapshot_clickhouse_insert_cohortpeople_queries - def test_cohortpeople_basic(self): - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["1"], - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["2"], - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - - cohort1 = Cohort.objects.create( - team=self.team, - groups=[ - { - "properties": [ - {"key": "$some_prop", "value": "something", "type": "person"}, - { - "key": "$another_prop", - "value": "something", - "type": "person", - }, - ] - } - ], - name="cohort1", - ) - - cohort1.calculate_people_ch(pending_version=0) - - results = self._get_cohortpeople(cohort1) - self.assertEqual(len(results), 2) - - def test_cohortpeople_action_basic(self): - action = _create_action(team=self.team, name="$pageview") - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["1"], - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="1", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(hours=12), - ) - - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["2"], - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="2", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(hours=12), - ) - - cohort1 = Cohort.objects.create(team=self.team, groups=[{"action_id": action.pk, "days": 1}], name="cohort1") - cohort1.calculate_people_ch(pending_version=0) - - results = self._get_cohortpeople(cohort1) - self.assertEqual(len(results), 2) - - cohort2 = Cohort.objects.create(team=self.team, groups=[{"action_id": action.pk, "days": 1}], name="cohort2") - cohort2.calculate_people_ch(pending_version=0) - - results = self._get_cohortpeople(cohort2) - self.assertEqual(len(results), 2) - - def _setup_actions_with_different_counts(self): - action = _create_action(team=self.team, name="$pageview") - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["1"], - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="1", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=1, hours=12), - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="1", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=0, hours=12), - ) - - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["2"], - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="2", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=1, hours=12), - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="2", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=0, hours=12), - ) - - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["3"], - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="3", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=0, hours=12), - ) - - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["4"], - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["5"], - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - return action - - def test_cohortpeople_action_count(self): - action = self._setup_actions_with_different_counts() - - # test operators - cohort1 = Cohort.objects.create( - team=self.team, - groups=[{"action_id": action.pk, "days": 3, "count": 2, "count_operator": "gte"}], - name="cohort1", - ) - cohort1.calculate_people_ch(pending_version=0) - - results = self._get_cohortpeople(cohort1) - self.assertEqual(len(results), 2) - - cohort2 = Cohort.objects.create( - team=self.team, - groups=[{"action_id": action.pk, "days": 3, "count": 1, "count_operator": "lte"}], - name="cohort2", - ) - cohort2.calculate_people_ch(pending_version=0) - - results = self._get_cohortpeople(cohort2) - self.assertEqual(len(results), 1) - - cohort3 = Cohort.objects.create( - team=self.team, - groups=[{"action_id": action.pk, "days": 3, "count": 1, "count_operator": "eq"}], - name="cohort3", - ) - cohort3.calculate_people_ch(pending_version=0) - - results = self._get_cohortpeople(cohort3) - self.assertEqual(len(results), 1) - - def test_cohortpeople_deleted_person(self): - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["1"], - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - p2 = Person.objects.create( - team_id=self.team.pk, - distinct_ids=["2"], - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - - cohort1 = Cohort.objects.create( - team=self.team, - groups=[ - { - "properties": [ - {"key": "$some_prop", "value": "something", "type": "person"}, - { - "key": "$another_prop", - "value": "something", - "type": "person", - }, - ] - } - ], - name="cohort1", - ) - - cohort1.calculate_people_ch(pending_version=0) - p2.delete() - cohort1.calculate_people_ch(pending_version=0) - - def test_cohortpeople_prop_changed(self): - with freeze_time((datetime.now() - timedelta(days=3)).strftime("%Y-%m-%d")): - p1 = Person.objects.create( - team_id=self.team.pk, - distinct_ids=["1"], - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - p2 = Person.objects.create( - team_id=self.team.pk, - distinct_ids=["2"], - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - - cohort1 = Cohort.objects.create( - team=self.team, - groups=[ - { - "properties": [ - { - "key": "$some_prop", - "value": "something", - "type": "person", - }, - { - "key": "$another_prop", - "value": "something", - "type": "person", - }, - ] - } - ], - name="cohort1", - ) - - cohort1.calculate_people_ch(pending_version=0) - - with freeze_time((datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d")): - p2.version = 1 - p2.properties = ({"$some_prop": "another", "$another_prop": "another"},) - p2.save() - - cohort1.calculate_people_ch(pending_version=1) - - results = self._get_cohortpeople(cohort1) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0][0], p1.uuid) - - def test_cohort_change(self): - p1 = Person.objects.create( - team_id=self.team.pk, - distinct_ids=["1"], - properties={"$some_prop": "something", "$another_prop": "something"}, - ) - p2 = Person.objects.create( - team_id=self.team.pk, - distinct_ids=["2"], - properties={"$some_prop": "another", "$another_prop": "another"}, - ) - - cohort1 = Cohort.objects.create( - team=self.team, - groups=[ - { - "properties": [ - {"key": "$some_prop", "value": "something", "type": "person"}, - { - "key": "$another_prop", - "value": "something", - "type": "person", - }, - ] - } - ], - name="cohort1", - ) - cohort1.calculate_people_ch(pending_version=0) - results = self._get_cohortpeople(cohort1) - - self.assertEqual(len(results), 1) - self.assertEqual(results[0][0], p1.uuid) - - cohort1.groups = [ - { - "properties": [ - {"key": "$some_prop", "value": "another", "type": "person"}, - {"key": "$another_prop", "value": "another", "type": "person"}, - ] - } - ] - cohort1.save() - - cohort1.calculate_people_ch(pending_version=1) - - results = self._get_cohortpeople(cohort1) - self.assertEqual(len(results), 1) - self.assertEqual(results[0][0], p2.uuid) - - def test_static_cohort_precalculated(self): - Person.objects.create(team_id=self.team.pk, distinct_ids=["1"]) - Person.objects.create(team_id=self.team.pk, distinct_ids=["123"]) - Person.objects.create(team_id=self.team.pk, distinct_ids=["2"]) - # Team leakage - team2 = Team.objects.create(organization=self.organization) - Person.objects.create(team=team2, distinct_ids=["1"]) - - cohort = Cohort.objects.create(team=self.team, groups=[], is_static=True, last_calculation=timezone.now()) - cohort.insert_users_by_list(["1", "123"]) - - cohort.calculate_people_ch(pending_version=0) - - with self.settings(USE_PRECALCULATED_CH_COHORT_PEOPLE=True): - sql, _ = format_filter_query(cohort, 0, HogQLContext(team_id=self.team.pk)) - self.assertQueryMatchesSnapshot(sql) - - def test_cohortpeople_with_valid_other_cohort_filter(self): - Person.objects.create(team_id=self.team.pk, distinct_ids=["1"], properties={"foo": "bar"}) - Person.objects.create(team_id=self.team.pk, distinct_ids=["2"], properties={"foo": "non"}) - - cohort0: Cohort = Cohort.objects.create( - team=self.team, - groups=[{"properties": [{"key": "foo", "value": "bar", "type": "person"}]}], - name="cohort0", - ) - cohort0.calculate_people_ch(pending_version=0) - - cohort1: Cohort = Cohort.objects.create( - team=self.team, - groups=[{"properties": [{"key": "id", "type": "cohort", "value": cohort0.id}]}], - name="cohort1", - ) - - cohort1.calculate_people_ch(pending_version=0) - - res = self._get_cohortpeople(cohort1) - self.assertEqual(len(res), 1) - - @snapshot_clickhouse_insert_cohortpeople_queries - def test_cohortpeople_with_not_in_cohort_operator(self): - _create_person( - distinct_ids=["1"], - team_id=self.team.pk, - properties={"$some_prop": "something1"}, - ) - _create_person( - distinct_ids=["2"], - team_id=self.team.pk, - properties={"$some_prop": "something2"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="1", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=10), - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="2", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=20), - ) - - flush_persons_and_events() - - cohort0: Cohort = Cohort.objects.create( - team=self.team, - groups=[{"properties": [{"key": "$some_prop", "value": "something1", "type": "person"}]}], - name="cohort0", - ) - cohort0.calculate_people_ch(pending_version=0) - - cohort1 = Cohort.objects.create( - team=self.team, - filters={ - "properties": { - "type": "AND", - "values": [ - { - "event_type": "events", - "key": "$pageview", - "negation": False, - "time_interval": "year", - "time_value": 2, - "type": "behavioral", - "value": "performed_event", - }, - { - "key": "id", - "negation": True, - "type": "cohort", - "value": cohort0.pk, - }, - ], - } - }, - name="cohort1", - ) - - cohort1.calculate_people_ch(pending_version=0) - - with self.settings(USE_PRECALCULATED_CH_COHORT_PEOPLE=True): - filter = Filter( - data={ - "properties": [ - { - "key": "id", - "value": cohort1.pk, - "type": "precalculated-cohort", - } - ] - }, - team=self.team, - ) - query, params = parse_prop_grouped_clauses( - team_id=self.team.pk, - property_group=filter.property_groups, - hogql_context=filter.hogql_context, - ) - final_query = "SELECT uuid, distinct_id FROM events WHERE team_id = %(team_id)s {}".format(query) - - result = sync_execute( - final_query, - {**params, **filter.hogql_context.values, "team_id": self.team.pk}, - ) - - self.assertEqual(len(result), 1) - self.assertEqual(result[0][1], "2") # distinct_id '2' is the one in cohort - - @snapshot_clickhouse_queries - def test_cohortpeople_with_not_in_cohort_operator_and_no_precalculation(self): - _create_person( - distinct_ids=["1"], - team_id=self.team.pk, - properties={"$some_prop": "something1"}, - ) - _create_person( - distinct_ids=["2"], - team_id=self.team.pk, - properties={"$some_prop": "something2"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="1", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=10), - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="2", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=20), - ) - - flush_persons_and_events() - - cohort0: Cohort = Cohort.objects.create( - team=self.team, - groups=[{"properties": [{"key": "$some_prop", "value": "something1", "type": "person"}]}], - name="cohort0", - ) - - cohort1 = Cohort.objects.create( - team=self.team, - filters={ - "properties": { - "type": "AND", - "values": [ - { - "event_type": "events", - "key": "$pageview", - "negation": False, - "time_interval": "year", - "time_value": 2, - "type": "behavioral", - "value": "performed_event", - }, - { - "key": "id", - "negation": True, - "type": "cohort", - "value": cohort0.pk, - }, - ], - } - }, - name="cohort1", - ) - - filter = Filter( - data={"properties": [{"key": "id", "value": cohort1.pk, "type": "cohort"}]}, - team=self.team, - ) - query, params = parse_prop_grouped_clauses( - team_id=self.team.pk, - property_group=filter.property_groups, - hogql_context=filter.hogql_context, - ) - final_query = "SELECT uuid, distinct_id FROM events WHERE team_id = %(team_id)s {}".format(query) - self.assertIn("\nFROM person_distinct_id2\n", final_query) - - result = sync_execute( - final_query, - {**params, **filter.hogql_context.values, "team_id": self.team.pk}, - ) - self.assertEqual(len(result), 1) - self.assertEqual(result[0][1], "2") # distinct_id '2' is the one in cohort - - @snapshot_clickhouse_insert_cohortpeople_queries - def test_cohortpeople_with_not_in_cohort_operator_for_behavioural_cohorts(self): - _create_person( - distinct_ids=["1"], - team_id=self.team.pk, - properties={"$some_prop": "something"}, - ) - _create_person( - distinct_ids=["2"], - team_id=self.team.pk, - properties={"$some_prop": "something"}, - ) - - _create_event( - event="signup", - team=self.team, - distinct_id="1", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=10), - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="1", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=10), - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="2", - properties={"attr": "some_val"}, - timestamp=datetime.now() - timedelta(days=20), - ) - flush_persons_and_events() - - cohort0: Cohort = Cohort.objects.create( - team=self.team, - groups=[ - { - "properties": [ - { - "event_type": "events", - "key": "signup", - "negation": False, - "time_interval": "day", - "time_value": 15, - "type": "behavioral", - "value": "performed_event_first_time", - }, - ] - } - ], - name="cohort0", - ) - cohort0.calculate_people_ch(pending_version=0) - - cohort1 = Cohort.objects.create( - team=self.team, - filters={ - "properties": { - "type": "AND", - "values": [ - { - "event_type": "events", - "key": "$pageview", - "negation": False, - "time_interval": "year", - "time_value": 2, - "type": "behavioral", - "value": "performed_event", - }, - { - "key": "id", - "negation": True, - "type": "cohort", - "value": cohort0.pk, - }, - ], - } - }, - name="cohort1", - ) - - cohort1.calculate_people_ch(pending_version=0) - - with self.settings(USE_PRECALCULATED_CH_COHORT_PEOPLE=True): - filter = Filter( - data={"properties": [{"key": "id", "value": cohort1.pk, "type": "cohort"}]}, - team=self.team, - ) - query, params = parse_prop_grouped_clauses( - team_id=self.team.pk, - property_group=filter.property_groups, - hogql_context=filter.hogql_context, - ) - final_query = "SELECT uuid, distinct_id FROM events WHERE team_id = %(team_id)s {}".format(query) - - result = sync_execute( - final_query, - {**params, **filter.hogql_context.values, "team_id": self.team.pk}, - ) - - self.assertEqual(len(result), 1) - self.assertEqual(result[0][1], "2") # distinct_id '2' is the one in cohort - - def test_cohortpeople_with_nonexistent_other_cohort_filter(self): - Person.objects.create(team_id=self.team.pk, distinct_ids=["1"], properties={"foo": "bar"}) - Person.objects.create(team_id=self.team.pk, distinct_ids=["2"], properties={"foo": "non"}) - - cohort1: Cohort = Cohort.objects.create( - team=self.team, - groups=[{"properties": [{"key": "id", "type": "cohort", "value": 666}]}], - name="cohort1", - ) - - cohort1.calculate_people_ch(pending_version=0) - - res = self._get_cohortpeople(cohort1) - self.assertEqual(len(res), 0) - - def test_clickhouse_empty_query(self): - cohort2 = Cohort.objects.create( - team=self.team, - groups=[{"properties": [{"key": "$some_prop", "value": "nomatchihope", "type": "person"}]}], - name="cohort1", - ) - - cohort2.calculate_people_ch(pending_version=0) - self.assertFalse(Cohort.objects.get().is_calculating) - - def test_query_with_multiple_new_style_cohorts(self): - action1 = Action.objects.create( - team=self.team, - name="action1", - steps_json=[ - { - "event": "$autocapture", - "url": "https://posthog.com/feedback/123", - "url_matching": "exact", - } - ], - ) - - # satiesfies all conditions - p1 = Person.objects.create( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$autocapture", - properties={"$current_url": "https://posthog.com/feedback/123"}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=2), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=1), - ) - - # doesn't satisfy action - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$autocapture", - properties={"$current_url": "https://posthog.com/feedback/123"}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(weeks=3), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=1), - ) - - # satisfies special condition (not pushed down person property in OR group) - p3 = Person.objects.create( - team_id=self.team.pk, - distinct_ids=["p3"], - properties={"name": "special", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$autocapture", - properties={"$current_url": "https://posthog.com/feedback/123"}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=2), - ) - - cohort2 = Cohort.objects.create( - team=self.team, - filters={ - "properties": { - "type": "AND", - "values": [ - { - "key": action1.pk, - "event_type": "actions", - "time_value": 2, - "time_interval": "week", - "value": "performed_event_first_time", - "type": "behavioral", - }, - { - "key": "email", - "value": "test@posthog.com", - "type": "person", - }, # this is pushed down - ], - } - }, - name="cohort2", - ) - - cohort1 = Cohort.objects.create( - team=self.team, - filters={ - "properties": { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "day", - "value": "performed_event", - "type": "behavioral", - }, - { - "key": "$pageview", - "event_type": "events", - "time_value": 2, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - }, - { - "key": "name", - "value": "special", - "type": "person", - }, # this is NOT pushed down - ], - }, - { - "type": "AND", - "values": [{"key": "id", "value": cohort2.pk, "type": "cohort"}], - }, - ], - } - }, - name="cohort1", - ) - - cohort1.calculate_people_ch(pending_version=0) - - result = self._get_cohortpeople(cohort1) - self.assertCountEqual([p1.uuid, p3.uuid], [r[0] for r in result]) - - def test_update_cohort(self): - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["1"], - properties={"$some_prop": "something"}, - ) - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["2"], - properties={"$another_prop": "something"}, - ) - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["3"], - properties={"$another_prop": "something"}, - ) - - cohort1 = Cohort.objects.create( - team=self.team, - groups=[{"properties": [{"key": "$some_prop", "value": "something", "type": "person"}]}], - name="cohort1", - ) - - cohort1.calculate_people_ch(pending_version=0) - - # Should only have p1 in this cohort - results = self._get_cohortpeople(cohort1) - self.assertEqual(len(results), 1) - - cohort1.groups = [{"properties": [{"key": "$another_prop", "value": "something", "type": "person"}]}] - cohort1.save() - cohort1.calculate_people_ch(pending_version=1) - - # Should only have p2, p3 in this cohort - results = self._get_cohortpeople(cohort1) - self.assertEqual(len(results), 2) - - cohort1.groups = [{"properties": [{"key": "$some_prop", "value": "something", "type": "person"}]}] - cohort1.save() - cohort1.calculate_people_ch(pending_version=2) - - # Should only have p1 again in this cohort - results = self._get_cohortpeople(cohort1) - self.assertEqual(len(results), 1) - - def test_cohort_versioning(self): - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["1"], - properties={"$some_prop": "something"}, - ) - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["2"], - properties={"$another_prop": "something"}, - ) - Person.objects.create( - team_id=self.team.pk, - distinct_ids=["3"], - properties={"$another_prop": "something"}, - ) - - # start the cohort at some later version - cohort1 = Cohort.objects.create( - team=self.team, - groups=[{"properties": [{"key": "$some_prop", "value": "something", "type": "person"}]}], - name="cohort1", - ) - - cohort1.calculate_people_ch(pending_version=5) - - cohort1.pending_version = 5 - cohort1.version = 5 - cohort1.save() - - # Should have p1 in this cohort even if version is different - results = self._get_cohortpeople(cohort1) - self.assertEqual(len(results), 1) diff --git a/ee/clickhouse/models/test/test_dead_letter_queue.py b/ee/clickhouse/models/test/test_dead_letter_queue.py deleted file mode 100644 index 220d7ada323..00000000000 --- a/ee/clickhouse/models/test/test_dead_letter_queue.py +++ /dev/null @@ -1,114 +0,0 @@ -import json -from datetime import datetime -from uuid import uuid4 - -from kafka import KafkaProducer - -from ee.clickhouse.models.test.utils.util import ( - delay_until_clickhouse_consumes_from_kafka, -) -from posthog.clickhouse.dead_letter_queue import ( - DEAD_LETTER_QUEUE_TABLE, - DEAD_LETTER_QUEUE_TABLE_MV_SQL, - INSERT_DEAD_LETTER_QUEUE_EVENT_SQL, - KAFKA_DEAD_LETTER_QUEUE_TABLE_SQL, -) -from posthog.client import sync_execute -from posthog.kafka_client.topics import KAFKA_DEAD_LETTER_QUEUE -from posthog.settings import KAFKA_HOSTS -from posthog.test.base import BaseTest, ClickhouseTestMixin - -TEST_EVENT_RAW_PAYLOAD = json.dumps({"event": "some event", "properties": {"distinct_id": 2, "token": "invalid token"}}) - - -def get_dlq_event(): - CREATED_AT = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") - ERROR_TIMESTAMP = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") - NOW = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") - - return { - "id": str(uuid4()), - "event_uuid": str(uuid4()), - "event": "some event", - "properties": "{ a: 1 }", - "distinct_id": "some distinct id", - "team_id": 1, - "elements_chain": "", - "created_at": CREATED_AT, - "ip": "127.0.0.1", - "site_url": "https://myawesomewebsite.com", - "now": NOW, - "raw_payload": TEST_EVENT_RAW_PAYLOAD, - "error_timestamp": ERROR_TIMESTAMP, - "error_location": "plugin-server", - "error": "createPerson failed", - } - - -def convert_query_result_to_dlq_event_dicts(query_result): - events_returned = [] - - for read_dlq_event in query_result: - events_returned.append( - { - "id": str(read_dlq_event[0]), - "event_uuid": str(read_dlq_event[1]), - "event": str(read_dlq_event[2]), - "properties": str(read_dlq_event[3]), - "distinct_id": str(read_dlq_event[4]), - "team_id": int(read_dlq_event[5]), - "elements_chain": str(read_dlq_event[6]), - "created_at": read_dlq_event[7].strftime("%Y-%m-%d %H:%M:%S.%f"), - "ip": str(read_dlq_event[8]), - "site_url": str(read_dlq_event[9]), - "now": read_dlq_event[10].strftime("%Y-%m-%d %H:%M:%S.%f"), - "raw_payload": str(read_dlq_event[11]), - "error_timestamp": read_dlq_event[12].strftime("%Y-%m-%d %H:%M:%S.%f"), - "error_location": str(read_dlq_event[13]), - "error": str(read_dlq_event[14]), - } - ) - return events_returned - - -class TestDeadLetterQueue(ClickhouseTestMixin, BaseTest): - def setUp(self): - sync_execute(KAFKA_DEAD_LETTER_QUEUE_TABLE_SQL()) - sync_execute(DEAD_LETTER_QUEUE_TABLE_MV_SQL) - super().setUp() - - def tearDown(self): - sync_execute("DROP TABLE IF EXISTS events_dead_letter_queue_mv") - sync_execute("DROP TABLE IF EXISTS kafka_events_dead_letter_queue") - super().tearDown() - - def test_direct_table_insert(self): - inserted_dlq_event = get_dlq_event() - sync_execute(INSERT_DEAD_LETTER_QUEUE_EVENT_SQL, inserted_dlq_event) - query_result = sync_execute(f"SELECT * FROM {DEAD_LETTER_QUEUE_TABLE}") - events_returned = convert_query_result_to_dlq_event_dicts(query_result) - # TRICKY: because it's hard to truncate the dlq table, we just check if the event is in the table along with events from other tests - # Because each generated event is unique, this works - self.assertIn(inserted_dlq_event, events_returned) - - def test_kafka_insert(self): - row_count_before_insert = sync_execute(f"SELECT count(1) FROM {DEAD_LETTER_QUEUE_TABLE}")[0][0] - inserted_dlq_event = get_dlq_event() - - new_error = "cannot reach db to fetch team" - inserted_dlq_event["error"] = new_error - - kafka_producer = KafkaProducer(bootstrap_servers=KAFKA_HOSTS) - - kafka_producer.send( - topic=KAFKA_DEAD_LETTER_QUEUE, - value=json.dumps(inserted_dlq_event).encode("utf-8"), - ) - - delay_until_clickhouse_consumes_from_kafka(DEAD_LETTER_QUEUE_TABLE, row_count_before_insert + 1) - - query_result = sync_execute(f"SELECT * FROM {DEAD_LETTER_QUEUE_TABLE}") - events_returned = convert_query_result_to_dlq_event_dicts(query_result) - # TRICKY: because it's hard to truncate the dlq table, we just check if the event is in the table along with events from other tests - # Because each generated event is unique, this works - self.assertIn(inserted_dlq_event, events_returned) diff --git a/ee/clickhouse/models/test/test_filters.py b/ee/clickhouse/models/test/test_filters.py deleted file mode 100644 index 67542868e7a..00000000000 --- a/ee/clickhouse/models/test/test_filters.py +++ /dev/null @@ -1,1467 +0,0 @@ -import json -from typing import Optional - -from posthog.client import query_with_columns, sync_execute -from posthog.constants import FILTER_TEST_ACCOUNTS -from posthog.models import Element, Organization, Person, Team -from posthog.models.cohort import Cohort -from posthog.models.event.sql import GET_EVENTS_WITH_PROPERTIES -from posthog.models.event.util import ClickhouseEventSerializer -from posthog.models.filters import Filter -from posthog.models.filters.retention_filter import RetentionFilter -from posthog.models.filters.test.test_filter import TestFilter as PGTestFilters -from posthog.models.filters.test.test_filter import property_to_Q_test_factory -from posthog.models.property.util import parse_prop_grouped_clauses -from posthog.queries.util import PersonPropertiesMode -from posthog.test.base import ClickhouseTestMixin, _create_event, _create_person -from posthog.test.test_journeys import journeys_for - - -def _filter_events(filter: Filter, team: Team, order_by: Optional[str] = None): - prop_filters, prop_filter_params = parse_prop_grouped_clauses( - property_group=filter.property_groups, - team_id=team.pk, - hogql_context=filter.hogql_context, - ) - params = {"team_id": team.pk, **prop_filter_params} - - events = query_with_columns( - GET_EVENTS_WITH_PROPERTIES.format( - filters=prop_filters, - order_by="ORDER BY {}".format(order_by) if order_by else "", - ), - params, - ) - parsed_events = ClickhouseEventSerializer(events, many=True, context={"elements": None, "people": None}).data - return parsed_events - - -def _filter_persons(filter: Filter, team: Team): - prop_filters, prop_filter_params = parse_prop_grouped_clauses( - property_group=filter.property_groups, - team_id=team.pk, - person_properties_mode=PersonPropertiesMode.USING_PERSON_PROPERTIES_COLUMN, - hogql_context=filter.hogql_context, - ) - # Note this query does not handle person rows changing over time - rows = sync_execute( - f"SELECT id, properties AS person_props FROM person WHERE team_id = %(team_id)s {prop_filters}", - {"team_id": team.pk, **prop_filter_params, **filter.hogql_context.values}, - ) - return [str(uuid) for uuid, _ in rows] - - -class TestFilters(PGTestFilters): - maxDiff = None - - def test_simplify_cohorts(self): - cohort = Cohort.objects.create( - team=self.team, - groups=[ - { - "properties": [ - { - "key": "email", - "operator": "icontains", - "value": ".com", - "type": "person", - } - ] - } - ], - ) - cohort.calculate_people_ch(pending_version=0) - - filter = Filter(data={"properties": [{"type": "cohort", "key": "id", "value": cohort.pk}]}) - filter_with_groups = Filter( - data={ - "properties": { - "type": "AND", - "values": [{"type": "cohort", "key": "id", "value": cohort.pk}], - } - } - ) - - self.assertEqual( - filter.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "AND", - "values": [ - { - "type": "person", - "key": "email", - "operator": "icontains", - "value": ".com", - } - ], - } - }, - ) - - self.assertEqual( - filter_with_groups.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "AND", - "values": [ - { - "type": "person", - "key": "email", - "operator": "icontains", - "value": ".com", - } - ], - } - }, - ) - - with self.settings(USE_PRECALCULATED_CH_COHORT_PEOPLE=True): - self.assertEqual( - filter.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "AND", - "values": [ - { - "key": "id", - "value": cohort.pk, - "type": "precalculated-cohort", - } - ], - } - }, - ) - - self.assertEqual( - filter_with_groups.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "AND", - "values": [ - { - "key": "id", - "value": cohort.pk, - "type": "precalculated-cohort", - } - ], - } - }, - ) - - def test_simplify_static_cohort(self): - cohort = Cohort.objects.create(team=self.team, groups=[], is_static=True) - filter = Filter(data={"properties": [{"type": "cohort", "key": "id", "value": cohort.pk}]}) - - self.assertEqual( - filter.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "AND", - "values": [{"type": "static-cohort", "key": "id", "value": cohort.pk}], - } - }, - ) - - def test_simplify_hasdone_cohort(self): - cohort = Cohort.objects.create(team=self.team, groups=[{"event_id": "$pageview", "days": 1}]) - filter = Filter(data={"properties": [{"type": "cohort", "key": "id", "value": cohort.pk}]}) - - self.assertEqual( - filter.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "AND", - "values": [{"type": "cohort", "key": "id", "value": cohort.pk}], - } - }, - ) - - def test_simplify_multi_group_cohort(self): - cohort = Cohort.objects.create( - team=self.team, - groups=[ - {"properties": [{"key": "$some_prop", "value": "something", "type": "person"}]}, - {"properties": [{"key": "$another_prop", "value": "something", "type": "person"}]}, - ], - ) - filter = Filter(data={"properties": [{"type": "cohort", "key": "id", "value": cohort.pk}]}) - - self.assertEqual( - filter.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "type": "AND", - "values": [ - { - "type": "person", - "key": "$some_prop", - "value": "something", - } - ], - }, - { - "type": "AND", - "values": [ - { - "type": "person", - "key": "$another_prop", - "value": "something", - } - ], - }, - ], - } - ], - } - }, - ) - - def test_recursive_cohort(self): - cohort = Cohort.objects.create( - team=self.team, - groups=[ - { - "properties": [ - { - "key": "email", - "operator": "icontains", - "value": ".com", - "type": "person", - } - ] - } - ], - ) - recursive_cohort = Cohort.objects.create( - team=self.team, - groups=[{"properties": [{"type": "cohort", "key": "id", "value": cohort.pk}]}], - ) - filter = Filter(data={"properties": [{"type": "cohort", "key": "id", "value": recursive_cohort.pk}]}) - - self.assertEqual( - filter.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "AND", - "values": [ - { - "key": "email", - "operator": "icontains", - "value": ".com", - "type": "person", - } - ], - } - }, - ) - - def test_simplify_cohorts_with_recursive_negation(self): - cohort = Cohort.objects.create( - team=self.team, - groups=[ - { - "properties": [ - { - "key": "email", - "operator": "icontains", - "value": ".com", - "type": "person", - } - ] - } - ], - ) - recursive_cohort = Cohort.objects.create( - team=self.team, - groups=[ - { - "properties": [ - {"key": "email", "value": "xyz", "type": "person"}, - { - "type": "cohort", - "key": "id", - "value": cohort.pk, - "negation": True, - }, - ] - } - ], - ) - filter = Filter( - data={ - "properties": [ - { - "type": "cohort", - "key": "id", - "value": recursive_cohort.pk, - "negation": True, - } - ] - } - ) - - self.assertEqual( - filter.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "AND", - "values": [ - { - "type": "cohort", - "key": "id", - "value": recursive_cohort.pk, - "negation": True, - } - ], - } - }, - ) - - def test_simplify_cohorts_with_simple_negation(self): - cohort = Cohort.objects.create( - team=self.team, - groups=[ - { - "properties": [ - { - "key": "email", - "operator": "icontains", - "value": ".com", - "type": "person", - } - ] - } - ], - ) - filter = Filter( - data={ - "properties": [ - { - "type": "cohort", - "key": "id", - "value": cohort.pk, - "negation": True, - } - ] - } - ) - - self.assertEqual( - filter.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "AND", - "values": [ - { - "type": "cohort", - "key": "id", - "value": cohort.pk, - "negation": True, - } - ], - } - }, - ) - - def test_simplify_no_such_cohort(self): - filter = Filter(data={"properties": [{"type": "cohort", "key": "id", "value": 555_555}]}) - - self.assertEqual( - filter.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "AND", - "values": [{"type": "cohort", "key": "id", "value": 555_555}], - } - }, - ) - - def test_simplify_entities(self): - cohort = Cohort.objects.create( - team=self.team, - groups=[ - { - "properties": [ - { - "key": "email", - "operator": "icontains", - "value": ".com", - "type": "person", - } - ] - } - ], - ) - filter = Filter( - data={ - "events": [ - { - "id": "$pageview", - "properties": [{"type": "cohort", "key": "id", "value": cohort.pk}], - } - ] - } - ) - - self.assertEqual( - filter.simplify(self.team).entities_to_dict(), - { - "events": [ - { - "type": "events", - "distinct_id_field": None, - "id": "$pageview", - "id_field": None, - "math": None, - "math_hogql": None, - "math_property": None, - "math_group_type_index": None, - "custom_name": None, - "order": None, - "name": "$pageview", - "properties": { - "type": "AND", - "values": [ - { - "key": "email", - "operator": "icontains", - "value": ".com", - "type": "person", - } - ], - }, - "table_name": None, - "timestamp_field": None, - } - ] - }, - ) - - def test_simplify_entities_with_group_math(self): - filter = Filter( - data={ - "events": [ - { - "id": "$pageview", - "math": "unique_group", - "math_group_type_index": 2, - } - ] - } - ) - - self.assertEqual( - filter.simplify(self.team).entities_to_dict(), - { - "events": [ - { - "type": "events", - "distinct_id_field": None, - "id": "$pageview", - "id_field": None, - "math": "unique_group", - "math_hogql": None, - "math_property": None, - "math_group_type_index": 2, - "custom_name": None, - "order": None, - "name": "$pageview", - "properties": { - "type": "AND", - "values": [ - { - "key": "$group_2", - "operator": "is_not", - "value": "", - "type": "event", - } - ], - }, - "table_name": None, - "timestamp_field": None, - } - ] - }, - ) - - def test_simplify_when_aggregating_by_group(self): - filter = RetentionFilter(data={"aggregation_group_type_index": 0}) - - self.assertEqual( - filter.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "AND", - "values": [ - { - "key": "$group_0", - "operator": "is_not", - "value": "", - "type": "event", - } - ], - } - }, - ) - - def test_simplify_funnel_entities_when_aggregating_by_group(self): - filter = Filter(data={"events": [{"id": "$pageview"}], "aggregation_group_type_index": 2}) - - self.assertEqual( - filter.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "AND", - "values": [ - { - "key": "$group_2", - "operator": "is_not", - "value": "", - "type": "event", - } - ], - } - }, - ) - - -class TestFiltering(ClickhouseTestMixin, property_to_Q_test_factory(_filter_persons, _create_person)): # type: ignore - def test_simple(self): - _create_event(team=self.team, distinct_id="test", event="$pageview") - _create_event( - team=self.team, - distinct_id="test", - event="$pageview", - properties={"$current_url": 1}, - ) # test for type incompatibility - _create_event( - team=self.team, - distinct_id="test", - event="$pageview", - properties={"$current_url": {"bla": "bla"}}, - ) # test for type incompatibility - _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"$current_url": "https://whatever.com"}, - ) - filter = Filter(data={"properties": {"$current_url": "https://whatever.com"}}) - events = _filter_events(filter, self.team) - self.assertEqual(len(events), 1) - - def test_multiple_equality(self): - _create_event(team=self.team, distinct_id="test", event="$pageview") - _create_event( - team=self.team, - distinct_id="test", - event="$pageview", - properties={"$current_url": 1}, - ) # test for type incompatibility - _create_event( - team=self.team, - distinct_id="test", - event="$pageview", - properties={"$current_url": {"bla": "bla"}}, - ) # test for type incompatibility - _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"$current_url": "https://whatever.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"$current_url": "https://example.com"}, - ) - filter = Filter(data={"properties": {"$current_url": ["https://whatever.com", "https://example.com"]}}) - events = _filter_events(filter, self.team) - self.assertEqual(len(events), 2) - - def test_numerical(self): - event1_uuid = _create_event( - team=self.team, - distinct_id="test", - event="$pageview", - properties={"$a_number": 5}, - ) - event2_uuid = _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"$a_number": 6}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"$a_number": "rubbish"}, - ) - filter = Filter(data={"properties": {"$a_number__gt": 5}}) - events = _filter_events(filter, self.team) - self.assertEqual(events[0]["id"], event2_uuid) - - filter = Filter(data={"properties": {"$a_number": 5}}) - events = _filter_events(filter, self.team) - self.assertEqual(events[0]["id"], event1_uuid) - - filter = Filter(data={"properties": {"$a_number__lt": 6}}) - events = _filter_events(filter, self.team) - self.assertEqual(events[0]["id"], event1_uuid) - - def test_numerical_person_properties(self): - _create_person(team_id=self.team.pk, distinct_ids=["p1"], properties={"$a_number": 4}) - _create_person(team_id=self.team.pk, distinct_ids=["p2"], properties={"$a_number": 5}) - _create_person(team_id=self.team.pk, distinct_ids=["p3"], properties={"$a_number": 6}) - - filter = Filter( - data={ - "properties": [ - { - "type": "person", - "key": "$a_number", - "value": 4, - "operator": "gt", - } - ] - } - ) - self.assertEqual(len(_filter_persons(filter, self.team)), 2) - - filter = Filter(data={"properties": [{"type": "person", "key": "$a_number", "value": 5}]}) - self.assertEqual(len(_filter_persons(filter, self.team)), 1) - - filter = Filter( - data={ - "properties": [ - { - "type": "person", - "key": "$a_number", - "value": 6, - "operator": "lt", - } - ] - } - ) - self.assertEqual(len(_filter_persons(filter, self.team)), 2) - - def test_contains(self): - _create_event(team=self.team, distinct_id="test", event="$pageview") - event2_uuid = _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"$current_url": "https://whatever.com"}, - ) - filter = Filter(data={"properties": {"$current_url__icontains": "whatever"}}) - events = _filter_events(filter, self.team) - self.assertEqual(events[0]["id"], event2_uuid) - - def test_regex(self): - event1_uuid = _create_event(team=self.team, distinct_id="test", event="$pageview") - event2_uuid = _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"$current_url": "https://whatever.com"}, - ) - filter = Filter(data={"properties": {"$current_url__regex": r"\.com$"}}) - events = _filter_events(filter, self.team) - self.assertEqual(events[0]["id"], event2_uuid) - - filter = Filter(data={"properties": {"$current_url__not_regex": r"\.eee$"}}) - events = _filter_events(filter, self.team, order_by="timestamp") - self.assertEqual(events[0]["id"], event1_uuid) - self.assertEqual(events[1]["id"], event2_uuid) - - def test_invalid_regex(self): - _create_event(team=self.team, distinct_id="test", event="$pageview") - _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"$current_url": "https://whatever.com"}, - ) - - filter = Filter(data={"properties": {"$current_url__regex": "?*"}}) - self.assertEqual(len(_filter_events(filter, self.team)), 0) - - filter = Filter(data={"properties": {"$current_url__not_regex": "?*"}}) - self.assertEqual(len(_filter_events(filter, self.team)), 0) - - def test_is_not(self): - event1_uuid = _create_event(team=self.team, distinct_id="test", event="$pageview") - event2_uuid = _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"$current_url": "https://something.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"$current_url": "https://whatever.com"}, - ) - filter = Filter(data={"properties": {"$current_url__is_not": "https://whatever.com"}}) - events = _filter_events(filter, self.team) - self.assertEqual( - sorted([events[0]["id"], events[1]["id"]]), - sorted([event1_uuid, event2_uuid]), - ) - self.assertEqual(len(events), 2) - - def test_does_not_contain(self): - event1_uuid = _create_event(team=self.team, event="$pageview", distinct_id="test") - event2_uuid = _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"$current_url": "https://something.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"$current_url": "https://whatever.com"}, - ) - event3_uuid = _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"$current_url": None}, - ) - filter = Filter(data={"properties": {"$current_url__not_icontains": "whatever.com"}}) - events = _filter_events(filter, self.team) - self.assertCountEqual([event["id"] for event in events], [event1_uuid, event2_uuid, event3_uuid]) - self.assertEqual(len(events), 3) - - def test_multiple(self): - event2_uuid = _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={ - "$current_url": "https://something.com", - "another_key": "value", - }, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"$current_url": "https://something.com"}, - ) - filter = Filter( - data={ - "properties": { - "$current_url__icontains": "something.com", - "another_key": "value", - } - } - ) - events = _filter_events(filter, self.team) - self.assertEqual(events[0]["id"], event2_uuid) - self.assertEqual(len(events), 1) - - def test_user_properties(self): - _create_person( - team_id=self.team.pk, - distinct_ids=["person1"], - properties={"group": "some group"}, - ) - _create_person( - team_id=self.team.pk, - distinct_ids=["person2"], - properties={"group": "another group"}, - ) - event2_uuid = _create_event( - team=self.team, - distinct_id="person1", - event="$pageview", - properties={ - "$current_url": "https://something.com", - "another_key": "value", - }, - ) - event_p2_uuid = _create_event( - team=self.team, - distinct_id="person2", - event="$pageview", - properties={"$current_url": "https://something.com"}, - ) - - # test for leakage - _, _, team2 = Organization.objects.bootstrap(None) - _create_person( - team_id=team2.pk, - distinct_ids=["person_team_2"], - properties={"group": "another group"}, - ) - _create_event( - team=team2, - distinct_id="person_team_2", - event="$pageview", - properties={ - "$current_url": "https://something.com", - "another_key": "value", - }, - ) - - filter = Filter(data={"properties": [{"key": "group", "value": "some group", "type": "person"}]}) - events = _filter_events(filter=filter, team=self.team, order_by=None) - self.assertEqual(len(events), 1) - self.assertEqual(events[0]["id"], event2_uuid) - - filter = Filter( - data={ - "properties": [ - { - "key": "group", - "operator": "is_not", - "value": "some group", - "type": "person", - } - ] - } - ) - events = _filter_events(filter=filter, team=self.team, order_by=None) - self.assertEqual(events[0]["id"], event_p2_uuid) - self.assertEqual(len(events), 1) - - def test_user_properties_numerical(self): - _create_person(team_id=self.team.pk, distinct_ids=["person1"], properties={"group": 1}) - _create_person(team_id=self.team.pk, distinct_ids=["person2"], properties={"group": 2}) - event2_uuid = _create_event( - team=self.team, - distinct_id="person1", - event="$pageview", - properties={ - "$current_url": "https://something.com", - "another_key": "value", - }, - ) - _create_event( - team=self.team, - distinct_id="person2", - event="$pageview", - properties={"$current_url": "https://something.com"}, - ) - filter = Filter( - data={ - "properties": [ - {"key": "group", "operator": "lt", "value": 2, "type": "person"}, - {"key": "group", "operator": "gt", "value": 0, "type": "person"}, - ] - } - ) - events = _filter_events(filter=filter, team=self.team, order_by=None) - self.assertEqual(events[0]["id"], event2_uuid) - self.assertEqual(len(events), 1) - - def test_boolean_filters(self): - _create_event(team=self.team, event="$pageview", distinct_id="test") - event2_uuid = _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"is_first_user": True}, - ) - filter = Filter(data={"properties": [{"key": "is_first_user", "value": "true"}]}) - events = _filter_events(filter, self.team) - self.assertEqual(events[0]["id"], event2_uuid) - self.assertEqual(len(events), 1) - - def test_is_not_set_and_is_set(self): - event1_uuid = _create_event(team=self.team, event="$pageview", distinct_id="test") - event2_uuid = _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"is_first_user": True}, - ) - filter = Filter( - data={ - "properties": [ - { - "key": "is_first_user", - "operator": "is_not_set", - "value": "is_not_set", - } - ] - } - ) - events = _filter_events(filter, self.team) - self.assertEqual(events[0]["id"], event1_uuid) - self.assertEqual(len(events), 1) - - filter = Filter(data={"properties": [{"key": "is_first_user", "operator": "is_set", "value": "is_set"}]}) - events = _filter_events(filter, self.team) - self.assertEqual(events[0]["id"], event2_uuid) - self.assertEqual(len(events), 1) - - def test_is_not_set_and_is_set_with_missing_value(self): - event1_uuid = _create_event(team=self.team, event="$pageview", distinct_id="test") - event2_uuid = _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"is_first_user": True}, - ) - filter = Filter(data={"properties": [{"key": "is_first_user", "operator": "is_not_set"}]}) - events = _filter_events(filter, self.team) - self.assertEqual(events[0]["id"], event1_uuid) - self.assertEqual(len(events), 1) - - filter = Filter(data={"properties": [{"key": "is_first_user", "operator": "is_set"}]}) - events = _filter_events(filter, self.team) - self.assertEqual(events[0]["id"], event2_uuid) - self.assertEqual(len(events), 1) - - def test_true_false(self): - _create_event(team=self.team, distinct_id="test", event="$pageview") - event2_uuid = _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"is_first": True}, - ) - filter = Filter(data={"properties": {"is_first": "true"}}) - events = _filter_events(filter, self.team) - self.assertEqual(events[0]["id"], event2_uuid) - - filter = Filter(data={"properties": {"is_first": ["true"]}}) - events = _filter_events(filter, self.team) - - self.assertEqual(events[0]["id"], event2_uuid) - - def test_is_not_true_false(self): - event_uuid = _create_event(team=self.team, distinct_id="test", event="$pageview") - _create_event( - team=self.team, - event="$pageview", - distinct_id="test", - properties={"is_first": True}, - ) - filter = Filter(data={"properties": [{"key": "is_first", "value": "true", "operator": "is_not"}]}) - events = _filter_events(filter, self.team) - self.assertEqual(events[0]["id"], event_uuid) - - def test_json_object(self): - _create_person( - team_id=self.team.pk, - distinct_ids=["person1"], - properties={"name": {"first_name": "Mary", "last_name": "Smith"}}, - ) - event1_uuid = _create_event( - team=self.team, - distinct_id="person1", - event="$pageview", - properties={"$current_url": "https://something.com"}, - ) - filter = Filter( - data={ - "properties": [ - { - "key": "name", - "value": json.dumps({"first_name": "Mary", "last_name": "Smith"}), - "type": "person", - } - ] - } - ) - events = _filter_events(filter=filter, team=self.team, order_by=None) - self.assertEqual(events[0]["id"], event1_uuid) - self.assertEqual(len(events), 1) - - def test_element_selectors(self): - _create_event( - team=self.team, - event="$autocapture", - distinct_id="distinct_id", - elements=[ - Element.objects.create(tag_name="a"), - Element.objects.create(tag_name="div"), - ], - ) - _create_event(team=self.team, event="$autocapture", distinct_id="distinct_id") - filter = Filter(data={"properties": [{"key": "selector", "value": "div > a", "type": "element"}]}) - events = _filter_events(filter=filter, team=self.team) - self.assertEqual(len(events), 1) - - def test_element_filter(self): - _create_event( - team=self.team, - event="$autocapture", - distinct_id="distinct_id", - elements=[ - Element.objects.create(tag_name="a", text="some text"), - Element.objects.create(tag_name="div"), - ], - ) - - _create_event( - team=self.team, - event="$autocapture", - distinct_id="distinct_id", - elements=[ - Element.objects.create(tag_name="a", text="some other text"), - Element.objects.create(tag_name="div"), - ], - ) - - _create_event(team=self.team, event="$autocapture", distinct_id="distinct_id") - filter = Filter( - data={ - "properties": [ - { - "key": "text", - "value": ["some text", "some other text"], - "type": "element", - } - ] - } - ) - events = _filter_events(filter=filter, team=self.team) - self.assertEqual(len(events), 2) - - filter2 = Filter(data={"properties": [{"key": "text", "value": "some text", "type": "element"}]}) - events_response_2 = _filter_events(filter=filter2, team=self.team) - self.assertEqual(len(events_response_2), 1) - - def test_filter_out_team_members(self): - _create_person( - team_id=self.team.pk, - distinct_ids=["team_member"], - properties={"email": "test@posthog.com"}, - ) - _create_person( - team_id=self.team.pk, - distinct_ids=["random_user"], - properties={"email": "test@gmail.com"}, - ) - self.team.test_account_filters = [ - { - "key": "email", - "value": "@posthog.com", - "operator": "not_icontains", - "type": "person", - } - ] - self.team.save() - _create_event(team=self.team, distinct_id="team_member", event="$pageview") - _create_event(team=self.team, distinct_id="random_user", event="$pageview") - filter = Filter( - data={FILTER_TEST_ACCOUNTS: True, "events": [{"id": "$pageview"}]}, - team=self.team, - ) - events = _filter_events(filter=filter, team=self.team) - self.assertEqual(len(events), 1) - - def test_filter_out_team_members_with_grouped_properties(self): - _create_person( - team_id=self.team.pk, - distinct_ids=["person1"], - properties={"email": "test1@gmail.com", "name": "test", "age": "10"}, - ) - _create_person( - team_id=self.team.pk, - distinct_ids=["person2"], - properties={"email": "test2@gmail.com", "name": "test", "age": "20"}, - ) - _create_person( - team_id=self.team.pk, - distinct_ids=["person3"], - properties={"email": "test3@gmail.com", "name": "test", "age": "30"}, - ) - _create_person( - team_id=self.team.pk, - distinct_ids=["person4"], - properties={"email": "test4@gmail.com", "name": "test", "age": "40"}, - ) - _create_person( - team_id=self.team.pk, - distinct_ids=["person5"], - properties={"email": "test@posthog.com", "name": "test", "age": "50"}, - ) - - self.team.test_account_filters = [ - { - "key": "email", - "value": "@posthog.com", - "operator": "not_icontains", - "type": "person", - } - ] - self.team.save() - - journeys_for( - team=self.team, - create_people=False, - events_by_person={ - "person1": [ - { - "event": "$pageview", - "properties": { - "key": "val", - "$browser": "Safari", - "$browser_version": 14, - }, - } - ], - "person2": [ - { - "event": "$pageview", - "properties": { - "key": "val", - "$browser": "Safari", - "$browser_version": 14, - }, - } - ], - "person3": [ - { - "event": "$pageview", - "properties": { - "key": "val", - "$browser": "Safari", - "$browser_version": 14, - }, - } - ], - "person4": [ - { - "event": "$pageview", - "properties": { - "key": "val", - "$browser": "Safari", - "$browser_version": 14, - }, - } - ], - "person5": [ - { - "event": "$pageview", - "properties": { - "key": "val", - "$browser": "Safari", - "$browser_version": 14, - }, - } - ], - }, - ) - - filter = Filter( - data={ - FILTER_TEST_ACCOUNTS: True, - "events": [{"id": "$pageview"}], - "properties": { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "age", - "value": "10", - "operator": "exact", - "type": "person", - }, - { - "key": "age", - "value": "20", - "operator": "exact", - "type": "person", - }, - # choose person 1 and 2 - ], - }, - { - "type": "AND", - "values": [ - { - "key": "$browser", - "value": "Safari", - "operator": "exact", - "type": "event", - }, - { - "key": "age", - "value": "50", - "operator": "exact", - "type": "person", - }, - # choose person 5 - ], - }, - ], - }, - }, - team=self.team, - ) - events = _filter_events(filter=filter, team=self.team) - # test account filters delete person 5, so only 1 and 2 remain - self.assertEqual(len(events), 2) - - def test_person_cohort_properties(self): - person1_distinct_id = "person1" - Person.objects.create( - team=self.team, - distinct_ids=[person1_distinct_id], - properties={"$some_prop": "something"}, - ) - - cohort1 = Cohort.objects.create( - team=self.team, - groups=[{"properties": [{"type": "person", "key": "$some_prop", "value": "something"}]}], - name="cohort1", - ) - - person2_distinct_id = "person2" - Person.objects.create( - team=self.team, - distinct_ids=[person2_distinct_id], - properties={"$some_prop": "different"}, - ) - cohort2 = Cohort.objects.create( - team=self.team, - groups=[ - { - "properties": [ - { - "type": "person", - "key": "$some_prop", - "value": "something", - "operator": "is_not", - } - ] - } - ], - name="cohort2", - ) - - filter = Filter( - data={"properties": [{"key": "id", "value": cohort1.pk, "type": "cohort"}]}, - team=self.team, - ) - - prop_clause, prop_clause_params = parse_prop_grouped_clauses( - property_group=filter.property_groups, - has_person_id_joined=False, - team_id=self.team.pk, - hogql_context=filter.hogql_context, - ) - query = """ - SELECT distinct_id FROM person_distinct_id2 WHERE team_id = %(team_id)s {prop_clause} - """.format(prop_clause=prop_clause) - # get distinct_id column of result - result = sync_execute( - query, - { - "team_id": self.team.pk, - **prop_clause_params, - **filter.hogql_context.values, - }, - )[0][0] - self.assertEqual(result, person1_distinct_id) - - # test cohort2 with negation - filter = Filter( - data={"properties": [{"key": "id", "value": cohort2.pk, "type": "cohort"}]}, - team=self.team, - ) - prop_clause, prop_clause_params = parse_prop_grouped_clauses( - property_group=filter.property_groups, - has_person_id_joined=False, - team_id=self.team.pk, - hogql_context=filter.hogql_context, - ) - query = """ - SELECT distinct_id FROM person_distinct_id2 WHERE team_id = %(team_id)s {prop_clause} - """.format(prop_clause=prop_clause) - # get distinct_id column of result - result = sync_execute( - query, - { - "team_id": self.team.pk, - **prop_clause_params, - **filter.hogql_context.values, - }, - )[0][0] - - self.assertEqual(result, person2_distinct_id) - - def test_simplify_nested(self): - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - { - "type": "AND", - "values": [ - { - "type": "person", - "key": "email", - "operator": "icontains", - "value": ".com", - } - ], - } - ], - }, - { - "type": "AND", - "values": [ - { - "type": "person", - "key": "email", - "operator": "icontains", - "value": "arg2", - }, - { - "type": "person", - "key": "email", - "operator": "icontains", - "value": "arg3", - }, - ], - }, - ], - } - } - ) - - # Can't remove the single prop groups if the parent group has multiple. The second list of conditions becomes property groups - # because of simplify now will return prop groups by default to ensure type consistency - self.assertEqual( - filter.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - { - "type": "person", - "key": "email", - "operator": "icontains", - "value": ".com", - } - ], - }, - { - "type": "AND", - "values": [ - { - "type": "AND", - "values": [ - { - "type": "person", - "key": "email", - "operator": "icontains", - "value": "arg2", - } - ], - }, - { - "type": "AND", - "values": [ - { - "type": "person", - "key": "email", - "operator": "icontains", - "value": "arg3", - } - ], - }, - ], - }, - ], - } - }, - ) - - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - { - "type": "AND", - "values": [ - { - "type": "person", - "key": "email", - "operator": "icontains", - "value": ".com", - } - ], - } - ], - }, - { - "type": "AND", - "values": [ - { - "type": "person", - "key": "email", - "operator": "icontains", - "value": "arg2", - } - ], - }, - ], - } - } - ) - - self.assertEqual( - filter.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - { - "type": "person", - "key": "email", - "operator": "icontains", - "value": ".com", - } - ], - }, - { - "type": "AND", - "values": [ - { - "type": "person", - "key": "email", - "operator": "icontains", - "value": "arg2", - } - ], - }, - ], - } - }, - ) diff --git a/ee/clickhouse/models/test/test_property.py b/ee/clickhouse/models/test/test_property.py deleted file mode 100644 index fd1791438ce..00000000000 --- a/ee/clickhouse/models/test/test_property.py +++ /dev/null @@ -1,2012 +0,0 @@ -from datetime import datetime -from typing import Literal, Union, cast -from uuid import UUID - -import pytest -from freezegun.api import freeze_time -from rest_framework.exceptions import ValidationError - -from ee.clickhouse.materialized_columns.columns import materialize -from posthog.client import sync_execute -from posthog.constants import PropertyOperatorType -from posthog.models.cohort import Cohort -from posthog.models.element import Element -from posthog.models.filters import Filter -from posthog.models.instance_setting import ( - get_instance_setting, -) -from posthog.models.organization import Organization -from posthog.models.property import Property, TableWithProperties -from posthog.models.property.util import ( - PropertyGroup, - get_property_string_expr, - get_single_or_multi_property_string_expr, - parse_prop_grouped_clauses, - prop_filter_json_extract, -) -from posthog.models.team import Team -from posthog.queries.person_distinct_id_query import get_team_distinct_ids_query -from posthog.queries.person_query import PersonQuery -from posthog.queries.property_optimizer import PropertyOptimizer -from posthog.queries.util import PersonPropertiesMode -from posthog.test.base import ( - BaseTest, - ClickhouseTestMixin, - _create_event, - _create_person, - cleanup_materialized_columns, - snapshot_clickhouse_queries, -) - - -class TestPropFormat(ClickhouseTestMixin, BaseTest): - CLASS_DATA_LEVEL_SETUP = False - - def _run_query(self, filter: Filter, **kwargs) -> list: - query, params = parse_prop_grouped_clauses( - property_group=filter.property_groups, - allow_denormalized_props=True, - team_id=self.team.pk, - hogql_context=filter.hogql_context, - **kwargs, - ) - final_query = "SELECT uuid FROM events WHERE team_id = %(team_id)s {}".format(query) - return sync_execute( - final_query, - {**params, **filter.hogql_context.values, "team_id": self.team.pk}, - ) - - def test_prop_person(self): - _create_person( - distinct_ids=["some_other_id"], - team_id=self.team.pk, - properties={"email": "another@posthog.com"}, - ) - - _create_person( - distinct_ids=["some_id"], - team_id=self.team.pk, - properties={"email": "test@posthog.com"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_id", - properties={"attr": "some_val"}, - ) - - filter = Filter(data={"properties": [{"key": "email", "value": "test@posthog.com", "type": "person"}]}) - self.assertEqual(len(self._run_query(filter)), 1) - - def test_prop_event(self): - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"attr": "some_other_val"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"attr": "some_val"}, - ) - - filter_exact = Filter(data={"properties": [{"key": "attr", "value": "some_val"}]}) - self.assertEqual(len(self._run_query(filter_exact)), 1) - - filter_regex = Filter(data={"properties": [{"key": "attr", "value": "some_.+_val", "operator": "regex"}]}) - self.assertEqual(len(self._run_query(filter_regex)), 1) - - filter_icontains = Filter(data={"properties": [{"key": "attr", "value": "Some_Val", "operator": "icontains"}]}) - self.assertEqual(len(self._run_query(filter_icontains)), 1) - - filter_not_icontains = Filter( - data={"properties": [{"key": "attr", "value": "other", "operator": "not_icontains"}]} - ) - self.assertEqual(len(self._run_query(filter_not_icontains)), 1) - - def test_prop_element(self): - _create_event( - event="$autocapture", - team=self.team, - distinct_id="whatever", - properties={"attr": "some_other_val"}, - elements=[ - Element( - tag_name="a", - href="/a-url", - attr_class=["small"], - text="bla bla", - nth_child=1, - nth_of_type=0, - ), - Element( - tag_name="button", - attr_class=["btn", "btn-primary"], - nth_child=0, - nth_of_type=0, - ), - Element(tag_name="div", nth_child=0, nth_of_type=0), - Element(tag_name="label", nth_child=0, nth_of_type=0, attr_id="nested"), - ], - ) - _create_event( - event="$autocapture", - team=self.team, - distinct_id="whatever", - properties={"attr": "some_val"}, - elements=[ - Element( - tag_name="a", - href="/a-url", - attr_class=["small"], - text='bla"bla', - attributes={}, - nth_child=1, - nth_of_type=0, - ), - Element( - tag_name="button", - attr_class=["btn", "btn-secondary"], - nth_child=0, - nth_of_type=0, - ), - Element(tag_name="div", nth_child=0, nth_of_type=0), - Element(tag_name="img", nth_child=0, nth_of_type=0, attr_id="nested"), - ], - ) - _create_event( - event="$autocapture", - team=self.team, - distinct_id="whatever", - elements=[ - Element(tag_name="a", href="/789", nth_child=0, nth_of_type=0), - Element( - tag_name="button", - attr_class=["btn", "btn-tertiary"], - nth_child=0, - nth_of_type=0, - ), - ], - ) - - # selector - - filter = Filter( - data={ - "properties": [ - { - "key": "selector", - "value": [".btn"], - "operator": "exact", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter)), 3) - - filter = Filter( - data={ - "properties": [ - { - "key": "selector", - "value": ".btn", - "operator": "exact", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter)), 3) - - filter = Filter( - data={ - "properties": [ - { - "key": "selector", - "value": [".btn-primary"], - "operator": "exact", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter)), 1) - - filter = Filter( - data={ - "properties": [ - { - "key": "selector", - "value": [".btn-secondary"], - "operator": "exact", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter)), 1) - - filter = Filter( - data={ - "properties": [ - { - "key": "selector", - "value": [".btn-primary", ".btn-secondary"], - "operator": "exact", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter)), 2) - - filter_selector_exact_empty = Filter( - data={ - "properties": [ - { - "key": "selector", - "value": [], - "operator": "exact", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter_selector_exact_empty)), 0) - - filter_selector_is_not_empty = Filter( - data={ - "properties": [ - { - "key": "selector", - "value": [], - "operator": "is_not", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter_selector_is_not_empty)), 3) - - # tag_name - - filter = Filter( - data={ - "properties": [ - { - "key": "tag_name", - "value": ["div"], - "operator": "exact", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter)), 2) - - filter = Filter( - data={ - "properties": [ - { - "key": "tag_name", - "value": "div", - "operator": "exact", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter)), 2) - - filter = Filter( - data={ - "properties": [ - { - "key": "tag_name", - "value": ["img"], - "operator": "exact", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter)), 1) - - filter = Filter( - data={ - "properties": [ - { - "key": "tag_name", - "value": ["label"], - "operator": "exact", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter)), 1) - - filter = Filter( - data={ - "properties": [ - { - "key": "tag_name", - "value": ["img", "label"], - "operator": "exact", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter)), 2) - - # href/text - - filter_href_exact = Filter( - data={ - "properties": [ - { - "key": "href", - "value": ["/a-url"], - "operator": "exact", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter_href_exact)), 2) - - filter_href_exact_double = Filter( - data={ - "properties": [ - { - "key": "href", - "value": ["/a-url", "/789"], - "operator": "exact", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter_href_exact_double)), 3) - - filter_href_exact_empty = Filter( - data={"properties": [{"key": "href", "value": [], "operator": "exact", "type": "element"}]} - ) - self.assertEqual(len(self._run_query(filter_href_exact_empty)), 0) - - filter_href_is_not = Filter( - data={ - "properties": [ - { - "key": "href", - "value": ["/a-url"], - "operator": "is_not", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter_href_is_not)), 1) - - filter_href_is_not_double = Filter( - data={ - "properties": [ - { - "key": "href", - "value": ["/a-url", "/789"], - "operator": "is_not", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter_href_is_not_double)), 0) - - filter_href_is_not_empty = Filter( - data={ - "properties": [ - { - "key": "href", - "value": [], - "operator": "is_not", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter_href_is_not_empty)), 3) - - filter_href_exact_with_tag_name_is_not = Filter( - data={ - "properties": [ - {"key": "href", "value": ["/a-url"], "type": "element"}, - { - "key": "tag_name", - "value": ["marquee"], - "operator": "is_not", - "type": "element", - }, - ] - } - ) - self.assertEqual(len(self._run_query(filter_href_exact_with_tag_name_is_not)), 2) - - filter_href_icontains = Filter( - data={ - "properties": [ - { - "key": "href", - "value": ["UrL"], - "operator": "icontains", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter_href_icontains)), 2) - - filter_href_regex = Filter( - data={ - "properties": [ - { - "key": "href", - "value": "/a-.+", - "operator": "regex", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter_href_regex)), 2) - - filter_href_not_regex = Filter( - data={ - "properties": [ - { - "key": "href", - "value": r"/\d+", - "operator": "not_regex", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter_href_not_regex)), 2) - - filter_text_icontains_with_doublequote = Filter( - data={ - "properties": [ - { - "key": "text", - "value": 'bla"bla', - "operator": "icontains", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter_text_icontains_with_doublequote)), 1) - - filter_text_is_set = Filter( - data={ - "properties": [ - { - "key": "text", - "value": "is_set", - "operator": "is_set", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter_text_is_set)), 2) - - filter_text_is_not_set = Filter( - data={ - "properties": [ - { - "key": "text", - "value": "is_not_set", - "operator": "is_not_set", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter_text_is_not_set)), 1) - - def test_prop_element_with_space(self): - _create_event( - event="$autocapture", - team=self.team, - distinct_id="whatever", - elements=[ - Element(tag_name="a", href="/789", nth_child=0, nth_of_type=0), - Element( - tag_name="button", - attr_class=["btn space", "btn-tertiary"], - nth_child=0, - nth_of_type=0, - ), - ], - ) - - # selector - - filter = Filter( - data={ - "properties": [ - { - "key": "selector", - "value": ["button"], - "operator": "exact", - "type": "element", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter)), 1) - - def test_prop_ints_saved_as_strings(self): - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"test_prop": "0"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"test_prop": "2"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"test_prop": 2}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"test_prop": "string"}, - ) - filter = Filter(data={"properties": [{"key": "test_prop", "value": "2"}]}) - self.assertEqual(len(self._run_query(filter)), 2) - - filter = Filter(data={"properties": [{"key": "test_prop", "value": 2}]}) - self.assertEqual(len(self._run_query(filter)), 2) - - # value passed as string - filter = Filter(data={"properties": [{"key": "test_prop", "value": "1", "operator": "gt"}]}) - self.assertEqual(len(self._run_query(filter)), 2) - filter = Filter(data={"properties": [{"key": "test_prop", "value": "3", "operator": "lt"}]}) - self.assertEqual(len(self._run_query(filter)), 3) - - # value passed as int - filter = Filter(data={"properties": [{"key": "test_prop", "value": 1, "operator": "gt"}]}) - self.assertEqual(len(self._run_query(filter)), 2) - - filter = Filter(data={"properties": [{"key": "test_prop", "value": 3, "operator": "lt"}]}) - self.assertEqual(len(self._run_query(filter)), 3) - - def test_prop_decimals(self): - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"test_prop": 1.4}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"test_prop": 1.3}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"test_prop": 2}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"test_prop": 2.5}, - ) - - filter = Filter(data={"properties": [{"key": "test_prop", "value": 1.5}]}) - self.assertEqual(len(self._run_query(filter)), 0) - - filter = Filter(data={"properties": [{"key": "test_prop", "value": 1.2, "operator": "gt"}]}) - self.assertEqual(len(self._run_query(filter)), 4) - - filter = Filter(data={"properties": [{"key": "test_prop", "value": "1.2", "operator": "gt"}]}) - self.assertEqual(len(self._run_query(filter)), 4) - - filter = Filter(data={"properties": [{"key": "test_prop", "value": 2.3, "operator": "lt"}]}) - self.assertEqual(len(self._run_query(filter)), 3) - - @snapshot_clickhouse_queries - def test_parse_groups(self): - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_id", - properties={"attr_1": "val_1", "attr_2": "val_2"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_id", - properties={"attr_1": "val_2"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_other_id", - properties={"attr_1": "val_3"}, - ) - - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [ - { - "type": "AND", - "values": [ - {"key": "attr_1", "value": "val_1"}, - {"key": "attr_2", "value": "val_2"}, - ], - }, - {"type": "OR", "values": [{"key": "attr_1", "value": "val_2"}]}, - ], - } - } - ) - - self.assertEqual(len(self._run_query(filter)), 2) - - def test_parse_groups_invalid_type(self): - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [ - { - "type": "AND", - "values": [ - {"key": "attr", "value": "val_1"}, - {"key": "attr_2", "value": "val_2"}, - ], - }, - {"type": "XOR", "values": [{"key": "attr", "value": "val_2"}]}, - ], - } - } - ) - with self.assertRaises(ValidationError): - self._run_query(filter) - - @snapshot_clickhouse_queries - def test_parse_groups_persons(self): - _create_person( - distinct_ids=["some_id"], - team_id=self.team.pk, - properties={"email": "1@posthog.com"}, - ) - - _create_person( - distinct_ids=["some_other_id"], - team_id=self.team.pk, - properties={"email": "2@posthog.com"}, - ) - _create_person( - distinct_ids=["some_other_random_id"], - team_id=self.team.pk, - properties={"email": "X@posthog.com"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_id", - properties={"attr": "val_1"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_other_id", - properties={"attr": "val_3"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_other_random_id", - properties={"attr": "val_3"}, - ) - - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "email", - "type": "person", - "value": "1@posthog.com", - } - ], - }, - { - "type": "OR", - "values": [ - { - "key": "email", - "type": "person", - "value": "2@posthog.com", - } - ], - }, - ], - } - } - ) - - self.assertEqual(len(self._run_query(filter)), 2) - - -class TestPropDenormalized(ClickhouseTestMixin, BaseTest): - CLASS_DATA_LEVEL_SETUP = False - - def _run_query(self, filter: Filter, join_person_tables=False) -> list: - outer_properties = PropertyOptimizer().parse_property_groups(filter.property_groups).outer - query, params = parse_prop_grouped_clauses( - team_id=self.team.pk, - property_group=outer_properties, - allow_denormalized_props=True, - person_properties_mode=PersonPropertiesMode.USING_PERSON_PROPERTIES_COLUMN, - hogql_context=filter.hogql_context, - ) - joins = "" - if join_person_tables: - person_query = PersonQuery(filter, self.team.pk) - person_subquery, person_join_params = person_query.get_query() - joins = f""" - INNER JOIN ({get_team_distinct_ids_query(self.team.pk)}) AS pdi ON events.distinct_id = pdi.distinct_id - INNER JOIN ({person_subquery}) person ON pdi.person_id = person.id - """ - params.update(person_join_params) - - final_query = f"SELECT uuid FROM events {joins} WHERE team_id = %(team_id)s {query}" - # Make sure we don't accidentally use json on the properties field - self.assertNotIn("json", final_query.lower()) - return sync_execute( - final_query, - {**params, **filter.hogql_context.values, "team_id": self.team.pk}, - ) - - def test_prop_event_denormalized(self): - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"test_prop": "some_other_val"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"test_prop": "some_val"}, - ) - - materialize("events", "test_prop") - materialize("events", "something_else") - - filter = Filter(data={"properties": [{"key": "test_prop", "value": "some_val"}]}) - self.assertEqual(len(self._run_query(filter)), 1) - - filter = Filter(data={"properties": [{"key": "test_prop", "value": "some_val", "operator": "is_not"}]}) - self.assertEqual(len(self._run_query(filter)), 1) - - filter = Filter(data={"properties": [{"key": "test_prop", "value": "some_val", "operator": "is_set"}]}) - self.assertEqual(len(self._run_query(filter)), 2) - - filter = Filter(data={"properties": [{"key": "test_prop", "value": "some_val", "operator": "is_not_set"}]}) - self.assertEqual(len(self._run_query(filter)), 0) - - filter = Filter(data={"properties": [{"key": "test_prop", "value": "_other_", "operator": "icontains"}]}) - self.assertEqual(len(self._run_query(filter)), 1) - - filter = Filter( - data={ - "properties": [ - { - "key": "test_prop", - "value": "_other_", - "operator": "not_icontains", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter)), 1) - - def test_prop_person_denormalized(self): - _create_person( - distinct_ids=["some_id"], - team_id=self.team.pk, - properties={"email": "test@posthog.com"}, - ) - _create_event(event="$pageview", team=self.team, distinct_id="some_id") - - materialize("person", "email") - - filter = Filter( - data={ - "properties": [ - { - "key": "email", - "type": "person", - "value": "posthog", - "operator": "icontains", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter, join_person_tables=True)), 1) - - filter = Filter( - data={ - "properties": [ - { - "key": "email", - "type": "person", - "value": "posthog", - "operator": "not_icontains", - } - ] - } - ) - self.assertEqual(len(self._run_query(filter, join_person_tables=True)), 0) - - def test_prop_person_groups_denormalized(self): - _filter = { - "properties": { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "event_prop2", - "value": ["foo2", "bar2"], - "type": "event", - "operator": None, - }, - { - "key": "person_prop2", - "value": "efg2", - "type": "person", - "operator": None, - }, - ], - }, - { - "type": "AND", - "values": [ - { - "key": "event_prop", - "value": ["foo", "bar"], - "type": "event", - "operator": None, - }, - { - "key": "person_prop", - "value": "efg", - "type": "person", - "operator": None, - }, - ], - }, - ], - } - } - - filter = Filter(data=_filter) - - _create_person(distinct_ids=["some_id_1"], team_id=self.team.pk, properties={}) - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_id_1", - properties={"event_prop2": "foo2"}, - ) - - _create_person( - distinct_ids=["some_id_2"], - team_id=self.team.pk, - properties={"person_prop2": "efg2"}, - ) - _create_event(event="$pageview", team=self.team, distinct_id="some_id_2") - - _create_person( - distinct_ids=["some_id_3"], - team_id=self.team.pk, - properties={"person_prop": "efg"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="some_id_3", - properties={"event_prop": "foo"}, - ) - - materialize("events", "event_prop") - materialize("events", "event_prop2") - materialize("person", "person_prop") - materialize("person", "person_prop2") - self.assertEqual(len(self._run_query(filter, join_person_tables=True)), 3) - - def test_prop_event_denormalized_ints(self): - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"test_prop": 0}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"test_prop": 2}, - ) - - materialize("events", "test_prop") - materialize("events", "something_else") - - filter = Filter(data={"properties": [{"key": "test_prop", "value": 1, "operator": "gt"}]}) - self.assertEqual(len(self._run_query(filter)), 1) - - filter = Filter(data={"properties": [{"key": "test_prop", "value": 1, "operator": "lt"}]}) - self.assertEqual(len(self._run_query(filter)), 1) - - filter = Filter(data={"properties": [{"key": "test_prop", "value": 0}]}) - self.assertEqual(len(self._run_query(filter)), 1) - - def test_get_property_string_expr(self): - string_expr = get_property_string_expr("events", "some_non_mat_prop", "'some_non_mat_prop'", "properties") - self.assertEqual( - string_expr, - ( - "replaceRegexpAll(JSONExtractRaw(properties, 'some_non_mat_prop'), '^\"|\"$', '')", - False, - ), - ) - - string_expr = get_property_string_expr( - "events", - "some_non_mat_prop", - "'some_non_mat_prop'", - "properties", - table_alias="e", - ) - self.assertEqual( - string_expr, - ( - "replaceRegexpAll(JSONExtractRaw(e.properties, 'some_non_mat_prop'), '^\"|\"$', '')", - False, - ), - ) - - materialize("events", "some_mat_prop") - string_expr = get_property_string_expr("events", "some_mat_prop", "'some_mat_prop'", "properties") - self.assertEqual(string_expr, ('"mat_some_mat_prop"', True)) - - string_expr = get_property_string_expr( - "events", "some_mat_prop", "'some_mat_prop'", "properties", table_alias="e" - ) - self.assertEqual(string_expr, ('e."mat_some_mat_prop"', True)) - - materialize("events", "some_mat_prop2", table_column="person_properties") - materialize("events", "some_mat_prop3", table_column="group2_properties") - string_expr = get_property_string_expr( - "events", - "some_mat_prop2", - "x", - "properties", - materialised_table_column="person_properties", - ) - self.assertEqual(string_expr, ('"mat_pp_some_mat_prop2"', True)) - - -@pytest.mark.django_db -def test_parse_prop_clauses_defaults(snapshot): - filter = Filter( - data={ - "properties": [ - {"key": "event_prop", "value": "value"}, - { - "key": "email", - "type": "person", - "value": "posthog", - "operator": "icontains", - }, - ] - } - ) - - assert ( - parse_prop_grouped_clauses( - property_group=filter.property_groups, - allow_denormalized_props=False, - team_id=1, - hogql_context=filter.hogql_context, - ) - == snapshot - ) - assert ( - parse_prop_grouped_clauses( - property_group=filter.property_groups, - person_properties_mode=PersonPropertiesMode.USING_PERSON_PROPERTIES_COLUMN, - allow_denormalized_props=False, - team_id=1, - hogql_context=filter.hogql_context, - ) - == snapshot - ) - assert ( - parse_prop_grouped_clauses( - team_id=1, - property_group=filter.property_groups, - person_properties_mode=PersonPropertiesMode.DIRECT, - allow_denormalized_props=False, - hogql_context=filter.hogql_context, - ) - == snapshot - ) - - -@pytest.mark.django_db -def test_parse_prop_clauses_precalculated_cohort(snapshot): - Cohort.objects.filter(pk=42).delete() - org = Organization.objects.create(name="other org") - - team = Team.objects.create(organization=org) - # force pk for snapshot consistency - cohort = Cohort.objects.create(pk=42, team=team, groups=[{"event_id": "$pageview", "days": 7}], name="cohort") - - filter = Filter( - data={"properties": [{"key": "id", "value": cohort.pk, "type": "precalculated-cohort"}]}, - team=team, - ) - - assert ( - parse_prop_grouped_clauses( - team_id=1, - property_group=filter.property_groups, - person_properties_mode=PersonPropertiesMode.USING_SUBQUERY, - allow_denormalized_props=False, - person_id_joined_alias="pdi.person_id", - hogql_context=filter.hogql_context, - ) - == snapshot - ) - - -# Regression test for: https://github.com/PostHog/posthog/pull/9283 -@pytest.mark.django_db -def test_parse_prop_clauses_funnel_step_element_prepend_regression(snapshot): - filter = Filter( - data={ - "properties": [ - { - "key": "text", - "type": "element", - "value": "Insights1", - "operator": "exact", - } - ] - } - ) - - assert ( - parse_prop_grouped_clauses( - property_group=filter.property_groups, - allow_denormalized_props=False, - team_id=1, - prepend="PREPEND", - hogql_context=filter.hogql_context, - ) - == snapshot - ) - - -@pytest.mark.django_db -def test_parse_groups_persons_edge_case_with_single_filter(snapshot): - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [{"key": "email", "type": "person", "value": "1@posthog.com"}], - } - } - ) - assert ( - parse_prop_grouped_clauses( - team_id=1, - property_group=filter.property_groups, - person_properties_mode=PersonPropertiesMode.USING_PERSON_PROPERTIES_COLUMN, - allow_denormalized_props=True, - hogql_context=filter.hogql_context, - ) - == snapshot - ) - - -TEST_BREAKDOWN_PROCESSING = [ - ( - "$browser", - "events", - "prop", - "properties", - ( - "replaceRegexpAll(JSONExtractRaw(properties, %(breakdown_param_1)s), '^\"|\"$', '') AS prop", - {"breakdown_param_1": "$browser"}, - ), - ), - ( - ["$browser"], - "events", - "value", - "properties", - ( - "array(replaceRegexpAll(JSONExtractRaw(properties, %(breakdown_param_1)s), '^\"|\"$', '')) AS value", - {"breakdown_param_1": "$browser"}, - ), - ), - ( - ["$browser", "$browser_version"], - "events", - "prop", - "properties", - ( - "array(replaceRegexpAll(JSONExtractRaw(properties, %(breakdown_param_1)s), '^\"|\"$', ''),replaceRegexpAll(JSONExtractRaw(properties, %(breakdown_param_2)s), '^\"|\"$', '')) AS prop", - {"breakdown_param_1": "$browser", "breakdown_param_2": "$browser_version"}, - ), - ), -] - - -@pytest.mark.django_db -@pytest.mark.parametrize("breakdown, table, query_alias, column, expected", TEST_BREAKDOWN_PROCESSING) -def test_breakdown_query_expression( - clean_up_materialised_columns, - breakdown: Union[str, list[str]], - table: TableWithProperties, - query_alias: Literal["prop", "value"], - column: str, - expected: str, -): - actual = get_single_or_multi_property_string_expr(breakdown, table, query_alias, column) - - assert actual == expected - - -TEST_BREAKDOWN_PROCESSING_MATERIALIZED = [ - ( - ["$browser"], - "events", - "value", - "properties", - "person_properties", - ( - "array(replaceRegexpAll(JSONExtractRaw(properties, %(breakdown_param_1)s), '^\"|\"$', '')) AS value", - {"breakdown_param_1": "$browser"}, - ), - ('array("mat_pp_$browser") AS value', {"breakdown_param_1": "$browser"}), - ) -] - - -@pytest.mark.django_db -@pytest.mark.parametrize( - "breakdown, table, query_alias, column, materialise_column, expected_with, expected_without", - TEST_BREAKDOWN_PROCESSING_MATERIALIZED, -) -def test_breakdown_query_expression_materialised( - clean_up_materialised_columns, - breakdown: Union[str, list[str]], - table: TableWithProperties, - query_alias: Literal["prop", "value"], - column: str, - materialise_column: str, - expected_with: str, - expected_without: str, -): - from posthog.models.team import util - - util.can_enable_actor_on_events = True - - materialize(table, breakdown[0], table_column="properties") - actual = get_single_or_multi_property_string_expr( - breakdown, - table, - query_alias, - column, - materialised_table_column=materialise_column, - ) - assert actual == expected_with - - materialize(table, breakdown[0], table_column=materialise_column) # type: ignore - actual = get_single_or_multi_property_string_expr( - breakdown, - table, - query_alias, - column, - materialised_table_column=materialise_column, - ) - - assert actual == expected_without - - -@pytest.fixture -def test_events(db, team) -> list[UUID]: - return [ - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"email": "test@posthog.com"}, - group2_properties={"email": "test@posthog.com"}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"email": "mongo@example.com"}, - group2_properties={"email": "mongo@example.com"}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"attr": "some_val"}, - group2_properties={"attr": "some_val"}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"attr": "50"}, - group2_properties={"attr": "50"}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"attr": 5}, - group2_properties={"attr": 5}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - # unix timestamp in seconds - properties={"unix_timestamp": int(datetime(2021, 4, 1, 18).timestamp())}, - group2_properties={"unix_timestamp": int(datetime(2021, 4, 1, 18).timestamp())}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - # unix timestamp in seconds - properties={"unix_timestamp": int(datetime(2021, 4, 1, 19).timestamp())}, - group2_properties={"unix_timestamp": int(datetime(2021, 4, 1, 19).timestamp())}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"long_date": f"{datetime(2021, 4, 1, 18):%Y-%m-%d %H:%M:%S%z}"}, - group2_properties={"long_date": f"{datetime(2021, 4, 1, 18):%Y-%m-%d %H:%M:%S%z}"}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"long_date": f"{datetime(2021, 4, 1, 19):%Y-%m-%d %H:%M:%S%z}"}, - group2_properties={"long_date": f"{datetime(2021, 4, 1, 19):%Y-%m-%d %H:%M:%S%z}"}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"short_date": f"{datetime(2021, 4, 4):%Y-%m-%d}"}, - group2_properties={"short_date": f"{datetime(2021, 4, 4):%Y-%m-%d}"}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"short_date": f"{datetime(2021, 4, 6):%Y-%m-%d}"}, - group2_properties={"short_date": f"{datetime(2021, 4, 6):%Y-%m-%d}"}, - ), - # unix timestamp in seconds with fractions of a second - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"sdk_$time": 1639427152.339}, - group2_properties={"sdk_$time": 1639427152.339}, - ), - # unix timestamp in milliseconds - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"unix_timestamp_milliseconds": 1641977394339}, - group2_properties={"unix_timestamp_milliseconds": 1641977394339}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"rfc_822_time": "Wed, 02 Oct 2002 15:00:00 +0200"}, - group2_properties={"rfc_822_time": "Wed, 02 Oct 2002 15:00:00 +0200"}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"iso_8601_$time": f"{datetime(2021, 4, 1, 19):%Y-%m-%dT%H:%M:%S%Z}"}, - group2_properties={"iso_8601_$time": f"{datetime(2021, 4, 1, 19):%Y-%m-%dT%H:%M:%S%Z}"}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"full_date_increasing_$time": f"{datetime(2021, 4, 1, 19):%d-%m-%Y %H:%M:%S}"}, - group2_properties={"full_date_increasing_$time": f"{datetime(2021, 4, 1, 19):%d-%m-%Y %H:%M:%S}"}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"with_slashes_$time": f"{datetime(2021, 4, 1, 19):%Y/%m/%d %H:%M:%S}"}, - group2_properties={"with_slashes_$time": f"{datetime(2021, 4, 1, 19):%Y/%m/%d %H:%M:%S}"}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"with_slashes_increasing_$time": f"{datetime(2021, 4, 1, 19):%d/%m/%Y %H:%M:%S}"}, - group2_properties={"with_slashes_increasing_$time": f"{datetime(2021, 4, 1, 19):%d/%m/%Y %H:%M:%S}"}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - # seven digit unix timestamp in seconds - 7840800 - # Clickhouse cannot parse this. It isn't matched in tests from TEST_PROPERTIES - properties={"unix_timestamp": int(datetime(1970, 4, 1, 18).timestamp())}, - group2_properties={"unix_timestamp": int(datetime(1970, 4, 1, 18).timestamp())}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - # nine digit unix timestamp in seconds - 323460000 - properties={"unix_timestamp": int(datetime(1980, 4, 1, 18).timestamp())}, - group2_properties={"unix_timestamp": int(datetime(1980, 4, 1, 18).timestamp())}, - ), - _create_event( - # matched by exact date test - event="$pageview", - team=team, - distinct_id="whatever", - properties={"date_only": f"{datetime(2021, 4, 1):%d/%m/%Y}"}, - group2_properties={"date_only": f"{datetime(2021, 4, 1):%d/%m/%Y}"}, - ), - _create_event( - # should not be matched by exact date test - event="$pageview", - team=team, - distinct_id="whatever", - properties={"date_only": f"{datetime(2021, 4, 1, 11):%d/%m/%Y}"}, - group2_properties={"date_only": f"{datetime(2021, 4, 1, 11):%d/%m/%Y}"}, - ), - _create_event( - # not matched by exact date test - event="$pageview", - team=team, - distinct_id="whatever", - properties={"date_only": f"{datetime(2021, 4, 2):%d/%m/%Y}"}, - group2_properties={"date_only": f"{datetime(2021, 4, 2):%d/%m/%Y}"}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"date_only_matched_against_date_and_time": f"{datetime(2021, 3, 31, 18):%d/%m/%Y %H:%M:%S}"}, - group2_properties={ - "date_only_matched_against_date_and_time": f"{datetime(2021, 3, 31, 18):%d/%m/%Y %H:%M:%S}" - }, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - properties={"date_only_matched_against_date_and_time": int(datetime(2021, 3, 31, 14).timestamp())}, - group2_properties={"date_only_matched_against_date_and_time": int(datetime(2021, 3, 31, 14).timestamp())}, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - # include milliseconds, to prove they're ignored in the query - properties={ - "date_exact_including_seconds_and_milliseconds": f"{datetime(2021, 3, 31, 18, 12, 12, 12):%d/%m/%Y %H:%M:%S.%f}" - }, - group2_properties={ - "date_exact_including_seconds_and_milliseconds": f"{datetime(2021, 3, 31, 18, 12, 12, 12):%d/%m/%Y %H:%M:%S.%f}" - }, - ), - _create_event( - event="$pageview", - team=team, - distinct_id="whatever", - # include milliseconds, to prove they're don't cause a date to be included in an after filter - properties={ - "date_exact_including_seconds_and_milliseconds": f"{datetime(2021, 3, 31, 23, 59, 59, 12):%d/%m/%Y %H:%M:%S.%f}" - }, - group2_properties={ - "date_exact_including_seconds_and_milliseconds": f"{datetime(2021, 3, 31, 23, 59, 59, 12):%d/%m/%Y %H:%M:%S.%f}" - }, - ), - ] - - -@pytest.fixture -def clean_up_materialised_columns(): - try: - yield - finally: - # after test cleanup - cleanup_materialized_columns() - - -TEST_PROPERTIES = [ - pytest.param(Property(key="email", value="test@posthog.com"), [0]), - pytest.param(Property(key="email", value="test@posthog.com", operator="exact"), [0]), - pytest.param( - Property( - key="email", - value=["pineapple@pizza.com", "mongo@example.com"], - operator="exact", - ), - [1], - ), - pytest.param( - Property(key="attr", value="5"), - [4], - id="matching a number only matches event index 4 from test_events", - ), - pytest.param( - Property(key="email", value="test@posthog.com", operator="is_not"), - range(1, 27), - id="matching on email is not a value matches all but the first event from test_events", - ), - pytest.param( - Property( - key="email", - value=["test@posthog.com", "mongo@example.com"], - operator="is_not", - ), - range(2, 27), - id="matching on email is not a value matches all but the first two events from test_events", - ), - pytest.param(Property(key="email", value=r".*est@.*", operator="regex"), [0]), - pytest.param(Property(key="email", value=r"?.", operator="regex"), []), - pytest.param(Property(key="email", operator="is_set", value="is_set"), [0, 1]), - pytest.param( - Property(key="email", operator="is_not_set", value="is_not_set"), - range(2, 27), - id="matching for email property not being set matches all but the first two events from test_events", - ), - pytest.param( - Property(key="unix_timestamp", operator="is_date_before", value="2021-04-02"), - [5, 6, 19], - id="matching before a unix timestamp only querying by date", - ), - pytest.param( - Property(key="unix_timestamp", operator="is_date_after", value="2021-03-31"), - [5, 6], - id="matching after a unix timestamp only querying by date", - ), - pytest.param( - Property(key="unix_timestamp", operator="is_date_before", value="2021-04-01 18:30:00"), - [5, 19], - id="matching before a unix timestamp querying by date and time", - ), - pytest.param( - Property(key="unix_timestamp", operator="is_date_after", value="2021-04-01 18:30:00"), - [6], - id="matching after a unix timestamp querying by date and time", - ), - pytest.param(Property(key="long_date", operator="is_date_before", value="2021-04-02"), [7, 8]), - pytest.param( - Property(key="long_date", operator="is_date_after", value="2021-03-31"), - [7, 8], - id="match after date only value against date and time formatted property", - ), - pytest.param( - Property(key="long_date", operator="is_date_before", value="2021-04-01 18:30:00"), - [7], - ), - pytest.param( - Property(key="long_date", operator="is_date_after", value="2021-04-01 18:30:00"), - [8], - ), - pytest.param(Property(key="short_date", operator="is_date_before", value="2021-04-05"), [9]), - pytest.param(Property(key="short_date", operator="is_date_after", value="2021-04-05"), [10]), - pytest.param( - Property(key="short_date", operator="is_date_before", value="2021-04-07"), - [9, 10], - ), - pytest.param( - Property(key="short_date", operator="is_date_after", value="2021-04-03"), - [9, 10], - ), - pytest.param( - Property(key="sdk_$time", operator="is_date_before", value="2021-12-25"), - [11], - id="matching a unix timestamp in seconds with fractional seconds after the decimal point", - ), - pytest.param( - Property( - key="unix_timestamp_milliseconds", - operator="is_date_after", - value="2022-01-11", - ), - [12], - id="matching unix timestamp in milliseconds after a given date (which ClickHouse doesn't support)", - ), - pytest.param( - Property( - key="unix_timestamp_milliseconds", - operator="is_date_before", - value="2022-01-13", - ), - [12], - id="matching unix timestamp in milliseconds before a given date (which ClickHouse doesn't support)", - ), - pytest.param( - Property(key="rfc_822_time", operator="is_date_before", value="2002-10-02 17:01:00"), - [13], - id="matching rfc 822 format date with timeszone offset before a given date", - ), - pytest.param( - Property(key="rfc_822_time", operator="is_date_after", value="2002-10-02 14:59:00"), - [], - id="matching rfc 822 format date takes into account timeszone offset after a given date", - ), - pytest.param( - Property(key="rfc_822_time", operator="is_date_after", value="2002-10-02 12:59:00"), - [13], - id="matching rfc 822 format date after a given date", - ), - pytest.param( - Property(key="iso_8601_$time", operator="is_date_before", value="2021-04-01 20:00:00"), - [14], - id="matching ISO 8601 format date before a given date", - ), - pytest.param( - Property(key="iso_8601_$time", operator="is_date_after", value="2021-04-01 18:00:00"), - [14], - id="matching ISO 8601 format date after a given date", - ), - pytest.param( - Property( - key="full_date_increasing_$time", - operator="is_date_before", - value="2021-04-01 20:00:00", - ), - [15], - id="matching full format date with date parts n increasing order before a given date", - ), - pytest.param( - Property( - key="full_date_increasing_$time", - operator="is_date_after", - value="2021-04-01 18:00:00", - ), - [15], - id="matching full format date with date parts in increasing order after a given date", - ), - pytest.param( - Property( - key="with_slashes_$time", - operator="is_date_before", - value="2021-04-01 20:00:00", - ), - [16], - id="matching full format date with date parts separated by slashes before a given date", - ), - pytest.param( - Property( - key="with_slashes_$time", - operator="is_date_after", - value="2021-04-01 18:00:00", - ), - [16], - id="matching full format date with date parts separated by slashes after a given date", - ), - pytest.param( - Property( - key="with_slashes_increasing_$time", - operator="is_date_before", - value="2021-04-01 20:00:00", - ), - [17], - id="matching full format date with date parts increasing in size and separated by slashes before a given date", - ), - pytest.param( - Property( - key="with_slashes_increasing_$time", - operator="is_date_after", - value="2021-04-01 18:00:00", - ), - [17], - id="matching full format date with date parts increasing in size and separated by slashes after a given date", - ), - pytest.param( - Property(key="date_only", operator="is_date_exact", value="2021-04-01"), - [20, 21], - id="can match dates exactly", - ), - pytest.param( - Property( - key="date_only_matched_against_date_and_time", - operator="is_date_exact", - value="2021-03-31", - ), - [23, 24], - id="can match dates exactly against datetimes and unix timestamps", - ), - pytest.param( - Property( - key="date_exact_including_seconds_and_milliseconds", - operator="is_date_exact", - value="2021-03-31 18:12:12", - ), - [25], - id="can match date times exactly against datetimes with milliseconds", - ), - pytest.param( - Property( - key="date_exact_including_seconds_and_milliseconds", - operator="is_date_after", - value="2021-03-31", - ), - [], - id="can match date only filter after against datetime with milliseconds", - ), - pytest.param( - Property(key="date_only", operator="is_date_after", value="2021-04-01"), - [22], - id="can match after date only values", - ), - pytest.param( - Property(key="date_only", operator="is_date_before", value="2021-04-02"), - [20, 21], - id="can match before date only values", - ), -] - - -@pytest.mark.parametrize("property,expected_event_indexes", TEST_PROPERTIES) -@freeze_time("2021-04-01T01:00:00.000Z") -def test_prop_filter_json_extract(test_events, clean_up_materialised_columns, property, expected_event_indexes, team): - query, params = prop_filter_json_extract(property, 0, allow_denormalized_props=False) - uuids = sorted( - [ - str(uuid) - for (uuid,) in sync_execute( - f"SELECT uuid FROM events WHERE team_id = %(team_id)s {query}", - {"team_id": team.pk, **params}, - ) - ] - ) - expected = sorted([test_events[index] for index in expected_event_indexes]) - - assert len(uuids) == len(expected) # helpful when diagnosing assertion failure below - assert uuids == expected - - -@pytest.mark.parametrize("property,expected_event_indexes", TEST_PROPERTIES) -@freeze_time("2021-04-01T01:00:00.000Z") -def test_prop_filter_json_extract_materialized( - test_events, clean_up_materialised_columns, property, expected_event_indexes, team -): - materialize("events", property.key) - - query, params = prop_filter_json_extract(property, 0, allow_denormalized_props=True) - - assert "JSONExtract" not in query - - uuids = sorted( - [ - str(uuid) - for (uuid,) in sync_execute( - f"SELECT uuid FROM events WHERE team_id = %(team_id)s {query}", - {"team_id": team.pk, **params}, - ) - ] - ) - expected = sorted([test_events[index] for index in expected_event_indexes]) - - assert uuids == expected - - -@pytest.mark.parametrize("property,expected_event_indexes", TEST_PROPERTIES) -@freeze_time("2021-04-01T01:00:00.000Z") -def test_prop_filter_json_extract_person_on_events_materialized( - test_events, clean_up_materialised_columns, property, expected_event_indexes, team -): - if not get_instance_setting("PERSON_ON_EVENTS_ENABLED"): - return - - # simulates a group property being materialised - materialize("events", property.key, table_column="group2_properties") - - query, params = prop_filter_json_extract(property, 0, allow_denormalized_props=True) - # this query uses the `properties` column, thus the materialized column is different. - assert ("JSON" in query) or ("AND 1 = 2" == query) - - query, params = prop_filter_json_extract( - property, 0, allow_denormalized_props=True, use_event_column="group2_properties" - ) - assert "JSON" not in query - - uuids = sorted( - [ - str(uuid) - for (uuid,) in sync_execute( - f"SELECT uuid FROM events WHERE team_id = %(team_id)s {query}", - {"team_id": team.pk, **params}, - ) - ] - ) - expected = sorted([test_events[index] for index in expected_event_indexes]) - - assert uuids == expected - - -def test_combine_group_properties(): - propertyA = Property(key="a", operator="exact", value=["a", "b", "c"]) - propertyB = Property(key="b", operator="exact", value=["d", "e", "f"]) - propertyC = Property(key="c", operator="exact", value=["g", "h", "i"]) - propertyD = Property(key="d", operator="exact", value=["j", "k", "l"]) - - property_group = PropertyGroup(PropertyOperatorType.OR, [propertyA, propertyB]) - - combined_group = property_group.combine_properties(PropertyOperatorType.AND, [propertyC, propertyD]) - assert combined_group.to_dict() == { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "a", - "operator": "exact", - "value": ["a", "b", "c"], - "type": "event", - }, - { - "key": "b", - "operator": "exact", - "value": ["d", "e", "f"], - "type": "event", - }, - ], - }, - { - "type": "AND", - "values": [ - { - "key": "c", - "operator": "exact", - "value": ["g", "h", "i"], - "type": "event", - }, - { - "key": "d", - "operator": "exact", - "value": ["j", "k", "l"], - "type": "event", - }, - ], - }, - ], - } - - combined_group = property_group.combine_properties(PropertyOperatorType.OR, [propertyC, propertyD]) - assert combined_group.to_dict() == { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "a", - "operator": "exact", - "value": ["a", "b", "c"], - "type": "event", - }, - { - "key": "b", - "operator": "exact", - "value": ["d", "e", "f"], - "type": "event", - }, - ], - }, - { - "type": "AND", - "values": [ - { - "key": "c", - "operator": "exact", - "value": ["g", "h", "i"], - "type": "event", - }, - { - "key": "d", - "operator": "exact", - "value": ["j", "k", "l"], - "type": "event", - }, - ], - }, - ], - } - - combined_group = property_group.combine_properties(PropertyOperatorType.OR, []) - assert combined_group.to_dict() == { - "type": "OR", - "values": [ - { - "key": "a", - "operator": "exact", - "value": ["a", "b", "c"], - "type": "event", - }, - { - "key": "b", - "operator": "exact", - "value": ["d", "e", "f"], - "type": "event", - }, - ], - } - - combined_group = PropertyGroup(PropertyOperatorType.AND, cast(list[Property], [])).combine_properties( - PropertyOperatorType.OR, [propertyC, propertyD] - ) - assert combined_group.to_dict() == { - "type": "AND", - "values": [ - { - "key": "c", - "operator": "exact", - "value": ["g", "h", "i"], - "type": "event", - }, - { - "key": "d", - "operator": "exact", - "value": ["j", "k", "l"], - "type": "event", - }, - ], - } - - -def test_session_property_validation(): - # Property key not valid for type session - with pytest.raises(ValidationError): - filter = Filter( - data={ - "properties": [ - { - "type": "session", - "key": "some_prop", - "value": 0, - "operator": "gt", - } - ] - } - ) - parse_prop_grouped_clauses( - team_id=1, - property_group=filter.property_groups, - hogql_context=filter.hogql_context, - ) - - # Operator not valid for $session_duration - with pytest.raises(ValidationError): - filter = Filter( - data={ - "properties": [ - { - "type": "session", - "key": "$session_duration", - "value": 0, - "operator": "is_set", - } - ] - } - ) - parse_prop_grouped_clauses( - team_id=1, - property_group=filter.property_groups, - hogql_context=filter.hogql_context, - ) - - # Value not valid for $session_duration - with pytest.raises(ValidationError): - filter = Filter( - data={ - "properties": [ - { - "type": "session", - "key": "$session_duration", - "value": "hey", - "operator": "gt", - } - ] - } - ) - parse_prop_grouped_clauses( - team_id=1, - property_group=filter.property_groups, - hogql_context=filter.hogql_context, - ) - - # Valid property values - filter = Filter( - data={ - "properties": [ - { - "type": "session", - "key": "$session_duration", - "value": "100", - "operator": "gt", - } - ] - } - ) - parse_prop_grouped_clauses( - team_id=1, - property_group=filter.property_groups, - hogql_context=filter.hogql_context, - ) diff --git a/ee/clickhouse/models/test/utils/util.py b/ee/clickhouse/models/test/utils/util.py deleted file mode 100644 index 6194a6a6a90..00000000000 --- a/ee/clickhouse/models/test/utils/util.py +++ /dev/null @@ -1,14 +0,0 @@ -from time import sleep, time - -from posthog.client import sync_execute - - -# this normally is unnecessary as CH is fast to consume from Kafka when testing -# but it helps prevent potential flakiness -def delay_until_clickhouse_consumes_from_kafka(table_name: str, target_row_count: int, timeout_seconds=10) -> None: - ts_start = time() - while time() < ts_start + timeout_seconds: - result = sync_execute(f"SELECT COUNT(1) FROM {table_name}") - if result[0][0] == target_row_count: - return - sleep(0.5) diff --git a/ee/clickhouse/queries/__init__.py b/ee/clickhouse/queries/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/clickhouse/queries/column_optimizer.py b/ee/clickhouse/queries/column_optimizer.py deleted file mode 100644 index df7873a7ce1..00000000000 --- a/ee/clickhouse/queries/column_optimizer.py +++ /dev/null @@ -1,115 +0,0 @@ -from collections import Counter as TCounter -from typing import cast - -from posthog.constants import TREND_FILTER_TYPE_ACTIONS, FunnelCorrelationType -from posthog.models.action.util import get_action_tables_and_properties -from posthog.models.filters.mixins.utils import cached_property -from posthog.models.filters.properties_timeline_filter import PropertiesTimelineFilter -from posthog.models.filters.stickiness_filter import StickinessFilter -from posthog.models.filters.utils import GroupTypeIndex -from posthog.models.property import PropertyIdentifier -from posthog.models.property.util import ( - box_value, - count_hogql_properties, - extract_tables_and_properties, -) -from posthog.queries.column_optimizer.foss_column_optimizer import FOSSColumnOptimizer -from posthog.queries.trends.util import is_series_group_based - - -class EnterpriseColumnOptimizer(FOSSColumnOptimizer): - @cached_property - def group_types_to_query(self) -> set[GroupTypeIndex]: - used_properties = self.used_properties_with_type("group") - return {cast(GroupTypeIndex, group_type_index) for _, _, group_type_index in used_properties} - - @cached_property - def properties_used_in_filter(self) -> TCounter[PropertyIdentifier]: - "Returns collection of properties + types that this query would use" - counter: TCounter[PropertyIdentifier] = extract_tables_and_properties(self.filter.property_groups.flat) - - if not isinstance(self.filter, StickinessFilter): - # Some breakdown types read properties - # - # See ee/clickhouse/queries/trends/breakdown.py#get_query or - # ee/clickhouse/queries/breakdown_props.py#get_breakdown_prop_values - if self.filter.breakdown_type in ["event", "person"]: - boxed_breakdown = box_value(self.filter.breakdown) - for b in boxed_breakdown: - if isinstance(b, str): - counter[ - ( - b, - self.filter.breakdown_type, - self.filter.breakdown_group_type_index, - ) - ] += 1 - elif self.filter.breakdown_type == "group": - # :TRICKY: We only support string breakdown for group properties - assert isinstance(self.filter.breakdown, str) - counter[ - ( - self.filter.breakdown, - self.filter.breakdown_type, - self.filter.breakdown_group_type_index, - ) - ] += 1 - elif self.filter.breakdown_type == "hogql": - if isinstance(self.filter.breakdown, list): - expr = str(self.filter.breakdown[0]) - else: - expr = str(self.filter.breakdown) - counter = count_hogql_properties(expr, counter) - - # If we have a breakdowns attribute then make sure we pull in everything we - # need to calculate it - for breakdown in self.filter.breakdowns or []: - if breakdown["type"] == "hogql": - counter = count_hogql_properties(breakdown["property"], counter) - else: - counter[ - ( - breakdown["property"], - breakdown["type"], - self.filter.breakdown_group_type_index, - ) - ] += 1 - - # Both entities and funnel exclusions can contain nested property filters - for entity in self.entities_used_in_filter(): - counter += extract_tables_and_properties(entity.property_groups.flat) - - # Math properties are also implicitly used. - # - # See posthog/queries/trends/util.py#process_math - if entity.math_property: - counter[(entity.math_property, "event", None)] += 1 - - # If groups are involved, they're also used - # - # See posthog/queries/trends/util.py#process_math - if is_series_group_based(entity): - counter[(f"$group_{entity.math_group_type_index}", "event", None)] += 1 - - if entity.math == "unique_session": - counter[(f"$session_id", "event", None)] += 1 - - # :TRICKY: If action contains property filters, these need to be included - # - # See ee/clickhouse/models/action.py#format_action_filter for an example - if entity.type == TREND_FILTER_TYPE_ACTIONS: - counter += get_action_tables_and_properties(entity.get_action()) - - if ( - not isinstance(self.filter, StickinessFilter | PropertiesTimelineFilter) - and self.filter.correlation_type == FunnelCorrelationType.PROPERTIES - and self.filter.correlation_property_names - ): - if self.filter.aggregation_group_type_index is not None: - for prop_value in self.filter.correlation_property_names: - counter[(prop_value, "group", self.filter.aggregation_group_type_index)] += 1 - else: - for prop_value in self.filter.correlation_property_names: - counter[(prop_value, "person", None)] += 1 - - return counter diff --git a/ee/clickhouse/queries/enterprise_cohort_query.py b/ee/clickhouse/queries/enterprise_cohort_query.py deleted file mode 100644 index 0629c6757a9..00000000000 --- a/ee/clickhouse/queries/enterprise_cohort_query.py +++ /dev/null @@ -1,422 +0,0 @@ -from typing import Any, cast - -from posthog.constants import PropertyOperatorType -from posthog.models.cohort.util import get_count_operator -from posthog.models.filters.mixins.utils import cached_property -from posthog.models.property.property import Property, PropertyGroup -from posthog.queries.foss_cohort_query import ( - FOSSCohortQuery, - parse_and_validate_positive_integer, - validate_entity, - validate_interval, - validate_seq_date_more_recent_than_date, -) -from posthog.queries.util import PersonPropertiesMode -from posthog.schema import PersonsOnEventsMode - - -def check_negation_clause(prop: PropertyGroup) -> tuple[bool, bool]: - has_negation_clause = False - has_primary_clase = False - if len(prop.values): - if isinstance(prop.values[0], PropertyGroup): - for p in cast(list[PropertyGroup], prop.values): - has_neg, has_primary = check_negation_clause(p) - has_negation_clause = has_negation_clause or has_neg - has_primary_clase = has_primary_clase or has_primary - - else: - for property in cast(list[Property], prop.values): - if property.negation: - has_negation_clause = True - else: - has_primary_clase = True - - if prop.type == PropertyOperatorType.AND and has_negation_clause and has_primary_clase: - # this negation is valid, since all conditions are met. - # So, we don't need to pair this with anything in the rest of the tree - # return no negations, and yes to primary clauses - return False, True - - return has_negation_clause, has_primary_clase - - -class EnterpriseCohortQuery(FOSSCohortQuery): - def get_query(self) -> tuple[str, dict[str, Any]]: - if not self._outer_property_groups: - # everything is pushed down, no behavioral stuff to do - # thus, use personQuery directly - return self._person_query.get_query(prepend=self._cohort_pk) - - # TODO: clean up this kludge. Right now, get_conditions has to run first so that _fields is populated for _get_behavioral_subquery() - conditions, condition_params = self._get_conditions() - self.params.update(condition_params) - - subq = [] - - if self.sequence_filters_to_query: - ( - sequence_query, - sequence_params, - sequence_query_alias, - ) = self._get_sequence_query() - subq.append((sequence_query, sequence_query_alias)) - self.params.update(sequence_params) - else: - ( - behavior_subquery, - behavior_subquery_params, - behavior_query_alias, - ) = self._get_behavior_subquery() - subq.append((behavior_subquery, behavior_query_alias)) - self.params.update(behavior_subquery_params) - - person_query, person_params, person_query_alias = self._get_persons_query(prepend=str(self._cohort_pk)) - subq.append((person_query, person_query_alias)) - self.params.update(person_params) - - # Since we can FULL OUTER JOIN, we may end up with pairs of uuids where one side is blank. Always try to choose the non blank ID - q, fields = self._build_sources(subq) - - # optimize_aggregation_in_order slows down this query but massively decreases memory usage - # this is fine for offline cohort calculation - final_query = f""" - SELECT {fields} AS id FROM - {q} - WHERE 1 = 1 - {conditions} - SETTINGS optimize_aggregation_in_order = 1, join_algorithm = 'auto' - """ - - return final_query, self.params - - def _get_condition_for_property(self, prop: Property, prepend: str, idx: int) -> tuple[str, dict[str, Any]]: - res: str = "" - params: dict[str, Any] = {} - - if prop.type == "behavioral": - if prop.value == "performed_event": - res, params = self.get_performed_event_condition(prop, prepend, idx) - elif prop.value == "performed_event_multiple": - res, params = self.get_performed_event_multiple(prop, prepend, idx) - elif prop.value == "stopped_performing_event": - res, params = self.get_stopped_performing_event(prop, prepend, idx) - elif prop.value == "restarted_performing_event": - res, params = self.get_restarted_performing_event(prop, prepend, idx) - elif prop.value == "performed_event_first_time": - res, params = self.get_performed_event_first_time(prop, prepend, idx) - elif prop.value == "performed_event_sequence": - res, params = self.get_performed_event_sequence(prop, prepend, idx) - elif prop.value == "performed_event_regularly": - res, params = self.get_performed_event_regularly(prop, prepend, idx) - elif prop.type == "person": - res, params = self.get_person_condition(prop, prepend, idx) - elif ( - prop.type == "static-cohort" - ): # "cohort" and "precalculated-cohort" are handled by flattening during initialization - res, params = self.get_static_cohort_condition(prop, prepend, idx) - else: - raise ValueError(f"Invalid property type for Cohort queries: {prop.type}") - - return res, params - - def get_stopped_performing_event(self, prop: Property, prepend: str, idx: int) -> tuple[str, dict[str, Any]]: - event = (prop.event_type, prop.key) - column_name = f"stopped_event_condition_{prepend}_{idx}" - - entity_query, entity_params = self._get_entity(event, prepend, idx) - date_value = parse_and_validate_positive_integer(prop.time_value, "time_value") - date_param = f"{prepend}_date_{idx}" - date_interval = validate_interval(prop.time_interval) - - seq_date_value = parse_and_validate_positive_integer(prop.seq_time_value, "time_value") - seq_date_param = f"{prepend}_seq_date_{idx}" - seq_date_interval = validate_interval(prop.seq_time_interval) - - validate_seq_date_more_recent_than_date((seq_date_value, seq_date_interval), (date_value, date_interval)) - - self._check_earliest_date((date_value, date_interval)) - - # The user was doing the event in this time period - event_was_happening_period = f"countIf(timestamp > now() - INTERVAL %({date_param})s {date_interval} AND timestamp <= now() - INTERVAL %({seq_date_param})s {seq_date_interval} AND {entity_query})" - # Then stopped in this time period - event_stopped_period = f"countIf(timestamp > now() - INTERVAL %({seq_date_param})s {seq_date_interval} AND timestamp <= now() AND {entity_query})" - - full_condition = f"({event_was_happening_period} > 0 AND {event_stopped_period} = 0) as {column_name}" - - self._fields.append(full_condition) - - return ( - f"{'NOT' if prop.negation else ''} {column_name}", - { - f"{date_param}": date_value, - f"{seq_date_param}": seq_date_value, - **entity_params, - }, - ) - - def get_restarted_performing_event(self, prop: Property, prepend: str, idx: int) -> tuple[str, dict[str, Any]]: - event = (prop.event_type, prop.key) - column_name = f"restarted_event_condition_{prepend}_{idx}" - - entity_query, entity_params = self._get_entity(event, prepend, idx) - date_value = parse_and_validate_positive_integer(prop.time_value, "time_value") - date_param = f"{prepend}_date_{idx}" - date_interval = validate_interval(prop.time_interval) - - seq_date_value = parse_and_validate_positive_integer(prop.seq_time_value, "time_value") - seq_date_param = f"{prepend}_seq_date_{idx}" - seq_date_interval = validate_interval(prop.seq_time_interval) - - validate_seq_date_more_recent_than_date((seq_date_value, seq_date_interval), (date_value, date_interval)) - - self._restrict_event_query_by_time = False - - # Events should have been fired in the initial_period - initial_period = f"countIf(timestamp <= now() - INTERVAL %({date_param})s {date_interval} AND {entity_query})" - # Then stopped in the event_stopped_period - event_stopped_period = f"countIf(timestamp > now() - INTERVAL %({date_param})s {date_interval} AND timestamp <= now() - INTERVAL %({seq_date_param})s {seq_date_interval} AND {entity_query})" - # Then restarted in the final event_restart_period - event_restarted_period = f"countIf(timestamp > now() - INTERVAL %({seq_date_param})s {seq_date_interval} AND timestamp <= now() AND {entity_query})" - - full_condition = ( - f"({initial_period} > 0 AND {event_stopped_period} = 0 AND {event_restarted_period} > 0) as {column_name}" - ) - - self._fields.append(full_condition) - - return ( - f"{'NOT' if prop.negation else ''} {column_name}", - { - f"{date_param}": date_value, - f"{seq_date_param}": seq_date_value, - **entity_params, - }, - ) - - def get_performed_event_first_time(self, prop: Property, prepend: str, idx: int) -> tuple[str, dict[str, Any]]: - event = (prop.event_type, prop.key) - entity_query, entity_params = self._get_entity(event, prepend, idx) - - column_name = f"first_time_condition_{prepend}_{idx}" - - date_value = parse_and_validate_positive_integer(prop.time_value, "time_value") - date_param = f"{prepend}_date_{idx}" - date_interval = validate_interval(prop.time_interval) - - self._restrict_event_query_by_time = False - - field = f"minIf(timestamp, {entity_query}) >= now() - INTERVAL %({date_param})s {date_interval} AND minIf(timestamp, {entity_query}) < now() as {column_name}" - - self._fields.append(field) - - return ( - f"{'NOT' if prop.negation else ''} {column_name}", - {f"{date_param}": date_value, **entity_params}, - ) - - def get_performed_event_regularly(self, prop: Property, prepend: str, idx: int) -> tuple[str, dict[str, Any]]: - event = (prop.event_type, prop.key) - entity_query, entity_params = self._get_entity(event, prepend, idx) - - column_name = f"performed_event_regularly_{prepend}_{idx}" - - date_interval = validate_interval(prop.time_interval) - - time_value_param = f"{prepend}_time_value_{idx}" - time_value = parse_and_validate_positive_integer(prop.time_value, "time_value") - - operator_value_param = f"{prepend}_operator_value_{idx}" - operator_value = parse_and_validate_positive_integer(prop.operator_value, "operator_value") - - min_periods_param = f"{prepend}_min_periods_{idx}" - min_period_count = parse_and_validate_positive_integer(prop.min_periods, "min_periods") - - total_period_count = parse_and_validate_positive_integer(prop.total_periods, "total_periods") - - if min_period_count > total_period_count: - raise ( - ValueError( - f"min_periods ({min_period_count}) cannot be greater than total_periods ({total_period_count})" - ) - ) - - params = { - time_value_param: time_value, - operator_value_param: operator_value, - min_periods_param: min_period_count, - } - periods = [] - - if total_period_count: - for period in range(total_period_count): - start_time_value = f"%({time_value_param})s * {period}" - end_time_value = f"%({time_value_param})s * ({period} + 1)" - # Clause that returns 1 if the event was performed the expected number of times in the given time interval, otherwise 0 - periods.append( - f"if(countIf({entity_query} and timestamp <= now() - INTERVAL {start_time_value} {date_interval} and timestamp > now() - INTERVAL {end_time_value} {date_interval}) {get_count_operator(prop.operator)} %({operator_value_param})s, 1, 0)" - ) - earliest_date = (total_period_count * time_value, date_interval) - self._check_earliest_date(earliest_date) - - field = "+".join(periods) + f">= %({min_periods_param})s" + f" as {column_name}" - - self._fields.append(field) - - return ( - f"{'NOT' if prop.negation else ''} {column_name}", - {**entity_params, **params}, - ) - - @cached_property - def sequence_filters_to_query(self) -> list[Property]: - props = [] - for prop in self._filter.property_groups.flat: - if prop.value == "performed_event_sequence": - props.append(prop) - return props - - @cached_property - def sequence_filters_lookup(self) -> dict[str, str]: - lookup = {} - for idx, prop in enumerate(self.sequence_filters_to_query): - lookup[str(prop.to_dict())] = f"{idx}" - return lookup - - def _get_sequence_query(self) -> tuple[str, dict[str, Any], str]: - params = {} - - materialized_columns = list(self._column_optimizer.event_columns_to_query) - names = [ - "event", - "properties", - "distinct_id", - "timestamp", - *materialized_columns, - ] - - person_prop_query = "" - person_prop_params: dict = {} - - _inner_fields = [f"{self._person_id_alias} AS person_id"] - _intermediate_fields = ["person_id"] - _outer_fields = ["person_id"] - - _inner_fields.extend(names) - _intermediate_fields.extend(names) - - for idx, prop in enumerate(self.sequence_filters_to_query): - ( - step_cols, - intermediate_cols, - aggregate_cols, - seq_params, - ) = self._get_sequence_filter(prop, idx) - _inner_fields.extend(step_cols) - _intermediate_fields.extend(intermediate_cols) - _outer_fields.extend(aggregate_cols) - params.update(seq_params) - - date_condition, date_params = self._get_date_condition() - params.update(date_params) - - event_param_name = f"{self._cohort_pk}_event_ids" - - if self.should_pushdown_persons and self._person_on_events_mode != PersonsOnEventsMode.DISABLED: - person_prop_query, person_prop_params = self._get_prop_groups( - self._inner_property_groups, - person_properties_mode=PersonPropertiesMode.DIRECT_ON_EVENTS, - person_id_joined_alias=self._person_id_alias, - ) - - new_query = f""" - SELECT {", ".join(_inner_fields)} FROM events AS {self.EVENT_TABLE_ALIAS} - {self._get_person_ids_query()} - WHERE team_id = %(team_id)s - AND event IN %({event_param_name})s - {date_condition} - {person_prop_query} - """ - - intermediate_query = f""" - SELECT {", ".join(_intermediate_fields)} FROM ({new_query}) - """ - - _outer_fields.extend(self._fields) - - outer_query = f""" - SELECT {", ".join(_outer_fields)} FROM ({intermediate_query}) - GROUP BY person_id - """ - return ( - outer_query, - { - "team_id": self._team_id, - event_param_name: self._events, - **params, - **person_prop_params, - }, - self.FUNNEL_QUERY_ALIAS, - ) - - def _get_sequence_filter(self, prop: Property, idx: int) -> tuple[list[str], list[str], list[str], dict[str, Any]]: - event = validate_entity((prop.event_type, prop.key)) - entity_query, entity_params = self._get_entity(event, f"event_sequence_{self._cohort_pk}", idx) - seq_event = validate_entity((prop.seq_event_type, prop.seq_event)) - - seq_entity_query, seq_entity_params = self._get_entity(seq_event, f"seq_event_sequence_{self._cohort_pk}", idx) - - time_value = parse_and_validate_positive_integer(prop.time_value, "time_value") - time_interval = validate_interval(prop.time_interval) - seq_date_value = parse_and_validate_positive_integer(prop.seq_time_value, "time_value") - seq_date_interval = validate_interval(prop.seq_time_interval) - self._check_earliest_date((time_value, time_interval)) - - event_prepend = f"event_{idx}" - - duplicate_event = 0 - if event == seq_event: - duplicate_event = 1 - - aggregate_cols = [] - aggregate_condition = f"{'NOT' if prop.negation else ''} max(if({entity_query} AND {event_prepend}_latest_0 < {event_prepend}_latest_1 AND {event_prepend}_latest_1 <= {event_prepend}_latest_0 + INTERVAL {seq_date_value} {seq_date_interval}, 2, 1)) = 2 AS {self.SEQUENCE_FIELD_ALIAS}_{self.sequence_filters_lookup[str(prop.to_dict())]}" - aggregate_cols.append(aggregate_condition) - - condition_cols = [] - timestamp_condition = f"min({event_prepend}_latest_1) over (PARTITION by person_id ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND {duplicate_event} PRECEDING) {event_prepend}_latest_1" - condition_cols.append(f"{event_prepend}_latest_0") - condition_cols.append(timestamp_condition) - - step_cols = [] - step_cols.append( - f"if({entity_query} AND timestamp > now() - INTERVAL {time_value} {time_interval}, 1, 0) AS {event_prepend}_step_0" - ) - step_cols.append(f"if({event_prepend}_step_0 = 1, timestamp, null) AS {event_prepend}_latest_0") - - step_cols.append( - f"if({seq_entity_query} AND timestamp > now() - INTERVAL {time_value} {time_interval}, 1, 0) AS {event_prepend}_step_1" - ) - step_cols.append(f"if({event_prepend}_step_1 = 1, timestamp, null) AS {event_prepend}_latest_1") - - return ( - step_cols, - condition_cols, - aggregate_cols, - { - **entity_params, - **seq_entity_params, - }, - ) - - def get_performed_event_sequence(self, prop: Property, prepend: str, idx: int) -> tuple[str, dict[str, Any]]: - return ( - f"{self.SEQUENCE_FIELD_ALIAS}_{self.sequence_filters_lookup[str(prop.to_dict())]}", - {}, - ) - - # Check if negations are always paired with a positive filter - # raise a value error warning that this is an invalid cohort - def _validate_negations(self) -> None: - has_pending_negation, has_primary_clause = check_negation_clause(self._filter.property_groups) - if has_pending_negation: - raise ValueError("Negations must be paired with a positive filter.") diff --git a/ee/clickhouse/queries/event_query.py b/ee/clickhouse/queries/event_query.py deleted file mode 100644 index 977ec53e743..00000000000 --- a/ee/clickhouse/queries/event_query.py +++ /dev/null @@ -1,73 +0,0 @@ -from typing import Optional, Union - -from ee.clickhouse.materialized_columns.columns import ColumnName -from ee.clickhouse.queries.column_optimizer import EnterpriseColumnOptimizer -from ee.clickhouse.queries.groups_join_query import GroupsJoinQuery -from posthog.models.filters.filter import Filter -from posthog.models.filters.path_filter import PathFilter -from posthog.models.filters.properties_timeline_filter import PropertiesTimelineFilter -from posthog.models.filters.retention_filter import RetentionFilter -from posthog.models.filters.session_recordings_filter import SessionRecordingsFilter -from posthog.models.filters.stickiness_filter import StickinessFilter -from posthog.models.property import PropertyName -from posthog.models.team import Team -from posthog.queries.event_query.event_query import EventQuery -from posthog.schema import PersonsOnEventsMode - - -class EnterpriseEventQuery(EventQuery): - _column_optimizer: EnterpriseColumnOptimizer - - def __init__( - self, - filter: Union[ - Filter, - PathFilter, - RetentionFilter, - StickinessFilter, - SessionRecordingsFilter, - PropertiesTimelineFilter, - ], - team: Team, - round_interval=False, - should_join_distinct_ids=False, - should_join_persons=False, - # Extra events/person table columns to fetch since parent query needs them - extra_fields: Optional[list[ColumnName]] = None, - extra_event_properties: Optional[list[PropertyName]] = None, - extra_person_fields: Optional[list[ColumnName]] = None, - override_aggregate_users_by_distinct_id: Optional[bool] = None, - person_on_events_mode: PersonsOnEventsMode = PersonsOnEventsMode.DISABLED, - **kwargs, - ) -> None: - if extra_person_fields is None: - extra_person_fields = [] - if extra_event_properties is None: - extra_event_properties = [] - if extra_fields is None: - extra_fields = [] - super().__init__( - filter=filter, - team=team, - round_interval=round_interval, - should_join_distinct_ids=should_join_distinct_ids, - should_join_persons=should_join_persons, - extra_fields=extra_fields, - extra_event_properties=extra_event_properties, - extra_person_fields=extra_person_fields, - override_aggregate_users_by_distinct_id=override_aggregate_users_by_distinct_id, - person_on_events_mode=person_on_events_mode, - **kwargs, - ) - - self._column_optimizer = EnterpriseColumnOptimizer(self._filter, self._team_id) - - def _get_groups_query(self) -> tuple[str, dict]: - if isinstance(self._filter, PropertiesTimelineFilter): - raise Exception("Properties Timeline never needs groups query") - return GroupsJoinQuery( - self._filter, - self._team_id, - self._column_optimizer, - person_on_events_mode=self._person_on_events_mode, - ).get_join_query() diff --git a/ee/clickhouse/queries/experiments/__init__.py b/ee/clickhouse/queries/experiments/__init__.py deleted file mode 100644 index 1e6c17112c5..00000000000 --- a/ee/clickhouse/queries/experiments/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# The FF variant name for control -CONTROL_VARIANT_KEY = "control" - -# controls minimum number of people to be exposed to a variant -# before the results are deemed significant -FF_DISTRIBUTION_THRESHOLD = 100 - -# If probability of a variant is below this threshold, it will be considered -# insignificant -MIN_PROBABILITY_FOR_SIGNIFICANCE = 0.9 diff --git a/ee/clickhouse/queries/experiments/funnel_experiment_result.py b/ee/clickhouse/queries/experiments/funnel_experiment_result.py deleted file mode 100644 index ea95e177a5c..00000000000 --- a/ee/clickhouse/queries/experiments/funnel_experiment_result.py +++ /dev/null @@ -1,404 +0,0 @@ -from dataclasses import asdict, dataclass -from datetime import datetime -import json -from typing import Optional -from zoneinfo import ZoneInfo - -from numpy.random import default_rng -from rest_framework.exceptions import ValidationError -import scipy.stats as stats -from sentry_sdk import capture_exception - -from ee.clickhouse.queries.experiments import ( - CONTROL_VARIANT_KEY, - FF_DISTRIBUTION_THRESHOLD, - MIN_PROBABILITY_FOR_SIGNIFICANCE, -) -from posthog.constants import ExperimentSignificanceCode, ExperimentNoResultsErrorKeys -from posthog.models.feature_flag import FeatureFlag -from posthog.models.filters.filter import Filter -from posthog.models.team import Team -from posthog.queries.funnels import ClickhouseFunnel - -Probability = float - - -@dataclass(frozen=True) -class Variant: - key: str - success_count: int - failure_count: int - - -EXPECTED_LOSS_SIGNIFICANCE_LEVEL = 0.01 - - -class ClickhouseFunnelExperimentResult: - """ - This class calculates Experiment Results. - It returns two things: - 1. A Funnel Breakdown based on Feature Flag values - 2. Probability that Feature Flag value 1 has better conversion rate then FeatureFlag value 2 - - Currently, we support a maximum of 10 feature flag values: control and 9 test variants - - The passed in Filter determines which funnel to create, along with the experiment start & end date values - - Calculating (2) uses sampling from a Beta distribution. If `control` value for the feature flag has 10 successes and 12 conversion failures, - we assume the conversion rate follows a Beta(10, 12) distribution. Same for `test` variant. - - Then, we calculcate how many times a sample from `test` variant is higher than a sample from the `control` variant. This becomes the - probability. - """ - - def __init__( - self, - filter: Filter, - team: Team, - feature_flag: FeatureFlag, - experiment_start_date: datetime, - experiment_end_date: Optional[datetime] = None, - funnel_class: type[ClickhouseFunnel] = ClickhouseFunnel, - ): - breakdown_key = f"$feature/{feature_flag.key}" - self.variants = [variant["key"] for variant in feature_flag.variants] - - # our filters assume that the given time ranges are in the project timezone. - # while start and end date are in UTC. - # so we need to convert them to the project timezone - if team.timezone: - start_date_in_project_timezone = experiment_start_date.astimezone(ZoneInfo(team.timezone)) - end_date_in_project_timezone = ( - experiment_end_date.astimezone(ZoneInfo(team.timezone)) if experiment_end_date else None - ) - - query_filter = filter.shallow_clone( - { - "date_from": start_date_in_project_timezone, - "date_to": end_date_in_project_timezone, - "explicit_date": True, - "breakdown": breakdown_key, - "breakdown_type": "event", - "properties": [], - # :TRICKY: We don't use properties set on filters, as these - # correspond to feature flag properties, not the funnel properties. - # This is also why we simplify only right now so new properties (from test account filters) - # are added appropriately. - "is_simplified": False, - } - ) - self.funnel = funnel_class(query_filter, team) - - def get_results(self, validate: bool = True): - funnel_results = self.funnel.run() - - basic_result_props = { - # TODO: check if this can error out or not?, i.e. results don't have 0 index? - "insight": [result for result in funnel_results if result[0]["breakdown_value"][0] in self.variants], - "filters": self.funnel._filter.to_dict(), - } - - try: - validate_event_variants(funnel_results, self.variants) - - filtered_results = [result for result in funnel_results if result[0]["breakdown_value"][0] in self.variants] - - control_variant, test_variants = self.get_variants(filtered_results) - - probabilities = self.calculate_results(control_variant, test_variants) - - mapping = { - variant.key: probability - for variant, probability in zip([control_variant, *test_variants], probabilities) - } - - significance_code, loss = self.are_results_significant(control_variant, test_variants, probabilities) - - credible_intervals = calculate_credible_intervals([control_variant, *test_variants]) - except ValidationError: - if validate: - raise - else: - return basic_result_props - - return { - **basic_result_props, - "probability": mapping, - "significant": significance_code == ExperimentSignificanceCode.SIGNIFICANT, - "significance_code": significance_code, - "expected_loss": loss, - "variants": [asdict(variant) for variant in [control_variant, *test_variants]], - "credible_intervals": credible_intervals, - } - - def get_variants(self, funnel_results): - control_variant = None - test_variants = [] - for result in funnel_results: - total = result[0]["count"] - success = result[-1]["count"] - failure = total - success - breakdown_value = result[0]["breakdown_value"][0] - if breakdown_value == CONTROL_VARIANT_KEY: - control_variant = Variant( - key=breakdown_value, - success_count=int(success), - failure_count=int(failure), - ) - else: - test_variants.append(Variant(breakdown_value, int(success), int(failure))) - - return control_variant, test_variants - - @staticmethod - def calculate_results( - control_variant: Variant, - test_variants: list[Variant], - priors: tuple[int, int] = (1, 1), - ) -> list[Probability]: - """ - Calculates probability that A is better than B. First variant is control, rest are test variants. - - Supports maximum 4 variants today - - For each variant, we create a Beta distribution of conversion rates, - where alpha (successes) = success count of variant + prior success - beta (failures) = failure count + variant + prior failures - - The prior is information about the world we already know. For example, a stronger prior for failures implies - you'd need extra evidence of successes to confirm that the variant is indeed better. - - By default, we choose a non-informative prior. That is, both success & failure are equally likely. - """ - - if not control_variant: - raise ValidationError("No control variant data found", code="no_data") - - if len(test_variants) >= 10: - raise ValidationError( - "Can't calculate A/B test results for more than 10 variants", - code="too_much_data", - ) - - if len(test_variants) < 1: - raise ValidationError( - "Can't calculate A/B test results for less than 2 variants", - code="no_data", - ) - - return calculate_probability_of_winning_for_each([control_variant, *test_variants]) - - @staticmethod - def are_results_significant( - control_variant: Variant, - test_variants: list[Variant], - probabilities: list[Probability], - ) -> tuple[ExperimentSignificanceCode, Probability]: - def get_conversion_rate(variant: Variant): - return variant.success_count / (variant.success_count + variant.failure_count) - - control_sample_size = control_variant.success_count + control_variant.failure_count - - for variant in test_variants: - # We need a feature flag distribution threshold because distribution of people - # can skew wildly when there are few people in the experiment - if variant.success_count + variant.failure_count < FF_DISTRIBUTION_THRESHOLD: - return ExperimentSignificanceCode.NOT_ENOUGH_EXPOSURE, 1 - - if control_sample_size < FF_DISTRIBUTION_THRESHOLD: - return ExperimentSignificanceCode.NOT_ENOUGH_EXPOSURE, 1 - - if ( - probabilities[0] < MIN_PROBABILITY_FOR_SIGNIFICANCE - and sum(probabilities[1:]) < MIN_PROBABILITY_FOR_SIGNIFICANCE - ): - # Sum of probability of winning for all variants except control is less than 90% - return ExperimentSignificanceCode.LOW_WIN_PROBABILITY, 1 - - best_test_variant = max( - test_variants, - key=lambda variant: get_conversion_rate(variant), - ) - - if get_conversion_rate(best_test_variant) > get_conversion_rate(control_variant): - expected_loss = calculate_expected_loss(best_test_variant, [control_variant]) - else: - expected_loss = calculate_expected_loss(control_variant, [best_test_variant]) - - if expected_loss >= EXPECTED_LOSS_SIGNIFICANCE_LEVEL: - return ExperimentSignificanceCode.HIGH_LOSS, expected_loss - - return ExperimentSignificanceCode.SIGNIFICANT, expected_loss - - -def calculate_expected_loss(target_variant: Variant, variants: list[Variant]) -> float: - """ - Calculates expected loss in conversion rate for a given variant. - Loss calculation comes from VWO's SmartStats technical paper: - https://cdn2.hubspot.net/hubfs/310840/VWO_SmartStats_technical_whitepaper.pdf (pg 12) - - > The loss function is the amount of uplift that one can expect to - be lost by choosing a given variant, given particular values of λA and λB - - The unit of the return value is conversion rate values - - """ - random_sampler = default_rng() - prior_success = 1 - prior_failure = 1 - simulations_count = 100_000 - - variant_samples = [] - for variant in variants: - # Get `N=simulations` samples from a Beta distribution with alpha = prior_success + variant_sucess, - # and beta = prior_failure + variant_failure - samples = random_sampler.beta( - variant.success_count + prior_success, - variant.failure_count + prior_failure, - simulations_count, - ) - variant_samples.append(samples) - - target_variant_samples = random_sampler.beta( - target_variant.success_count + prior_success, - target_variant.failure_count + prior_failure, - simulations_count, - ) - - loss = 0 - variant_conversions = list(zip(*variant_samples)) - for i in range(simulations_count): - loss += max(0, max(variant_conversions[i]) - target_variant_samples[i]) - - return loss / simulations_count - - -def simulate_winning_variant_for_conversion(target_variant: Variant, variants: list[Variant]) -> Probability: - random_sampler = default_rng() - prior_success = 1 - prior_failure = 1 - simulations_count = 100_000 - - variant_samples = [] - for variant in variants: - # Get `N=simulations` samples from a Beta distribution with alpha = prior_success + variant_sucess, - # and beta = prior_failure + variant_failure - samples = random_sampler.beta( - variant.success_count + prior_success, - variant.failure_count + prior_failure, - simulations_count, - ) - variant_samples.append(samples) - - target_variant_samples = random_sampler.beta( - target_variant.success_count + prior_success, - target_variant.failure_count + prior_failure, - simulations_count, - ) - - winnings = 0 - variant_conversions = list(zip(*variant_samples)) - for i in range(simulations_count): - if target_variant_samples[i] > max(variant_conversions[i]): - winnings += 1 - - return winnings / simulations_count - - -def calculate_probability_of_winning_for_each(variants: list[Variant]) -> list[Probability]: - """ - Calculates the probability of winning for each variant. - """ - if len(variants) > 10: - raise ValidationError( - "Can't calculate A/B test results for more than 10 variants", - code="too_much_data", - ) - - probabilities = [] - # simulate winning for each test variant - for index, variant in enumerate(variants): - probabilities.append(simulate_winning_variant_for_conversion(variant, variants[:index] + variants[index + 1 :])) - - total_test_probabilities = sum(probabilities[1:]) - - return [max(0, 1 - total_test_probabilities), *probabilities[1:]] - - -def calculate_credible_intervals(variants, lower_bound=0.025, upper_bound=0.975): - """ - Calculate the Bayesian credible intervals for a list of variants. - If no lower/upper bound provided, the function calculates the 95% credible interval. - """ - intervals = {} - - for variant in variants: - try: - if variant.success_count < 0 or variant.failure_count < 0: - capture_exception( - Exception("Invalid variant success/failure count"), - { - "variant": variant.key, - "success_count": variant.success_count, - "failure_count": variant.failure_count, - }, - ) - return {} - - # Calculate the credible interval - # Laplace smoothing: we add 1 to alpha and beta to avoid division errors if either is zero - alpha = variant.success_count + 1 - beta = variant.failure_count + 1 - credible_interval = stats.beta.ppf([lower_bound, upper_bound], alpha, beta) - - intervals[variant.key] = (credible_interval[0], credible_interval[1]) - except Exception as e: - capture_exception( - Exception(f"Error calculating credible interval for variant {variant.key}"), - {"error": str(e)}, - ) - return {} - - return intervals - - -def validate_event_variants(funnel_results, variants): - errors = { - ExperimentNoResultsErrorKeys.NO_EVENTS: True, - ExperimentNoResultsErrorKeys.NO_FLAG_INFO: True, - ExperimentNoResultsErrorKeys.NO_CONTROL_VARIANT: True, - ExperimentNoResultsErrorKeys.NO_TEST_VARIANT: True, - } - - if not funnel_results or not funnel_results[0]: - raise ValidationError(code="no-results", detail=json.dumps(errors)) - - errors[ExperimentNoResultsErrorKeys.NO_EVENTS] = False - - # Funnels: the first step must be present for *any* results to show up - eventsWithOrderZero = [] - for eventArr in funnel_results: - for event in eventArr: - if event.get("order") == 0: - eventsWithOrderZero.append(event) - - # Check if "control" is present - for event in eventsWithOrderZero: - event_variant = event.get("breakdown_value")[0] - if event_variant == "control": - errors[ExperimentNoResultsErrorKeys.NO_CONTROL_VARIANT] = False - errors[ExperimentNoResultsErrorKeys.NO_FLAG_INFO] = False - break - - # Check if at least one of the test variants is present - test_variants = [variant for variant in variants if variant != "control"] - for event in eventsWithOrderZero: - event_variant = event.get("breakdown_value")[0] - if event_variant in test_variants: - errors[ExperimentNoResultsErrorKeys.NO_TEST_VARIANT] = False - errors[ExperimentNoResultsErrorKeys.NO_FLAG_INFO] = False - break - - has_errors = any(errors.values()) - if has_errors: - raise ValidationError(detail=json.dumps(errors)) diff --git a/ee/clickhouse/queries/experiments/secondary_experiment_result.py b/ee/clickhouse/queries/experiments/secondary_experiment_result.py deleted file mode 100644 index bd485c43622..00000000000 --- a/ee/clickhouse/queries/experiments/secondary_experiment_result.py +++ /dev/null @@ -1,84 +0,0 @@ -from datetime import datetime -from typing import Optional - -from rest_framework.exceptions import ValidationError -from ee.clickhouse.queries.experiments.funnel_experiment_result import ClickhouseFunnelExperimentResult -from ee.clickhouse.queries.experiments.trend_experiment_result import ( - ClickhouseTrendExperimentResult, - uses_math_aggregation_by_user_or_property_value, -) - -from posthog.constants import INSIGHT_FUNNELS, INSIGHT_TRENDS -from posthog.models.feature_flag import FeatureFlag -from posthog.models.filters.filter import Filter -from posthog.models.team import Team - - -class ClickhouseSecondaryExperimentResult: - """ - This class calculates secondary metric values for Experiments. - It returns value of metric for each variant. - - We adjust the metric filter based on Experiment parameters. - """ - - def __init__( - self, - filter: Filter, - team: Team, - feature_flag: FeatureFlag, - experiment_start_date: datetime, - experiment_end_date: Optional[datetime] = None, - ): - self.variants = [variant["key"] for variant in feature_flag.variants] - self.team = team - self.feature_flag = feature_flag - self.filter = filter - self.experiment_start_date = experiment_start_date - self.experiment_end_date = experiment_end_date - - def get_results(self): - if self.filter.insight == INSIGHT_TRENDS: - significance_results = ClickhouseTrendExperimentResult( - self.filter, self.team, self.feature_flag, self.experiment_start_date, self.experiment_end_date - ).get_results(validate=False) - variants = self.get_trend_count_data_for_variants(significance_results["insight"]) - - elif self.filter.insight == INSIGHT_FUNNELS: - significance_results = ClickhouseFunnelExperimentResult( - self.filter, self.team, self.feature_flag, self.experiment_start_date, self.experiment_end_date - ).get_results(validate=False) - variants = self.get_funnel_conversion_rate_for_variants(significance_results["insight"]) - - else: - raise ValidationError("Secondary metrics need to be funnel or trend insights") - - return {"result": variants, **significance_results} - - def get_funnel_conversion_rate_for_variants(self, insight_results) -> dict[str, float]: - variants = {} - for result in insight_results: - total = result[0]["count"] - success = result[-1]["count"] - breakdown_value = result[0]["breakdown_value"][0] - - if breakdown_value in self.variants: - variants[breakdown_value] = round(int(success) / int(total), 3) - - return variants - - def get_trend_count_data_for_variants(self, insight_results) -> dict[str, float]: - # this assumes the Trend insight is Cumulative, unless using count per user - variants = {} - - for result in insight_results: - count = result["count"] - breakdown_value = result["breakdown_value"] - - if uses_math_aggregation_by_user_or_property_value(self.filter): - count = result["count"] / len(result.get("data", [0])) - - if breakdown_value in self.variants: - variants[breakdown_value] = count - - return variants diff --git a/ee/clickhouse/queries/experiments/test_experiment_result.py b/ee/clickhouse/queries/experiments/test_experiment_result.py deleted file mode 100644 index 6c32a34dc08..00000000000 --- a/ee/clickhouse/queries/experiments/test_experiment_result.py +++ /dev/null @@ -1,811 +0,0 @@ -import unittest -from functools import lru_cache -from math import exp, lgamma, log - -from flaky import flaky - -from ee.clickhouse.queries.experiments.funnel_experiment_result import ( - ClickhouseFunnelExperimentResult, - Variant, - calculate_expected_loss, - calculate_credible_intervals as calculate_funnel_credible_intervals, -) - -from ee.clickhouse.queries.experiments.trend_experiment_result import ( - ClickhouseTrendExperimentResult, - Variant as CountVariant, - calculate_credible_intervals as calculate_trend_credible_intervals, -) -from ee.clickhouse.queries.experiments.trend_experiment_result import calculate_p_value -from posthog.constants import ExperimentSignificanceCode - -Probability = float - - -@lru_cache(maxsize=100000) -def logbeta(x: int, y: int) -> float: - return lgamma(x) + lgamma(y) - lgamma(x + y) - - -# Helper function to calculate probability using a different method than the one used in actual code -# calculation: https://www.evanmiller.org/bayesian-ab-testing.html#binary_ab - - -def calculate_probability_of_winning_for_target(target_variant: Variant, other_variants: list[Variant]) -> Probability: - """ - Calculates the probability of winning for target variant. - """ - target = target_variant.success_count + 1, target_variant.failure_count + 1 - variants = [(variant.success_count + 1, variant.failure_count + 1) for variant in other_variants] - - if len(variants) == 1: - # simple case - return probability_B_beats_A(variants[0][0], variants[0][1], target[0], target[1]) - - elif len(variants) == 2: - return probability_C_beats_A_and_B( - variants[0][0], - variants[0][1], - variants[1][0], - variants[1][1], - target[0], - target[1], - ) - - elif len(variants) == 3: - return probability_D_beats_A_B_and_C( - variants[0][0], - variants[0][1], - variants[1][0], - variants[1][1], - variants[2][0], - variants[2][1], - target[0], - target[1], - ) - else: - return 0 - - -def probability_B_beats_A(A_success: int, A_failure: int, B_success: int, B_failure: int) -> Probability: - total: Probability = 0 - for i in range(B_success): - total += exp( - logbeta(A_success + i, A_failure + B_failure) - - log(B_failure + i) - - logbeta(1 + i, B_failure) - - logbeta(A_success, A_failure) - ) - - return total - - -def probability_C_beats_A_and_B( - A_success: int, - A_failure: int, - B_success: int, - B_failure: int, - C_success: int, - C_failure: int, -): - total: Probability = 0 - for i in range(A_success): - for j in range(B_success): - total += exp( - logbeta(C_success + i + j, C_failure + A_failure + B_failure) - - log(A_failure + i) - - log(B_failure + j) - - logbeta(1 + i, A_failure) - - logbeta(1 + j, B_failure) - - logbeta(C_success, C_failure) - ) - - return ( - 1 - - probability_B_beats_A(C_success, C_failure, A_success, A_failure) - - probability_B_beats_A(C_success, C_failure, B_success, B_failure) - + total - ) - - -def probability_D_beats_A_B_and_C( - A_success: int, - A_failure: int, - B_success: int, - B_failure: int, - C_success: int, - C_failure: int, - D_success: int, - D_failure: int, -): - total: Probability = 0 - for i in range(A_success): - for j in range(B_success): - for k in range(C_success): - total += exp( - logbeta( - D_success + i + j + k, - D_failure + A_failure + B_failure + C_failure, - ) - - log(A_failure + i) - - log(B_failure + j) - - log(C_failure + k) - - logbeta(1 + i, A_failure) - - logbeta(1 + j, B_failure) - - logbeta(1 + k, C_failure) - - logbeta(D_success, D_failure) - ) - - return ( - 1 - - probability_B_beats_A(A_success, A_failure, D_success, D_failure) - - probability_B_beats_A(B_success, B_failure, D_success, D_failure) - - probability_B_beats_A(C_success, C_failure, D_success, D_failure) - + probability_C_beats_A_and_B(A_success, A_failure, B_success, B_failure, D_success, D_failure) - + probability_C_beats_A_and_B(A_success, A_failure, C_success, C_failure, D_success, D_failure) - + probability_C_beats_A_and_B(B_success, B_failure, C_success, C_failure, D_success, D_failure) - - total - ) - - -@flaky(max_runs=10, min_passes=1) -class TestFunnelExperimentCalculator(unittest.TestCase): - def test_calculate_results(self): - variant_test = Variant("A", 100, 10) - variant_control = Variant("B", 100, 18) - - _, probability = ClickhouseFunnelExperimentResult.calculate_results(variant_control, [variant_test]) - self.assertAlmostEqual(probability, 0.918, places=2) - - significant, loss = ClickhouseFunnelExperimentResult.are_results_significant( - variant_control, [variant_test], [probability] - ) - self.assertAlmostEqual(loss, 0.0016, places=3) - self.assertEqual(significant, ExperimentSignificanceCode.SIGNIFICANT) - - credible_intervals = calculate_funnel_credible_intervals([variant_control, variant_test]) - # Cross-checked with: https://www.causascientia.org/math_stat/ProportionCI.html - self.assertAlmostEqual(credible_intervals[variant_control.key][0], 0.7715, places=3) - self.assertAlmostEqual(credible_intervals[variant_control.key][1], 0.9010, places=3) - self.assertAlmostEqual(credible_intervals[variant_test.key][0], 0.8405, places=3) - self.assertAlmostEqual(credible_intervals[variant_test.key][1], 0.9494, places=3) - - def test_simulation_result_is_close_to_closed_form_solution(self): - variant_test = Variant("A", 100, 10) - variant_control = Variant("B", 100, 18) - - _, probability = ClickhouseFunnelExperimentResult.calculate_results(variant_control, [variant_test]) - self.assertAlmostEqual(probability, 0.918, places=1) - - alternative_probability = calculate_probability_of_winning_for_target(variant_test, [variant_control]) - self.assertAlmostEqual(probability, alternative_probability, places=1) - - def test_calculate_results_for_two_test_variants(self): - variant_test_1 = Variant("A", 100, 10) - variant_test_2 = Variant("B", 100, 3) - variant_control = Variant("C", 100, 18) - - probabilities = ClickhouseFunnelExperimentResult.calculate_results( - variant_control, [variant_test_1, variant_test_2] - ) - self.assertAlmostEqual(sum(probabilities), 1) - self.assertAlmostEqual(probabilities[0], 0.0, places=1) - self.assertAlmostEqual(probabilities[1], 0.033, places=1) - self.assertAlmostEqual(probabilities[2], 0.967, places=1) - - alternative_probability_for_control = calculate_probability_of_winning_for_target( - variant_control, [variant_test_1, variant_test_2] - ) - self.assertAlmostEqual(probabilities[0], alternative_probability_for_control, places=2) - - self.assertAlmostEqual( - calculate_expected_loss(variant_test_2, [variant_control, variant_test_1]), - 0.0004, - places=3, - ) - - # this loss only checks variant 2 against control - significant, loss = ClickhouseFunnelExperimentResult.are_results_significant( - variant_control, [variant_test_1, variant_test_2], probabilities - ) - self.assertAlmostEqual(loss, 0.00000, places=3) - self.assertEqual(significant, ExperimentSignificanceCode.SIGNIFICANT) - - credible_intervals = calculate_funnel_credible_intervals([variant_control, variant_test_1, variant_test_2]) - # Cross-checked with: https://www.causascientia.org/math_stat/ProportionCI.html - self.assertAlmostEqual(credible_intervals[variant_control.key][0], 0.7715, places=3) - self.assertAlmostEqual(credible_intervals[variant_control.key][1], 0.9010, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][0], 0.8405, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][1], 0.9494, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][0], 0.9180, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][1], 0.9894, places=3) - - def test_calculate_results_for_two_test_variants_almost_equal(self): - variant_test_1 = Variant("A", 120, 60) - variant_test_2 = Variant("B", 110, 52) - variant_control = Variant("C", 130, 65) - - probabilities = ClickhouseFunnelExperimentResult.calculate_results( - variant_control, [variant_test_1, variant_test_2] - ) - self.assertAlmostEqual(sum(probabilities), 1) - self.assertAlmostEqual(probabilities[0], 0.277, places=1) - self.assertAlmostEqual(probabilities[1], 0.282, places=1) - self.assertAlmostEqual(probabilities[2], 0.440, places=1) - - alternative_probability_for_control = calculate_probability_of_winning_for_target( - variant_control, [variant_test_1, variant_test_2] - ) - self.assertAlmostEqual(probabilities[0], alternative_probability_for_control, places=1) - - self.assertAlmostEqual( - calculate_expected_loss(variant_test_2, [variant_control, variant_test_1]), - 0.022, - places=2, - ) - - significant, loss = ClickhouseFunnelExperimentResult.are_results_significant( - variant_control, [variant_test_1, variant_test_2], probabilities - ) - self.assertAlmostEqual(loss, 1, places=3) - self.assertEqual(significant, ExperimentSignificanceCode.LOW_WIN_PROBABILITY) - - credible_intervals = calculate_funnel_credible_intervals([variant_control, variant_test_1, variant_test_2]) - # Cross-checked with: https://www.causascientia.org/math_stat/ProportionCI.html - self.assertAlmostEqual(credible_intervals[variant_control.key][0], 0.5977, places=3) - self.assertAlmostEqual(credible_intervals[variant_control.key][1], 0.7290, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][0], 0.5948, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][1], 0.7314, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][0], 0.6035, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][1], 0.7460, places=3) - - def test_absolute_loss_less_than_one_percent_but_not_significant(self): - variant_test_1 = Variant("A", 286, 2014) - variant_control = Variant("B", 267, 2031) - - probabilities = ClickhouseFunnelExperimentResult.calculate_results(variant_control, [variant_test_1]) - self.assertAlmostEqual(sum(probabilities), 1) - self.assertAlmostEqual(probabilities[0], 0.197, places=1) - self.assertAlmostEqual(probabilities[1], 0.802, places=1) - - self.assertAlmostEqual(calculate_expected_loss(variant_test_1, [variant_control]), 0.0010, places=3) - - significant, loss = ClickhouseFunnelExperimentResult.are_results_significant( - variant_control, [variant_test_1], probabilities - ) - self.assertAlmostEqual(loss, 1, places=3) - self.assertEqual(significant, ExperimentSignificanceCode.LOW_WIN_PROBABILITY) - - credible_intervals = calculate_funnel_credible_intervals([variant_control, variant_test_1]) - # Cross-checked with: https://www.causascientia.org/math_stat/ProportionCI.html - self.assertAlmostEqual(credible_intervals[variant_control.key][0], 0.1037, places=3) - self.assertAlmostEqual(credible_intervals[variant_control.key][1], 0.1299, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][0], 0.1114, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][1], 0.1384, places=3) - - def test_calculate_results_for_three_test_variants(self): - variant_test_1 = Variant("A", 100, 10) - variant_test_2 = Variant("B", 100, 3) - variant_test_3 = Variant("C", 100, 30) - variant_control = Variant("D", 100, 18) - - probabilities = ClickhouseFunnelExperimentResult.calculate_results( - variant_control, [variant_test_1, variant_test_2, variant_test_3] - ) - self.assertAlmostEqual(sum(probabilities), 1) - self.assertAlmostEqual(probabilities[0], 0.0, places=1) - self.assertAlmostEqual(probabilities[1], 0.033, places=1) - self.assertAlmostEqual(probabilities[2], 0.967, places=1) - self.assertAlmostEqual(probabilities[3], 0.0, places=1) - - alternative_probability_for_control = calculate_probability_of_winning_for_target( - variant_control, [variant_test_1, variant_test_2, variant_test_3] - ) - - self.assertAlmostEqual(probabilities[0], alternative_probability_for_control, places=1) - - self.assertAlmostEqual( - calculate_expected_loss(variant_test_2, [variant_control, variant_test_1, variant_test_3]), - 0.0004, - places=2, - ) - - significant, loss = ClickhouseFunnelExperimentResult.are_results_significant( - variant_control, - [variant_test_1, variant_test_2, variant_test_3], - probabilities, - ) - self.assertAlmostEqual(loss, 0.0004, places=2) - self.assertEqual(significant, ExperimentSignificanceCode.SIGNIFICANT) - - credible_intervals = calculate_funnel_credible_intervals( - [variant_control, variant_test_1, variant_test_2, variant_test_3] - ) - # Cross-checked with: https://www.causascientia.org/math_stat/ProportionCI.html - self.assertAlmostEqual(credible_intervals[variant_control.key][0], 0.7715, places=3) - self.assertAlmostEqual(credible_intervals[variant_control.key][1], 0.9010, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][0], 0.8405, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][1], 0.9494, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][0], 0.9180, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][1], 0.9894, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_3.key][0], 0.6894, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_3.key][1], 0.8332, places=3) - - def test_calculate_results_for_three_test_variants_almost_equal(self): - variant_test_1 = Variant("A", 120, 60) - variant_test_2 = Variant("B", 110, 52) - variant_test_3 = Variant("C", 100, 46) - variant_control = Variant("D", 130, 65) - - probabilities = ClickhouseFunnelExperimentResult.calculate_results( - variant_control, [variant_test_1, variant_test_2, variant_test_3] - ) - self.assertAlmostEqual(sum(probabilities), 1) - self.assertAlmostEqual(probabilities[0], 0.168, places=1) - self.assertAlmostEqual(probabilities[1], 0.174, places=1) - self.assertAlmostEqual(probabilities[2], 0.292, places=1) - self.assertAlmostEqual(probabilities[3], 0.365, places=1) - - alternative_probability_for_control = calculate_probability_of_winning_for_target( - variant_control, [variant_test_1, variant_test_2, variant_test_3] - ) - self.assertAlmostEqual(probabilities[0], alternative_probability_for_control, places=1) - - self.assertAlmostEqual( - calculate_expected_loss(variant_test_2, [variant_control, variant_test_1, variant_test_3]), - 0.033, - places=2, - ) - - # passing in artificial probabilities to subvert the low_probability threshold - significant, loss = ClickhouseFunnelExperimentResult.are_results_significant( - variant_control, [variant_test_1, variant_test_2, variant_test_3], [1, 0] - ) - self.assertAlmostEqual(loss, 0.012, places=2) - self.assertEqual(significant, ExperimentSignificanceCode.HIGH_LOSS) - - credible_intervals = calculate_funnel_credible_intervals( - [variant_control, variant_test_1, variant_test_2, variant_test_3] - ) - # Cross-checked with: https://www.causascientia.org/math_stat/ProportionCI.html - self.assertAlmostEqual(credible_intervals[variant_control.key][0], 0.5977, places=3) - self.assertAlmostEqual(credible_intervals[variant_control.key][1], 0.7290, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][0], 0.5948, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][1], 0.7314, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][0], 0.6035, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][1], 0.7460, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_3.key][0], 0.6054, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_3.key][1], 0.7547, places=3) - - def test_calculate_results_for_three_test_variants_much_better_than_control(self): - variant_test_1 = Variant("A", 130, 60) - variant_test_2 = Variant("B", 135, 62) - variant_test_3 = Variant("C", 132, 60) - variant_control = Variant("D", 80, 65) - - probabilities = ClickhouseFunnelExperimentResult.calculate_results( - variant_control, [variant_test_1, variant_test_2, variant_test_3] - ) - self.assertAlmostEqual(sum(probabilities), 1) - - alternative_probability_for_control = calculate_probability_of_winning_for_target( - variant_control, [variant_test_1, variant_test_2, variant_test_3] - ) - self.assertAlmostEqual(probabilities[0], alternative_probability_for_control, places=1) - - significant, loss = ClickhouseFunnelExperimentResult.are_results_significant( - variant_control, - [variant_test_1, variant_test_2, variant_test_3], - probabilities, - ) - self.assertAlmostEqual(loss, 0, places=2) - self.assertEqual(significant, ExperimentSignificanceCode.SIGNIFICANT) - - credible_intervals = calculate_funnel_credible_intervals( - [variant_control, variant_test_1, variant_test_2, variant_test_3] - ) - # Cross-checked with: https://www.causascientia.org/math_stat/ProportionCI.html - self.assertAlmostEqual(credible_intervals[variant_control.key][0], 0.4703, places=3) - self.assertAlmostEqual(credible_intervals[variant_control.key][1], 0.6303, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][0], 0.6148, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][1], 0.7460, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][0], 0.6172, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][1], 0.7460, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_3.key][0], 0.6186, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_3.key][1], 0.7488, places=3) - - def test_calculate_results_for_seven_test_variants(self): - variant_test_1 = Variant("A", 100, 17) - variant_test_2 = Variant("B", 100, 16) - variant_test_3 = Variant("C", 100, 30) - variant_test_4 = Variant("D", 100, 31) - variant_test_5 = Variant("E", 100, 29) - variant_test_6 = Variant("F", 100, 32) - variant_test_7 = Variant("G", 100, 33) - variant_control = Variant("H", 100, 18) - - probabilities = ClickhouseFunnelExperimentResult.calculate_results( - variant_control, - [ - variant_test_1, - variant_test_2, - variant_test_3, - variant_test_4, - variant_test_5, - variant_test_6, - variant_test_7, - ], - ) - self.assertAlmostEqual(sum(probabilities), 1) - self.assertAlmostEqual(probabilities[0], 0.241, places=1) - self.assertAlmostEqual(probabilities[1], 0.322, places=1) - self.assertAlmostEqual(probabilities[2], 0.425, places=1) - self.assertAlmostEqual(probabilities[3], 0.002, places=2) - self.assertAlmostEqual(probabilities[4], 0.001, places=2) - self.assertAlmostEqual(probabilities[5], 0.004, places=2) - self.assertAlmostEqual(probabilities[6], 0.001, places=2) - self.assertAlmostEqual(probabilities[7], 0.0, places=2) - - self.assertAlmostEqual( - calculate_expected_loss( - variant_test_2, - [ - variant_control, - variant_test_1, - variant_test_3, - variant_test_4, - variant_test_5, - variant_test_6, - variant_test_7, - ], - ), - 0.0208, - places=2, - ) - - significant, loss = ClickhouseFunnelExperimentResult.are_results_significant( - variant_control, - [ - variant_test_1, - variant_test_2, - variant_test_3, - variant_test_4, - variant_test_5, - variant_test_6, - variant_test_7, - ], - probabilities, - ) - self.assertAlmostEqual(loss, 1, places=2) - self.assertEqual(significant, ExperimentSignificanceCode.LOW_WIN_PROBABILITY) - - credible_intervals = calculate_funnel_credible_intervals( - [ - variant_control, - variant_test_1, - variant_test_2, - variant_test_3, - variant_test_4, - variant_test_5, - variant_test_6, - variant_test_7, - ] - ) - # Cross-checked with: https://www.causascientia.org/math_stat/ProportionCI.html - self.assertAlmostEqual(credible_intervals[variant_control.key][0], 0.7715, places=3) - self.assertAlmostEqual(credible_intervals[variant_control.key][1], 0.9010, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][0], 0.7793, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][1], 0.9070, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][0], 0.7874, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][1], 0.9130, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_3.key][0], 0.6894, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_3.key][1], 0.8332, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_4.key][0], 0.6835, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_4.key][1], 0.8278, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_5.key][0], 0.6955, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_5.key][1], 0.8385, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_6.key][0], 0.6776, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_6.key][1], 0.8226, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_7.key][0], 0.6718, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_7.key][1], 0.8174, places=3) - - def test_calculate_results_control_is_significant(self): - variant_test = Variant("test", 100, 18) - variant_control = Variant("control", 100, 10) - - probabilities = ClickhouseFunnelExperimentResult.calculate_results(variant_control, [variant_test]) - - self.assertAlmostEqual(probabilities[0], 0.918, places=2) - - significant, loss = ClickhouseFunnelExperimentResult.are_results_significant( - variant_control, [variant_test], probabilities - ) - - self.assertAlmostEqual(loss, 0.0016, places=3) - self.assertEqual(significant, ExperimentSignificanceCode.SIGNIFICANT) - - credible_intervals = calculate_funnel_credible_intervals([variant_control, variant_test]) - # Cross-checked with: https://www.causascientia.org/math_stat/ProportionCI.html - self.assertAlmostEqual(credible_intervals[variant_control.key][0], 0.8405, places=3) - self.assertAlmostEqual(credible_intervals[variant_control.key][1], 0.9494, places=3) - self.assertAlmostEqual(credible_intervals[variant_test.key][0], 0.7715, places=3) - self.assertAlmostEqual(credible_intervals[variant_test.key][1], 0.9010, places=3) - - def test_calculate_results_many_variants_control_is_significant(self): - variant_test_1 = Variant("test_1", 100, 20) - variant_test_2 = Variant("test_2", 100, 21) - variant_test_3 = Variant("test_3", 100, 22) - variant_test_4 = Variant("test_4", 100, 23) - variant_test_5 = Variant("test_5", 100, 24) - variant_test_6 = Variant("test_6", 100, 25) - variant_control = Variant("control", 100, 10) - - variants_test = [ - variant_test_1, - variant_test_2, - variant_test_3, - variant_test_4, - variant_test_5, - variant_test_6, - ] - - probabilities = ClickhouseFunnelExperimentResult.calculate_results(variant_control, variants_test) - - self.assertAlmostEqual(probabilities[0], 0.901, places=2) - - significant, loss = ClickhouseFunnelExperimentResult.are_results_significant( - variant_control, variants_test, probabilities - ) - - self.assertAlmostEqual(loss, 0.0008, places=3) - self.assertEqual(significant, ExperimentSignificanceCode.SIGNIFICANT) - - credible_intervals = calculate_funnel_credible_intervals( - [ - variant_control, - variant_test_1, - variant_test_2, - variant_test_3, - variant_test_4, - variant_test_5, - variant_test_6, - ] - ) - # Cross-checked with: https://www.causascientia.org/math_stat/ProportionCI.html - self.assertAlmostEqual(credible_intervals[variant_control.key][0], 0.8405, places=3) - self.assertAlmostEqual(credible_intervals[variant_control.key][1], 0.9494, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][0], 0.7563, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][1], 0.8892, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][0], 0.7489, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][1], 0.8834, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_3.key][0], 0.7418, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_3.key][1], 0.8776, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_4.key][0], 0.7347, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_4.key][1], 0.8718, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_5.key][0], 0.7279, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_5.key][1], 0.8661, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_6.key][0], 0.7211, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_6.key][1], 0.8605, places=3) - - -# calculation: https://www.evanmiller.org/bayesian-ab-testing.html#count_ab -def calculate_probability_of_winning_for_target_count_data( - target_variant: CountVariant, other_variants: list[CountVariant] -) -> Probability: - """ - Calculates the probability of winning for target variant. - """ - target = 1 + target_variant.count, target_variant.exposure - variants = [(1 + variant.count, variant.exposure) for variant in other_variants] - - if len(variants) == 1: - # simple case - return probability_B_beats_A_count_data(variants[0][0], variants[0][1], target[0], target[1]) - - elif len(variants) == 2: - return probability_C_beats_A_and_B_count_data( - variants[0][0], - variants[0][1], - variants[1][0], - variants[1][1], - target[0], - target[1], - ) - else: - return 0 - - -def probability_B_beats_A_count_data(A_count: int, A_exposure: float, B_count: int, B_exposure: float) -> Probability: - total: Probability = 0 - for i in range(B_count): - total += exp( - i * log(B_exposure) - + A_count * log(A_exposure) - - (i + A_count) * log(B_exposure + A_exposure) - - log(i + A_count) - - logbeta(i + 1, A_count) - ) - - return total - - -def probability_C_beats_A_and_B_count_data( - A_count: int, - A_exposure: float, - B_count: int, - B_exposure: float, - C_count: int, - C_exposure: float, -) -> Probability: - total: Probability = 0 - - for i in range(B_count): - for j in range(A_count): - total += exp( - i * log(B_exposure) - + j * log(A_exposure) - + C_count * log(C_exposure) - - (i + j + C_count) * log(B_exposure + A_exposure + C_exposure) - + lgamma(i + j + C_count) - - lgamma(i + 1) - - lgamma(j + 1) - - lgamma(C_count) - ) - return ( - 1 - - probability_B_beats_A_count_data(C_count, C_exposure, A_count, A_exposure) - - probability_B_beats_A_count_data(C_count, C_exposure, B_count, B_exposure) - + total - ) - - -@flaky(max_runs=10, min_passes=1) -class TestTrendExperimentCalculator(unittest.TestCase): - def test_calculate_results(self): - variant_control = CountVariant("A", 20, 1, 200) - variant_test = CountVariant("B", 30, 1, 200) - - probabilities = ClickhouseTrendExperimentResult.calculate_results(variant_control, [variant_test]) - self.assertAlmostEqual(probabilities[1], 0.92, places=1) - - computed_probability = calculate_probability_of_winning_for_target_count_data(variant_test, [variant_control]) - self.assertAlmostEqual(probabilities[1], computed_probability, places=1) - - # p value testing matches https://www.evanmiller.org/ab-testing/poisson-means.html - p_value = calculate_p_value(variant_control, [variant_test]) - self.assertAlmostEqual(p_value, 0.20, places=2) - - credible_intervals = calculate_trend_credible_intervals([variant_control, variant_test]) - self.assertAlmostEqual(credible_intervals[variant_control.key][0], 0.0650, places=3) - self.assertAlmostEqual(credible_intervals[variant_control.key][1], 0.1544, places=3) - self.assertAlmostEqual(credible_intervals[variant_test.key][0], 0.1053, places=3) - self.assertAlmostEqual(credible_intervals[variant_test.key][1], 0.2141, places=3) - - def test_calculate_results_small_numbers(self): - variant_control = CountVariant("A", 2, 1, 200) - variant_test = CountVariant("B", 1, 1, 200) - - probabilities = ClickhouseTrendExperimentResult.calculate_results(variant_control, [variant_test]) - self.assertAlmostEqual(probabilities[1], 0.31, places=1) - - computed_probability = calculate_probability_of_winning_for_target_count_data(variant_test, [variant_control]) - self.assertAlmostEqual(probabilities[1], computed_probability, places=1) - - p_value = calculate_p_value(variant_control, [variant_test]) - self.assertAlmostEqual(p_value, 1, places=2) - - credible_intervals = calculate_trend_credible_intervals([variant_control, variant_test]) - self.assertAlmostEqual(credible_intervals[variant_control.key][0], 0.0031, places=3) - self.assertAlmostEqual(credible_intervals[variant_control.key][1], 0.0361, places=3) - self.assertAlmostEqual(credible_intervals[variant_test.key][0], 0.0012, places=3) - self.assertAlmostEqual(credible_intervals[variant_test.key][1], 0.0279, places=3) - - def test_calculate_count_data_probability(self): - probability = probability_B_beats_A_count_data(15, 1, 30, 1) - - # same relative exposure should give same results - probability2 = probability_B_beats_A_count_data(15, 10, 30, 10) - - self.assertAlmostEqual(probability, 0.988, places=1) - self.assertAlmostEqual(probability, probability2) - - def test_calculate_results_with_three_variants(self): - variant_control = CountVariant("A", 20, 1, 200) - variant_test_1 = CountVariant("B", 26, 1, 200) - variant_test_2 = CountVariant("C", 19, 1, 200) - - probabilities = ClickhouseTrendExperimentResult.calculate_results( - variant_control, [variant_test_1, variant_test_2] - ) - self.assertAlmostEqual(probabilities[0], 0.16, places=1) - self.assertAlmostEqual(probabilities[1], 0.72, places=1) - self.assertAlmostEqual(probabilities[2], 0.12, places=1) - - computed_probability = calculate_probability_of_winning_for_target_count_data( - variant_control, [variant_test_1, variant_test_2] - ) - self.assertAlmostEqual(probabilities[0], computed_probability, places=1) - - p_value = calculate_p_value(variant_control, [variant_test_1, variant_test_2]) - self.assertAlmostEqual(p_value, 0.46, places=2) - - credible_intervals = calculate_trend_credible_intervals([variant_control, variant_test_1, variant_test_2]) - self.assertAlmostEqual(credible_intervals[variant_control.key][0], 0.0650, places=3) - self.assertAlmostEqual(credible_intervals[variant_control.key][1], 0.1544, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][0], 0.0890, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][1], 0.1905, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][0], 0.0611, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][1], 0.1484, places=3) - - def test_calculate_significance_when_target_variants_underperform(self): - variant_control = CountVariant("A", 250, 1, 200) - variant_test_1 = CountVariant("B", 180, 1, 200) - variant_test_2 = CountVariant("C", 50, 1, 200) - - # in this case, should choose B as best test variant - p_value = calculate_p_value(variant_control, [variant_test_1, variant_test_2]) - self.assertAlmostEqual(p_value, 0.001, places=3) - - # manually assign probabilities to control test case - significant, p_value = ClickhouseTrendExperimentResult.are_results_significant( - variant_control, [variant_test_1, variant_test_2], [0.5, 0.4, 0.1] - ) - self.assertAlmostEqual(p_value, 1, places=3) - self.assertEqual(significant, ExperimentSignificanceCode.LOW_WIN_PROBABILITY) - - # new B variant is worse, such that control probability ought to be high enough - variant_test_1 = CountVariant("B", 100, 1, 200) - - significant, p_value = ClickhouseTrendExperimentResult.are_results_significant( - variant_control, [variant_test_1, variant_test_2], [0.95, 0.03, 0.02] - ) - self.assertAlmostEqual(p_value, 0, places=3) - self.assertEqual(significant, ExperimentSignificanceCode.SIGNIFICANT) - - credible_intervals = calculate_trend_credible_intervals([variant_control, variant_test_1, variant_test_2]) - self.assertAlmostEqual(credible_intervals[variant_control.key][0], 1.1045, places=3) - self.assertAlmostEqual(credible_intervals[variant_control.key][1], 1.4149, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][0], 0.4113, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][1], 0.6081, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][0], 0.1898, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][1], 0.3295, places=3) - - def test_results_with_different_exposures(self): - variant_control = CountVariant("A", 50, 1.3, 260) - variant_test_1 = CountVariant("B", 30, 1.8, 360) - variant_test_2 = CountVariant("C", 20, 0.7, 140) - - probabilities = ClickhouseTrendExperimentResult.calculate_results( - variant_control, [variant_test_1, variant_test_2] - ) # a is control - self.assertAlmostEqual(probabilities[0], 0.86, places=1) - self.assertAlmostEqual(probabilities[1], 0, places=1) - self.assertAlmostEqual(probabilities[2], 0.13, places=1) - - computed_probability = calculate_probability_of_winning_for_target_count_data( - variant_test_1, [variant_control, variant_test_2] - ) - self.assertAlmostEqual(probabilities[1], computed_probability, places=1) - - computed_probability = calculate_probability_of_winning_for_target_count_data( - variant_control, [variant_test_1, variant_test_2] - ) - self.assertAlmostEqual(probabilities[0], computed_probability, places=1) - - p_value = calculate_p_value(variant_control, [variant_test_1, variant_test_2]) - self.assertAlmostEqual(p_value, 0, places=3) - - significant, p_value = ClickhouseTrendExperimentResult.are_results_significant( - variant_control, [variant_test_1, variant_test_2], probabilities - ) - self.assertAlmostEqual(p_value, 1, places=3) - # False because max probability is less than 0.9 - self.assertEqual(significant, ExperimentSignificanceCode.LOW_WIN_PROBABILITY) - - credible_intervals = calculate_trend_credible_intervals([variant_control, variant_test_1, variant_test_2]) - self.assertAlmostEqual(credible_intervals[variant_control.key][0], 0.1460, places=3) - self.assertAlmostEqual(credible_intervals[variant_control.key][1], 0.2535, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][0], 0.0585, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_1.key][1], 0.1190, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][0], 0.0929, places=3) - self.assertAlmostEqual(credible_intervals[variant_test_2.key][1], 0.2206, places=3) diff --git a/ee/clickhouse/queries/experiments/test_utils.py b/ee/clickhouse/queries/experiments/test_utils.py deleted file mode 100644 index f01b00d0bb2..00000000000 --- a/ee/clickhouse/queries/experiments/test_utils.py +++ /dev/null @@ -1,160 +0,0 @@ -from ee.clickhouse.queries.experiments.utils import requires_flag_warning -from posthog.constants import INSIGHT_FUNNELS -from posthog.models.action.action import Action -from posthog.models.filters.filter import Filter -from posthog.test.base import APIBaseTest, ClickhouseTestMixin -from posthog.test.test_journeys import journeys_for - - -class TestUtils(ClickhouseTestMixin, APIBaseTest): - def test_with_no_feature_flag_properties_on_events(self): - journeys_for( - team=self.team, - events_by_person={ - "person1": [ - {"event": "user signed up", "properties": {"$os": "Windows"}}, - ], - "person2": [ - {"event": "user signed up", "properties": {"$os": "Windows"}}, - ], - }, - ) - - filter = Filter( - data={ - "events": [{"id": "user signed up", "type": "events", "order": 0}], - "insight": INSIGHT_FUNNELS, - } - ) - - self.assertTrue(requires_flag_warning(filter, self.team)) - - def test_with_feature_flag_properties_on_events(self): - journeys_for( - team=self.team, - events_by_person={ - "person1": [ - { - "event": "user signed up", - "properties": {"$os": "Windows", "$feature/aloha": "control"}, - }, - ], - "person2": [ - { - "event": "user signed up", - "properties": {"$os": "Windows", "$feature/aloha": "test"}, - }, - ], - }, - ) - - filter = Filter( - data={ - "events": [{"id": "user signed up", "type": "events", "order": 0}], - "insight": INSIGHT_FUNNELS, - } - ) - - self.assertFalse(requires_flag_warning(filter, self.team)) - - def test_with_no_feature_flag_properties_on_actions(self): - action_credit_card = Action.objects.create( - team=self.team, - name="paid", - steps_json=[ - { - "event": "paid", - "properties": [ - { - "key": "$os", - "type": "event", - "value": ["Windows"], - "operator": "exact", - } - ], - }, - { - "event": "$autocapture", - "tag_name": "button", - "text": "Pay $10", - }, - ], - ) - - filter = Filter( - data={ - "events": [{"id": "user signed up", "type": "events", "order": 0}], - "actions": [ - {"id": action_credit_card.pk, "type": "actions", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - } - ) - - journeys_for( - team=self.team, - events_by_person={ - "person1": [ - {"event": "user signed up", "properties": {"$os": "Windows"}}, - {"event": "paid", "properties": {"$os": "Windows"}}, - ], - "person2": [ - {"event": "paid", "properties": {"$os": "Windows"}}, - ], - "person3": [ - {"event": "user signed up", "properties": {"$os": "Windows"}}, - ], - }, - ) - - self.assertTrue(requires_flag_warning(filter, self.team)) - - def test_with_feature_flag_properties_on_actions(self): - action_credit_card = Action.objects.create( - team=self.team, - name="paid", - steps_json=[ - { - "event": "paid", - "properties": [ - { - "key": "$os", - "type": "event", - "value": ["Windows"], - "operator": "exact", - } - ], - } - ], - ) - - filter = Filter( - data={ - "events": [{"id": "user signed up", "type": "events", "order": 0}], - "actions": [ - {"id": action_credit_card.pk, "type": "actions", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - } - ) - - journeys_for( - team=self.team, - events_by_person={ - "person1": [ - {"event": "user signed up", "properties": {"$os": "Windows"}}, - {"event": "paid", "properties": {"$os": "Windows"}}, - ], - "person2": [ - { - "event": "paid", - "properties": {"$os": "Windows", "$feature/aloha": "test"}, - }, - ], - "person3": [ - {"event": "user signed up", "properties": {"$os": "Windows"}}, - ], - }, - ) - - self.assertFalse(requires_flag_warning(filter, self.team)) diff --git a/ee/clickhouse/queries/experiments/trend_experiment_result.py b/ee/clickhouse/queries/experiments/trend_experiment_result.py deleted file mode 100644 index 781d5690f00..00000000000 --- a/ee/clickhouse/queries/experiments/trend_experiment_result.py +++ /dev/null @@ -1,543 +0,0 @@ -import json -from dataclasses import asdict, dataclass -from datetime import datetime -from functools import lru_cache -from math import exp, lgamma, log -from typing import Optional -from zoneinfo import ZoneInfo - -from numpy.random import default_rng -from rest_framework.exceptions import ValidationError -import scipy.stats as stats -from sentry_sdk import capture_exception - -from ee.clickhouse.queries.experiments import ( - CONTROL_VARIANT_KEY, - FF_DISTRIBUTION_THRESHOLD, - MIN_PROBABILITY_FOR_SIGNIFICANCE, -) -from posthog.constants import ( - ACTIONS, - EVENTS, - TRENDS_CUMULATIVE, - TRENDS_LINEAR, - UNIQUE_USERS, - ExperimentSignificanceCode, - ExperimentNoResultsErrorKeys, -) -from posthog.models.feature_flag import FeatureFlag -from posthog.models.filters.filter import Filter -from posthog.models.team import Team -from posthog.queries.trends.trends import Trends -from posthog.queries.trends.util import ALL_SUPPORTED_MATH_FUNCTIONS - -Probability = float - -P_VALUE_SIGNIFICANCE_LEVEL = 0.05 - - -@dataclass(frozen=True) -class Variant: - key: str - count: int - # a fractional value, representing the proportion of the variant's exposure events relative to *control* exposure events - # default: the proportion of unique users relative to the *control* unique users - exposure: float - # count of total exposure events exposed for a variant - # default: total number of unique users exposed to the variant (via "Feature flag called" event) - absolute_exposure: int - - -def uses_math_aggregation_by_user_or_property_value(filter: Filter): - # sync with frontend: https://github.com/PostHog/posthog/blob/master/frontend/src/scenes/experiments/experimentLogic.tsx#L662 - # the selector experimentCountPerUserMath - - entities = filter.entities - math_keys = ALL_SUPPORTED_MATH_FUNCTIONS - - # 'sum' doesn't need special handling, we can have custom exposure for sum filters - if "sum" in math_keys: - math_keys.remove("sum") - - return any(entity.math in math_keys for entity in entities) - - -class ClickhouseTrendExperimentResult: - """ - This class calculates Experiment Results. - It returns two things: - 1. A trend Breakdown based on Feature Flag values - 2. Probability that Feature Flag value 1 has better conversion rate then FeatureFlag value 2 - - Currently, it only supports two feature flag values: control and test - - The passed in Filter determines which trend to create, along with the experiment start & end date values - - Calculating (2) uses the formula here: https://www.evanmiller.org/bayesian-ab-testing.html#count_ab - """ - - def __init__( - self, - filter: Filter, - team: Team, - feature_flag: FeatureFlag, - experiment_start_date: datetime, - experiment_end_date: Optional[datetime] = None, - trend_class: type[Trends] = Trends, - custom_exposure_filter: Optional[Filter] = None, - ): - breakdown_key = f"$feature/{feature_flag.key}" - self.variants = [variant["key"] for variant in feature_flag.variants] - - # our filters assume that the given time ranges are in the project timezone. - # while start and end date are in UTC. - # so we need to convert them to the project timezone - if team.timezone: - start_date_in_project_timezone = experiment_start_date.astimezone(ZoneInfo(team.timezone)) - end_date_in_project_timezone = ( - experiment_end_date.astimezone(ZoneInfo(team.timezone)) if experiment_end_date else None - ) - - uses_math_aggregation = uses_math_aggregation_by_user_or_property_value(filter) - - # Keep in sync with https://github.com/PostHog/posthog/blob/master/frontend/src/scenes/experiments/ExperimentView/components.tsx#L91 - query_filter = filter.shallow_clone( - { - "display": TRENDS_CUMULATIVE if not uses_math_aggregation else TRENDS_LINEAR, - "date_from": start_date_in_project_timezone, - "date_to": end_date_in_project_timezone, - "explicit_date": True, - "breakdown": breakdown_key, - "breakdown_type": "event", - "properties": [ - { - "key": breakdown_key, - "value": self.variants, - "operator": "exact", - "type": "event", - } - ], - # :TRICKY: We don't use properties set on filters, instead using experiment variant options - # :TRICKY: We don't use properties set on filters, as these - # correspond to feature flag properties, not the trend properties. - # This is also why we simplify only right now so new properties (from test account filters) - # are added appropriately. - "is_simplified": False, - } - ) - - if uses_math_aggregation: - # A trend experiment can have only one metric, so take the first one to calculate exposure - # We copy the entity to avoid mutating the original filter - entity = query_filter.shallow_clone({}).entities[0] - # :TRICKY: With count per user aggregation, our exposure filter is implicit: - # (1) We calculate the unique users for this event -> this is the exposure - # (2) We calculate the total count of this event -> this is the trend goal metric / arrival rate for probability calculation - # TODO: When we support group aggregation per user, change this. - entity.math = None - exposure_entity = entity.to_dict() - entity.math = UNIQUE_USERS - count_entity = entity.to_dict() - - target_entities = [exposure_entity, count_entity] - query_filter_actions = [] - query_filter_events = [] - if entity.type == ACTIONS: - query_filter_actions = target_entities - else: - query_filter_events = target_entities - - # two entities in exposure, one for count, the other for result - exposure_filter = query_filter.shallow_clone( - { - "display": TRENDS_CUMULATIVE, - ACTIONS: query_filter_actions, - EVENTS: query_filter_events, - } - ) - - else: - # TODO: Exposure doesn't need to compute daily values, so instead of - # using TRENDS_CUMULATIVE, we can use TRENDS_TABLE to just get the total. - if custom_exposure_filter: - exposure_filter = custom_exposure_filter.shallow_clone( - { - "display": TRENDS_CUMULATIVE, - "date_from": experiment_start_date, - "date_to": experiment_end_date, - "explicit_date": True, - "breakdown": breakdown_key, - "breakdown_type": "event", - "properties": [ - { - "key": breakdown_key, - "value": self.variants, - "operator": "exact", - "type": "event", - } - ], - # :TRICKY: We don't use properties set on filters, as these - # correspond to feature flag properties, not the trend-exposure properties. - # This is also why we simplify only right now so new properties (from test account filters) - # are added appropriately. - "is_simplified": False, - } - ) - else: - exposure_filter = filter.shallow_clone( - { - "display": TRENDS_CUMULATIVE, - "date_from": experiment_start_date, - "date_to": experiment_end_date, - "explicit_date": True, - ACTIONS: [], - EVENTS: [ - { - "id": "$feature_flag_called", - "name": "$feature_flag_called", - "order": 0, - "type": "events", - "math": "dau", - } - ], - "breakdown_type": "event", - "breakdown": "$feature_flag_response", - "properties": [ - { - "key": "$feature_flag_response", - "value": self.variants, - "operator": "exact", - "type": "event", - }, - { - "key": "$feature_flag", - "value": [feature_flag.key], - "operator": "exact", - "type": "event", - }, - ], - # :TRICKY: We don't use properties set on filters, as these - # correspond to feature flag properties, not the trend-exposure properties. - # This is also why we simplify only right now so new properties (from test account filters) - # are added appropriately. - "is_simplified": False, - } - ) - - self.query_filter = query_filter - self.exposure_filter = exposure_filter - self.team = team - self.insight = trend_class() - - def get_results(self, validate: bool = True): - insight_results = self.insight.run(self.query_filter, self.team) - exposure_results = self.insight.run(self.exposure_filter, self.team) - - basic_result_props = {"insight": insight_results, "filters": self.query_filter.to_dict()} - - try: - validate_event_variants(insight_results, self.variants) - - control_variant, test_variants = self.get_variants(insight_results, exposure_results) - - probabilities = self.calculate_results(control_variant, test_variants) - - mapping = { - variant.key: probability - for variant, probability in zip([control_variant, *test_variants], probabilities) - } - - significance_code, p_value = self.are_results_significant(control_variant, test_variants, probabilities) - - credible_intervals = calculate_credible_intervals([control_variant, *test_variants]) - except ValidationError: - if validate: - raise - else: - return basic_result_props - - return { - **basic_result_props, - "probability": mapping, - "significant": significance_code == ExperimentSignificanceCode.SIGNIFICANT, - "significance_code": significance_code, - "p_value": p_value, - "variants": [asdict(variant) for variant in [control_variant, *test_variants]], - "credible_intervals": credible_intervals, - } - - def get_variants(self, insight_results, exposure_results): - # this assumes the Trend insight is Cumulative - control_variant = None - test_variants = [] - exposure_counts = {} - exposure_ratios = {} - - # :TRICKY: With count per user aggregation, our exposure filter is implicit: - # (1) We calculate the unique users for this event -> this is the exposure - # (2) We calculate the total count of this event -> this is the trend goal metric / arrival rate for probability calculation - # TODO: When we support group aggregation per user, change this. - if uses_math_aggregation_by_user_or_property_value(self.query_filter): - filtered_exposure_results = [ - result for result in exposure_results if result["action"]["math"] == UNIQUE_USERS - ] - filtered_insight_results = [ - result for result in exposure_results if result["action"]["math"] != UNIQUE_USERS - ] - else: - filtered_exposure_results = exposure_results - filtered_insight_results = insight_results - - for result in filtered_exposure_results: - count = result["count"] - breakdown_value = result["breakdown_value"] - exposure_counts[breakdown_value] = count - - control_exposure = exposure_counts.get(CONTROL_VARIANT_KEY, 0) - - if control_exposure != 0: - for key, count in exposure_counts.items(): - exposure_ratios[key] = count / control_exposure - - for result in filtered_insight_results: - count = result["count"] - breakdown_value = result["breakdown_value"] - if breakdown_value == CONTROL_VARIANT_KEY: - # count exposure value is always 1, the baseline - control_variant = Variant( - key=breakdown_value, - count=int(count), - exposure=1, - absolute_exposure=exposure_counts.get(breakdown_value, 1), - ) - else: - test_variants.append( - Variant( - breakdown_value, - int(count), - exposure_ratios.get(breakdown_value, 1), - exposure_counts.get(breakdown_value, 1), - ) - ) - - return control_variant, test_variants - - @staticmethod - def calculate_results(control_variant: Variant, test_variants: list[Variant]) -> list[Probability]: - """ - Calculates probability that A is better than B. First variant is control, rest are test variants. - - Supports maximum 10 variants today - - For each variant, we create a Gamma distribution of arrival rates, - where alpha (shape parameter) = count of variant + 1 - beta (exposure parameter) = 1 - """ - if not control_variant: - raise ValidationError("No control variant data found", code="no_data") - - if len(test_variants) >= 10: - raise ValidationError( - "Can't calculate A/B test results for more than 10 variants", - code="too_much_data", - ) - - if len(test_variants) < 1: - raise ValidationError( - "Can't calculate A/B test results for less than 2 variants", - code="no_data", - ) - - return calculate_probability_of_winning_for_each([control_variant, *test_variants]) - - @staticmethod - def are_results_significant( - control_variant: Variant, - test_variants: list[Variant], - probabilities: list[Probability], - ) -> tuple[ExperimentSignificanceCode, Probability]: - # TODO: Experiment with Expected Loss calculations for trend experiments - - for variant in test_variants: - # We need a feature flag distribution threshold because distribution of people - # can skew wildly when there are few people in the experiment - if variant.absolute_exposure < FF_DISTRIBUTION_THRESHOLD: - return ExperimentSignificanceCode.NOT_ENOUGH_EXPOSURE, 1 - - if control_variant.absolute_exposure < FF_DISTRIBUTION_THRESHOLD: - return ExperimentSignificanceCode.NOT_ENOUGH_EXPOSURE, 1 - - if ( - probabilities[0] < MIN_PROBABILITY_FOR_SIGNIFICANCE - and sum(probabilities[1:]) < MIN_PROBABILITY_FOR_SIGNIFICANCE - ): - # Sum of probability of winning for all variants except control is less than 90% - return ExperimentSignificanceCode.LOW_WIN_PROBABILITY, 1 - - p_value = calculate_p_value(control_variant, test_variants) - - if p_value >= P_VALUE_SIGNIFICANCE_LEVEL: - return ExperimentSignificanceCode.HIGH_P_VALUE, p_value - - return ExperimentSignificanceCode.SIGNIFICANT, p_value - - -def simulate_winning_variant_for_arrival_rates(target_variant: Variant, variants: list[Variant]) -> float: - random_sampler = default_rng() - simulations_count = 100_000 - - variant_samples = [] - for variant in variants: - # Get `N=simulations` samples from a Gamma distribution with alpha = variant_sucess + 1, - # and exposure = relative exposure of variant - samples = random_sampler.gamma(variant.count + 1, 1 / variant.exposure, simulations_count) - variant_samples.append(samples) - - target_variant_samples = random_sampler.gamma( - target_variant.count + 1, 1 / target_variant.exposure, simulations_count - ) - - winnings = 0 - variant_conversions = list(zip(*variant_samples)) - for i in range(simulations_count): - if target_variant_samples[i] > max(variant_conversions[i]): - winnings += 1 - - return winnings / simulations_count - - -def calculate_probability_of_winning_for_each(variants: list[Variant]) -> list[Probability]: - """ - Calculates the probability of winning for each variant. - """ - - if len(variants) > 10: - raise ValidationError( - "Can't calculate A/B test results for more than 10 variants", - code="too_much_data", - ) - - probabilities = [] - # simulate winning for each test variant - for index, variant in enumerate(variants): - probabilities.append( - simulate_winning_variant_for_arrival_rates(variant, variants[:index] + variants[index + 1 :]) - ) - - total_test_probabilities = sum(probabilities[1:]) - - return [max(0, 1 - total_test_probabilities), *probabilities[1:]] - - -@lru_cache(maxsize=100_000) -def combinationln(n: int, k: int) -> float: - """ - Returns the log of the binomial coefficient. - """ - return lgamma(n + 1) - lgamma(k + 1) - lgamma(n - k + 1) - - -def intermediate_poisson_term(count: int, iterator: int, relative_exposure: float): - return exp( - combinationln(count, iterator) - + iterator * log(relative_exposure) - + (count - iterator) * log(1 - relative_exposure) - ) - - -def poisson_p_value(control_count, control_exposure, test_count, test_exposure): - """ - Calculates the p-value of the A/B test. - Calculations from: https://www.evanmiller.org/statistical-formulas-for-programmers.html#count_test - """ - relative_exposure = test_exposure / (control_exposure + test_exposure) - total_count = control_count + test_count - - low_p_value = 0.0 - high_p_value = 0.0 - - for i in range(test_count + 1): - low_p_value += intermediate_poisson_term(total_count, i, relative_exposure) - - for i in range(test_count, total_count + 1): - high_p_value += intermediate_poisson_term(total_count, i, relative_exposure) - - return min(1, 2 * min(low_p_value, high_p_value)) - - -def calculate_p_value(control_variant: Variant, test_variants: list[Variant]) -> Probability: - best_test_variant = max(test_variants, key=lambda variant: variant.count) - - return poisson_p_value( - control_variant.count, - control_variant.exposure, - best_test_variant.count, - best_test_variant.exposure, - ) - - -def calculate_credible_intervals(variants, lower_bound=0.025, upper_bound=0.975): - """ - Calculate the Bayesian credible intervals for the mean (average events per unit) - for a list of variants in a Trend experiment. - If no lower/upper bound is provided, the function calculates the 95% credible interval. - """ - intervals = {} - - for variant in variants: - try: - # Alpha (shape parameter) is count + 1, assuming a Gamma distribution for counts - alpha = variant.count + 1 - - # Beta (scale parameter) is the inverse of absolute_exposure, - # representing the average rate of events per user - beta = 1 / variant.absolute_exposure - - # Calculate the credible interval for the mean using Gamma distribution - credible_interval = stats.gamma.ppf([lower_bound, upper_bound], a=alpha, scale=beta) - - intervals[variant.key] = (credible_interval[0], credible_interval[1]) - - except Exception as e: - capture_exception( - Exception(f"Error calculating credible interval for variant {variant.key}"), - {"error": str(e)}, - ) - return {} - - return intervals - - -def validate_event_variants(trend_results, variants): - errors = { - ExperimentNoResultsErrorKeys.NO_EVENTS: True, - ExperimentNoResultsErrorKeys.NO_FLAG_INFO: True, - ExperimentNoResultsErrorKeys.NO_CONTROL_VARIANT: True, - ExperimentNoResultsErrorKeys.NO_TEST_VARIANT: True, - } - - if not trend_results or not trend_results[0]: - raise ValidationError(code="no-results", detail=json.dumps(errors)) - - errors[ExperimentNoResultsErrorKeys.NO_EVENTS] = False - - # Check if "control" is present - for event in trend_results: - event_variant = event.get("breakdown_value") - if event_variant == "control": - errors[ExperimentNoResultsErrorKeys.NO_CONTROL_VARIANT] = False - errors[ExperimentNoResultsErrorKeys.NO_FLAG_INFO] = False - break - - # Check if at least one of the test variants is present - test_variants = [variant for variant in variants if variant != "control"] - for event in trend_results: - event_variant = event.get("breakdown_value") - if event_variant in test_variants: - errors[ExperimentNoResultsErrorKeys.NO_TEST_VARIANT] = False - errors[ExperimentNoResultsErrorKeys.NO_FLAG_INFO] = False - break - - has_errors = any(errors.values()) - if has_errors: - raise ValidationError(detail=json.dumps(errors)) diff --git a/ee/clickhouse/queries/experiments/utils.py b/ee/clickhouse/queries/experiments/utils.py deleted file mode 100644 index 5837a6aa9dc..00000000000 --- a/ee/clickhouse/queries/experiments/utils.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import Union - -from posthog.client import sync_execute -from posthog.constants import TREND_FILTER_TYPE_ACTIONS -from posthog.models.filters.filter import Filter -from posthog.models.team.team import Team -from posthog.queries.query_date_range import QueryDateRange - - -def requires_flag_warning(filter: Filter, team: Team) -> bool: - date_params = {} - query_date_range = QueryDateRange(filter=filter, team=team, should_round=False) - parsed_date_from, date_from_params = query_date_range.date_from - parsed_date_to, date_to_params = query_date_range.date_to - date_params.update(date_from_params) - date_params.update(date_to_params) - - date_query = f""" - {parsed_date_from} - {parsed_date_to} - """ - - events: set[Union[int, str]] = set() - entities_to_use = filter.entities - - for entity in entities_to_use: - if entity.type == TREND_FILTER_TYPE_ACTIONS: - action = entity.get_action() - for step_event in action.get_step_events(): - if step_event: - # TODO: Fix this to detect if "all events" (i.e. None) is in the list and change the entiry query to e.g. AND 1=1 - events.add(step_event) - elif entity.id is not None: - events.add(entity.id) - - entity_query = f"AND event IN %(events_list)s" - entity_params = {"events_list": sorted(events)} - - events_result = sync_execute( - f""" - SELECT - event, - groupArraySample(%(limit)s)(properties) - FROM events - WHERE - team_id = %(team_id)s - {entity_query} - {date_query} - GROUP BY event - """, - { - "team_id": team.pk, - "limit": filter.limit or 20, - **date_params, - **entity_params, - **filter.hogql_context.values, - }, - ) - - requires_flag_warning = True - - for _event, property_group_list in events_result: - for property_group in property_group_list: - if "$feature/" in property_group: - requires_flag_warning = False - break - - return requires_flag_warning diff --git a/ee/clickhouse/queries/funnels/__init__.py b/ee/clickhouse/queries/funnels/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/clickhouse/queries/funnels/funnel_correlation.py b/ee/clickhouse/queries/funnels/funnel_correlation.py deleted file mode 100644 index d23c459c7ef..00000000000 --- a/ee/clickhouse/queries/funnels/funnel_correlation.py +++ /dev/null @@ -1,972 +0,0 @@ -import dataclasses -import urllib.parse -from typing import ( - Any, - Literal, - Optional, - TypedDict, - Union, - cast, -) - -from rest_framework.exceptions import ValidationError - -from ee.clickhouse.queries.column_optimizer import EnterpriseColumnOptimizer -from ee.clickhouse.queries.groups_join_query import GroupsJoinQuery -from posthog.clickhouse.materialized_columns import get_materialized_columns -from posthog.constants import ( - AUTOCAPTURE_EVENT, - TREND_FILTER_TYPE_ACTIONS, - FunnelCorrelationType, -) -from posthog.models.element.element import chain_to_elements -from posthog.models.event.util import ElementSerializer -from posthog.models.filters import Filter -from posthog.models.property.util import get_property_string_expr -from posthog.models.team import Team -from posthog.queries.funnels.utils import get_funnel_order_actor_class -from posthog.queries.insight import insight_sync_execute -from posthog.queries.person_distinct_id_query import get_team_distinct_ids_query -from posthog.queries.person_query import PersonQuery -from posthog.queries.util import alias_poe_mode_for_legacy, correct_result_for_sampling -from posthog.schema import PersonsOnEventsMode -from posthog.utils import generate_short_id - - -class EventDefinition(TypedDict): - event: str - properties: dict[str, Any] - elements: list - - -class EventOddsRatio(TypedDict): - event: str - - success_count: int - failure_count: int - - odds_ratio: float - correlation_type: Literal["success", "failure"] - - -class EventOddsRatioSerialized(TypedDict): - event: EventDefinition - - success_count: int - success_people_url: Optional[str] - - failure_count: int - failure_people_url: Optional[str] - - odds_ratio: float - correlation_type: Literal["success", "failure"] - - -class FunnelCorrelationResponse(TypedDict): - """ - The structure that the diagnose response will be returned in. - NOTE: TypedDict is used here to comply with existing formats from other - queries, but we could use, for example, a dataclass - """ - - events: list[EventOddsRatioSerialized] - skewed: bool - - -@dataclasses.dataclass -class EventStats: - success_count: int - failure_count: int - - -@dataclasses.dataclass -class EventContingencyTable: - """ - Represents a contingency table for a single event. Note that this isn't a - complete contingency table, but rather only includes totals for - failure/success as opposed to including the number of successes for cases - that a persons _doesn't_ visit an event. - """ - - event: str - visited: EventStats - - success_total: int - failure_total: int - - -class FunnelCorrelation: - TOTAL_IDENTIFIER = "Total_Values_In_Query" - ELEMENTS_DIVIDER = "__~~__" - AUTOCAPTURE_EVENT_TYPE = "$event_type" - MIN_PERSON_COUNT = 25 - MIN_PERSON_PERCENTAGE = 0.02 - PRIOR_COUNT = 1 - - def __init__( - self, - filter: Filter, # Used to filter people - team: Team, # Used to partition by team - base_uri: str = "/", # Used to generate absolute urls - ) -> None: - self._filter = filter - self._team = team - self._base_uri = base_uri - - if self._filter.funnel_step is None: - self._filter = self._filter.shallow_clone({"funnel_step": 1}) - # Funnel Step by default set to 1, to give us all people who entered the funnel - - # Used for generating the funnel persons cte - - filter_data = { - key: value - for key, value in self._filter.to_dict().items() - # NOTE: we want to filter anything about correlation, as the - # funnel persons endpoint does not understand or need these - # params. - if not key.startswith("funnel_correlation_") - } - # NOTE: we always use the final matching event for the recording because this - # is the the right event for both drop off and successful funnels - filter_data.update({"include_final_matching_events": self._filter.include_recordings}) - filter = Filter(data=filter_data, hogql_context=self._filter.hogql_context) - - funnel_order_actor_class = get_funnel_order_actor_class(filter) - - self._funnel_actors_generator = funnel_order_actor_class( - filter, - self._team, - # NOTE: we want to include the latest timestamp of the `target_step`, - # from this we can deduce if the person reached the end of the funnel, - # i.e. successful - include_timestamp=True, - # NOTE: we don't need these as we have all the information we need to - # deduce if the person was successful or not - include_preceding_timestamp=False, - include_properties=self.properties_to_include, - ) - - @property - def properties_to_include(self) -> list[str]: - props_to_include = [] - if ( - alias_poe_mode_for_legacy(self._team.person_on_events_mode) != PersonsOnEventsMode.DISABLED - and self._filter.correlation_type == FunnelCorrelationType.PROPERTIES - ): - # When dealing with properties, make sure funnel response comes with properties - # so we don't have to join on persons/groups to get these properties again - mat_event_cols = get_materialized_columns("events") - - for property_name in cast(list, self._filter.correlation_property_names): - if self._filter.aggregation_group_type_index is not None: - continue # We don't support group properties on events at this time - else: - if "$all" == property_name: - return [f"person_properties"] - - possible_mat_col = mat_event_cols.get((property_name, "person_properties")) - - if possible_mat_col is not None: - props_to_include.append(possible_mat_col) - else: - props_to_include.append(f"person_properties") - - return props_to_include - - def support_autocapture_elements(self) -> bool: - if ( - self._filter.correlation_type == FunnelCorrelationType.EVENT_WITH_PROPERTIES - and AUTOCAPTURE_EVENT in self._filter.correlation_event_names - ): - return True - return False - - def get_contingency_table_query(self) -> tuple[str, dict[str, Any]]: - """ - Returns a query string and params, which are used to generate the contingency table. - The query returns success and failure count for event / property values, along with total success and failure counts. - """ - if self._filter.correlation_type == FunnelCorrelationType.PROPERTIES: - return self.get_properties_query() - - if self._filter.correlation_type == FunnelCorrelationType.EVENT_WITH_PROPERTIES: - return self.get_event_property_query() - - return self.get_event_query() - - def get_event_query(self) -> tuple[str, dict[str, Any]]: - funnel_persons_query, funnel_persons_params = self.get_funnel_actors_cte() - - event_join_query = self._get_events_join_query() - - query = f""" - WITH - funnel_actors as ({funnel_persons_query}), - toDateTime(%(date_to)s, %(timezone)s) AS date_to, - toDateTime(%(date_from)s, %(timezone)s) AS date_from, - %(target_step)s AS target_step, - %(funnel_step_names)s as funnel_step_names - - SELECT - event.event AS name, - - -- If we have a `person.steps = target_step`, we know the person - -- reached the end of the funnel - countDistinctIf( - actors.actor_id, - actors.steps = target_step - ) AS success_count, - - -- And the converse being for failures - countDistinctIf( - actors.actor_id, - actors.steps <> target_step - ) AS failure_count - - FROM events AS event - {event_join_query} - AND event.event NOT IN %(exclude_event_names)s - GROUP BY name - - -- To get the total success/failure numbers, we do an aggregation on - -- the funnel people CTE and count distinct actor_ids - UNION ALL - - SELECT - -- We're not using WITH TOTALS because the resulting queries are - -- not runnable in Metabase - '{self.TOTAL_IDENTIFIER}' as name, - - countDistinctIf( - actors.actor_id, - actors.steps = target_step - ) AS success_count, - - countDistinctIf( - actors.actor_id, - actors.steps <> target_step - ) AS failure_count - FROM funnel_actors AS actors - """ - params = { - **funnel_persons_params, - "funnel_step_names": self._get_funnel_step_names(), - "target_step": len(self._filter.entities), - "exclude_event_names": self._filter.correlation_event_exclude_names, - } - - return query, params - - def get_event_property_query(self) -> tuple[str, dict[str, Any]]: - if not self._filter.correlation_event_names: - raise ValidationError("Event Property Correlation expects atleast one event name to run correlation on") - - funnel_persons_query, funnel_persons_params = self.get_funnel_actors_cte() - - event_join_query = self._get_events_join_query() - - if self.support_autocapture_elements(): - event_type_expression, _ = get_property_string_expr( - "events", - self.AUTOCAPTURE_EVENT_TYPE, - f"'{self.AUTOCAPTURE_EVENT_TYPE}'", - "properties", - ) - array_join_query = f""" - 'elements_chain' as prop_key, - concat({event_type_expression}, '{self.ELEMENTS_DIVIDER}', elements_chain) as prop_value, - tuple(prop_key, prop_value) as prop - """ - else: - array_join_query = f""" - arrayJoin(JSONExtractKeysAndValues(properties, 'String')) as prop - """ - - query = f""" - WITH - funnel_actors as ({funnel_persons_query}), - toDateTime(%(date_to)s, %(timezone)s) AS date_to, - toDateTime(%(date_from)s, %(timezone)s) AS date_from, - %(target_step)s AS target_step, - %(funnel_step_names)s as funnel_step_names - - SELECT concat(event_name, '::', prop.1, '::', prop.2) as name, - countDistinctIf(actor_id, steps = target_step) as success_count, - countDistinctIf(actor_id, steps <> target_step) as failure_count - FROM ( - SELECT - actors.actor_id as actor_id, - actors.steps as steps, - events.event as event_name, - -- Same as what we do in $all property queries - {array_join_query} - FROM events AS event - {event_join_query} - AND event.event IN %(event_names)s - ) - GROUP BY name - -- Discard high cardinality / low hits properties - -- This removes the long tail of random properties with empty, null, or very small values - HAVING (success_count + failure_count) > 2 - AND prop.1 NOT IN %(exclude_property_names)s - - UNION ALL - -- To get the total success/failure numbers, we do an aggregation on - -- the funnel people CTE and count distinct actor_ids - SELECT - '{self.TOTAL_IDENTIFIER}' as name, - - countDistinctIf( - actors.actor_id, - actors.steps = target_step - ) AS success_count, - - countDistinctIf( - actors.actor_id, - actors.steps <> target_step - ) AS failure_count - FROM funnel_actors AS actors - """ - params = { - **funnel_persons_params, - "funnel_step_names": self._get_funnel_step_names(), - "target_step": len(self._filter.entities), - "event_names": self._filter.correlation_event_names, - "exclude_property_names": self._filter.correlation_event_exclude_property_names, - } - - return query, params - - def get_properties_query(self) -> tuple[str, dict[str, Any]]: - if not self._filter.correlation_property_names: - raise ValidationError("Property Correlation expects atleast one Property to run correlation on") - - funnel_actors_query, funnel_actors_params = self.get_funnel_actors_cte() - - person_prop_query, person_prop_params = self._get_properties_prop_clause() - - ( - aggregation_join_query, - aggregation_join_params, - ) = self._get_aggregation_join_query() - - query = f""" - WITH - funnel_actors as ({funnel_actors_query}), - %(target_step)s AS target_step - SELECT - concat(prop.1, '::', prop.2) as name, - -- We generate a unique identifier for each property value as: PropertyName::Value - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM ( - SELECT - actor_id, - funnel_actors.steps as steps, - /* - We can extract multiple property values at the same time, since we're - already querying the person table. - This gives us something like: - -------------------- - person1, steps, [property_value_0, property_value_1, property_value_2] - person2, steps, [property_value_0, property_value_1, property_value_2] - - To group by property name, we need to extract the property from the array. ArrayJoin helps us do that. - It transforms the above into: - - -------------------- - - person1, steps, property_value_0 - person1, steps, property_value_1 - person1, steps, property_value_2 - - person2, steps, property_value_0 - person2, steps, property_value_1 - person2, steps, property_value_2 - - To avoid clashes and clarify the values, we also zip with the property name, to generate - tuples like: (property_name, property_value), which we then group by - */ - {person_prop_query} - FROM funnel_actors - {aggregation_join_query} - - ) aggregation_target_with_props - -- Group by the tuple items: (property_name, property_value) generated by zip - GROUP BY prop.1, prop.2 - HAVING prop.1 NOT IN %(exclude_property_names)s - UNION ALL - SELECT - '{self.TOTAL_IDENTIFIER}' as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM funnel_actors - """ - params = { - **funnel_actors_params, - **person_prop_params, - **aggregation_join_params, - "target_step": len(self._filter.entities), - "property_names": self._filter.correlation_property_names, - "exclude_property_names": self._filter.correlation_property_exclude_names, - } - - return query, params - - def _get_aggregation_target_join_query(self) -> str: - if self._team.person_on_events_mode == PersonsOnEventsMode.PERSON_ID_NO_OVERRIDE_PROPERTIES_ON_EVENTS: - aggregation_person_join = f""" - JOIN funnel_actors as actors - ON event.person_id = actors.actor_id - """ - - else: - aggregation_person_join = f""" - JOIN ({get_team_distinct_ids_query(self._team.pk)}) AS pdi - ON pdi.distinct_id = events.distinct_id - - -- NOTE: I would love to right join here, so we count get total - -- success/failure numbers in one pass, but this causes out of memory - -- error mentioning issues with right filling. I'm sure there's a way - -- to do it but lifes too short. - JOIN funnel_actors AS actors - ON pdi.person_id = actors.actor_id - """ - - aggregation_group_join = f""" - JOIN funnel_actors AS actors - ON actors.actor_id = events.$group_{self._filter.aggregation_group_type_index} - """ - - return ( - aggregation_group_join if self._filter.aggregation_group_type_index is not None else aggregation_person_join - ) - - def _get_events_join_query(self) -> str: - """ - This query is used to join and filter the events table corresponding to the funnel_actors CTE. - It expects the following variables to be present in the CTE expression: - - funnel_actors - - date_to - - date_from - - funnel_step_names - """ - - return f""" - {self._get_aggregation_target_join_query()} - - -- Make sure we're only looking at events before the final step, or - -- failing that, date_to - WHERE - -- add this condition in to ensure we can filter events before - -- joining funnel_actors - toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - - AND event.team_id = {self._team.pk} - - -- Add in per actor filtering on event time range. We just want - -- to include events that happened within the bounds of the - -- actors time in the funnel. - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE( - actors.final_timestamp, - actors.first_timestamp + INTERVAL {self._funnel_actors_generator._filter.funnel_window_interval} {self._funnel_actors_generator._filter.funnel_window_interval_unit_ch()}, - date_to) - -- Ensure that the event is not outside the bounds of the funnel conversion window - - -- Exclude funnel steps - AND event.event NOT IN funnel_step_names - """ - - def _get_aggregation_join_query(self): - if self._filter.aggregation_group_type_index is None: - person_query, person_query_params = PersonQuery( - self._filter, - self._team.pk, - EnterpriseColumnOptimizer(self._filter, self._team.pk), - ).get_query() - - return ( - f""" - JOIN ({person_query}) person - ON person.id = funnel_actors.actor_id - """, - person_query_params, - ) - else: - return GroupsJoinQuery(self._filter, self._team.pk, join_key="funnel_actors.actor_id").get_join_query() - - def _get_properties_prop_clause(self): - if ( - alias_poe_mode_for_legacy(self._team.person_on_events_mode) != PersonsOnEventsMode.DISABLED - and self._filter.aggregation_group_type_index is None - ): - aggregation_properties_alias = "person_properties" - else: - group_properties_field = f"groups_{self._filter.aggregation_group_type_index}.group_properties_{self._filter.aggregation_group_type_index}" - aggregation_properties_alias = ( - PersonQuery.PERSON_PROPERTIES_ALIAS - if self._filter.aggregation_group_type_index is None - else group_properties_field - ) - - if "$all" in cast(list, self._filter.correlation_property_names): - return ( - f""" - arrayJoin(JSONExtractKeysAndValues({aggregation_properties_alias}, 'String')) as prop - """, - {}, - ) - else: - person_property_expressions = [] - person_property_params = {} - for index, property_name in enumerate(cast(list, self._filter.correlation_property_names)): - param_name = f"property_name_{index}" - if self._filter.aggregation_group_type_index is not None: - expression, _ = get_property_string_expr( - "groups" - if alias_poe_mode_for_legacy(self._team.person_on_events_mode) == PersonsOnEventsMode.DISABLED - else "events", - property_name, - f"%({param_name})s", - aggregation_properties_alias, - materialised_table_column=aggregation_properties_alias, - ) - else: - expression, _ = get_property_string_expr( - "person" - if alias_poe_mode_for_legacy(self._team.person_on_events_mode) == PersonsOnEventsMode.DISABLED - else "events", - property_name, - f"%({param_name})s", - aggregation_properties_alias, - materialised_table_column=( - aggregation_properties_alias - if alias_poe_mode_for_legacy(self._team.person_on_events_mode) - != PersonsOnEventsMode.DISABLED - else "properties" - ), - ) - person_property_params[param_name] = property_name - person_property_expressions.append(expression) - - return ( - f""" - arrayJoin(arrayZip( - %(property_names)s, - [{','.join(person_property_expressions)}] - )) as prop - """, - person_property_params, - ) - - def _get_funnel_step_names(self): - events: set[Union[int, str]] = set() - for entity in self._filter.entities: - if entity.type == TREND_FILTER_TYPE_ACTIONS: - action = entity.get_action() - events.update([x for x in action.get_step_events() if x]) - elif entity.id is not None: - events.add(entity.id) - - return sorted(events) - - def _run(self) -> tuple[list[EventOddsRatio], bool]: - """ - Run the diagnose query. - - Funnel Correlation queries take as input the same as the funnel query, - and returns the correlation of person events with a person successfully - getting to the end of the funnel. We use Odds Ratios as the correlation - metric. See https://en.wikipedia.org/wiki/Odds_ratio for more details. - - Roughly speaking, to calculate the odds ratio, we build a contingency - table https://en.wikipedia.org/wiki/Contingency_table for each - dimension, then calculate the odds ratio for each. - - For example, take for simplicity the cohort of all people, and the - success criteria of having a "signed up" event. First we would build a - contingency table like: - - | | success | failure | total | - | -----------------: | :-----: | :-----: | :---: | - | watched video | 5 | 1 | 6 | - | didn't watch video | 2 | 10 | 12 | - - - Then the odds that a person signs up given they watched the video is 5 / - 1. - - And the odds that a person signs up given they didn't watch the video is - 2 / 10. - - So we say the odds ratio is 5 / 1 over 2 / 10 = 25 . The further away the - odds ratio is from 1, the greater the correlation. - - Requirements: - - - Intitially we only need to consider the names of events that a cohort - person has emitted. So we explicitly are not interested in e.g. - correlating properties, although this will be a follow-up. - - Non-functional requirements: - - - there can be perhaps millions of people in a cohort, so we should - consider this when writing the algorithm. e.g. we should probably - avoid pulling all people into across the wire. - - there can be an order of magnitude more events than people, so we - should avoid pulling all events across the wire. - - there may be a large but not huge number of distinct events, let's say - 100 different names for events. We should avoid n+1 queries for the - event names dimension - - Contincency tables are something we can pull out of the db, so we can - have a query that: - - 1. filters people by the cohort criteria - 2. groups these people by the success criteria - 3. groups people by our criterion with which we want to test - correlation, e.g. "watched video" - - """ - self._filter.team = self._team - - ( - event_contingency_tables, - success_total, - failure_total, - ) = self.get_partial_event_contingency_tables() - - success_total = int(correct_result_for_sampling(success_total, self._filter.sampling_factor)) - failure_total = int(correct_result_for_sampling(failure_total, self._filter.sampling_factor)) - - if not success_total or not failure_total: - return [], True - - skewed_totals = False - - # If the ratio is greater than 1:10, then we have a skewed result, so we should - # warn the user. - if success_total / failure_total > 10 or failure_total / success_total > 10: - skewed_totals = True - - odds_ratios = [ - get_entity_odds_ratio(event_stats, FunnelCorrelation.PRIOR_COUNT) - for event_stats in event_contingency_tables - if not FunnelCorrelation.are_results_insignificant(event_stats) - ] - - positively_correlated_events = sorted( - [odds_ratio for odds_ratio in odds_ratios if odds_ratio["correlation_type"] == "success"], - key=lambda x: x["odds_ratio"], - reverse=True, - ) - - negatively_correlated_events = sorted( - [odds_ratio for odds_ratio in odds_ratios if odds_ratio["correlation_type"] == "failure"], - key=lambda x: x["odds_ratio"], - reverse=False, - ) - - # Return the top ten positively correlated events, and top then negatively correlated events - events = positively_correlated_events[:10] + negatively_correlated_events[:10] - return events, skewed_totals - - def construct_people_url( - self, - success: bool, - event_definition: EventDefinition, - cache_invalidation_key: str, - ) -> Optional[str]: - """ - Given an event_definition and success/failure flag, returns a url that - get be used to GET the associated people for the event/sucess pair. The - primary purpose of this is to reduce the risk of clients of the API - fetching incorrect people, given an event definition. - """ - if not self._filter.correlation_type or self._filter.correlation_type == FunnelCorrelationType.EVENTS: - return self.construct_event_correlation_people_url( - success=success, - event_definition=event_definition, - cache_invalidation_key=cache_invalidation_key, - ) - - elif self._filter.correlation_type == FunnelCorrelationType.EVENT_WITH_PROPERTIES: - return self.construct_event_with_properties_people_url( - success=success, - event_definition=event_definition, - cache_invalidation_key=cache_invalidation_key, - ) - - elif self._filter.correlation_type == FunnelCorrelationType.PROPERTIES: - return self.construct_person_properties_people_url( - success=success, - event_definition=event_definition, - cache_invalidation_key=cache_invalidation_key, - ) - - return None - - def construct_event_correlation_people_url( - self, - success: bool, - event_definition: EventDefinition, - cache_invalidation_key: str, - ) -> str: - # NOTE: we need to convert certain params to strings. I don't think this - # class should need to know these details, but shallow_clone is - # expecting the values as they are serialized in the url - # TODO: remove url serialization details from this class, it likely - # belongs in the application layer, or perhaps `FunnelCorrelationPeople` - params = self._filter.shallow_clone( - { - "funnel_correlation_person_converted": "true" if success else "false", - "funnel_correlation_person_entity": { - "id": event_definition["event"], - "type": "events", - }, - } - ).to_params() - return f"{self._base_uri}api/person/funnel/correlation/?{urllib.parse.urlencode(params)}&cache_invalidation_key={cache_invalidation_key}" - - def construct_event_with_properties_people_url( - self, - success: bool, - event_definition: EventDefinition, - cache_invalidation_key: str, - ) -> str: - if self.support_autocapture_elements(): - # If we have an $autocapture event, we need to special case the - # url by converting the `elements` chain into an `Action` - event_name, _, _ = event_definition["event"].split("::") - elements = event_definition["elements"] - first_element = elements[0] - elements_as_action = { - "tag_name": first_element["tag_name"], - "href": first_element["href"], - "text": first_element["text"], - "selector": build_selector(elements), - } - params = self._filter.shallow_clone( - { - "funnel_correlation_person_converted": "true" if success else "false", - "funnel_correlation_person_entity": { - "id": event_name, - "type": "events", - "properties": [ - { - "key": property_key, - "value": [property_value], - "type": "element", - "operator": "exact", - } - for property_key, property_value in elements_as_action.items() - if property_value is not None - ], - }, - } - ).to_params() - return f"{self._base_uri}api/person/funnel/correlation/?{urllib.parse.urlencode(params)}&cache_invalidation_key={cache_invalidation_key}" - - event_name, property_name, property_value = event_definition["event"].split("::") - params = self._filter.shallow_clone( - { - "funnel_correlation_person_converted": "true" if success else "false", - "funnel_correlation_person_entity": { - "id": event_name, - "type": "events", - "properties": [ - { - "key": property_name, - "value": property_value, - "type": "event", - "operator": "exact", - } - ], - }, - } - ).to_params() - return f"{self._base_uri}api/person/funnel/correlation/?{urllib.parse.urlencode(params)}" - - def construct_person_properties_people_url( - self, - success: bool, - event_definition: EventDefinition, - cache_invalidation_key: str, - ) -> str: - # NOTE: for property correlations, we just use the regular funnel - # persons endpoint, with the breakdown value set, and we assume that - # event.event will be of the format "{property_name}::{property_value}" - property_name, property_value = event_definition["event"].split("::") - prop_type = "group" if self._filter.aggregation_group_type_index else "person" - params = self._filter.shallow_clone( - { - "funnel_correlation_person_converted": "true" if success else "false", - "funnel_correlation_property_values": [ - { - "key": property_name, - "value": property_value, - "type": prop_type, - "operator": "exact", - "group_type_index": self._filter.aggregation_group_type_index, - } - ], - } - ).to_params() - return f"{self._base_uri}api/person/funnel/correlation?{urllib.parse.urlencode(params)}&cache_invalidation_key={cache_invalidation_key}" - - def format_results(self, results: tuple[list[EventOddsRatio], bool]) -> FunnelCorrelationResponse: - odds_ratios, skewed_totals = results - return { - "events": [self.serialize_event_odds_ratio(odds_ratio=odds_ratio) for odds_ratio in odds_ratios], - "skewed": skewed_totals, - } - - def run(self) -> FunnelCorrelationResponse: - if not self._filter.entities: - return FunnelCorrelationResponse(events=[], skewed=False) - - return self.format_results(self._run()) - - def get_partial_event_contingency_tables(self) -> tuple[list[EventContingencyTable], int, int]: - """ - For each event a person that started going through the funnel, gets stats - for how many of these users are sucessful and how many are unsuccessful. - - It's a partial table as it doesn't include numbers of the negation of the - event, but does include the total success/failure numbers, which is enough - for us to calculate the odds ratio. - """ - - query, params = self.get_contingency_table_query() - results_with_total = insight_sync_execute( - query, - {**params, **self._filter.hogql_context.values}, - query_type="funnel_correlation", - filter=self._filter, - team_id=self._team.pk, - ) - - # Get the total success/failure counts from the results - results = [result for result in results_with_total if result[0] != self.TOTAL_IDENTIFIER] - _, success_total, failure_total = next( - result for result in results_with_total if result[0] == self.TOTAL_IDENTIFIER - ) - - # Add a little structure, and keep it close to the query definition so it's - # obvious what's going on with result indices. - return ( - [ - EventContingencyTable( - event=result[0], - visited=EventStats(success_count=result[1], failure_count=result[2]), - success_total=success_total, - failure_total=failure_total, - ) - for result in results - ], - success_total, - failure_total, - ) - - def get_funnel_actors_cte(self) -> tuple[str, dict[str, Any]]: - extra_fields = ["steps", "final_timestamp", "first_timestamp"] - - for prop in self.properties_to_include: - extra_fields.append(prop) - - return self._funnel_actors_generator.actor_query(limit_actors=False, extra_fields=extra_fields) - - @staticmethod - def are_results_insignificant(event_contingency_table: EventContingencyTable) -> bool: - """ - Check if the results are insignificant, i.e. if the success/failure counts are - significantly different from the total counts - """ - - total_count = event_contingency_table.success_total + event_contingency_table.failure_total - - if event_contingency_table.visited.success_count + event_contingency_table.visited.failure_count < min( - FunnelCorrelation.MIN_PERSON_COUNT, - FunnelCorrelation.MIN_PERSON_PERCENTAGE * total_count, - ): - return True - - return False - - def serialize_event_odds_ratio(self, odds_ratio: EventOddsRatio) -> EventOddsRatioSerialized: - event_definition = self.serialize_event_with_property(event=odds_ratio["event"]) - cache_invalidation_key = generate_short_id() - return { - "success_count": odds_ratio["success_count"], - "success_people_url": self.construct_people_url( - success=True, - event_definition=event_definition, - cache_invalidation_key=cache_invalidation_key, - ), - "failure_count": odds_ratio["failure_count"], - "failure_people_url": self.construct_people_url( - success=False, - event_definition=event_definition, - cache_invalidation_key=cache_invalidation_key, - ), - "odds_ratio": odds_ratio["odds_ratio"], - "correlation_type": odds_ratio["correlation_type"], - "event": event_definition, - } - - def serialize_event_with_property(self, event: str) -> EventDefinition: - """ - Format the event name for display. - """ - if not self.support_autocapture_elements(): - return EventDefinition(event=event, properties={}, elements=[]) - - event_name, property_name, property_value = event.split("::") - if event_name == AUTOCAPTURE_EVENT and property_name == "elements_chain": - event_type, elements_chain = property_value.split(self.ELEMENTS_DIVIDER) - return EventDefinition( - event=event, - properties={self.AUTOCAPTURE_EVENT_TYPE: event_type}, - elements=cast( - list, - ElementSerializer(chain_to_elements(elements_chain), many=True).data, - ), - ) - - return EventDefinition(event=event, properties={}, elements=[]) - - -def get_entity_odds_ratio(event_contingency_table: EventContingencyTable, prior_counts: int) -> EventOddsRatio: - # Add 1 to all values to prevent divide by zero errors, and introduce a [prior](https://en.wikipedia.org/wiki/Prior_probability) - odds_ratio = ( - (event_contingency_table.visited.success_count + prior_counts) - * (event_contingency_table.failure_total - event_contingency_table.visited.failure_count + prior_counts) - ) / ( - (event_contingency_table.success_total - event_contingency_table.visited.success_count + prior_counts) - * (event_contingency_table.visited.failure_count + prior_counts) - ) - - return EventOddsRatio( - event=event_contingency_table.event, - success_count=event_contingency_table.visited.success_count, - failure_count=event_contingency_table.visited.failure_count, - odds_ratio=odds_ratio, - correlation_type="success" if odds_ratio > 1 else "failure", - ) - - -def build_selector(elements: list[dict[str, Any]]) -> str: - # build a CSS select given an "elements_chain" - # NOTE: my source of what this should be doing is - # https://github.com/PostHog/posthog/blob/cc054930a47fb59940531e99a856add49a348ee5/frontend/src/scenes/events/createActionFromEvent.tsx#L36:L36 - # - def element_to_selector(element: dict[str, Any]) -> str: - if attr_id := element.get("attr_id"): - return f'[id="{attr_id}"]' - - return element["tag_name"] - - return " > ".join([element_to_selector(element) for element in elements]) diff --git a/ee/clickhouse/queries/funnels/funnel_correlation_persons.py b/ee/clickhouse/queries/funnels/funnel_correlation_persons.py deleted file mode 100644 index b02a8b8e9b6..00000000000 --- a/ee/clickhouse/queries/funnels/funnel_correlation_persons.py +++ /dev/null @@ -1,211 +0,0 @@ -from typing import Optional, Union - -from django.db.models.query import QuerySet -from rest_framework.exceptions import ValidationError - -from ee.clickhouse.queries.funnels.funnel_correlation import FunnelCorrelation -from posthog.constants import ( - FUNNEL_CORRELATION_PERSON_LIMIT, - FunnelCorrelationType, - PropertyOperatorType, -) -from posthog.models import Person -from posthog.models.entity import Entity -from posthog.models.filters.filter import Filter -from posthog.models.filters.mixins.utils import cached_property -from posthog.models.group import Group -from posthog.models.team import Team -from posthog.queries.actor_base_query import ( - ActorBaseQuery, - SerializedGroup, - SerializedPerson, -) -from posthog.queries.funnels.funnel_event_query import FunnelEventQuery -from posthog.queries.util import get_person_properties_mode - - -class FunnelCorrelationActors(ActorBaseQuery): - _filter: Filter - QUERY_TYPE = "funnel_correlation_actors" - - def __init__(self, filter: Filter, team: Team, base_uri: str = "/", **kwargs) -> None: - self._base_uri = base_uri - self._filter = filter - self._team = team - - if not self._filter.correlation_person_limit: - self._filter = self._filter.shallow_clone({FUNNEL_CORRELATION_PERSON_LIMIT: 100}) - - @cached_property - def aggregation_group_type_index(self): - return self._filter.aggregation_group_type_index - - def actor_query(self, limit_actors: Optional[bool] = True): - if self._filter.correlation_type == FunnelCorrelationType.PROPERTIES: - return _FunnelPropertyCorrelationActors(self._filter, self._team, self._base_uri).actor_query( - limit_actors=limit_actors - ) - else: - return _FunnelEventsCorrelationActors(self._filter, self._team, self._base_uri).actor_query( - limit_actors=limit_actors - ) - - def get_actors( - self, - ) -> tuple[ - Union[QuerySet[Person], QuerySet[Group]], - Union[list[SerializedGroup], list[SerializedPerson]], - int, - ]: - if self._filter.correlation_type == FunnelCorrelationType.PROPERTIES: - return _FunnelPropertyCorrelationActors(self._filter, self._team, self._base_uri).get_actors() - else: - return _FunnelEventsCorrelationActors(self._filter, self._team, self._base_uri).get_actors() - - -class _FunnelEventsCorrelationActors(ActorBaseQuery): - _filter: Filter - QUERY_TYPE = "funnel_events_correlation_actors" - - def __init__(self, filter: Filter, team: Team, base_uri: str = "/") -> None: - self._funnel_correlation = FunnelCorrelation(filter, team, base_uri=base_uri) - super().__init__(team, filter) - - @cached_property - def aggregation_group_type_index(self): - return self._filter.aggregation_group_type_index - - def actor_query(self, limit_actors: Optional[bool] = True): - if not self._filter.correlation_person_entity: - raise ValidationError("No entity for persons specified") - - assert isinstance(self._filter.correlation_person_entity, Entity) - - ( - funnel_persons_query, - funnel_persons_params, - ) = self._funnel_correlation.get_funnel_actors_cte() - - prop_filters = self._filter.correlation_person_entity.property_groups - - # TRICKY: We use "events" as an alias here while the eventquery uses "e" by default - event_query = FunnelEventQuery(self._filter, self._team) - event_query.EVENT_TABLE_ALIAS = "events" - - prop_query, prop_params = event_query._get_prop_groups( - prop_filters, - person_properties_mode=get_person_properties_mode(self._team), - person_id_joined_alias=event_query._get_person_id_alias(self._team.person_on_events_mode), - ) - - conversion_filter = ( - f'AND actors.steps {"=" if self._filter.correlation_persons_converted else "<>"} target_step' - if self._filter.correlation_persons_converted is not None - else "" - ) - - event_join_query = self._funnel_correlation._get_events_join_query() - - recording_event_select_statement = ( - ", any(actors.matching_events) AS matching_events" if self._filter.include_recordings else "" - ) - - query = f""" - WITH - funnel_actors as ({funnel_persons_query}), - toDateTime(%(date_to)s, %(timezone)s) AS date_to, - toDateTime(%(date_from)s, %(timezone)s) AS date_from, - %(target_step)s AS target_step, - %(funnel_step_names)s as funnel_step_names - SELECT - actors.actor_id AS actor_id - {recording_event_select_statement} - FROM events AS event - {event_join_query} - AND event.event = %(target_event)s - {conversion_filter} - {prop_query} - GROUP BY actor_id - ORDER BY actor_id - {"LIMIT %(limit)s" if limit_actors else ""} - {"OFFSET %(offset)s" if limit_actors else ""} - """ - - params = { - **funnel_persons_params, - **prop_params, - "target_event": self._filter.correlation_person_entity.id, - "funnel_step_names": [entity.id for entity in self._filter.events], - "target_step": len(self._filter.entities), - "limit": self._filter.correlation_person_limit, - "offset": self._filter.correlation_person_offset, - } - - return query, params - - -class _FunnelPropertyCorrelationActors(ActorBaseQuery): - _filter: Filter - QUERY_TYPE = "funnel_property_correlation_actors" - - def __init__(self, filter: Filter, team: Team, base_uri: str = "/") -> None: - # Filtering on persons / groups properties can be pushed down to funnel_actors CTE - new_correlation_filter = filter.shallow_clone( - { - "properties": filter.property_groups.combine_properties( - PropertyOperatorType.AND, filter.correlation_property_values or [] - ).to_dict() - } - ) - self._funnel_correlation = FunnelCorrelation(new_correlation_filter, team, base_uri=base_uri) - super().__init__(team, filter) - - @cached_property - def aggregation_group_type_index(self): - return self._filter.aggregation_group_type_index - - def actor_query( - self, - limit_actors: Optional[bool] = True, - extra_fields: Optional[list[str]] = None, - ): - if not self._filter.correlation_property_values: - raise ValidationError("Property Correlation expects atleast one Property to get persons for") - - ( - funnel_persons_query, - funnel_persons_params, - ) = self._funnel_correlation.get_funnel_actors_cte() - - conversion_filter = ( - f'funnel_actors.steps {"=" if self._filter.correlation_persons_converted else "<>"} target_step' - if self._filter.correlation_persons_converted is not None - else "" - ) - - recording_event_select_statement = ( - ", any(funnel_actors.matching_events) AS matching_events" if self._filter.include_recordings else "" - ) - - query = f""" - WITH - funnel_actors AS ({funnel_persons_query}), - %(target_step)s AS target_step - SELECT - funnel_actors.actor_id AS actor_id - {recording_event_select_statement} - FROM funnel_actors - WHERE {conversion_filter} - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - {"LIMIT %(limit)s" if limit_actors else ""} - {"OFFSET %(offset)s" if limit_actors else ""} - """ - params = { - **funnel_persons_params, - "target_step": len(self._filter.entities), - "limit": self._filter.correlation_person_limit, - "offset": self._filter.correlation_person_offset, - } - - return query, params diff --git a/ee/clickhouse/queries/funnels/test/__init__.py b/ee/clickhouse/queries/funnels/test/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/clickhouse/queries/funnels/test/__snapshots__/test_funnel.ambr b/ee/clickhouse/queries/funnels/test/__snapshots__/test_funnel.ambr deleted file mode 100644 index 464eb8dbf60..00000000000 --- a/ee/clickhouse/queries/funnels/test/__snapshots__/test_funnel.ambr +++ /dev/null @@ -1,3540 +0,0 @@ -# serializer version: 1 -# name: TestFunnelGroupBreakdown.test_funnel_aggregate_by_groups_breakdown_group_person_on_events - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group0_properties, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestFunnelGroupBreakdown.test_funnel_aggregate_by_groups_breakdown_group_person_on_events.1 - ''' - - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - countIf(steps = 3) step_3, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - avg(step_2_average_conversion_time_inner) step_2_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time, - median(step_2_median_conversion_time_inner) step_2_median_conversion_time, - prop - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - e.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - GROUP BY prop - ''' -# --- -# name: TestFunnelGroupBreakdown.test_funnel_aggregate_by_groups_breakdown_group_person_on_events_poe_v2 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group0_properties, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestFunnelGroupBreakdown.test_funnel_aggregate_by_groups_breakdown_group_person_on_events_poe_v2.1 - ''' - - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - countIf(steps = 3) step_3, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - avg(step_2_average_conversion_time_inner) step_2_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time, - median(step_2_median_conversion_time_inner) step_2_median_conversion_time, - prop - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - GROUP BY prop - ''' -# --- -# name: TestFunnelGroupBreakdown.test_funnel_breakdown_group - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestFunnelGroupBreakdown.test_funnel_breakdown_group.1 - ''' - - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - countIf(steps = 3) step_3, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - avg(step_2_average_conversion_time_inner) step_2_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time, - median(step_2_median_conversion_time_inner) step_2_median_conversion_time, - prop - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - GROUP BY prop - ''' -# --- -# name: TestFunnelGroupBreakdown.test_funnel_breakdown_group.2 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestFunnelGroupBreakdown.test_funnel_breakdown_group.3 - ''' - - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - WHERE steps IN [1, 2, 3] - AND arrayFlatten(array(prop)) = arrayFlatten(array('finance')) - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- -# name: TestFunnelGroupBreakdown.test_funnel_breakdown_group.4 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestFunnelGroupBreakdown.test_funnel_breakdown_group.5 - ''' - - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - WHERE steps IN [2, 3] - AND arrayFlatten(array(prop)) = arrayFlatten(array('finance')) - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- -# name: TestFunnelGroupBreakdown.test_funnel_breakdown_group.6 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestFunnelGroupBreakdown.test_funnel_breakdown_group.7 - ''' - - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - WHERE steps IN [1, 2, 3] - AND arrayFlatten(array(prop)) = arrayFlatten(array('technology')) - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- -# name: TestFunnelGroupBreakdown.test_funnel_breakdown_group.8 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestFunnelGroupBreakdown.test_funnel_breakdown_group.9 - ''' - - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - WHERE steps IN [2, 3] - AND arrayFlatten(array(prop)) = arrayFlatten(array('technology')) - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- -# name: TestStrictFunnelGroupBreakdown.test_funnel_aggregate_by_groups_breakdown_group_person_on_events - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group0_properties, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestStrictFunnelGroupBreakdown.test_funnel_aggregate_by_groups_breakdown_group_person_on_events.1 - ''' - - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - countIf(steps = 3) step_3, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - avg(step_2_average_conversion_time_inner) step_2_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time, - median(step_2_median_conversion_time_inner) step_2_median_conversion_time, - prop - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - e.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - GROUP BY prop - ''' -# --- -# name: TestStrictFunnelGroupBreakdown.test_funnel_aggregate_by_groups_breakdown_group_person_on_events_poe_v2 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group0_properties, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestStrictFunnelGroupBreakdown.test_funnel_aggregate_by_groups_breakdown_group_person_on_events_poe_v2.1 - ''' - - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - countIf(steps = 3) step_3, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - avg(step_2_average_conversion_time_inner) step_2_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time, - median(step_2_median_conversion_time_inner) step_2_median_conversion_time, - prop - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - GROUP BY prop - ''' -# --- -# name: TestStrictFunnelGroupBreakdown.test_funnel_breakdown_group - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestStrictFunnelGroupBreakdown.test_funnel_breakdown_group.1 - ''' - - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - countIf(steps = 3) step_3, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - avg(step_2_average_conversion_time_inner) step_2_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time, - median(step_2_median_conversion_time_inner) step_2_median_conversion_time, - prop - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - GROUP BY prop - ''' -# --- -# name: TestStrictFunnelGroupBreakdown.test_funnel_breakdown_group.2 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestStrictFunnelGroupBreakdown.test_funnel_breakdown_group.3 - ''' - - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner, - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time, - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps, - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN 2 PRECEDING AND 2 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (1=1) ))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - WHERE steps IN [1, 2, 3] - AND arrayFlatten(array(prop)) = arrayFlatten(array('finance')) - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- -# name: TestStrictFunnelGroupBreakdown.test_funnel_breakdown_group.4 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestStrictFunnelGroupBreakdown.test_funnel_breakdown_group.5 - ''' - - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner, - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time, - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps, - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN 2 PRECEDING AND 2 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (1=1) ))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - WHERE steps IN [2, 3] - AND arrayFlatten(array(prop)) = arrayFlatten(array('finance')) - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- -# name: TestStrictFunnelGroupBreakdown.test_funnel_breakdown_group.6 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestStrictFunnelGroupBreakdown.test_funnel_breakdown_group.7 - ''' - - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner, - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time, - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps, - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN 2 PRECEDING AND 2 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (1=1) ))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - WHERE steps IN [1, 2, 3] - AND arrayFlatten(array(prop)) = arrayFlatten(array('technology')) - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- -# name: TestStrictFunnelGroupBreakdown.test_funnel_breakdown_group.8 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestStrictFunnelGroupBreakdown.test_funnel_breakdown_group.9 - ''' - - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner, - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time, - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps, - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN 2 PRECEDING AND 2 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (1=1) ))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - WHERE steps IN [2, 3] - AND arrayFlatten(array(prop)) = arrayFlatten(array('technology')) - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_aggregate_by_groups_breakdown_group_person_on_events - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group0_properties, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_aggregate_by_groups_breakdown_group_person_on_events.1 - ''' - - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - countIf(steps = 3) step_3, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - avg(step_2_average_conversion_time_inner) step_2_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time, - median(step_2_median_conversion_time_inner) step_2_median_conversion_time, - prop - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - e.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - GROUP BY prop - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_aggregate_by_groups_breakdown_group_person_on_events_poe_v2 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group0_properties, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_aggregate_by_groups_breakdown_group_person_on_events_poe_v2.1 - ''' - - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - countIf(steps = 3) step_3, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - avg(step_2_average_conversion_time_inner) step_2_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time, - median(step_2_median_conversion_time_inner) step_2_median_conversion_time, - prop - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - GROUP BY prop - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.1 - ''' - - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - countIf(steps = 3) step_3, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - avg(step_2_average_conversion_time_inner) step_2_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time, - median(step_2_median_conversion_time_inner) step_2_median_conversion_time, - prop - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 , - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - GROUP BY prop - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.10 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.11 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.12 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.13 - ''' - - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner, - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time, - prop - FROM - (SELECT *, - arraySort([latest_0,latest_1,latest_2]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 7 DAY, 1, 0),if(latest_0 < latest_2 AND latest_2 <= latest_0 + INTERVAL 7 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1,latest_2]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 7 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time, - if(isNotNull(conversion_times[3]) - AND conversion_times[3] <= conversion_times[2] + INTERVAL 7 DAY, dateDiff('second', conversion_times[2], conversion_times[3]), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))) - WHERE step_0 = 1 - UNION ALL SELECT *, - arraySort([latest_0,latest_1,latest_2]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 7 DAY, 1, 0),if(latest_0 < latest_2 AND latest_2 <= latest_0 + INTERVAL 7 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1,latest_2]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 7 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time, - if(isNotNull(conversion_times[3]) - AND conversion_times[3] <= conversion_times[2] + INTERVAL 7 DAY, dateDiff('second', conversion_times[2], conversion_times[3]), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'play movie', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'buy', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'sign up', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))) - WHERE step_0 = 1 - UNION ALL SELECT *, - arraySort([latest_0,latest_1,latest_2]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 7 DAY, 1, 0),if(latest_0 < latest_2 AND latest_2 <= latest_0 + INTERVAL 7 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1,latest_2]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 7 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time, - if(isNotNull(conversion_times[3]) - AND conversion_times[3] <= conversion_times[2] + INTERVAL 7 DAY, dateDiff('second', conversion_times[2], conversion_times[3]), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'buy', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'sign up', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'play movie', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - WHERE steps IN [1, 2, 3] - AND arrayFlatten(array(prop)) = arrayFlatten(array('technology')) - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.14 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.15 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.16 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.17 - ''' - - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner, - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time, - prop - FROM - (SELECT *, - arraySort([latest_0,latest_1,latest_2]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 7 DAY, 1, 0),if(latest_0 < latest_2 AND latest_2 <= latest_0 + INTERVAL 7 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1,latest_2]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 7 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time, - if(isNotNull(conversion_times[3]) - AND conversion_times[3] <= conversion_times[2] + INTERVAL 7 DAY, dateDiff('second', conversion_times[2], conversion_times[3]), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))) - WHERE step_0 = 1 - UNION ALL SELECT *, - arraySort([latest_0,latest_1,latest_2]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 7 DAY, 1, 0),if(latest_0 < latest_2 AND latest_2 <= latest_0 + INTERVAL 7 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1,latest_2]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 7 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time, - if(isNotNull(conversion_times[3]) - AND conversion_times[3] <= conversion_times[2] + INTERVAL 7 DAY, dateDiff('second', conversion_times[2], conversion_times[3]), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'play movie', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'buy', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'sign up', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))) - WHERE step_0 = 1 - UNION ALL SELECT *, - arraySort([latest_0,latest_1,latest_2]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 7 DAY, 1, 0),if(latest_0 < latest_2 AND latest_2 <= latest_0 + INTERVAL 7 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1,latest_2]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 7 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time, - if(isNotNull(conversion_times[3]) - AND conversion_times[3] <= conversion_times[2] + INTERVAL 7 DAY, dateDiff('second', conversion_times[2], conversion_times[3]), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'buy', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'sign up', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'play movie', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - WHERE steps IN [2, 3] - AND arrayFlatten(array(prop)) = arrayFlatten(array('technology')) - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.2 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.3 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.4 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.5 - ''' - - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner, - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time, - prop - FROM - (SELECT *, - arraySort([latest_0,latest_1,latest_2]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 7 DAY, 1, 0),if(latest_0 < latest_2 AND latest_2 <= latest_0 + INTERVAL 7 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1,latest_2]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 7 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time, - if(isNotNull(conversion_times[3]) - AND conversion_times[3] <= conversion_times[2] + INTERVAL 7 DAY, dateDiff('second', conversion_times[2], conversion_times[3]), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))) - WHERE step_0 = 1 - UNION ALL SELECT *, - arraySort([latest_0,latest_1,latest_2]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 7 DAY, 1, 0),if(latest_0 < latest_2 AND latest_2 <= latest_0 + INTERVAL 7 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1,latest_2]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 7 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time, - if(isNotNull(conversion_times[3]) - AND conversion_times[3] <= conversion_times[2] + INTERVAL 7 DAY, dateDiff('second', conversion_times[2], conversion_times[3]), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'play movie', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'buy', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'sign up', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))) - WHERE step_0 = 1 - UNION ALL SELECT *, - arraySort([latest_0,latest_1,latest_2]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 7 DAY, 1, 0),if(latest_0 < latest_2 AND latest_2 <= latest_0 + INTERVAL 7 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1,latest_2]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 7 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time, - if(isNotNull(conversion_times[3]) - AND conversion_times[3] <= conversion_times[2] + INTERVAL 7 DAY, dateDiff('second', conversion_times[2], conversion_times[3]), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'buy', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'sign up', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'play movie', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - WHERE steps IN [1, 2, 3] - AND arrayFlatten(array(prop)) = arrayFlatten(array('finance')) - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.6 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.7 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.8 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestUnorderedFunnelGroupBreakdown.test_funnel_breakdown_group.9 - ''' - - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner, - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time, - step_2_conversion_time, - prop - FROM - (SELECT *, - arraySort([latest_0,latest_1,latest_2]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 7 DAY, 1, 0),if(latest_0 < latest_2 AND latest_2 <= latest_0 + INTERVAL 7 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1,latest_2]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 7 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time, - if(isNotNull(conversion_times[3]) - AND conversion_times[3] <= conversion_times[2] + INTERVAL 7 DAY, dateDiff('second', conversion_times[2], conversion_times[3]), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'sign up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'play movie', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'buy', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))) - WHERE step_0 = 1 - UNION ALL SELECT *, - arraySort([latest_0,latest_1,latest_2]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 7 DAY, 1, 0),if(latest_0 < latest_2 AND latest_2 <= latest_0 + INTERVAL 7 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1,latest_2]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 7 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time, - if(isNotNull(conversion_times[3]) - AND conversion_times[3] <= conversion_times[2] + INTERVAL 7 DAY, dateDiff('second', conversion_times[2], conversion_times[3]), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'play movie', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'buy', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'sign up', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))) - WHERE step_0 = 1 - UNION ALL SELECT *, - arraySort([latest_0,latest_1,latest_2]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 7 DAY, 1, 0),if(latest_0 < latest_2 AND latest_2 <= latest_0 + INTERVAL 7 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1,latest_2]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 7 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time, - if(isNotNull(conversion_times[3]) - AND conversion_times[3] <= conversion_times[2] + INTERVAL 7 DAY, dateDiff('second', conversion_times[2], conversion_times[3]), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 , - if(has(['technology', 'finance'], prop), prop, 'Other') as prop - FROM - (SELECT *, - prop_vals as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'buy', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'sign up', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'play movie', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, isNotNull(prop)) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['buy', 'play movie', 'sign up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) ))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - WHERE steps IN [2, 3] - AND arrayFlatten(array(prop)) = arrayFlatten(array('finance')) - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- diff --git a/ee/clickhouse/queries/funnels/test/__snapshots__/test_funnel_correlation.ambr b/ee/clickhouse/queries/funnels/test/__snapshots__/test_funnel_correlation.ambr deleted file mode 100644 index be604f128dc..00000000000 --- a/ee/clickhouse/queries/funnels/test/__snapshots__/test_funnel_correlation.ambr +++ /dev/null @@ -1,4900 +0,0 @@ -# serializer version: 1 -# name: TestClickhouseFunnelCorrelation.test_action_events_are_excluded_from_correlations - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(((event = 'user signed up' - AND (has(['val'], replaceRegexpAll(JSONExtractRaw(properties, 'key'), '^"|"$', ''))))) , 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(((event = 'paid' - AND (has(['val'], replaceRegexpAll(JSONExtractRaw(properties, 'key'), '^"|"$', ''))))) , 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['paid', 'user signed up', 'user signed up', 'paid'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up', 'user signed up', 'paid'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['paid', 'user signed up'] as funnel_step_names - SELECT event.event AS name, - countDistinctIf(actors.actor_id, actors.steps = target_step) AS success_count, - countDistinctIf(actors.actor_id, actors.steps <> target_step) AS failure_count - FROM events AS event - JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON pdi.distinct_id = events.distinct_id - JOIN funnel_actors AS actors ON pdi.person_id = actors.actor_id - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event NOT IN [] - GROUP BY name - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actors.actor_id, actors.steps = target_step) AS success_count, - countDistinctIf(actors.actor_id, actors.steps <> target_step) AS failure_count - FROM funnel_actors AS actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_basic_funnel_correlation_with_properties - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT concat(prop.1, '::', prop.2) as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM - (SELECT actor_id, - funnel_actors.steps as steps, - arrayJoin(arrayZip(['$browser'], [replaceRegexpAll(JSONExtractRaw(person_props, '$browser'), '^"|"$', '')])) as prop - FROM funnel_actors - JOIN - (SELECT id, - argMax(properties, version) as person_props - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = funnel_actors.actor_id) aggregation_target_with_props - GROUP BY prop.1, - prop.2 - HAVING prop.1 NOT IN [] - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM funnel_actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_basic_funnel_correlation_with_properties.1 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['Positive'], replaceRegexpAll(JSONExtractRaw(properties, '$browser'), '^"|"$', ''))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['Positive'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), '$browser'), '^"|"$', ''))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_basic_funnel_correlation_with_properties.2 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['Positive'], replaceRegexpAll(JSONExtractRaw(properties, '$browser'), '^"|"$', ''))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['Positive'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), '$browser'), '^"|"$', ''))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps <> target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_basic_funnel_correlation_with_properties.3 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['Negative'], replaceRegexpAll(JSONExtractRaw(properties, '$browser'), '^"|"$', ''))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['Negative'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), '$browser'), '^"|"$', ''))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_basic_funnel_correlation_with_properties.4 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['Negative'], replaceRegexpAll(JSONExtractRaw(properties, '$browser'), '^"|"$', ''))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['Negative'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), '$browser'), '^"|"$', ''))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps <> target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_basic_funnel_correlation_with_properties_materialized - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT concat(prop.1, '::', prop.2) as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM - (SELECT actor_id, - funnel_actors.steps as steps, - arrayJoin(arrayZip(['$browser'], ["pmat_$browser"])) as prop - FROM funnel_actors - JOIN - (SELECT id, - argMax(pmat_$browser, version) as pmat_$browser - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = funnel_actors.actor_id) aggregation_target_with_props - GROUP BY prop.1, - prop.2 - HAVING prop.1 NOT IN [] - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM funnel_actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_basic_funnel_correlation_with_properties_materialized.1 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['Positive'], "pmat_$browser")) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['Positive'], argMax(person."pmat_$browser", version))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_basic_funnel_correlation_with_properties_materialized.2 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['Positive'], "pmat_$browser")) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['Positive'], argMax(person."pmat_$browser", version))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps <> target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_basic_funnel_correlation_with_properties_materialized.3 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['Negative'], "pmat_$browser")) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['Negative'], argMax(person."pmat_$browser", version))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_basic_funnel_correlation_with_properties_materialized.4 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['Negative'], "pmat_$browser")) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['Negative'], argMax(person."pmat_$browser", version))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps <> target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_event_properties_and_groups - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_1" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['paid', 'user signed up'] as funnel_step_names - SELECT concat(event_name, '::', prop.1, '::', prop.2) as name, - countDistinctIf(actor_id, steps = target_step) as success_count, - countDistinctIf(actor_id, steps <> target_step) as failure_count - FROM - (SELECT actors.actor_id as actor_id, - actors.steps as steps, - events.event as event_name, - arrayJoin(JSONExtractKeysAndValues(properties, 'String')) as prop - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_1 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event IN ['positively_related', 'negatively_related'] ) - GROUP BY name - HAVING (success_count + failure_count) > 2 - AND prop.1 NOT IN [] - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actors.actor_id, actors.steps = target_step) AS success_count, - countDistinctIf(actors.actor_id, actors.steps <> target_step) AS failure_count - FROM funnel_actors AS actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_event_properties_and_groups_materialized - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_1" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['paid', 'user signed up'] as funnel_step_names - SELECT concat(event_name, '::', prop.1, '::', prop.2) as name, - countDistinctIf(actor_id, steps = target_step) as success_count, - countDistinctIf(actor_id, steps <> target_step) as failure_count - FROM - (SELECT actors.actor_id as actor_id, - actors.steps as steps, - events.event as event_name, - arrayJoin(JSONExtractKeysAndValues(properties, 'String')) as prop - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_1 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event IN ['positively_related', 'negatively_related'] ) - GROUP BY name - HAVING (success_count + failure_count) > 2 - AND prop.1 NOT IN [] - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actors.actor_id, actors.steps = target_step) AS success_count, - countDistinctIf(actors.actor_id, actors.steps <> target_step) AS failure_count - FROM funnel_actors AS actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['paid', 'user signed up'] as funnel_step_names - SELECT event.event AS name, - countDistinctIf(actors.actor_id, actors.steps = target_step) AS success_count, - countDistinctIf(actors.actor_id, actors.steps <> target_step) AS failure_count - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event NOT IN [] - GROUP BY name - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actors.actor_id, actors.steps = target_step) AS success_count, - countDistinctIf(actors.actor_id, actors.steps <> target_step) AS failure_count - FROM funnel_actors AS actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups.1 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['user signed up', 'paid'] as funnel_step_names - SELECT actors.actor_id AS actor_id - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event = 'positively_related' - AND actors.steps = target_step - GROUP BY actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups.2 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['user signed up', 'paid'] as funnel_step_names - SELECT actors.actor_id AS actor_id - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event = 'positively_related' - AND actors.steps <> target_step - GROUP BY actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups.3 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['user signed up', 'paid'] as funnel_step_names - SELECT actors.actor_id AS actor_id - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event = 'negatively_related' - AND actors.steps = target_step - GROUP BY actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups.4 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['user signed up', 'paid'] as funnel_step_names - SELECT actors.actor_id AS actor_id - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event = 'negatively_related' - AND actors.steps <> target_step - GROUP BY actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups.5 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['finance'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['paid', 'user signed up'] as funnel_step_names - SELECT event.event AS name, - countDistinctIf(actors.actor_id, actors.steps = target_step) AS success_count, - countDistinctIf(actors.actor_id, actors.steps <> target_step) AS failure_count - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event NOT IN [] - GROUP BY name - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actors.actor_id, actors.steps = target_step) AS success_count, - countDistinctIf(actors.actor_id, actors.steps <> target_step) AS failure_count - FROM funnel_actors AS actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups.6 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['finance'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['user signed up', 'paid'] as funnel_step_names - SELECT actors.actor_id AS actor_id - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event = 'negatively_related' - AND actors.steps = target_step - GROUP BY actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups.7 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['finance'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['user signed up', 'paid'] as funnel_step_names - SELECT actors.actor_id AS actor_id - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event = 'negatively_related' - AND actors.steps <> target_step - GROUP BY actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups_poe_v2 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['paid', 'user signed up'] as funnel_step_names - SELECT event.event AS name, - countDistinctIf(actors.actor_id, actors.steps = target_step) AS success_count, - countDistinctIf(actors.actor_id, actors.steps <> target_step) AS failure_count - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event NOT IN [] - GROUP BY name - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actors.actor_id, actors.steps = target_step) AS success_count, - countDistinctIf(actors.actor_id, actors.steps <> target_step) AS failure_count - FROM funnel_actors AS actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups_poe_v2.1 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['user signed up', 'paid'] as funnel_step_names - SELECT actors.actor_id AS actor_id - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event = 'positively_related' - AND actors.steps = target_step - GROUP BY actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups_poe_v2.2 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['user signed up', 'paid'] as funnel_step_names - SELECT actors.actor_id AS actor_id - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event = 'positively_related' - AND actors.steps <> target_step - GROUP BY actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups_poe_v2.3 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['user signed up', 'paid'] as funnel_step_names - SELECT actors.actor_id AS actor_id - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event = 'negatively_related' - AND actors.steps = target_step - GROUP BY actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups_poe_v2.4 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['user signed up', 'paid'] as funnel_step_names - SELECT actors.actor_id AS actor_id - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event = 'negatively_related' - AND actors.steps <> target_step - GROUP BY actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups_poe_v2.5 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['finance'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['paid', 'user signed up'] as funnel_step_names - SELECT event.event AS name, - countDistinctIf(actors.actor_id, actors.steps = target_step) AS success_count, - countDistinctIf(actors.actor_id, actors.steps <> target_step) AS failure_count - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event NOT IN [] - GROUP BY name - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actors.actor_id, actors.steps = target_step) AS success_count, - countDistinctIf(actors.actor_id, actors.steps <> target_step) AS failure_count - FROM funnel_actors AS actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups_poe_v2.6 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['finance'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['user signed up', 'paid'] as funnel_step_names - SELECT actors.actor_id AS actor_id - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event = 'negatively_related' - AND actors.steps = target_step - GROUP BY actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_events_and_groups_poe_v2.7 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['finance'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2020-01-14 23:59:59', 'UTC') AS date_to, - toDateTime('2020-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['user signed up', 'paid'] as funnel_step_names - SELECT actors.actor_id AS actor_id - FROM events AS event - JOIN funnel_actors AS actors ON actors.actor_id = events.$group_0 - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event = 'negatively_related' - AND actors.steps <> target_step - GROUP BY actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT concat(prop.1, '::', prop.2) as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM - (SELECT actor_id, - funnel_actors.steps as steps, - arrayJoin(arrayZip(['industry'], [replaceRegexpAll(JSONExtractRaw(groups_0.group_properties_0, 'industry'), '^"|"$', '')])) as prop - FROM funnel_actors - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON funnel_actors.actor_id == groups_0.group_key) aggregation_target_with_props - GROUP BY prop.1, - prop.2 - HAVING prop.1 NOT IN [] - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM funnel_actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups.1 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['positive'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups.2 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['positive'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps <> target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups.3 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['negative'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups.4 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['negative'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps <> target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups.5 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT concat(prop.1, '::', prop.2) as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM - (SELECT actor_id, - funnel_actors.steps as steps, - arrayJoin(JSONExtractKeysAndValues(groups_0.group_properties_0, 'String')) as prop - FROM funnel_actors - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON funnel_actors.actor_id == groups_0.group_key) aggregation_target_with_props - GROUP BY prop.1, - prop.2 - HAVING prop.1 NOT IN [] - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM funnel_actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_materialized - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT concat(prop.1, '::', prop.2) as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM - (SELECT actor_id, - funnel_actors.steps as steps, - arrayJoin(arrayZip(['industry'], [replaceRegexpAll(JSONExtractRaw(groups_0.group_properties_0, 'industry'), '^"|"$', '')])) as prop - FROM funnel_actors - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON funnel_actors.actor_id == groups_0.group_key) aggregation_target_with_props - GROUP BY prop.1, - prop.2 - HAVING prop.1 NOT IN [] - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM funnel_actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_materialized.1 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['positive'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_materialized.2 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['positive'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps <> target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_materialized.3 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['negative'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_materialized.4 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['negative'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps <> target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_materialized.5 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT concat(prop.1, '::', prop.2) as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM - (SELECT actor_id, - funnel_actors.steps as steps, - arrayJoin(JSONExtractKeysAndValues(groups_0.group_properties_0, 'String')) as prop - FROM funnel_actors - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON funnel_actors.actor_id == groups_0.group_key) aggregation_target_with_props - GROUP BY prop.1, - prop.2 - HAVING prop.1 NOT IN [] - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM funnel_actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - e.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT concat(prop.1, '::', prop.2) as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM - (SELECT actor_id, - funnel_actors.steps as steps, - arrayJoin(arrayZip(['industry'], [replaceRegexpAll(JSONExtractRaw(groups_0.group_properties_0, 'industry'), '^"|"$', '')])) as prop - FROM funnel_actors - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON funnel_actors.actor_id == groups_0.group_key) aggregation_target_with_props - GROUP BY prop.1, - prop.2 - HAVING prop.1 NOT IN [] - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM funnel_actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events.1 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - e.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['positive'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events.2 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - e.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['positive'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps <> target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events.3 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - e.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['negative'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events.4 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - e.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['negative'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps <> target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events.5 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - e.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT concat(prop.1, '::', prop.2) as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM - (SELECT actor_id, - funnel_actors.steps as steps, - arrayJoin(JSONExtractKeysAndValues(groups_0.group_properties_0, 'String')) as prop - FROM funnel_actors - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON funnel_actors.actor_id == groups_0.group_key) aggregation_target_with_props - GROUP BY prop.1, - prop.2 - HAVING prop.1 NOT IN [] - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM funnel_actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events_materialized - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - e.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT concat(prop.1, '::', prop.2) as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM - (SELECT actor_id, - funnel_actors.steps as steps, - arrayJoin(arrayZip(['industry'], [replaceRegexpAll(JSONExtractRaw(groups_0.group_properties_0, 'industry'), '^"|"$', '')])) as prop - FROM funnel_actors - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON funnel_actors.actor_id == groups_0.group_key) aggregation_target_with_props - GROUP BY prop.1, - prop.2 - HAVING prop.1 NOT IN [] - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM funnel_actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events_materialized.1 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - e.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['positive'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events_materialized.2 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - e.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['positive'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps <> target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events_materialized.3 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - e.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['negative'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events_materialized.4 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - e.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['negative'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps <> target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events_materialized.5 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - e.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT concat(prop.1, '::', prop.2) as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM - (SELECT actor_id, - funnel_actors.steps as steps, - arrayJoin(JSONExtractKeysAndValues(groups_0.group_properties_0, 'String')) as prop - FROM funnel_actors - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON funnel_actors.actor_id == groups_0.group_key) aggregation_target_with_props - GROUP BY prop.1, - prop.2 - HAVING prop.1 NOT IN [] - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM funnel_actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events_poe_v2 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT concat(prop.1, '::', prop.2) as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM - (SELECT actor_id, - funnel_actors.steps as steps, - arrayJoin(arrayZip(['industry'], [replaceRegexpAll(JSONExtractRaw(groups_0.group_properties_0, 'industry'), '^"|"$', '')])) as prop - FROM funnel_actors - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON funnel_actors.actor_id == groups_0.group_key) aggregation_target_with_props - GROUP BY prop.1, - prop.2 - HAVING prop.1 NOT IN [] - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM funnel_actors - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events_poe_v2.1 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['positive'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events_poe_v2.2 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['positive'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps <> target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events_poe_v2.3 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['negative'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events_poe_v2.4 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['negative'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id - FROM funnel_actors - WHERE funnel_actors.steps <> target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelation.test_funnel_correlation_with_properties_and_groups_person_on_events_poe_v2.5 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND notEmpty(e.person_id) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT concat(prop.1, '::', prop.2) as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM - (SELECT actor_id, - funnel_actors.steps as steps, - arrayJoin(JSONExtractKeysAndValues(groups_0.group_properties_0, 'String')) as prop - FROM funnel_actors - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON funnel_actors.actor_id == groups_0.group_key) aggregation_target_with_props - GROUP BY prop.1, - prop.2 - HAVING prop.1 NOT IN [] - UNION ALL - SELECT 'Total_Values_In_Query' as name, - countDistinctIf(actor_id, steps = target_step) AS success_count, - countDistinctIf(actor_id, steps <> target_step) AS failure_count - FROM funnel_actors - ''' -# --- diff --git a/ee/clickhouse/queries/funnels/test/__snapshots__/test_funnel_correlations_persons.ambr b/ee/clickhouse/queries/funnels/test/__snapshots__/test_funnel_correlations_persons.ambr deleted file mode 100644 index b616e1ac7e3..00000000000 --- a/ee/clickhouse/queries/funnels/test/__snapshots__/test_funnel_correlations_persons.ambr +++ /dev/null @@ -1,785 +0,0 @@ -# serializer version: 1 -# name: TestClickhouseFunnelCorrelationsActors.test_funnel_correlation_on_event_with_recordings - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id, - final_matching_events as matching_events , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - groupArray(10)(step_0_matching_event) as step_0_matching_events, - groupArray(10)(step_1_matching_event) as step_1_matching_events, - groupArray(10)(final_matching_event) as final_matching_events , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - ("latest_0", - "uuid_0", - "$session_id_0", - "$window_id_0") as step_0_matching_event, - ("latest_1", - "uuid_1", - "$session_id_1", - "$window_id_1") as step_1_matching_event, - if(isNull(latest_0),(null, null, null, null),if(isNull(latest_1), step_0_matching_event, step_1_matching_event)) as final_matching_event , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - ("latest_0", - "uuid_0", - "$session_id_0", - "$window_id_0") as step_0_matching_event, - ("latest_1", - "uuid_1", - "$session_id_1", - "$window_id_1") as step_1_matching_event, - if(isNull(latest_0),(null, null, null, null),if(isNull(latest_1), step_0_matching_event, step_1_matching_event)) as final_matching_event - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - "uuid_0", - "$session_id_0", - "$window_id_0", - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - last_value("uuid_1") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) "uuid_1", - last_value("$session_id_1") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) "$session_id_1", - last_value("$window_id_1") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) "$window_id_1" - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - e.uuid AS uuid, - pdi.person_id as person_id, - if(event = '$pageview', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(step_0 = 1, "uuid", null) as "uuid_0", - if(step_0 = 1, "$session_id", null) as "$session_id_0", - if(step_0 = 1, "$window_id", null) as "$window_id_0", - if(event = 'insight analyzed', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(step_1 = 1, "uuid", null) as "uuid_1", - if(step_1 = 1, "$session_id", null) as "$session_id_1", - if(step_1 = 1, "$window_id", null) as "$window_id_1" - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['$pageview', 'insight analyzed'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview', 'insight analyzed'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2021-01-08 23:59:59', 'UTC') AS date_to, - toDateTime('2021-01-01 00:00:00', 'UTC') AS date_from, - 2 AS target_step, - ['$pageview', 'insight analyzed'] as funnel_step_names - SELECT actors.actor_id AS actor_id , - any(actors.matching_events) AS matching_events - FROM events AS event - JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON pdi.distinct_id = events.distinct_id - JOIN funnel_actors AS actors ON pdi.person_id = actors.actor_id - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event = 'insight loaded' - AND actors.steps = target_step - GROUP BY actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelationsActors.test_funnel_correlation_on_event_with_recordings.1 - ''' - - SELECT DISTINCT session_id - FROM session_replay_events - WHERE team_id = 2 - and session_id in ['s2'] - AND min_first_timestamp >= '2020-12-31 00:00:00' - AND max_last_timestamp <= '2021-01-09 23:59:59' - ''' -# --- -# name: TestClickhouseFunnelCorrelationsActors.test_funnel_correlation_on_event_with_recordings.2 - ''' - WITH funnel_actors as - (SELECT aggregation_target AS actor_id, - final_matching_events as matching_events , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner, - groupArray(10)(step_0_matching_event) as step_0_matching_events, - groupArray(10)(step_1_matching_event) as step_1_matching_events, - groupArray(10)(step_2_matching_event) as step_2_matching_events, - groupArray(10)(final_matching_event) as final_matching_events , - argMax(latest_0, steps) as timestamp, - argMax(latest_2, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time, - ("latest_0", - "uuid_0", - "$session_id_0", - "$window_id_0") as step_0_matching_event, - ("latest_1", - "uuid_1", - "$session_id_1", - "$window_id_1") as step_1_matching_event, - ("latest_2", - "uuid_2", - "$session_id_2", - "$window_id_2") as step_2_matching_event, - if(isNull(latest_0),(null, null, null, null),if(isNull(latest_1), step_0_matching_event, if(isNull(latest_2), step_1_matching_event, step_2_matching_event))) as final_matching_event , - latest_0, - latest_2, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 14 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time, - ("latest_0", - "uuid_0", - "$session_id_0", - "$window_id_0") as step_0_matching_event, - ("latest_1", - "uuid_1", - "$session_id_1", - "$window_id_1") as step_1_matching_event, - ("latest_2", - "uuid_2", - "$session_id_2", - "$window_id_2") as step_2_matching_event, - if(isNull(latest_0),(null, null, null, null),if(isNull(latest_1), step_0_matching_event, if(isNull(latest_2), step_1_matching_event, step_2_matching_event))) as final_matching_event - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - "uuid_0", - "$session_id_0", - "$window_id_0", - step_1, - latest_1, - "uuid_1", - "$session_id_1", - "$window_id_1", - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2, - last_value("uuid_2") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) "uuid_2", - last_value("$session_id_2") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) "$session_id_2", - last_value("$window_id_2") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) "$window_id_2" - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - "uuid_0", - "$session_id_0", - "$window_id_0", - step_1, - latest_1, - "uuid_1", - "$session_id_1", - "$window_id_1", - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2, - if(latest_2 < latest_1, NULL, "uuid_2") as "uuid_2", - if(latest_2 < latest_1, NULL, "$session_id_2") as "$session_id_2", - if(latest_2 < latest_1, NULL, "$window_id_2") as "$window_id_2" - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - "uuid_0", - "$session_id_0", - "$window_id_0", - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - last_value("uuid_1") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) "uuid_1", - last_value("$session_id_1") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) "$session_id_1", - last_value("$window_id_1") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) "$window_id_1", - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2, - last_value("uuid_2") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) "uuid_2", - last_value("$session_id_2") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) "$session_id_2", - last_value("$window_id_2") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) "$window_id_2" - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - e.uuid AS uuid, - pdi.person_id as person_id, - if(event = '$pageview', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(step_0 = 1, "uuid", null) as "uuid_0", - if(step_0 = 1, "$session_id", null) as "$session_id_0", - if(step_0 = 1, "$window_id", null) as "$window_id_0", - if(event = 'insight analyzed', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(step_1 = 1, "uuid", null) as "uuid_1", - if(step_1 = 1, "$session_id", null) as "$session_id_1", - if(step_1 = 1, "$window_id", null) as "$window_id_1", - if(event = 'insight updated', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2, - if(step_2 = 1, "uuid", null) as "uuid_2", - if(step_2 = 1, "$session_id", null) as "$session_id_2", - if(step_2 = 1, "$window_id", null) as "$window_id_2" - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['$pageview', 'insight analyzed', 'insight updated'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview', 'insight analyzed', 'insight updated'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2, 3] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - toDateTime('2021-01-08 23:59:59', 'UTC') AS date_to, - toDateTime('2021-01-01 00:00:00', 'UTC') AS date_from, - 3 AS target_step, - ['$pageview', 'insight analyzed', 'insight updated'] as funnel_step_names - SELECT actors.actor_id AS actor_id , - any(actors.matching_events) AS matching_events - FROM events AS event - JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON pdi.distinct_id = events.distinct_id - JOIN funnel_actors AS actors ON pdi.person_id = actors.actor_id - WHERE toTimeZone(toDateTime(event.timestamp), 'UTC') >= date_from - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < date_to - AND event.team_id = 2 - AND toTimeZone(toDateTime(event.timestamp), 'UTC') > actors.first_timestamp - AND toTimeZone(toDateTime(event.timestamp), 'UTC') < COALESCE(actors.final_timestamp, actors.first_timestamp + INTERVAL 14 DAY, date_to) - AND event.event NOT IN funnel_step_names - AND event.event = 'insight loaded' - AND actors.steps <> target_step - GROUP BY actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelationsActors.test_funnel_correlation_on_event_with_recordings.3 - ''' - - SELECT DISTINCT session_id - FROM session_replay_events - WHERE team_id = 2 - and session_id in ['s2'] - AND min_first_timestamp >= '2020-12-31 00:00:00' - AND max_last_timestamp <= '2021-01-09 23:59:59' - ''' -# --- -# name: TestClickhouseFunnelCorrelationsActors.test_funnel_correlation_on_properties_with_recordings - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id, - final_matching_events as matching_events , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - groupArray(10)(step_0_matching_event) as step_0_matching_events, - groupArray(10)(step_1_matching_event) as step_1_matching_events, - groupArray(10)(final_matching_event) as final_matching_events , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - ("latest_0", - "uuid_0", - "$session_id_0", - "$window_id_0") as step_0_matching_event, - ("latest_1", - "uuid_1", - "$session_id_1", - "$window_id_1") as step_1_matching_event, - if(isNull(latest_0),(null, null, null, null),if(isNull(latest_1), step_0_matching_event, step_1_matching_event)) as final_matching_event , - latest_0, - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - ("latest_0", - "uuid_0", - "$session_id_0", - "$window_id_0") as step_0_matching_event, - ("latest_1", - "uuid_1", - "$session_id_1", - "$window_id_1") as step_1_matching_event, - if(isNull(latest_0),(null, null, null, null),if(isNull(latest_1), step_0_matching_event, step_1_matching_event)) as final_matching_event - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - "uuid_0", - "$session_id_0", - "$window_id_0", - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - last_value("uuid_1") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) "uuid_1", - last_value("$session_id_1") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) "$session_id_1", - last_value("$window_id_1") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) "$window_id_1" - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - e.uuid AS uuid, - pdi.person_id as person_id, - if(event = '$pageview', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(step_0 = 1, "uuid", null) as "uuid_0", - if(step_0 = 1, "$session_id", null) as "$session_id_0", - if(step_0 = 1, "$window_id", null) as "$window_id_0", - if(event = 'insight analyzed', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(step_1 = 1, "uuid", null) as "uuid_1", - if(step_1 = 1, "$session_id", null) as "$session_id_1", - if(step_1 = 1, "$window_id", null) as "$window_id_1" - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['$pageview', 'insight analyzed'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['bar'], replaceRegexpAll(JSONExtractRaw(properties, 'foo'), '^"|"$', ''))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['bar'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'foo'), '^"|"$', ''))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event IN ['$pageview', 'insight analyzed'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-01-08 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id , - any(funnel_actors.matching_events) AS matching_events - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelationsActors.test_funnel_correlation_on_properties_with_recordings.1 - ''' - - SELECT DISTINCT session_id - FROM session_replay_events - WHERE team_id = 2 - and session_id in ['s2'] - AND min_first_timestamp >= '2020-12-31 00:00:00' - AND max_last_timestamp <= '2021-01-09 23:59:59' - ''' -# --- -# name: TestClickhouseFunnelCorrelationsActors.test_strict_funnel_correlation_with_recordings - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id, - final_matching_events as matching_events , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp, - groupArray(10)(step_0_matching_event) as step_0_matching_events, - groupArray(10)(step_1_matching_event) as step_1_matching_events, - groupArray(10)(final_matching_event) as final_matching_events - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0, - ("latest_0", - "uuid_0", - "$session_id_0", - "$window_id_0") as step_0_matching_event, - ("latest_1", - "uuid_1", - "$session_id_1", - "$window_id_1") as step_1_matching_event, - if(isNull(latest_0),(null, null, null, null),if(isNull(latest_1), step_0_matching_event, step_1_matching_event)) as final_matching_event - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps, - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - ("latest_0", - "uuid_0", - "$session_id_0", - "$window_id_0") as step_0_matching_event, - ("latest_1", - "uuid_1", - "$session_id_1", - "$window_id_1") as step_1_matching_event, - if(isNull(latest_0),(null, null, null, null),if(isNull(latest_1), step_0_matching_event, step_1_matching_event)) as final_matching_event - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - "uuid_0", - "$session_id_0", - "$window_id_0", - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) latest_1, - min("uuid_1") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) "uuid_1", - min("$session_id_1") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) "$session_id_1", - min("$window_id_1") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) "$window_id_1" - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - e.uuid AS uuid, - pdi.person_id as person_id, - if(event = '$pageview', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(step_0 = 1, "uuid", null) as "uuid_0", - if(step_0 = 1, "$session_id", null) as "$session_id_0", - if(step_0 = 1, "$window_id", null) as "$window_id_0", - if(event = 'insight analyzed', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(step_1 = 1, "uuid", null) as "uuid_1", - if(step_1 = 1, "$session_id", null) as "$session_id_1", - if(step_1 = 1, "$window_id", null) as "$window_id_1" - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['bar'], replaceRegexpAll(JSONExtractRaw(properties, 'foo'), '^"|"$', ''))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['bar'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'foo'), '^"|"$', ''))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-01-08 23:59:59', 'UTC') - AND (1=1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id , - any(funnel_actors.matching_events) AS matching_events - FROM funnel_actors - WHERE funnel_actors.steps = target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelationsActors.test_strict_funnel_correlation_with_recordings.1 - ''' - - SELECT DISTINCT session_id - FROM session_replay_events - WHERE team_id = 2 - and session_id in ['s2'] - AND min_first_timestamp >= '2020-12-31 00:00:00' - AND max_last_timestamp <= '2021-01-09 23:59:59' - ''' -# --- -# name: TestClickhouseFunnelCorrelationsActors.test_strict_funnel_correlation_with_recordings.2 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id, - final_matching_events as matching_events , timestamp, steps, - final_timestamp, - first_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_1, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp, - groupArray(10)(step_0_matching_event) as step_0_matching_events, - groupArray(10)(step_1_matching_event) as step_1_matching_events, - groupArray(10)(final_matching_event) as final_matching_events - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time , - latest_0, - latest_1, - latest_0, - ("latest_0", - "uuid_0", - "$session_id_0", - "$window_id_0") as step_0_matching_event, - ("latest_1", - "uuid_1", - "$session_id_1", - "$window_id_1") as step_1_matching_event, - if(isNull(latest_0),(null, null, null, null),if(isNull(latest_1), step_0_matching_event, step_1_matching_event)) as final_matching_event - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps, - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - ("latest_0", - "uuid_0", - "$session_id_0", - "$window_id_0") as step_0_matching_event, - ("latest_1", - "uuid_1", - "$session_id_1", - "$window_id_1") as step_1_matching_event, - if(isNull(latest_0),(null, null, null, null),if(isNull(latest_1), step_0_matching_event, step_1_matching_event)) as final_matching_event - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - "uuid_0", - "$session_id_0", - "$window_id_0", - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) latest_1, - min("uuid_1") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) "uuid_1", - min("$session_id_1") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) "$session_id_1", - min("$window_id_1") over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) "$window_id_1" - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - e.uuid AS uuid, - pdi.person_id as person_id, - if(event = '$pageview', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(step_0 = 1, "uuid", null) as "uuid_0", - if(step_0 = 1, "$session_id", null) as "$session_id_0", - if(step_0 = 1, "$window_id", null) as "$window_id_0", - if(event = 'insight analyzed', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(step_1 = 1, "uuid", null) as "uuid_1", - if(step_1 = 1, "$session_id", null) as "$session_id_1", - if(step_1 = 1, "$window_id", null) as "$window_id_1" - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['bar'], replaceRegexpAll(JSONExtractRaw(properties, 'foo'), '^"|"$', ''))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['bar'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'foo'), '^"|"$', ''))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-01-08 23:59:59', 'UTC') - AND (1=1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000), - 2 AS target_step - SELECT funnel_actors.actor_id AS actor_id , - any(funnel_actors.matching_events) AS matching_events - FROM funnel_actors - WHERE funnel_actors.steps <> target_step - GROUP BY funnel_actors.actor_id - ORDER BY actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseFunnelCorrelationsActors.test_strict_funnel_correlation_with_recordings.3 - ''' - - SELECT DISTINCT session_id - FROM session_replay_events - WHERE team_id = 2 - and session_id in ['s3'] - AND min_first_timestamp >= '2020-12-31 00:00:00' - AND max_last_timestamp <= '2021-01-09 23:59:59' - ''' -# --- diff --git a/ee/clickhouse/queries/funnels/test/breakdown_cases.py b/ee/clickhouse/queries/funnels/test/breakdown_cases.py deleted file mode 100644 index f5f68078620..00000000000 --- a/ee/clickhouse/queries/funnels/test/breakdown_cases.py +++ /dev/null @@ -1,416 +0,0 @@ -from datetime import datetime -from typing import Any - -from posthog.constants import INSIGHT_FUNNELS -from posthog.models.filters import Filter -from posthog.models.group.util import create_group -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.models.instance_setting import override_instance_config -from posthog.queries.funnels.funnel_unordered import ClickhouseFunnelUnordered -from posthog.queries.funnels.test.breakdown_cases import ( - FunnelStepResult, - assert_funnel_results_equal, -) -from posthog.test.base import ( - APIBaseTest, - snapshot_clickhouse_queries, - also_test_with_person_on_events_v2, -) -from posthog.test.test_journeys import journeys_for - - -def funnel_breakdown_group_test_factory(Funnel, FunnelPerson, _create_event, _create_action, _create_person): - class TestFunnelBreakdownGroup(APIBaseTest): - def _get_actor_ids_at_step(self, filter, funnel_step, breakdown_value=None): - person_filter = filter.shallow_clone({"funnel_step": funnel_step, "funnel_step_breakdown": breakdown_value}) - _, serialized_result, _ = FunnelPerson(person_filter, self.team).get_actors() - - return [val["id"] for val in serialized_result] - - def _create_groups(self): - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - GroupTypeMapping.objects.create(team=self.team, group_type="company", group_type_index=1) - - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:5", - properties={"industry": "finance"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:6", - properties={"industry": "technology"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="org:5", - properties={"industry": "random"}, - ) - - def _assert_funnel_breakdown_result_is_correct(self, result, steps: list[FunnelStepResult]): - def funnel_result(step: FunnelStepResult, order: int) -> dict[str, Any]: - return { - "action_id": step.name if step.type == "events" else step.action_id, - "name": step.name, - "custom_name": None, - "order": order, - "people": [], - "count": step.count, - "type": step.type, - "average_conversion_time": step.average_conversion_time, - "median_conversion_time": step.median_conversion_time, - "breakdown": step.breakdown, - "breakdown_value": step.breakdown, - **( - { - "action_id": None, - "name": f"Completed {order+1} step{'s' if order > 0 else ''}", - } - if Funnel == ClickhouseFunnelUnordered - else {} - ), - } - - step_results = [] - for index, step_result in enumerate(steps): - step_results.append(funnel_result(step_result, index)) - - assert_funnel_results_equal(result, step_results) - - @snapshot_clickhouse_queries - def test_funnel_breakdown_group(self): - self._create_groups() - - people = journeys_for( - { - "person1": [ - { - "event": "sign up", - "timestamp": datetime(2020, 1, 1, 12), - "properties": {"$group_0": "org:5", "$browser": "Chrome"}, - }, - { - "event": "play movie", - "timestamp": datetime(2020, 1, 1, 13), - "properties": {"$group_0": "org:5", "$browser": "Chrome"}, - }, - { - "event": "buy", - "timestamp": datetime(2020, 1, 1, 15), - "properties": {"$group_0": "org:5", "$browser": "Chrome"}, - }, - ], - "person2": [ - { - "event": "sign up", - "timestamp": datetime(2020, 1, 2, 14), - "properties": {"$group_0": "org:6", "$browser": "Safari"}, - }, - { - "event": "play movie", - "timestamp": datetime(2020, 1, 2, 16), - "properties": {"$group_0": "org:6", "$browser": "Safari"}, - }, - ], - "person3": [ - { - "event": "sign up", - "timestamp": datetime(2020, 1, 2, 14), - "properties": {"$group_0": "org:6", "$browser": "Safari"}, - } - ], - }, - self.team, - ) - - filters = { - "events": [ - {"id": "sign up", "order": 0}, - {"id": "play movie", "order": 1}, - {"id": "buy", "order": 2}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-08", - "funnel_window_days": 7, - "breakdown": "industry", - "breakdown_type": "group", - "breakdown_group_type_index": 0, - } - - filter = Filter(data=filters, team=self.team) - result = Funnel(filter, self.team).run() - - self._assert_funnel_breakdown_result_is_correct( - result[0], - [ - FunnelStepResult(name="sign up", breakdown="finance", count=1), - FunnelStepResult( - name="play movie", - breakdown="finance", - count=1, - average_conversion_time=3600.0, - median_conversion_time=3600.0, - ), - FunnelStepResult( - name="buy", - breakdown="finance", - count=1, - average_conversion_time=7200.0, - median_conversion_time=7200.0, - ), - ], - ) - - # Querying persons when aggregating by persons should be ok, despite group breakdown - self.assertCountEqual( - self._get_actor_ids_at_step(filter, 1, "finance"), - [people["person1"].uuid], - ) - self.assertCountEqual( - self._get_actor_ids_at_step(filter, 2, "finance"), - [people["person1"].uuid], - ) - - self._assert_funnel_breakdown_result_is_correct( - result[1], - [ - FunnelStepResult(name="sign up", breakdown="technology", count=2), - FunnelStepResult( - name="play movie", - breakdown="technology", - count=1, - average_conversion_time=7200.0, - median_conversion_time=7200.0, - ), - FunnelStepResult(name="buy", breakdown="technology", count=0), - ], - ) - - self.assertCountEqual( - self._get_actor_ids_at_step(filter, 1, "technology"), - [people["person2"].uuid, people["person3"].uuid], - ) - self.assertCountEqual( - self._get_actor_ids_at_step(filter, 2, "technology"), - [people["person2"].uuid], - ) - - # TODO: Delete this test when moved to person-on-events - @also_test_with_person_on_events_v2 - def test_funnel_aggregate_by_groups_breakdown_group(self): - self._create_groups() - - journeys_for( - { - "person1": [ - { - "event": "sign up", - "timestamp": datetime(2020, 1, 1, 12), - "properties": {"$group_0": "org:5", "$browser": "Chrome"}, - }, - { - "event": "play movie", - "timestamp": datetime(2020, 1, 1, 13), - "properties": {"$group_0": "org:5", "$browser": "Chrome"}, - }, - { - "event": "buy", - "timestamp": datetime(2020, 1, 1, 15), - "properties": {"$group_0": "org:5", "$browser": "Chrome"}, - }, - ], - "person2": [ - { - "event": "sign up", - "timestamp": datetime(2020, 1, 2, 14), - "properties": {"$group_0": "org:6", "$browser": "Safari"}, - }, - { - "event": "play movie", - "timestamp": datetime(2020, 1, 2, 16), - "properties": {"$group_0": "org:6", "$browser": "Safari"}, - }, - ], - "person3": [ - { - "event": "buy", - "timestamp": datetime(2020, 1, 2, 18), - "properties": {"$group_0": "org:6", "$browser": "Safari"}, - } - ], - }, - self.team, - ) - - filters = { - "events": [ - {"id": "sign up", "order": 0}, - {"id": "play movie", "order": 1}, - {"id": "buy", "order": 2}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-08", - "funnel_window_days": 7, - "breakdown": "industry", - "breakdown_type": "group", - "breakdown_group_type_index": 0, - "aggregation_group_type_index": 0, - } - - result = Funnel(Filter(data=filters, team=self.team), self.team).run() - - self._assert_funnel_breakdown_result_is_correct( - result[0], - [ - FunnelStepResult(name="sign up", breakdown="finance", count=1), - FunnelStepResult( - name="play movie", - breakdown="finance", - count=1, - average_conversion_time=3600.0, - median_conversion_time=3600.0, - ), - FunnelStepResult( - name="buy", - breakdown="finance", - count=1, - average_conversion_time=7200.0, - median_conversion_time=7200.0, - ), - ], - ) - - self._assert_funnel_breakdown_result_is_correct( - result[1], - [ - FunnelStepResult(name="sign up", breakdown="technology", count=1), - FunnelStepResult( - name="play movie", - breakdown="technology", - count=1, - average_conversion_time=7200.0, - median_conversion_time=7200.0, - ), - FunnelStepResult( - name="buy", - breakdown="technology", - count=1, - average_conversion_time=7200.0, - median_conversion_time=7200.0, - ), - ], - ) - - @also_test_with_person_on_events_v2 - @snapshot_clickhouse_queries - def test_funnel_aggregate_by_groups_breakdown_group_person_on_events(self): - self._create_groups() - - journeys_for( - { - "person1": [ - { - "event": "sign up", - "timestamp": datetime(2020, 1, 1, 12), - "properties": {"$group_0": "org:5", "$browser": "Chrome"}, - }, - { - "event": "play movie", - "timestamp": datetime(2020, 1, 1, 13), - "properties": {"$group_0": "org:5", "$browser": "Chrome"}, - }, - { - "event": "buy", - "timestamp": datetime(2020, 1, 1, 15), - "properties": {"$group_0": "org:5", "$browser": "Chrome"}, - }, - ], - "person2": [ - { - "event": "sign up", - "timestamp": datetime(2020, 1, 2, 14), - "properties": {"$group_0": "org:6", "$browser": "Safari"}, - }, - { - "event": "play movie", - "timestamp": datetime(2020, 1, 2, 16), - "properties": {"$group_0": "org:6", "$browser": "Safari"}, - }, - ], - "person3": [ - { - "event": "buy", - "timestamp": datetime(2020, 1, 2, 18), - "properties": {"$group_0": "org:6", "$browser": "Safari"}, - } - ], - }, - self.team, - ) - - filters = { - "events": [ - {"id": "sign up", "order": 0}, - {"id": "play movie", "order": 1}, - {"id": "buy", "order": 2}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-08", - "funnel_window_days": 7, - "breakdown": "industry", - "breakdown_type": "group", - "breakdown_group_type_index": 0, - "aggregation_group_type_index": 0, - } - with override_instance_config("PERSON_ON_EVENTS_ENABLED", True): - result = Funnel(Filter(data=filters, team=self.team), self.team).run() - - self._assert_funnel_breakdown_result_is_correct( - result[0], - [ - FunnelStepResult(name="sign up", breakdown="finance", count=1), - FunnelStepResult( - name="play movie", - breakdown="finance", - count=1, - average_conversion_time=3600.0, - median_conversion_time=3600.0, - ), - FunnelStepResult( - name="buy", - breakdown="finance", - count=1, - average_conversion_time=7200.0, - median_conversion_time=7200.0, - ), - ], - ) - - self._assert_funnel_breakdown_result_is_correct( - result[1], - [ - FunnelStepResult(name="sign up", breakdown="technology", count=1), - FunnelStepResult( - name="play movie", - breakdown="technology", - count=1, - average_conversion_time=7200.0, - median_conversion_time=7200.0, - ), - FunnelStepResult( - name="buy", - breakdown="technology", - count=1, - average_conversion_time=7200.0, - median_conversion_time=7200.0, - ), - ], - ) - - return TestFunnelBreakdownGroup diff --git a/ee/clickhouse/queries/funnels/test/test_funnel.py b/ee/clickhouse/queries/funnels/test/test_funnel.py deleted file mode 100644 index 5942a267a04..00000000000 --- a/ee/clickhouse/queries/funnels/test/test_funnel.py +++ /dev/null @@ -1,205 +0,0 @@ -from datetime import datetime - -from ee.clickhouse.queries.funnels.test.breakdown_cases import ( - funnel_breakdown_group_test_factory, -) -from posthog.constants import INSIGHT_FUNNELS -from posthog.models.action import Action -from posthog.models.cohort import Cohort -from posthog.models.filters import Filter -from posthog.models.group.util import create_group -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.queries.funnels.funnel import ClickhouseFunnel -from posthog.queries.funnels.funnel_persons import ClickhouseFunnelActors -from posthog.queries.funnels.funnel_strict_persons import ClickhouseFunnelStrictActors -from posthog.queries.funnels.funnel_unordered_persons import ( - ClickhouseFunnelUnorderedActors, -) -from posthog.queries.funnels.test.test_funnel import _create_action -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - _create_event, - _create_person, -) -from posthog.test.test_journeys import journeys_for - - -class TestFunnelGroupBreakdown( - ClickhouseTestMixin, - funnel_breakdown_group_test_factory( - ClickhouseFunnel, - ClickhouseFunnelActors, - _create_event, - _create_action, - _create_person, - ), -): # type: ignore - pass - - -class TestUnorderedFunnelGroupBreakdown( - ClickhouseTestMixin, - funnel_breakdown_group_test_factory( - ClickhouseFunnel, - ClickhouseFunnelUnorderedActors, - _create_event, - _create_action, - _create_person, - ), -): # type: ignore - pass - - -class TestStrictFunnelGroupBreakdown( - ClickhouseTestMixin, - funnel_breakdown_group_test_factory( - ClickhouseFunnel, - ClickhouseFunnelStrictActors, - _create_event, - _create_action, - _create_person, - ), -): # type: ignore - pass - - -class TestClickhouseFunnel(ClickhouseTestMixin, APIBaseTest): - maxDiff = None - - def test_funnel_aggregation_with_groups_with_cohort_filtering(self): - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - GroupTypeMapping.objects.create(team=self.team, group_type="company", group_type_index=1) - - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:5", - properties={"industry": "finance"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:6", - properties={"industry": "technology"}, - ) - - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:1", - properties={}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:2", - properties={}, - ) - - _create_person( - distinct_ids=[f"user_1"], - team=self.team, - properties={"email": "fake@test.com"}, - ) - _create_person( - distinct_ids=[f"user_2"], - team=self.team, - properties={"email": "fake@test.com"}, - ) - _create_person( - distinct_ids=[f"user_3"], - team=self.team, - properties={"email": "fake_2@test.com"}, - ) - - Action.objects.create(team=self.team, name="action1", steps_json=[{"event": "$pageview"}]) - - cohort = Cohort.objects.create( - team=self.team, - groups=[ - { - "properties": [ - { - "key": "email", - "operator": "icontains", - "value": "fake@test.com", - "type": "person", - } - ] - } - ], - ) - - events_by_person = { - "user_1": [ - { - "event": "$pageview", - "timestamp": datetime(2020, 1, 2, 14), - "properties": {"$group_0": "org:5"}, - }, - { - "event": "user signed up", - "timestamp": datetime(2020, 1, 2, 14), - "properties": {"$group_0": "org:5"}, - }, - { - "event": "user signed up", # same person, different group, so should count as different step 1 in funnel - "timestamp": datetime(2020, 1, 10, 14), - "properties": {"$group_0": "org:6"}, - }, - ], - "user_2": [ - { # different person, same group, so should count as step two in funnel - "event": "paid", - "timestamp": datetime(2020, 1, 3, 14), - "properties": {"$group_0": "org:5"}, - } - ], - "user_3": [ - { - "event": "user signed up", - "timestamp": datetime(2020, 1, 2, 14), - "properties": {"$group_0": "org:7"}, - }, - { # person not in cohort so should be filtered out - "event": "paid", - "timestamp": datetime(2020, 1, 3, 14), - "properties": {"$group_0": "org:7"}, - }, - ], - } - journeys_for(events_by_person, self.team) - cohort.calculate_people_ch(pending_version=0) - - filters = { - "events": [ - { - "id": "user signed up", - "type": "events", - "order": 0, - "properties": [ - { - "type": "precalculated-cohort", - "key": "id", - "value": cohort.pk, - } - ], - }, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "aggregation_group_type_index": 0, - } - - filter = Filter(data=filters) - funnel = ClickhouseFunnel(filter, self.team) - result = funnel.run() - - self.assertEqual(result[0]["name"], "user signed up") - self.assertEqual(result[0]["count"], 2) - - self.assertEqual(result[1]["name"], "paid") - self.assertEqual(result[1]["count"], 1) diff --git a/ee/clickhouse/queries/funnels/test/test_funnel_correlation.py b/ee/clickhouse/queries/funnels/test/test_funnel_correlation.py deleted file mode 100644 index 979c84d8547..00000000000 --- a/ee/clickhouse/queries/funnels/test/test_funnel_correlation.py +++ /dev/null @@ -1,2080 +0,0 @@ -import unittest - -from rest_framework.exceptions import ValidationError - -from ee.clickhouse.queries.funnels.funnel_correlation import ( - EventContingencyTable, - EventStats, - FunnelCorrelation, -) -from ee.clickhouse.queries.funnels.funnel_correlation_persons import ( - FunnelCorrelationActors, -) -from posthog.constants import INSIGHT_FUNNELS -from posthog.models.action import Action -from posthog.models.element import Element -from posthog.models.filters import Filter -from posthog.models.group.util import create_group -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.models.instance_setting import override_instance_config -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - _create_event, - _create_person, - also_test_with_materialized_columns, - flush_persons_and_events, - snapshot_clickhouse_queries, - also_test_with_person_on_events_v2, -) -from posthog.test.test_journeys import journeys_for - - -def _create_action(**kwargs): - team = kwargs.pop("team") - name = kwargs.pop("name") - properties = kwargs.pop("properties", {}) - action = Action.objects.create(team=team, name=name, steps_json=[{"event": name, "properties": properties}]) - return action - - -class TestClickhouseFunnelCorrelation(ClickhouseTestMixin, APIBaseTest): - maxDiff = None - - def _get_actors_for_event(self, filter: Filter, event_name: str, properties=None, success=True): - actor_filter = filter.shallow_clone( - { - "funnel_correlation_person_entity": { - "id": event_name, - "type": "events", - "properties": properties, - }, - "funnel_correlation_person_converted": "TrUe" if success else "falSE", - } - ) - - _, serialized_actors, _ = FunnelCorrelationActors(actor_filter, self.team).get_actors() - return [str(row["id"]) for row in serialized_actors] - - def _get_actors_for_property(self, filter: Filter, property_values: list, success=True): - actor_filter = filter.shallow_clone( - { - "funnel_correlation_property_values": [ - { - "key": prop, - "value": value, - "type": type, - "group_type_index": group_type_index, - } - for prop, value, type, group_type_index in property_values - ], - "funnel_correlation_person_converted": "TrUe" if success else "falSE", - } - ) - _, serialized_actors, _ = FunnelCorrelationActors(actor_filter, self.team).get_actors() - return [str(row["id"]) for row in serialized_actors] - - def test_basic_funnel_correlation_with_events(self): - filters = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "events", - } - - filter = Filter(data=filters) - correlation = FunnelCorrelation(filter, self.team) - - for i in range(10): - _create_person(distinct_ids=[f"user_{i}"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - if i % 2 == 0: - _create_event( - team=self.team, - event="positively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - ) - - for i in range(10, 20): - _create_person(distinct_ids=[f"user_{i}"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - if i % 2 == 0: - _create_event( - team=self.team, - event="negatively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - ) - - result = correlation._run()[0] - - odds_ratios = [item.pop("odds_ratio") for item in result] # type: ignore - expected_odds_ratios = [11, 1 / 11] - - for odds, expected_odds in zip(odds_ratios, expected_odds_ratios): - self.assertAlmostEqual(odds, expected_odds) - - self.assertEqual( - result, - [ - { - "event": "positively_related", - "success_count": 5, - "failure_count": 0, - # "odds_ratio": 11.0, - "correlation_type": "success", - }, - { - "event": "negatively_related", - "success_count": 0, - "failure_count": 5, - # "odds_ratio": 1 / 11, - "correlation_type": "failure", - }, - ], - ) - - self.assertEqual(len(self._get_actors_for_event(filter, "positively_related")), 5) - self.assertEqual( - len(self._get_actors_for_event(filter, "positively_related", success=False)), - 0, - ) - self.assertEqual( - len(self._get_actors_for_event(filter, "negatively_related", success=False)), - 5, - ) - self.assertEqual(len(self._get_actors_for_event(filter, "negatively_related")), 0) - - # Now exclude positively_related - filter = filter.shallow_clone({"funnel_correlation_exclude_event_names": ["positively_related"]}) - correlation = FunnelCorrelation(filter, self.team) - - result = correlation._run()[0] - - odds_ratio = result[0].pop("odds_ratio") # type: ignore - expected_odds_ratio = 1 / 11 - - self.assertAlmostEqual(odds_ratio, expected_odds_ratio) - - self.assertEqual( - result, - [ - { - "event": "negatively_related", - "success_count": 0, - "failure_count": 5, - # "odds_ratio": 1 / 11, - "correlation_type": "failure", - } - ], - ) - # Getting specific people isn't affected by exclude_events - self.assertEqual(len(self._get_actors_for_event(filter, "positively_related")), 5) - self.assertEqual( - len(self._get_actors_for_event(filter, "positively_related", success=False)), - 0, - ) - self.assertEqual( - len(self._get_actors_for_event(filter, "negatively_related", success=False)), - 5, - ) - self.assertEqual(len(self._get_actors_for_event(filter, "negatively_related")), 0) - - @snapshot_clickhouse_queries - def test_action_events_are_excluded_from_correlations(self): - journey = {} - - for i in range(3): - person_id = f"user_{i}" - events = [ - { - "event": "user signed up", - "timestamp": "2020-01-02T14:00:00", - "properties": {"key": "val"}, - }, - # same event, but missing property, so not part of action. - {"event": "user signed up", "timestamp": "2020-01-02T14:10:00"}, - ] - if i % 2 == 0: - events.append({"event": "positively_related", "timestamp": "2020-01-03T14:00:00"}) - events.append( - { - "event": "paid", - "timestamp": "2020-01-04T14:00:00", - "properties": {"key": "val"}, - } - ) - - journey[person_id] = events - - # one failure needed - journey["failure"] = [ - { - "event": "user signed up", - "timestamp": "2020-01-02T14:00:00", - "properties": {"key": "val"}, - } - ] - - journeys_for(events_by_person=journey, team=self.team) # type: ignore - - sign_up_action = _create_action( - name="user signed up", - team=self.team, - properties=[{"key": "key", "type": "event", "value": ["val"], "operator": "exact"}], - ) - - paid_action = _create_action( - name="paid", - team=self.team, - properties=[{"key": "key", "type": "event", "value": ["val"], "operator": "exact"}], - ) - filters = { - "events": [], - "actions": [ - {"id": sign_up_action.id, "order": 0}, - {"id": paid_action.id, "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "events", - } - - filter = Filter(data=filters) - correlation = FunnelCorrelation(filter, self.team) - result = correlation._run()[0] - - #  missing user signed up and paid from result set, as expected - self.assertEqual( - result, - [ - { - "event": "positively_related", - "success_count": 2, - "failure_count": 0, - "odds_ratio": 3, - "correlation_type": "success", - } - ], - ) - - @also_test_with_person_on_events_v2 - @snapshot_clickhouse_queries - def test_funnel_correlation_with_events_and_groups(self): - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:5", - properties={"industry": "finance"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:7", - properties={"industry": "finance"}, - ) - - for i in range(10, 20): - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key=f"org:{i}", - properties={}, - ) - _create_person(distinct_ids=[f"user_{i}"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - properties={"$group_0": f"org:{i}"}, - ) - if i % 2 == 0: - _create_event( - team=self.team, - event="positively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - properties={"$group_0": f"org:{i}"}, - ) - # this event shouldn't show up when dealing with groups - _create_event( - team=self.team, - event="positively_related_without_group", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - properties={"$group_0": f"org:{i}"}, - ) - - # one fail group - _create_person(distinct_ids=[f"user_fail"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_fail", - timestamp="2020-01-02T14:00:00Z", - properties={"$group_0": f"org:5"}, - ) - _create_event( - team=self.team, - event="negatively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - properties={"$group_0": f"org:5"}, - ) - - # one success group with same filter property - _create_person(distinct_ids=[f"user_succ"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_succ", - timestamp="2020-01-02T14:00:00Z", - properties={"$group_0": f"org:7"}, - ) - _create_event( - team=self.team, - event="negatively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - properties={"$group_0": f"org:7"}, - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_succ", - timestamp="2020-01-04T14:00:00Z", - properties={"$group_0": f"org:7"}, - ) - - filters = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "events", - "aggregation_group_type_index": 0, - } - - filter = Filter(data=filters) - result = FunnelCorrelation(filter, self.team)._run()[0] - - odds_ratios = [item.pop("odds_ratio") for item in result] # type: ignore - expected_odds_ratios = [12 / 7, 1 / 11] - - for odds, expected_odds in zip(odds_ratios, expected_odds_ratios): - self.assertAlmostEqual(odds, expected_odds) - - self.assertEqual( - result, - [ - { - "event": "positively_related", - "success_count": 5, - "failure_count": 0, - # "odds_ratio": 12/7, - "correlation_type": "success", - }, - { - "event": "negatively_related", - "success_count": 1, - "failure_count": 1, - # "odds_ratio": 1 / 11, - "correlation_type": "failure", - }, - ], - ) - - self.assertEqual(len(self._get_actors_for_event(filter, "positively_related")), 5) - self.assertEqual( - len(self._get_actors_for_event(filter, "positively_related", success=False)), - 0, - ) - self.assertEqual(len(self._get_actors_for_event(filter, "negatively_related")), 1) - self.assertEqual( - len(self._get_actors_for_event(filter, "negatively_related", success=False)), - 1, - ) - - # Now exclude all groups in positive - filter = filter.shallow_clone( - { - "properties": [ - { - "key": "industry", - "value": "finance", - "type": "group", - "group_type_index": 0, - } - ] - } - ) - result = FunnelCorrelation(filter, self.team)._run()[0] - - odds_ratio = result[0].pop("odds_ratio") # type: ignore - expected_odds_ratio = 1 - # success total and failure totals remove other groups too - - self.assertAlmostEqual(odds_ratio, expected_odds_ratio) - - self.assertEqual( - result, - [ - { - "event": "negatively_related", - "success_count": 1, - "failure_count": 1, - # "odds_ratio": 1, - "correlation_type": "failure", - } - ], - ) - - self.assertEqual(len(self._get_actors_for_event(filter, "negatively_related")), 1) - self.assertEqual( - len(self._get_actors_for_event(filter, "negatively_related", success=False)), - 1, - ) - - @also_test_with_materialized_columns(event_properties=[], person_properties=["$browser"]) - @snapshot_clickhouse_queries - def test_basic_funnel_correlation_with_properties(self): - filters = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "properties", - "funnel_correlation_names": ["$browser"], - } - - filter = Filter(data=filters) - correlation = FunnelCorrelation(filter, self.team) - - for i in range(10): - _create_person( - distinct_ids=[f"user_{i}"], - team_id=self.team.pk, - properties={"$browser": "Positive"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - ) - - for i in range(10, 20): - _create_person( - distinct_ids=[f"user_{i}"], - team_id=self.team.pk, - properties={"$browser": "Negative"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - if i % 2 == 0: - _create_event( - team=self.team, - event="negatively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - ) - - # One Positive with failure - _create_person( - distinct_ids=[f"user_fail"], - team_id=self.team.pk, - properties={"$browser": "Positive"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_fail", - timestamp="2020-01-02T14:00:00Z", - ) - - # One Negative with success - _create_person( - distinct_ids=[f"user_succ"], - team_id=self.team.pk, - properties={"$browser": "Negative"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_succ", - timestamp="2020-01-02T14:00:00Z", - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_succ", - timestamp="2020-01-04T14:00:00Z", - ) - - result = correlation._run()[0] - - odds_ratios = [item.pop("odds_ratio") for item in result] # type: ignore - - # Success Total = 11, Failure Total = 11 - # - # Browser::Positive - # Success: 10 - # Failure: 1 - - # Browser::Negative - # Success: 1 - # Failure: 10 - - prior_count = 1 - expected_odds_ratios = [ - ((10 + prior_count) / (1 + prior_count)) * ((11 - 1 + prior_count) / (11 - 10 + prior_count)), - ((1 + prior_count) / (10 + prior_count)) * ((11 - 10 + prior_count) / (11 - 1 + prior_count)), - ] - - for odds, expected_odds in zip(odds_ratios, expected_odds_ratios): - self.assertAlmostEqual(odds, expected_odds) - - self.assertEqual( - result, - [ - { - "event": "$browser::Positive", - "success_count": 10, - "failure_count": 1, - # "odds_ratio": 121/4, - "correlation_type": "success", - }, - { - "event": "$browser::Negative", - "success_count": 1, - "failure_count": 10, - # "odds_ratio": 4/121, - "correlation_type": "failure", - }, - ], - ) - - self.assertEqual( - len(self._get_actors_for_property(filter, [("$browser", "Positive", "person", None)])), - 10, - ) - self.assertEqual( - len(self._get_actors_for_property(filter, [("$browser", "Positive", "person", None)], False)), - 1, - ) - self.assertEqual( - len(self._get_actors_for_property(filter, [("$browser", "Negative", "person", None)])), - 1, - ) - self.assertEqual( - len(self._get_actors_for_property(filter, [("$browser", "Negative", "person", None)], False)), - 10, - ) - - # TODO: Delete this test when moved to person-on-events - @also_test_with_materialized_columns( - event_properties=[], person_properties=["$browser"], verify_no_jsonextract=False - ) - @snapshot_clickhouse_queries - def test_funnel_correlation_with_properties_and_groups(self): - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - - for i in range(10): - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key=f"org:{i}", - properties={"industry": "positive"}, - ) - _create_person( - distinct_ids=[f"user_{i}"], - team_id=self.team.pk, - properties={"$browser": "Positive"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - properties={"$group_0": f"org:{i}"}, - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - properties={"$group_0": f"org:{i}"}, - ) - - for i in range(10, 20): - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key=f"org:{i}", - properties={"industry": "negative"}, - ) - _create_person( - distinct_ids=[f"user_{i}"], - team_id=self.team.pk, - properties={"$browser": "Negative"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - properties={"$group_0": f"org:{i}"}, - ) - if i % 2 == 0: - _create_event( - team=self.team, - event="negatively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - properties={"$group_0": f"org:{i}"}, - ) - - # One Positive with failure - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key=f"org:fail", - properties={"industry": "positive"}, - ) - _create_person( - distinct_ids=[f"user_fail"], - team_id=self.team.pk, - properties={"$browser": "Positive"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_fail", - timestamp="2020-01-02T14:00:00Z", - properties={"$group_0": f"org:fail"}, - ) - - # One Negative with success - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key=f"org:succ", - properties={"industry": "negative"}, - ) - _create_person( - distinct_ids=[f"user_succ"], - team_id=self.team.pk, - properties={"$browser": "Negative"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_succ", - timestamp="2020-01-02T14:00:00Z", - properties={"$group_0": f"org:succ"}, - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_succ", - timestamp="2020-01-04T14:00:00Z", - properties={"$group_0": f"org:succ"}, - ) - - filters = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "properties", - "funnel_correlation_names": ["industry"], - "aggregation_group_type_index": 0, - } - - filter = Filter(data=filters) - correlation = FunnelCorrelation(filter, self.team) - result = correlation._run()[0] - - odds_ratios = [item.pop("odds_ratio") for item in result] # type: ignore - - # Success Total = 11, Failure Total = 11 - # - # Industry::Positive - # Success: 10 - # Failure: 1 - - # Industry::Negative - # Success: 1 - # Failure: 10 - - prior_count = 1 - expected_odds_ratios = [ - ((10 + prior_count) / (1 + prior_count)) * ((11 - 1 + prior_count) / (11 - 10 + prior_count)), - ((1 + prior_count) / (10 + prior_count)) * ((11 - 10 + prior_count) / (11 - 1 + prior_count)), - ] - - for odds, expected_odds in zip(odds_ratios, expected_odds_ratios): - self.assertAlmostEqual(odds, expected_odds) - - self.assertEqual( - result, - [ - { - "event": "industry::positive", - "success_count": 10, - "failure_count": 1, - # "odds_ratio": 121/4, - "correlation_type": "success", - }, - { - "event": "industry::negative", - "success_count": 1, - "failure_count": 10, - # "odds_ratio": 4/121, - "correlation_type": "failure", - }, - ], - ) - - self.assertEqual( - len(self._get_actors_for_property(filter, [("industry", "positive", "group", 0)])), - 10, - ) - self.assertEqual( - len(self._get_actors_for_property(filter, [("industry", "positive", "group", 0)], False)), - 1, - ) - self.assertEqual( - len(self._get_actors_for_property(filter, [("industry", "negative", "group", 0)])), - 1, - ) - self.assertEqual( - len(self._get_actors_for_property(filter, [("industry", "negative", "group", 0)], False)), - 10, - ) - - # test with `$all` as property - # _run property correlation with filter on all properties - filter = filter.shallow_clone({"funnel_correlation_names": ["$all"]}) - correlation = FunnelCorrelation(filter, self.team) - - new_result = correlation._run()[0] - - odds_ratios = [item.pop("odds_ratio") for item in new_result] # type: ignore - - for odds, expected_odds in zip(odds_ratios, expected_odds_ratios): - self.assertAlmostEqual(odds, expected_odds) - - self.assertEqual(new_result, result) - - @also_test_with_materialized_columns( - event_properties=[], - person_properties=["$browser"], - verify_no_jsonextract=False, - ) - @also_test_with_person_on_events_v2 - @snapshot_clickhouse_queries - def test_funnel_correlation_with_properties_and_groups_person_on_events(self): - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - - for i in range(10): - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key=f"org:{i}", - properties={"industry": "positive"}, - ) - _create_person( - distinct_ids=[f"user_{i}"], - team_id=self.team.pk, - properties={"$browser": "Positive"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - properties={"$group_0": f"org:{i}"}, - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - properties={"$group_0": f"org:{i}"}, - ) - - for i in range(10, 20): - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key=f"org:{i}", - properties={"industry": "negative"}, - ) - _create_person( - distinct_ids=[f"user_{i}"], - team_id=self.team.pk, - properties={"$browser": "Negative"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - properties={"$group_0": f"org:{i}"}, - ) - if i % 2 == 0: - _create_event( - team=self.team, - event="negatively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - properties={"$group_0": f"org:{i}"}, - ) - - # One Positive with failure - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key=f"org:fail", - properties={"industry": "positive"}, - ) - _create_person( - distinct_ids=[f"user_fail"], - team_id=self.team.pk, - properties={"$browser": "Positive"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_fail", - timestamp="2020-01-02T14:00:00Z", - properties={"$group_0": f"org:fail"}, - ) - - # One Negative with success - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key=f"org:succ", - properties={"industry": "negative"}, - ) - _create_person( - distinct_ids=[f"user_succ"], - team_id=self.team.pk, - properties={"$browser": "Negative"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_succ", - timestamp="2020-01-02T14:00:00Z", - properties={"$group_0": f"org:succ"}, - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_succ", - timestamp="2020-01-04T14:00:00Z", - properties={"$group_0": f"org:succ"}, - ) - - filters = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "properties", - "funnel_correlation_names": ["industry"], - "aggregation_group_type_index": 0, - } - - with override_instance_config("PERSON_ON_EVENTS_ENABLED", True): - filter = Filter(data=filters) - correlation = FunnelCorrelation(filter, self.team) - result = correlation._run()[0] - - odds_ratios = [item.pop("odds_ratio") for item in result] # type: ignore - - # Success Total = 11, Failure Total = 11 - # - # Industry::Positive - # Success: 10 - # Failure: 1 - - # Industry::Negative - # Success: 1 - # Failure: 10 - - prior_count = 1 - expected_odds_ratios = [ - ((10 + prior_count) / (1 + prior_count)) * ((11 - 1 + prior_count) / (11 - 10 + prior_count)), - ((1 + prior_count) / (10 + prior_count)) * ((11 - 10 + prior_count) / (11 - 1 + prior_count)), - ] - - for odds, expected_odds in zip(odds_ratios, expected_odds_ratios): - self.assertAlmostEqual(odds, expected_odds) - - self.assertEqual( - result, - [ - { - "event": "industry::positive", - "success_count": 10, - "failure_count": 1, - # "odds_ratio": 121/4, - "correlation_type": "success", - }, - { - "event": "industry::negative", - "success_count": 1, - "failure_count": 10, - # "odds_ratio": 4/121, - "correlation_type": "failure", - }, - ], - ) - - self.assertEqual( - len(self._get_actors_for_property(filter, [("industry", "positive", "group", 0)])), - 10, - ) - self.assertEqual( - len(self._get_actors_for_property(filter, [("industry", "positive", "group", 0)], False)), - 1, - ) - self.assertEqual( - len(self._get_actors_for_property(filter, [("industry", "negative", "group", 0)])), - 1, - ) - self.assertEqual( - len(self._get_actors_for_property(filter, [("industry", "negative", "group", 0)], False)), - 10, - ) - - # test with `$all` as property - # _run property correlation with filter on all properties - filter = filter.shallow_clone({"funnel_correlation_names": ["$all"]}) - correlation = FunnelCorrelation(filter, self.team) - - new_result = correlation._run()[0] - - odds_ratios = [item.pop("odds_ratio") for item in new_result] # type: ignore - - for odds, expected_odds in zip(odds_ratios, expected_odds_ratios): - self.assertAlmostEqual(odds, expected_odds) - - self.assertEqual(new_result, result) - - def test_no_divide_by_zero_errors(self): - filters = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - } - - filter = Filter(data=filters) - correlation = FunnelCorrelation(filter, self.team) - - for i in range(2): - _create_person( - distinct_ids=[f"user_{i}"], - team_id=self.team.pk, - properties={"$browser": "Positive"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - # failure count for this event is 0 - _create_event( - team=self.team, - event="positive", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - ) - - for i in range(2, 4): - _create_person( - distinct_ids=[f"user_{i}"], - team_id=self.team.pk, - properties={"$browser": "Negative"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - if i % 2 == 0: - # success count for this event is 0 - _create_event( - team=self.team, - event="negatively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - ) - - results = correlation._run() - self.assertFalse(results[1]) - - result = results[0] - - odds_ratios = [item.pop("odds_ratio") for item in result] # type: ignore - expected_odds_ratios = [9, 1 / 3] - - for odds, expected_odds in zip(odds_ratios, expected_odds_ratios): - self.assertAlmostEqual(odds, expected_odds) - - self.assertEqual( - result, - [ - { - "event": "positive", - "success_count": 2, - "failure_count": 0, - # "odds_ratio": 9.0, - "correlation_type": "success", - }, - { - "event": "negatively_related", - "success_count": 0, - "failure_count": 1, - # "odds_ratio": 1 / 3, - "correlation_type": "failure", - }, - ], - ) - - def test_correlation_with_properties_raises_validation_error(self): - filters = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "properties", - # "funnel_correlation_names": ["$browser"], missing value - } - - filter = Filter(data=filters) - correlation = FunnelCorrelation(filter, self.team) - - _create_person( - distinct_ids=[f"user_1"], - team_id=self.team.pk, - properties={"$browser": "Positive"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_1", - timestamp="2020-01-02T14:00:00Z", - ) - _create_event( - team=self.team, - event="rick", - distinct_id=f"user_1", - timestamp="2020-01-03T14:00:00Z", - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_1", - timestamp="2020-01-04T14:00:00Z", - ) - flush_persons_and_events() - - with self.assertRaises(ValidationError): - correlation._run() - - filter = filter.shallow_clone({"funnel_correlation_type": "event_with_properties"}) - # missing "funnel_correlation_event_names": ["rick"], - with self.assertRaises(ValidationError): - FunnelCorrelation(filter, self.team)._run() - - @also_test_with_materialized_columns( - event_properties=[], person_properties=["$browser"], verify_no_jsonextract=False - ) - def test_correlation_with_multiple_properties(self): - filters = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "properties", - "funnel_correlation_names": ["$browser", "$nice"], - } - - filter = Filter(data=filters) - correlation = FunnelCorrelation(filter, self.team) - - #  5 successful people with both properties - for i in range(5): - _create_person( - distinct_ids=[f"user_{i}"], - team_id=self.team.pk, - properties={"$browser": "Positive", "$nice": "very"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - ) - - #  10 successful people with some different properties - for i in range(5, 15): - _create_person( - distinct_ids=[f"user_{i}"], - team_id=self.team.pk, - properties={"$browser": "Positive", "$nice": "not"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - ) - - # 5 Unsuccessful people with some common properties - for i in range(15, 20): - _create_person( - distinct_ids=[f"user_{i}"], - team_id=self.team.pk, - properties={"$browser": "Negative", "$nice": "smh"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - - # One Positive with failure, no $nice property - _create_person( - distinct_ids=[f"user_fail"], - team_id=self.team.pk, - properties={"$browser": "Positive"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_fail", - timestamp="2020-01-02T14:00:00Z", - ) - - # One Negative with success, no $nice property - _create_person( - distinct_ids=[f"user_succ"], - team_id=self.team.pk, - properties={"$browser": "Negative"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_succ", - timestamp="2020-01-02T14:00:00Z", - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_succ", - timestamp="2020-01-04T14:00:00Z", - ) - - result = correlation._run()[0] - - # Success Total = 5 + 10 + 1 = 16 - # Failure Total = 5 + 1 = 6 - # Add 1 for priors - - odds_ratios = [item.pop("odds_ratio") for item in result] # type: ignore - expected_odds_ratios = [ - (16 / 2) * ((7 - 1) / (17 - 15)), - (11 / 1) * ((7 - 0) / (17 - 10)), - (6 / 1) * ((7 - 0) / (17 - 5)), - (1 / 6) * ((7 - 5) / (17 - 0)), - (2 / 6) * ((7 - 5) / (17 - 1)), - (2 / 2) * ((7 - 1) / (17 - 1)), - ] - # (success + 1) / (failure + 1) - - for odds, expected_odds in zip(odds_ratios, expected_odds_ratios): - self.assertAlmostEqual(odds, expected_odds) - - expected_result = [ - { - "event": "$browser::Positive", - "success_count": 15, - "failure_count": 1, - # "odds_ratio": 24, - "correlation_type": "success", - }, - { - "event": "$nice::not", - "success_count": 10, - "failure_count": 0, - # "odds_ratio": 11, - "correlation_type": "success", - }, - { - "event": "$nice::very", - "success_count": 5, - "failure_count": 0, - # "odds_ratio": 3.5, - "correlation_type": "success", - }, - { - "event": "$nice::smh", - "success_count": 0, - "failure_count": 5, - # "odds_ratio": 0.0196078431372549, - "correlation_type": "failure", - }, - { - "event": "$browser::Negative", - "success_count": 1, - "failure_count": 5, - # "odds_ratio": 0.041666666666666664, - "correlation_type": "failure", - }, - { - "event": "$nice::", - "success_count": 1, - "failure_count": 1, - # "odds_ratio": 0.375, - "correlation_type": "failure", - }, - ] - - self.assertEqual(result, expected_result) - - # _run property correlation with filter on all properties - filter = filter.shallow_clone({"funnel_correlation_names": ["$all"]}) - correlation = FunnelCorrelation(filter, self.team) - - new_result = correlation._run()[0] - - odds_ratios = [item.pop("odds_ratio") for item in new_result] # type: ignore - - new_expected_odds_ratios = expected_odds_ratios[:-1] - new_expected_result = expected_result[:-1] - # When querying all properties, we don't consider properties that don't exist for part of the data - # since users aren't explicitly asking for that property. Thus, - # We discard $nice:: because it's an empty result set - - for odds, expected_odds in zip(odds_ratios, new_expected_odds_ratios): - self.assertAlmostEqual(odds, expected_odds) - - self.assertEqual(new_result, new_expected_result) - - filter = filter.shallow_clone({"funnel_correlation_exclude_names": ["$browser"]}) - # search for $all but exclude $browser - correlation = FunnelCorrelation(filter, self.team) - - new_result = correlation._run()[0] - odds_ratios = [item.pop("odds_ratio") for item in new_result] # type: ignore - - new_expected_odds_ratios = expected_odds_ratios[1:4] # choosing the $nice property values - new_expected_result = expected_result[1:4] - - for odds, expected_odds in zip(odds_ratios, new_expected_odds_ratios): - self.assertAlmostEqual(odds, expected_odds) - - self.assertEqual(new_result, new_expected_result) - - self.assertEqual( - len(self._get_actors_for_property(filter, [("$nice", "not", "person", None)])), - 10, - ) - self.assertEqual( - len(self._get_actors_for_property(filter, [("$nice", "", "person", None)], False)), - 1, - ) - self.assertEqual( - len(self._get_actors_for_property(filter, [("$nice", "very", "person", None)])), - 5, - ) - - def test_discarding_insignificant_events(self): - filters = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "events", - } - - filter = Filter(data=filters) - correlation = FunnelCorrelation(filter, self.team) - - for i in range(10): - _create_person(distinct_ids=[f"user_{i}"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - if i % 2 == 0: - _create_event( - team=self.team, - event="positively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - ) - if i % 10 == 0: - _create_event( - team=self.team, - event="low_sig_positively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:20:00Z", - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - ) - - for i in range(10, 20): - _create_person(distinct_ids=[f"user_{i}"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - if i % 2 == 0: - _create_event( - team=self.team, - event="negatively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - ) - if i % 5 == 0: - _create_event( - team=self.team, - event="low_sig_negatively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - ) - - #  Total 10 positive, 10 negative - # low sig count = 1 and 2, high sig count >= 5 - # Thus, to discard the low sig count, % needs to be >= 10%, or count >= 2 - - # Discard both due to % - FunnelCorrelation.MIN_PERSON_PERCENTAGE = 0.11 - FunnelCorrelation.MIN_PERSON_COUNT = 25 - result = correlation._run()[0] - self.assertEqual(len(result), 2) - - def test_events_within_conversion_window_for_correlation(self): - filters = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "funnel_window_interval": "10", - "funnel_window_interval_unit": "minute", - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "events", - } - - filter = Filter(data=filters) - correlation = FunnelCorrelation(filter, self.team) - - _create_person(distinct_ids=["user_successful"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id="user_successful", - timestamp="2020-01-02T14:00:00Z", - ) - _create_event( - team=self.team, - event="positively_related", - distinct_id="user_successful", - timestamp="2020-01-02T14:02:00Z", - ) - _create_event( - team=self.team, - event="paid", - distinct_id="user_successful", - timestamp="2020-01-02T14:06:00Z", - ) - - _create_person(distinct_ids=["user_dropoff"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id="user_dropoff", - timestamp="2020-01-02T14:00:00Z", - ) - _create_event( - team=self.team, - event="NOT_negatively_related", - distinct_id="user_dropoff", - timestamp="2020-01-02T14:15:00Z", # event happened outside conversion window - ) - - result = correlation._run()[0] - - odds_ratios = [item.pop("odds_ratio") for item in result] # type: ignore - expected_odds_ratios = [4] - - for odds, expected_odds in zip(odds_ratios, expected_odds_ratios): - self.assertAlmostEqual(odds, expected_odds) - - self.assertEqual( - result, - [ - { - "event": "positively_related", - "success_count": 1, - "failure_count": 0, - # "odds_ratio": 4.0, - "correlation_type": "success", - } - ], - ) - - @also_test_with_materialized_columns(["blah", "signup_source"], verify_no_jsonextract=False) - def test_funnel_correlation_with_event_properties(self): - filters = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "event_with_properties", - "funnel_correlation_event_names": [ - "positively_related", - "negatively_related", - ], - } - - filter = Filter(data=filters) - correlation = FunnelCorrelation(filter, self.team) - - for i in range(10): - _create_person(distinct_ids=[f"user_{i}"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - if i % 2 == 0: - _create_event( - team=self.team, - event="positively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - properties={ - "signup_source": "facebook" if i % 4 == 0 else "email", - "blah": "value_bleh", - }, - ) - # source: email occurs only twice, so would be discarded from result set - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - ) - - for i in range(10, 20): - _create_person(distinct_ids=[f"user_{i}"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - if i % 2 == 0: - _create_event( - team=self.team, - event="negatively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - properties={"signup_source": "shazam" if i % 6 == 0 else "email"}, - ) - # source: shazam occurs only once, so would be discarded from result set - - result = correlation._run()[0] - - odds_ratios = [item.pop("odds_ratio") for item in result] # type: ignore - expected_odds_ratios = [11, 5.5, 2 / 11] - - for odds, expected_odds in zip(odds_ratios, expected_odds_ratios): - self.assertAlmostEqual(odds, expected_odds) - - self.assertEqual( - result, - [ - { - "event": "positively_related::blah::value_bleh", - "success_count": 5, - "failure_count": 0, - # "odds_ratio": 11.0, - "correlation_type": "success", - }, - { - "event": "positively_related::signup_source::facebook", - "success_count": 3, - "failure_count": 0, - # "odds_ratio": 5.5, - "correlation_type": "success", - }, - { - "event": "negatively_related::signup_source::email", - "success_count": 0, - "failure_count": 3, - # "odds_ratio": 0.18181818181818182, - "correlation_type": "failure", - }, - ], - ) - - self.assertEqual( - len(self._get_actors_for_event(filter, "positively_related", {"blah": "value_bleh"})), - 5, - ) - self.assertEqual( - len(self._get_actors_for_event(filter, "positively_related", {"signup_source": "facebook"})), - 3, - ) - self.assertEqual( - len(self._get_actors_for_event(filter, "positively_related", {"signup_source": "facebook"}, False)), - 0, - ) - self.assertEqual( - len(self._get_actors_for_event(filter, "negatively_related", {"signup_source": "email"}, False)), - 3, - ) - - @also_test_with_materialized_columns(["blah", "signup_source"], verify_no_jsonextract=False) - @snapshot_clickhouse_queries - def test_funnel_correlation_with_event_properties_and_groups(self): - # same test as test_funnel_correlation_with_event_properties but with events attached to groups - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=1) - - for i in range(10): - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key=f"org:{i}", - properties={"industry": "positive"}, - ) - _create_person(distinct_ids=[f"user_{i}"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - properties={"$group_1": f"org:{i}"}, - ) - if i % 2 == 0: - _create_event( - team=self.team, - event="positively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - properties={ - "signup_source": "facebook" if i % 4 == 0 else "email", - "blah": "value_bleh", - "$group_1": f"org:{i}", - }, - ) - # source: email occurs only twice, so would be discarded from result set - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - properties={"$group_1": f"org:{i}"}, - ) - - for i in range(10, 20): - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key=f"org:{i}", - properties={"industry": "positive"}, - ) - _create_person(distinct_ids=[f"user_{i}"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - properties={"$group_1": f"org:{i}"}, - ) - if i % 2 == 0: - _create_event( - team=self.team, - event="negatively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - properties={ - "signup_source": "shazam" if i % 6 == 0 else "email", - "$group_1": f"org:{i}", - }, - ) - # source: shazam occurs only once, so would be discarded from result set - - filters = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "aggregation_group_type_index": 1, - "funnel_correlation_type": "event_with_properties", - "funnel_correlation_event_names": [ - "positively_related", - "negatively_related", - ], - } - - filter = Filter(data=filters) - correlation = FunnelCorrelation(filter, self.team) - result = correlation._run()[0] - - odds_ratios = [item.pop("odds_ratio") for item in result] # type: ignore - expected_odds_ratios = [11, 5.5, 2 / 11] - - for odds, expected_odds in zip(odds_ratios, expected_odds_ratios): - self.assertAlmostEqual(odds, expected_odds) - - self.assertEqual( - result, - [ - { - "event": "positively_related::blah::value_bleh", - "success_count": 5, - "failure_count": 0, - # "odds_ratio": 11.0, - "correlation_type": "success", - }, - { - "event": "positively_related::signup_source::facebook", - "success_count": 3, - "failure_count": 0, - # "odds_ratio": 5.5, - "correlation_type": "success", - }, - { - "event": "negatively_related::signup_source::email", - "success_count": 0, - "failure_count": 3, - # "odds_ratio": 0.18181818181818182, - "correlation_type": "failure", - }, - ], - ) - - def test_funnel_correlation_with_event_properties_exclusions(self): - filters = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "event_with_properties", - "funnel_correlation_event_names": ["positively_related"], - "funnel_correlation_event_exclude_property_names": ["signup_source"], - } - - filter = Filter(data=filters) - correlation = FunnelCorrelation(filter, self.team) - - # Need more than 2 events to get a correlation - for i in range(3): - _create_person(distinct_ids=[f"user_{i}"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - _create_event( - team=self.team, - event="positively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - properties={"signup_source": "facebook", "blah": "value_bleh"}, - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - ) - - # Atleast one person that fails, to ensure we get results - _create_person(distinct_ids=[f"user_fail"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_fail", - timestamp="2020-01-02T14:00:00Z", - ) - - result = correlation._run()[0] - self.assertEqual( - result, - [ - { - "event": "positively_related::blah::value_bleh", - "success_count": 3, - "failure_count": 0, - "odds_ratio": 8, - "correlation_type": "success", - }, - #  missing signup_source, as expected - ], - ) - - self.assertEqual( - len(self._get_actors_for_event(filter, "positively_related", {"blah": "value_bleh"})), - 3, - ) - - # If you search for persons with a specific property, even if excluded earlier, you should get them - self.assertEqual( - len(self._get_actors_for_event(filter, "positively_related", {"signup_source": "facebook"})), - 3, - ) - - @also_test_with_materialized_columns(["$event_type", "signup_source"]) - def test_funnel_correlation_with_event_properties_autocapture(self): - filters = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "event_with_properties", - "funnel_correlation_event_names": ["$autocapture"], - } - - filter = Filter(data=filters) - correlation = FunnelCorrelation(filter, self.team) - - # Need a minimum of 3 hits to get a correlation result - for i in range(6): - _create_person(distinct_ids=[f"user_{i}"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - _create_event( - team=self.team, - event="$autocapture", - distinct_id=f"user_{i}", - elements=[Element(nth_of_type=1, nth_child=0, tag_name="a", href="/movie")], - timestamp="2020-01-03T14:00:00Z", - properties={"signup_source": "email", "$event_type": "click"}, - ) - # Test two different types of autocapture elements, with different counts, so we can accurately test results - if i % 2 == 0: - _create_event( - team=self.team, - event="$autocapture", - distinct_id=f"user_{i}", - elements=[ - Element( - nth_of_type=1, - nth_child=0, - tag_name="button", - text="Pay $10", - ) - ], - timestamp="2020-01-03T14:00:00Z", - properties={"signup_source": "facebook", "$event_type": "submit"}, - ) - - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - ) - - # Atleast one person that fails, to ensure we get results - _create_person(distinct_ids=[f"user_fail"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_fail", - timestamp="2020-01-02T14:00:00Z", - ) - - result = correlation._run()[0] - - # $autocapture results only return elements chain - self.assertEqual( - result, - [ - { - "event": '$autocapture::elements_chain::click__~~__a:href="/movie"nth-child="0"nth-of-type="1"', - "success_count": 6, - "failure_count": 0, - "odds_ratio": 14.0, - "correlation_type": "success", - }, - { - "event": '$autocapture::elements_chain::submit__~~__button:nth-child="0"nth-of-type="1"text="Pay $10"', - "success_count": 3, - "failure_count": 0, - "odds_ratio": 2.0, - "correlation_type": "success", - }, - ], - ) - - self.assertEqual( - len(self._get_actors_for_event(filter, "$autocapture", {"signup_source": "facebook"})), - 3, - ) - self.assertEqual( - len(self._get_actors_for_event(filter, "$autocapture", {"$event_type": "click"})), - 6, - ) - self.assertEqual( - len( - self._get_actors_for_event( - filter, - "$autocapture", - [ - { - "key": "tag_name", - "operator": "exact", - "type": "element", - "value": "button", - }, - { - "key": "text", - "operator": "exact", - "type": "element", - "value": "Pay $10", - }, - ], - ) - ), - 3, - ) - self.assertEqual( - len( - self._get_actors_for_event( - filter, - "$autocapture", - [ - { - "key": "tag_name", - "operator": "exact", - "type": "element", - "value": "a", - }, - { - "key": "href", - "operator": "exact", - "type": "element", - "value": "/movie", - }, - ], - ) - ), - 6, - ) - - -class TestCorrelationFunctions(unittest.TestCase): - def test_are_results_insignificant(self): - # Same setup as above test: test_discarding_insignificant_events - contingency_tables = [ - EventContingencyTable( - event="negatively_related", - visited=EventStats(success_count=0, failure_count=5), - success_total=10, - failure_total=10, - ), - EventContingencyTable( - event="positively_related", - visited=EventStats(success_count=5, failure_count=0), - success_total=10, - failure_total=10, - ), - EventContingencyTable( - event="low_sig_negatively_related", - visited=EventStats(success_count=0, failure_count=2), - success_total=10, - failure_total=10, - ), - EventContingencyTable( - event="low_sig_positively_related", - visited=EventStats(success_count=1, failure_count=0), - success_total=10, - failure_total=10, - ), - ] - - # Discard both low_sig due to % - FunnelCorrelation.MIN_PERSON_PERCENTAGE = 0.11 - FunnelCorrelation.MIN_PERSON_COUNT = 25 - result = [ - 1 - for contingency_table in contingency_tables - if not FunnelCorrelation.are_results_insignificant(contingency_table) - ] - self.assertEqual(len(result), 2) - - # Discard one low_sig due to % - FunnelCorrelation.MIN_PERSON_PERCENTAGE = 0.051 - FunnelCorrelation.MIN_PERSON_COUNT = 25 - result = [ - 1 - for contingency_table in contingency_tables - if not FunnelCorrelation.are_results_insignificant(contingency_table) - ] - self.assertEqual(len(result), 3) - - # Discard both due to count - FunnelCorrelation.MIN_PERSON_PERCENTAGE = 0.5 - FunnelCorrelation.MIN_PERSON_COUNT = 3 - result = [ - 1 - for contingency_table in contingency_tables - if not FunnelCorrelation.are_results_insignificant(contingency_table) - ] - self.assertEqual(len(result), 2) - - # Discard one due to count - FunnelCorrelation.MIN_PERSON_PERCENTAGE = 0.5 - FunnelCorrelation.MIN_PERSON_COUNT = 2 - result = [ - 1 - for contingency_table in contingency_tables - if not FunnelCorrelation.are_results_insignificant(contingency_table) - ] - self.assertEqual(len(result), 3) - - # Discard everything due to % - FunnelCorrelation.MIN_PERSON_PERCENTAGE = 0.5 - FunnelCorrelation.MIN_PERSON_COUNT = 100 - result = [ - 1 - for contingency_table in contingency_tables - if not FunnelCorrelation.are_results_insignificant(contingency_table) - ] - self.assertEqual(len(result), 0) - - # Discard everything due to count - FunnelCorrelation.MIN_PERSON_PERCENTAGE = 0.5 - FunnelCorrelation.MIN_PERSON_COUNT = 6 - result = [ - 1 - for contingency_table in contingency_tables - if not FunnelCorrelation.are_results_insignificant(contingency_table) - ] - self.assertEqual(len(result), 0) diff --git a/ee/clickhouse/queries/funnels/test/test_funnel_correlations_persons.py b/ee/clickhouse/queries/funnels/test/test_funnel_correlations_persons.py deleted file mode 100644 index 4617ffde3c2..00000000000 --- a/ee/clickhouse/queries/funnels/test/test_funnel_correlations_persons.py +++ /dev/null @@ -1,650 +0,0 @@ -import urllib.parse -from datetime import datetime, timedelta -from unittest.mock import patch -from uuid import UUID - -from django.utils import timezone -from freezegun import freeze_time - -from ee.clickhouse.queries.funnels.funnel_correlation_persons import ( - FunnelCorrelationActors, -) -from posthog.constants import INSIGHT_FUNNELS -from posthog.models import Cohort, Filter -from posthog.models.person import Person -from posthog.session_recordings.queries.test.session_replay_sql import ( - produce_replay_summary, -) -from posthog.tasks.calculate_cohort import insert_cohort_from_insight_filter -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - _create_event, - _create_person, - snapshot_clickhouse_queries, -) -from posthog.test.test_journeys import journeys_for - -FORMAT_TIME = "%Y-%m-%d 00:00:00" -MAX_STEP_COLUMN = 0 -COUNT_COLUMN = 1 -PERSON_ID_COLUMN = 2 - - -class TestClickhouseFunnelCorrelationsActors(ClickhouseTestMixin, APIBaseTest): - maxDiff = None - - def _setup_basic_test(self): - filters = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "events", - } - - filter = Filter(data=filters) - - success_target_persons = [] - failure_target_persons = [] - events_by_person = {} - for i in range(10): - person_id = f"user_{i}" - person = _create_person(distinct_ids=[person_id], team_id=self.team.pk) - events_by_person[person_id] = [{"event": "user signed up", "timestamp": datetime(2020, 1, 2, 14)}] - - if i % 2 == 0: - events_by_person[person_id].append( - { - "event": "positively_related", - "timestamp": datetime(2020, 1, 3, 14), - } - ) - - success_target_persons.append(str(person.uuid)) - - events_by_person[person_id].append({"event": "paid", "timestamp": datetime(2020, 1, 4, 14)}) - - for i in range(10, 20): - person_id = f"user_{i}" - person = _create_person(distinct_ids=[person_id], team_id=self.team.pk) - events_by_person[person_id] = [{"event": "user signed up", "timestamp": datetime(2020, 1, 2, 14)}] - if i % 2 == 0: - events_by_person[person_id].append( - { - "event": "negatively_related", - "timestamp": datetime(2020, 1, 3, 14), - } - ) - failure_target_persons.append(str(person.uuid)) - - # One positively_related as failure - person_fail_id = f"user_fail" - person_fail = _create_person(distinct_ids=[person_fail_id], team_id=self.team.pk) - events_by_person[person_fail_id] = [ - {"event": "user signed up", "timestamp": datetime(2020, 1, 2, 14)}, - {"event": "positively_related", "timestamp": datetime(2020, 1, 3, 14)}, - ] - - # One negatively_related as success - person_success_id = f"user_succ" - person_succ = _create_person(distinct_ids=[person_success_id], team_id=self.team.pk) - events_by_person[person_success_id] = [ - {"event": "user signed up", "timestamp": datetime(2020, 1, 2, 14)}, - {"event": "negatively_related", "timestamp": datetime(2020, 1, 3, 14)}, - {"event": "paid", "timestamp": datetime(2020, 1, 4, 14)}, - ] - journeys_for(events_by_person, self.team, create_people=False) - - return ( - filter, - success_target_persons, - failure_target_persons, - person_fail, - person_succ, - ) - - def test_basic_funnel_correlation_with_events(self): - ( - filter, - success_target_persons, - failure_target_persons, - person_fail, - person_succ, - ) = self._setup_basic_test() - - # test positively_related successes - filter = filter.shallow_clone( - { - "funnel_correlation_person_entity": { - "id": "positively_related", - "type": "events", - }, - "funnel_correlation_person_converted": "TrUe", - } - ) - _, serialized_actors, _ = FunnelCorrelationActors(filter, self.team).get_actors() - - self.assertCountEqual([str(val["id"]) for val in serialized_actors], success_target_persons) - - # test negatively_related failures - filter = filter.shallow_clone( - { - "funnel_correlation_person_entity": { - "id": "negatively_related", - "type": "events", - }, - "funnel_correlation_person_converted": "falsE", - } - ) - - _, serialized_actors, _ = FunnelCorrelationActors(filter, self.team).get_actors() - - self.assertCountEqual([str(val["id"]) for val in serialized_actors], failure_target_persons) - - # test positively_related failures - filter = filter.shallow_clone( - { - "funnel_correlation_person_entity": { - "id": "positively_related", - "type": "events", - }, - "funnel_correlation_person_converted": "False", - } - ) - _, serialized_actors, _ = FunnelCorrelationActors(filter, self.team).get_actors() - - self.assertCountEqual([str(val["id"]) for val in serialized_actors], [str(person_fail.uuid)]) - - # test negatively_related successes - filter = filter.shallow_clone( - { - "funnel_correlation_person_entity": { - "id": "negatively_related", - "type": "events", - }, - "funnel_correlation_person_converted": "trUE", - } - ) - _, serialized_actors, _ = FunnelCorrelationActors(filter, self.team).get_actors() - - self.assertCountEqual([str(val["id"]) for val in serialized_actors], [str(person_succ.uuid)]) - - # test all positively_related - filter = filter.shallow_clone( - { - "funnel_correlation_person_entity": { - "id": "positively_related", - "type": "events", - }, - "funnel_correlation_person_converted": None, - } - ) - _, serialized_actors, _ = FunnelCorrelationActors(filter, self.team).get_actors() - - self.assertCountEqual( - [str(val["id"]) for val in serialized_actors], - [*success_target_persons, str(person_fail.uuid)], - ) - - # test all negatively_related - filter = filter.shallow_clone( - { - "funnel_correlation_person_entity": { - "id": "negatively_related", - "type": "events", - }, - "funnel_correlation_person_converted": None, - } - ) - _, serialized_actors, _ = FunnelCorrelationActors(filter, self.team).get_actors() - - self.assertCountEqual( - [str(val["id"]) for val in serialized_actors], - [*failure_target_persons, str(person_succ.uuid)], - ) - - @patch("posthog.tasks.calculate_cohort.insert_cohort_from_insight_filter.delay") - def test_create_funnel_correlation_cohort(self, _insert_cohort_from_insight_filter): - ( - filter, - success_target_persons, - failure_target_persons, - person_fail, - person_succ, - ) = self._setup_basic_test() - - params = { - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "events", - "funnel_correlation_person_entity": { - "id": "positively_related", - "type": "events", - }, - "funnel_correlation_person_converted": "TrUe", - } - - response = self.client.post( - f"/api/projects/{self.team.id}/cohorts/?{urllib.parse.urlencode(params)}", - {"name": "test", "is_static": True}, - ).json() - - cohort_id = response["id"] - - _insert_cohort_from_insight_filter.assert_called_once_with( - cohort_id, - { - "events": "[{'id': 'user signed up', 'type': 'events', 'order': 0}, {'id': 'paid', 'type': 'events', 'order': 1}]", - "insight": "FUNNELS", - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "events", - "funnel_correlation_person_entity": "{'id': 'positively_related', 'type': 'events'}", - "funnel_correlation_person_converted": "TrUe", - }, - ) - - insert_cohort_from_insight_filter(cohort_id, params) - - cohort = Cohort.objects.get(pk=cohort_id) - people = Person.objects.filter(cohort__id=cohort.pk) - self.assertEqual(cohort.errors_calculating, 0) - self.assertEqual(people.count(), 5) - self.assertEqual(cohort.count, 5) - - def test_people_arent_returned_multiple_times(self): - people = journeys_for( - { - "user_1": [ - {"event": "user signed up", "timestamp": datetime(2020, 1, 2, 14)}, - { - "event": "positively_related", - "timestamp": datetime(2020, 1, 3, 14), - }, - # duplicate event - { - "event": "positively_related", - "timestamp": datetime(2020, 1, 3, 14), - }, - {"event": "paid", "timestamp": datetime(2020, 1, 4, 14)}, - ] - }, - self.team, - ) - - filter = Filter( - data={ - "events": [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ], - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "events", - "funnel_correlation_person_entity": { - "id": "positively_related", - "type": "events", - }, - "funnel_correlation_person_converted": "TrUe", - } - ) - _, serialized_actors, _ = FunnelCorrelationActors(filter, self.team).get_actors() - - self.assertCountEqual([str(val["id"]) for val in serialized_actors], [str(people["user_1"].uuid)]) - - @snapshot_clickhouse_queries - @freeze_time("2021-01-02 00:00:00.000Z") - def test_funnel_correlation_on_event_with_recordings(self): - p1 = _create_person(distinct_ids=["user_1"], team=self.team, properties={"foo": "bar"}) - _create_event( - event="$pageview", - distinct_id="user_1", - team=self.team, - timestamp=timezone.now(), - properties={"$session_id": "s2", "$window_id": "w1"}, - event_uuid="11111111-1111-1111-1111-111111111111", - ) - _create_event( - event="insight loaded", - distinct_id="user_1", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=2)), - properties={"$session_id": "s2", "$window_id": "w2"}, - event_uuid="31111111-1111-1111-1111-111111111111", - ) - _create_event( - event="insight analyzed", - distinct_id="user_1", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=3)), - properties={"$session_id": "s2", "$window_id": "w2"}, - event_uuid="21111111-1111-1111-1111-111111111111", - ) - - timestamp = datetime(2021, 1, 2, 0, 0, 0) - produce_replay_summary( - team_id=self.team.pk, - session_id="s2", - distinct_id="user_1", - first_timestamp=timestamp, - last_timestamp=timestamp, - ) - - # Success filter - filter = Filter( - data={ - "insight": INSIGHT_FUNNELS, - "date_from": "2021-01-01", - "date_to": "2021-01-08", - "funnel_correlation_type": "events", - "events": [ - {"id": "$pageview", "order": 0}, - {"id": "insight analyzed", "order": 1}, - ], - "include_recordings": "true", - "funnel_correlation_person_entity": { - "id": "insight loaded", - "type": "events", - }, - "funnel_correlation_person_converted": "True", - } - ) - _, results, _ = FunnelCorrelationActors(filter, self.team).get_actors() - - self.assertEqual(results[0]["id"], p1.uuid) - self.assertEqual( - results[0]["matched_recordings"], - [ - { - "events": [ - { - "timestamp": timezone.now() + timedelta(minutes=3), - "uuid": UUID("21111111-1111-1111-1111-111111111111"), - "window_id": "w2", - } - ], - "session_id": "s2", - } - ], - ) - - # Drop off filter - filter = Filter( - data={ - "insight": INSIGHT_FUNNELS, - "date_from": "2021-01-01", - "date_to": "2021-01-08", - "funnel_correlation_type": "events", - "events": [ - {"id": "$pageview", "order": 0}, - {"id": "insight analyzed", "order": 1}, - {"id": "insight updated", "order": 2}, - ], - "include_recordings": "true", - "funnel_correlation_person_entity": { - "id": "insight loaded", - "type": "events", - }, - "funnel_correlation_person_converted": "False", - } - ) - _, results, _ = FunnelCorrelationActors(filter, self.team).get_actors() - - self.assertEqual(results[0]["id"], p1.uuid) - self.assertEqual( - results[0]["matched_recordings"], - [ - { - "events": [ - { - "timestamp": timezone.now() + timedelta(minutes=3), - "uuid": UUID("21111111-1111-1111-1111-111111111111"), - "window_id": "w2", - } - ], - "session_id": "s2", - } - ], - ) - - @snapshot_clickhouse_queries - @freeze_time("2021-01-02 00:00:00.000Z") - def test_funnel_correlation_on_properties_with_recordings(self): - p1 = _create_person(distinct_ids=["user_1"], team=self.team, properties={"foo": "bar"}) - _create_event( - event="$pageview", - distinct_id="user_1", - team=self.team, - timestamp=timezone.now(), - properties={"$session_id": "s2", "$window_id": "w1"}, - event_uuid="11111111-1111-1111-1111-111111111111", - ) - _create_event( - event="insight analyzed", - distinct_id="user_1", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=3)), - properties={"$session_id": "s2", "$window_id": "w2"}, - event_uuid="21111111-1111-1111-1111-111111111111", - ) - - timestamp = datetime(2021, 1, 2, 0, 0, 0) - produce_replay_summary( - team_id=self.team.pk, - session_id="s2", - distinct_id="user_1", - first_timestamp=timestamp, - last_timestamp=timestamp, - ) - - # Success filter - filter = Filter( - data={ - "insight": INSIGHT_FUNNELS, - "date_from": "2021-01-01", - "date_to": "2021-01-08", - "funnel_correlation_type": "properties", - "events": [ - {"id": "$pageview", "order": 0}, - {"id": "insight analyzed", "order": 1}, - ], - "include_recordings": "true", - "funnel_correlation_property_values": [ - { - "key": "foo", - "value": "bar", - "operator": "exact", - "type": "person", - } - ], - "funnel_correlation_person_converted": "True", - } - ) - _, results, _ = FunnelCorrelationActors(filter, self.team).get_actors() - - self.assertEqual(results[0]["id"], p1.uuid) - self.assertEqual( - results[0]["matched_recordings"], - [ - { - "events": [ - { - "timestamp": timezone.now() + timedelta(minutes=3), - "uuid": UUID("21111111-1111-1111-1111-111111111111"), - "window_id": "w2", - } - ], - "session_id": "s2", - } - ], - ) - - @snapshot_clickhouse_queries - @freeze_time("2021-01-02 00:00:00.000Z") - def test_strict_funnel_correlation_with_recordings(self): - # First use that successfully completes the strict funnel - p1 = _create_person(distinct_ids=["user_1"], team=self.team, properties={"foo": "bar"}) - _create_event( - event="$pageview", - distinct_id="user_1", - team=self.team, - timestamp=timezone.now(), - properties={"$session_id": "s2", "$window_id": "w1"}, - event_uuid="11111111-1111-1111-1111-111111111111", - ) - _create_event( - event="insight analyzed", - distinct_id="user_1", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=3)), - properties={"$session_id": "s2", "$window_id": "w2"}, - event_uuid="31111111-1111-1111-1111-111111111111", - ) - _create_event( - event="insight analyzed", # Second event should not be returned - distinct_id="user_1", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=4)), - properties={"$session_id": "s2", "$window_id": "w2"}, - event_uuid="41111111-1111-1111-1111-111111111111", - ) - timestamp = datetime(2021, 1, 2, 0, 0, 0) - produce_replay_summary( - team_id=self.team.pk, - session_id="s2", - distinct_id="user_1", - first_timestamp=timestamp, - last_timestamp=timestamp, - ) - - # Second user with strict funnel drop off, but completed the step events for a normal funnel - p2 = _create_person(distinct_ids=["user_2"], team=self.team, properties={"foo": "bar"}) - _create_event( - event="$pageview", - distinct_id="user_2", - team=self.team, - timestamp=timezone.now(), - properties={"$session_id": "s3", "$window_id": "w1"}, - event_uuid="51111111-1111-1111-1111-111111111111", - ) - _create_event( - event="insight loaded", # Interupting event - distinct_id="user_2", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=3)), - properties={"$session_id": "s3", "$window_id": "w2"}, - event_uuid="61111111-1111-1111-1111-111111111111", - ) - _create_event( - event="insight analyzed", - distinct_id="user_2", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=4)), - properties={"$session_id": "s3", "$window_id": "w2"}, - event_uuid="71111111-1111-1111-1111-111111111111", - ) - timestamp1 = datetime(2021, 1, 2, 0, 0, 0) - produce_replay_summary( - team_id=self.team.pk, - session_id="s3", - distinct_id="user_2", - first_timestamp=timestamp1, - last_timestamp=timestamp1, - ) - - # Success filter - filter = Filter( - data={ - "insight": INSIGHT_FUNNELS, - "date_from": "2021-01-01", - "date_to": "2021-01-08", - "funnel_order_type": "strict", - "funnel_correlation_type": "properties", - "events": [ - {"id": "$pageview", "order": 0}, - {"id": "insight analyzed", "order": 1}, - ], - "include_recordings": "true", - "funnel_correlation_property_values": [ - { - "key": "foo", - "value": "bar", - "operator": "exact", - "type": "person", - } - ], - "funnel_correlation_person_converted": "True", - } - ) - _, results, _ = FunnelCorrelationActors(filter, self.team).get_actors() - - self.assertEqual(len(results), 1) - self.assertEqual(results[0]["id"], p1.uuid) - self.assertEqual( - results[0]["matched_recordings"], - [ - { - "events": [ - { - "timestamp": timezone.now() + timedelta(minutes=3), - "uuid": UUID("31111111-1111-1111-1111-111111111111"), - "window_id": "w2", - } - ], - "session_id": "s2", - } - ], - ) - - # Drop off filter - filter = Filter( - data={ - "insight": INSIGHT_FUNNELS, - "date_from": "2021-01-01", - "date_to": "2021-01-08", - "funnel_order_type": "strict", - "funnel_correlation_type": "properties", - "events": [ - {"id": "$pageview", "order": 0}, - {"id": "insight analyzed", "order": 1}, - ], - "include_recordings": "true", - "funnel_correlation_property_values": [ - { - "key": "foo", - "value": "bar", - "operator": "exact", - "type": "person", - } - ], - "funnel_correlation_person_converted": "False", - } - ) - _, results, _ = FunnelCorrelationActors(filter, self.team).get_actors() - - self.assertEqual(results[0]["id"], p2.uuid) - self.assertEqual( - results[0]["matched_recordings"], - [ - { - "events": [ - { - "timestamp": timezone.now(), - "uuid": UUID("51111111-1111-1111-1111-111111111111"), - "window_id": "w1", - } - ], - "session_id": "s3", - } - ], - ) diff --git a/ee/clickhouse/queries/groups_join_query.py b/ee/clickhouse/queries/groups_join_query.py deleted file mode 100644 index 5a48bc0d0ef..00000000000 --- a/ee/clickhouse/queries/groups_join_query.py +++ /dev/null @@ -1,92 +0,0 @@ -from typing import Optional, Union - -from ee.clickhouse.queries.column_optimizer import EnterpriseColumnOptimizer -from posthog.models import Filter -from posthog.models.filters.path_filter import PathFilter -from posthog.models.filters.retention_filter import RetentionFilter -from posthog.models.filters.stickiness_filter import StickinessFilter -from posthog.models.filters.utils import GroupTypeIndex -from posthog.models.property.util import parse_prop_grouped_clauses -from posthog.queries.util import PersonPropertiesMode, alias_poe_mode_for_legacy -from posthog.schema import PersonsOnEventsMode - - -class GroupsJoinQuery: - """ - Query class responsible for joining with `groups` clickhouse table based on filters - """ - - _filter: Union[Filter, PathFilter, RetentionFilter, StickinessFilter] - _team_id: int - _column_optimizer: EnterpriseColumnOptimizer - - def __init__( - self, - filter: Union[Filter, PathFilter, RetentionFilter, StickinessFilter], - team_id: int, - column_optimizer: Optional[EnterpriseColumnOptimizer] = None, - join_key: Optional[str] = None, - person_on_events_mode: PersonsOnEventsMode = PersonsOnEventsMode.DISABLED, - ) -> None: - self._filter = filter - self._team_id = team_id - self._column_optimizer = column_optimizer or EnterpriseColumnOptimizer(self._filter, self._team_id) - self._join_key = join_key - self._person_on_events_mode = alias_poe_mode_for_legacy(person_on_events_mode) - - def get_join_query(self) -> tuple[str, dict]: - join_queries, params = [], {} - - for group_type_index in self._column_optimizer.group_types_to_query: - var = f"group_index_{group_type_index}" - group_join_key = self._join_key or f'"$group_{group_type_index}"' - join_queries.append( - f""" - LEFT JOIN ( - SELECT - group_key, - argMax(group_properties, _timestamp) AS group_properties_{group_type_index} - FROM groups - WHERE team_id = %(team_id)s AND group_type_index = %({var})s - GROUP BY group_key - ) groups_{group_type_index} - ON {group_join_key} == groups_{group_type_index}.group_key - """ - ) - - params["team_id"] = self._team_id - params[var] = group_type_index - - return "\n".join(join_queries), params - - def get_filter_query(self, group_type_index: GroupTypeIndex) -> tuple[str, dict]: - var = f"group_index_{group_type_index}" - params = { - "team_id": self._team_id, - var: group_type_index, - } - - aggregated_group_filters, filter_params = parse_prop_grouped_clauses( - self._team_id, - self._filter.property_groups, - prepend=f"group_properties_{group_type_index}", - has_person_id_joined=False, - group_properties_joined=True, - person_properties_mode=PersonPropertiesMode.DIRECT, - _top_level=True, - hogql_context=self._filter.hogql_context, - ) - - params.update(filter_params) - - query = f""" - SELECT - group_key, - argMax(group_properties, _timestamp) AS group_properties_{group_type_index} - FROM groups - WHERE team_id = %(team_id)s AND group_type_index = %({var})s - GROUP BY group_key - HAVING 1=1 - {aggregated_group_filters} - """ - return query, params diff --git a/ee/clickhouse/queries/paths/__init__.py b/ee/clickhouse/queries/paths/__init__.py deleted file mode 100644 index a04370231ab..00000000000 --- a/ee/clickhouse/queries/paths/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .paths import * -from .paths_actors import * diff --git a/ee/clickhouse/queries/paths/paths.py b/ee/clickhouse/queries/paths/paths.py deleted file mode 100644 index f20744ee672..00000000000 --- a/ee/clickhouse/queries/paths/paths.py +++ /dev/null @@ -1,163 +0,0 @@ -from re import escape -from typing import Literal, Optional, Union, cast - -from jsonschema import ValidationError - -from ee.clickhouse.queries.paths.paths_event_query import ClickhousePathEventQuery -from posthog.constants import FUNNEL_PATH_BETWEEN_STEPS -from posthog.models import Filter -from posthog.models.filters.path_filter import PathFilter -from posthog.models.team.team import Team -from posthog.queries.funnels.funnel_persons import ClickhouseFunnelActors -from posthog.queries.paths.paths import Paths - - -class ClickhousePaths(Paths): - event_query = ClickhousePathEventQuery - - def __init__(self, filter: PathFilter, team: Team, funnel_filter: Optional[Filter] = None) -> None: - super().__init__(filter, team, funnel_filter) - - if self._filter.path_groupings: - regex_groupings = [] - for grouping in self._filter.path_groupings: - regex_grouping = escape(grouping) - # don't allow arbitrary regex for now - regex_grouping = regex_grouping.replace("\\*", ".*") - regex_groupings.append(regex_grouping) - self.params["regex_groupings"] = regex_groupings - - if ( - self._filter.max_edge_weight - and self._filter.min_edge_weight - and self._filter.max_edge_weight < self._filter.min_edge_weight - ): - raise ValidationError("Max Edge weight can't be lower than min edge weight") - - def get_edge_weight_clause(self) -> tuple[str, dict]: - params: dict[str, int] = {} - - conditions = [] - - if self._filter.min_edge_weight: - params["min_edge_weight"] = self._filter.min_edge_weight - conditions.append("event_count >= %(min_edge_weight)s") - - if self._filter.max_edge_weight: - params["max_edge_weight"] = self._filter.max_edge_weight - conditions.append("event_count <= %(max_edge_weight)s") - - if conditions: - return f"HAVING {' AND '.join(conditions)}", params - - return "", params - - def get_target_point_filter(self) -> str: - if self._filter.end_point and self._filter.start_point: - return "WHERE start_target_index > 0 AND end_target_index > 0" - elif self._filter.end_point or self._filter.start_point: - return f"WHERE target_index > 0" - else: - return "" - - def get_target_clause(self) -> tuple[str, dict]: - params: dict[str, Union[str, None]] = { - "target_point": None, - "secondary_target_point": None, - } - - if self._filter.end_point and self._filter.start_point: - params.update( - { - "target_point": self._filter.end_point, - "secondary_target_point": self._filter.start_point, - } - ) - - clause = f""" - , indexOf(compact_path, %(secondary_target_point)s) as start_target_index - , if(start_target_index > 0, arraySlice(compact_path, start_target_index), compact_path) as start_filtered_path - , if(start_target_index > 0, arraySlice(timings, start_target_index), timings) as start_filtered_timings - , indexOf(start_filtered_path, %(target_point)s) as end_target_index - , if(end_target_index > 0, arrayResize(start_filtered_path, end_target_index), start_filtered_path) as filtered_path - , if(end_target_index > 0, arrayResize(start_filtered_timings, end_target_index), start_filtered_timings) as filtered_timings - , if(length(filtered_path) > %(event_in_session_limit)s, arrayConcat(arraySlice(filtered_path, 1, intDiv(%(event_in_session_limit)s,2)), ['...'], arraySlice(filtered_path, (-1)*intDiv(%(event_in_session_limit)s, 2), intDiv(%(event_in_session_limit)s, 2))), filtered_path) AS limited_path - , if(length(filtered_timings) > %(event_in_session_limit)s, arrayConcat(arraySlice(filtered_timings, 1, intDiv(%(event_in_session_limit)s, 2)), [filtered_timings[1+intDiv(%(event_in_session_limit)s, 2)]], arraySlice(filtered_timings, (-1)*intDiv(%(event_in_session_limit)s, 2), intDiv(%(event_in_session_limit)s, 2))), filtered_timings) AS limited_timings - """ - - # Add target clause for extra fields - clause += " ".join( - [ - f""" - , if(start_target_index > 0, arraySlice({field}s, start_target_index), {field}s) as start_filtered_{field}s - , if(end_target_index > 0, arrayResize(start_filtered_{field}s, end_target_index), start_filtered_{field}s) as filtered_{field}s - , if(length(filtered_{field}s) > %(event_in_session_limit)s, arrayConcat(arraySlice(filtered_{field}s, 1, intDiv(%(event_in_session_limit)s, 2)), [filtered_{field}s[1+intDiv(%(event_in_session_limit)s, 2)]], arraySlice(filtered_{field}s, (-1)*intDiv(%(event_in_session_limit)s, 2), intDiv(%(event_in_session_limit)s, 2))), filtered_{field}s) AS limited_{field}s - """ - for field in self.extra_event_fields_and_properties - ] - ) - - else: - clause, params = super().get_target_clause() - - return clause, params - - def get_path_query_funnel_cte(self, funnel_filter: Filter): - funnel_persons_generator = ClickhouseFunnelActors( - funnel_filter, - self._team, - include_timestamp=bool(self._filter.funnel_paths), - include_preceding_timestamp=self._filter.funnel_paths == FUNNEL_PATH_BETWEEN_STEPS, - ) - ( - funnel_persons_query, - funnel_persons_param, - ) = funnel_persons_generator.actor_query(limit_actors=False) - funnel_persons_query_new_params = funnel_persons_query.replace("%(", "%(funnel_") - new_funnel_params = {"funnel_" + str(key): val for key, val in funnel_persons_param.items()} - self.params.update(new_funnel_params) - return f""" - WITH {self.event_query.FUNNEL_PERSONS_ALIAS} AS ( - {funnel_persons_query_new_params} - ) - """ - - def get_session_threshold_clause(self) -> str: - if self.should_query_funnel(): - self._funnel_filter = cast(Filter, self._funnel_filter) # typing mess - - # TODO: cleanup funnels interval interpolation mess so this can get cleaned up - if self._funnel_filter.funnel_window_interval: - funnel_window_interval = self._funnel_filter.funnel_window_interval - funnel_window_interval_unit = self._funnel_filter.funnel_window_interval_unit_ch() - elif self._funnel_filter.funnel_window_days: - funnel_window_interval = self._funnel_filter.funnel_window_days - funnel_window_interval_unit = "DAY" - else: - funnel_window_interval = 14 - funnel_window_interval_unit = "DAY" - # Not possible to directly compare two interval data types, so using a proxy Date. - return f"arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL {funnel_window_interval} {funnel_window_interval_unit}, 0, 1), paths_tuple)" - - return "arraySplit(x -> if(x.3 < %(session_time_threshold)s, 0, 1), paths_tuple)" - - def should_query_funnel(self) -> bool: - if self._filter.funnel_paths and self._funnel_filter: - return True - return False - - def get_array_compacting_function(self) -> Literal["arrayResize", "arraySlice"]: - if self._filter.end_point: - return "arrayResize" - else: - return "arraySlice" - - def get_filtered_path_ordering(self) -> tuple[str, ...]: - fields_to_include = ["filtered_path", "filtered_timings"] + [ - f"filtered_{field}s" for field in self.extra_event_fields_and_properties - ] - - if self._filter.end_point: - return tuple([f"arraySlice({field}, (-1) * %(event_in_session_limit)s)" for field in fields_to_include]) - else: - return tuple([f"arraySlice({field}, 1, %(event_in_session_limit)s)" for field in fields_to_include]) diff --git a/ee/clickhouse/queries/paths/paths_actors.py b/ee/clickhouse/queries/paths/paths_actors.py deleted file mode 100644 index d293d1d9324..00000000000 --- a/ee/clickhouse/queries/paths/paths_actors.py +++ /dev/null @@ -1,6 +0,0 @@ -from ee.clickhouse.queries.paths.paths import ClickhousePaths -from posthog.queries.paths.paths_actors import PathsActors - - -class ClickhousePathsActors(PathsActors, ClickhousePaths): # type: ignore - pass diff --git a/ee/clickhouse/queries/paths/paths_event_query.py b/ee/clickhouse/queries/paths/paths_event_query.py deleted file mode 100644 index c4780adb0cb..00000000000 --- a/ee/clickhouse/queries/paths/paths_event_query.py +++ /dev/null @@ -1,5 +0,0 @@ -from posthog.queries.paths.paths_event_query import PathEventQuery - - -class ClickhousePathEventQuery(PathEventQuery): - pass diff --git a/ee/clickhouse/queries/related_actors_query.py b/ee/clickhouse/queries/related_actors_query.py deleted file mode 100644 index 4b6198c2227..00000000000 --- a/ee/clickhouse/queries/related_actors_query.py +++ /dev/null @@ -1,126 +0,0 @@ -from datetime import timedelta -from functools import cached_property -from typing import Optional, Union - -from django.utils.timezone import now - -from posthog.client import sync_execute -from posthog.models import Team -from posthog.models.filters.utils import validate_group_type_index -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.models.property import GroupTypeIndex -from posthog.queries.actor_base_query import ( - SerializedActor, - SerializedGroup, - SerializedPerson, - get_groups, - get_serialized_people, -) -from posthog.queries.person_distinct_id_query import get_team_distinct_ids_query - - -class RelatedActorsQuery: - DISTINCT_ID_TABLE_ALIAS = "pdi" - - """ - This query calculates other groups and persons that are related to a person or a group. - - Two actors are considered related if they have had shared events in the past 90 days. - """ - - def __init__( - self, - team: Team, - group_type_index: Optional[Union[GroupTypeIndex, str]], - id: str, - ): - self.team = team - self.group_type_index = validate_group_type_index("group_type_index", group_type_index) - self.id = id - - def run(self) -> list[SerializedActor]: - results: list[SerializedActor] = [] - results.extend(self._query_related_people()) - for group_type_mapping in GroupTypeMapping.objects.filter(team_id=self.team.pk): - results.extend(self._query_related_groups(group_type_mapping.group_type_index)) - return results - - @property - def is_aggregating_by_groups(self) -> bool: - return self.group_type_index is not None - - def _query_related_people(self) -> list[SerializedPerson]: - if not self.is_aggregating_by_groups: - return [] - - # :KLUDGE: We need to fetch distinct_id + person properties to be able to link to user properly. - person_ids = self._take_first( - sync_execute( - f""" - SELECT DISTINCT {self.DISTINCT_ID_TABLE_ALIAS}.person_id - FROM events e - {self._distinct_ids_join} - WHERE team_id = %(team_id)s - AND timestamp > %(after)s - AND timestamp < %(before)s - AND {self._filter_clause} - """, - self._params, - ) - ) - - serialized_people = get_serialized_people(self.team, person_ids) - return serialized_people - - def _query_related_groups(self, group_type_index: GroupTypeIndex) -> list[SerializedGroup]: - if group_type_index == self.group_type_index: - return [] - - group_ids = self._take_first( - sync_execute( - f""" - SELECT DISTINCT $group_{group_type_index} AS group_key - FROM events e - {'' if self.is_aggregating_by_groups else self._distinct_ids_join} - JOIN ( - SELECT group_key - FROM groups - WHERE team_id = %(team_id)s AND group_type_index = %(group_type_index)s - GROUP BY group_key - ) groups ON $group_{group_type_index} = groups.group_key - WHERE team_id = %(team_id)s - AND timestamp > %(after)s - AND timestamp < %(before)s - AND group_key != '' - AND {self._filter_clause} - ORDER BY group_key - """, - {**self._params, "group_type_index": group_type_index}, - ) - ) - - _, serialize_groups = get_groups(self.team.pk, group_type_index, group_ids) - return serialize_groups - - def _take_first(self, rows: list) -> list: - return [row[0] for row in rows] - - @property - def _filter_clause(self): - if self.is_aggregating_by_groups: - return f"$group_{self.group_type_index} = %(id)s" - else: - return f"{self.DISTINCT_ID_TABLE_ALIAS}.person_id = %(id)s" - - @property - def _distinct_ids_join(self): - return f"JOIN ({get_team_distinct_ids_query(self.team.pk)}) {self.DISTINCT_ID_TABLE_ALIAS} on e.distinct_id = {self.DISTINCT_ID_TABLE_ALIAS}.distinct_id" - - @cached_property - def _params(self): - return { - "team_id": self.team.pk, - "id": self.id, - "after": (now() - timedelta(days=90)).strftime("%Y-%m-%dT%H:%M:%S.%f"), - "before": now().strftime("%Y-%m-%dT%H:%M:%S.%f"), - } diff --git a/ee/clickhouse/queries/retention/__init__.py b/ee/clickhouse/queries/retention/__init__.py deleted file mode 100644 index dcdcf4349a8..00000000000 --- a/ee/clickhouse/queries/retention/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .retention import * diff --git a/ee/clickhouse/queries/retention/retention.py b/ee/clickhouse/queries/retention/retention.py deleted file mode 100644 index 6d97b9bcae7..00000000000 --- a/ee/clickhouse/queries/retention/retention.py +++ /dev/null @@ -1,12 +0,0 @@ -from ee.clickhouse.queries.retention.retention_actors import ( - ClickhouseRetentionActorsByPeriod, -) -from ee.clickhouse.queries.retention.retention_event_query import ( - ClickhouseRetentionEventsQuery, -) -from posthog.queries.retention.retention import Retention - - -class ClickhouseRetention(Retention): - event_query = ClickhouseRetentionEventsQuery - actors_by_period_query = ClickhouseRetentionActorsByPeriod diff --git a/ee/clickhouse/queries/retention/retention_actors.py b/ee/clickhouse/queries/retention/retention_actors.py deleted file mode 100644 index 48c16c67973..00000000000 --- a/ee/clickhouse/queries/retention/retention_actors.py +++ /dev/null @@ -1,14 +0,0 @@ -from ee.clickhouse.queries.retention.retention_event_query import ( - ClickhouseRetentionEventsQuery, -) -from posthog.models.filters.mixins.utils import cached_property -from posthog.queries.retention.actors_query import RetentionActorsByPeriod - - -# Note: This class does not respect the entire flor from ActorBaseQuery because the result shape differs from other actor queries -class ClickhouseRetentionActorsByPeriod(RetentionActorsByPeriod): - _retention_events_query = ClickhouseRetentionEventsQuery - - @cached_property - def aggregation_group_type_index(self): - return self._filter.aggregation_group_type_index diff --git a/ee/clickhouse/queries/retention/retention_event_query.py b/ee/clickhouse/queries/retention/retention_event_query.py deleted file mode 100644 index 82a0c86807c..00000000000 --- a/ee/clickhouse/queries/retention/retention_event_query.py +++ /dev/null @@ -1,16 +0,0 @@ -from posthog.models.group.util import get_aggregation_target_field -from posthog.queries.retention.retention_events_query import RetentionEventsQuery - - -class ClickhouseRetentionEventsQuery(RetentionEventsQuery): - def target_field(self) -> str: - if self._aggregate_users_by_distinct_id and not self._filter.aggregation_group_type_index: - return f"{self.EVENT_TABLE_ALIAS}.distinct_id AS target" - else: - return "{} as target".format( - get_aggregation_target_field( - self._filter.aggregation_group_type_index, - self.EVENT_TABLE_ALIAS, - self._person_id_alias, - ) - ) diff --git a/ee/clickhouse/queries/stickiness/__init__.py b/ee/clickhouse/queries/stickiness/__init__.py deleted file mode 100644 index 516cae3fe85..00000000000 --- a/ee/clickhouse/queries/stickiness/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .stickiness import * diff --git a/ee/clickhouse/queries/stickiness/stickiness.py b/ee/clickhouse/queries/stickiness/stickiness.py deleted file mode 100644 index 65f48c57e3e..00000000000 --- a/ee/clickhouse/queries/stickiness/stickiness.py +++ /dev/null @@ -1,12 +0,0 @@ -from ee.clickhouse.queries.stickiness.stickiness_actors import ( - ClickhouseStickinessActors, -) -from ee.clickhouse.queries.stickiness.stickiness_event_query import ( - ClickhouseStickinessEventsQuery, -) -from posthog.queries.stickiness.stickiness import Stickiness - - -class ClickhouseStickiness(Stickiness): - event_query_class = ClickhouseStickinessEventsQuery - actor_query_class = ClickhouseStickinessActors diff --git a/ee/clickhouse/queries/stickiness/stickiness_actors.py b/ee/clickhouse/queries/stickiness/stickiness_actors.py deleted file mode 100644 index 0405aa86742..00000000000 --- a/ee/clickhouse/queries/stickiness/stickiness_actors.py +++ /dev/null @@ -1,15 +0,0 @@ -from ee.clickhouse.queries.stickiness.stickiness_event_query import ( - ClickhouseStickinessEventsQuery, -) -from posthog.models.filters.mixins.utils import cached_property -from posthog.queries.stickiness.stickiness_actors import StickinessActors - - -class ClickhouseStickinessActors(StickinessActors): - event_query_class = ClickhouseStickinessEventsQuery - - @cached_property - def aggregation_group_type_index(self): - if self.entity.math == "unique_group": - return self.entity.math_group_type_index - return None diff --git a/ee/clickhouse/queries/stickiness/stickiness_event_query.py b/ee/clickhouse/queries/stickiness/stickiness_event_query.py deleted file mode 100644 index db15ba05a97..00000000000 --- a/ee/clickhouse/queries/stickiness/stickiness_event_query.py +++ /dev/null @@ -1,11 +0,0 @@ -from posthog.models.group.util import get_aggregation_target_field -from posthog.queries.stickiness.stickiness_event_query import StickinessEventsQuery - - -class ClickhouseStickinessEventsQuery(StickinessEventsQuery): - def aggregation_target(self): - return get_aggregation_target_field( - self._entity.math_group_type_index, - self.EVENT_TABLE_ALIAS, - self._person_id_alias, - ) diff --git a/ee/clickhouse/queries/test/__init__.py b/ee/clickhouse/queries/test/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/clickhouse/queries/test/__snapshots__/test_breakdown_props.ambr b/ee/clickhouse/queries/test/__snapshots__/test_breakdown_props.ambr deleted file mode 100644 index 0b209648161..00000000000 --- a/ee/clickhouse/queries/test/__snapshots__/test_breakdown_props.ambr +++ /dev/null @@ -1,252 +0,0 @@ -# serializer version: 1 -# name: TestBreakdownProps.test_breakdown_group_props - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-12 23:59:59', 'UTC') - AND ((isNull(replaceRegexpAll(JSONExtractRaw(group_properties_0, 'out'), '^"|"$', '')) - OR NOT JSONHas(group_properties_0, 'out'))) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 6 - OFFSET 0 - ''' -# --- -# name: TestBreakdownProps.test_breakdown_group_props.1 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '') AS value, - count(*) as count - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-12 23:59:59', 'UTC') - AND ((isNull(replaceRegexpAll(JSONExtractRaw(group_properties_0, 'out'), '^"|"$', '')) - OR NOT JSONHas(group_properties_0, 'out'))) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 6 - OFFSET 0 - ''' -# --- -# name: TestBreakdownProps.test_breakdown_person_props - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(person_props, '$browser'), '^"|"$', '') AS value, - count(*) as count - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id, - argMax(properties, version) as person_props - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON pdi.person_id = person.id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2019-12-21 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-04 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 6 - OFFSET 0 - ''' -# --- -# name: TestBreakdownProps.test_breakdown_person_props_materialized - ''' - - SELECT "pmat_$browser" AS value, - count(*) as count - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id, - argMax(pmat_$browser, version) as pmat_$browser - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON pdi.person_id = person.id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2019-12-21 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-04 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 6 - OFFSET 0 - ''' -# --- -# name: TestBreakdownProps.test_breakdown_person_props_with_entity_filter_and_or_props_with_partial_pushdown - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(person_props, '$browser'), '^"|"$', '') AS value, - count(*) as count - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id, - argMax(properties, version) as person_props - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND ((replaceRegexpAll(JSONExtractRaw(properties, '$browser'), '^"|"$', '') ILIKE '%test%')) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND ((replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), '$browser'), '^"|"$', '') ILIKE '%test%')) SETTINGS optimize_aggregation_in_order = 1) person ON pdi.person_id = person.id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2019-12-21 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-04 23:59:59', 'UTC') - AND ((has(['test2'], replaceRegexpAll(JSONExtractRaw(person_props, '$os'), '^"|"$', '')) - OR has(['val'], replaceRegexpAll(JSONExtractRaw(e.properties, 'key'), '^"|"$', '')))) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 6 - OFFSET 0 - ''' -# --- -# name: TestBreakdownProps.test_breakdown_session_props - ''' - - SELECT sessions.session_duration AS value, - count(*) as count - FROM events e - INNER JOIN - (SELECT "$session_id", - dateDiff('second', min(timestamp), max(timestamp)) as session_duration - FROM events - WHERE "$session_id" != '' - AND team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-02 00:00:00', 'UTC') - INTERVAL 24 HOUR - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-12 23:59:59', 'UTC') + INTERVAL 24 HOUR - GROUP BY "$session_id") AS sessions ON sessions."$session_id" = e."$session_id" - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-02 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-12 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestBreakdownProps.test_breakdown_with_math_property_session - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(person_props, '$browser'), '^"|"$', '') AS value, - sum(session_duration) as count - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id, - argMax(properties, version) as person_props - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON pdi.person_id = person.id - INNER JOIN - (SELECT "$session_id", - dateDiff('second', min(timestamp), max(timestamp)) as session_duration - FROM events - WHERE "$session_id" != '' - AND team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-02 00:00:00', 'UTC') - INTERVAL 24 HOUR - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-12 23:59:59', 'UTC') + INTERVAL 24 HOUR - GROUP BY "$session_id") AS sessions ON sessions."$session_id" = e."$session_id" - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-02 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-12 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: TestBreakdownProps.test_breakdown_with_math_property_session.1 - ''' - - SELECT replaceRegexpAll(JSONExtractRaw(person_props, '$browser'), '^"|"$', '') AS value, - count(*) as count - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id, - argMax(properties, version) as person_props - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON pdi.person_id = person.id - INNER JOIN - (SELECT "$session_id", - dateDiff('second', min(timestamp), max(timestamp)) as session_duration - FROM events - WHERE "$session_id" != '' - AND team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-02 00:00:00', 'UTC') - INTERVAL 24 HOUR - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-12 23:59:59', 'UTC') + INTERVAL 24 HOUR - GROUP BY "$session_id") AS sessions ON sessions."$session_id" = e."$session_id" - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-02 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-12 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- diff --git a/ee/clickhouse/queries/test/__snapshots__/test_cohort_query.ambr b/ee/clickhouse/queries/test/__snapshots__/test_cohort_query.ambr deleted file mode 100644 index 02b9e77db69..00000000000 --- a/ee/clickhouse/queries/test/__snapshots__/test_cohort_query.ambr +++ /dev/null @@ -1,762 +0,0 @@ -# serializer version: 1 -# name: TestCohortQuery.test_basic_query - ''' - - SELECT person.person_id AS id - FROM - (SELECT pdi.person_id AS person_id, - countIf(timestamp > now() - INTERVAL 1 day - AND timestamp < now() - AND event = '$pageview' - AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_level_0_0, - countIf(timestamp > now() - INTERVAL 2 week - AND timestamp < now() - AND event = '$pageview' - AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_level_1_0, - minIf(timestamp, ((replaceRegexpAll(JSONExtractRaw(properties, '$current_url'), '^"|"$', '') = 'https://posthog.com/feedback/123' - AND event = '$autocapture'))) >= now() - INTERVAL 2 week - AND minIf(timestamp, ((replaceRegexpAll(JSONExtractRaw(properties, '$current_url'), '^"|"$', '') = 'https://posthog.com/feedback/123' - AND event = '$autocapture'))) < now() as first_time_condition_None_level_level_0_level_1_level_0_0 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview', '$pageview', '$autocapture'] - GROUP BY person_id) behavior_query - INNER JOIN - (SELECT *, - id AS person_id - FROM - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (((has(['test@posthog.com'], replaceRegexpAll(JSONExtractRaw(properties, 'email'), '^"|"$', ''))))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (((has(['test@posthog.com'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'email'), '^"|"$', ''))))) SETTINGS optimize_aggregation_in_order = 1)) person ON person.person_id = behavior_query.person_id - WHERE 1 = 1 - AND ((((performed_event_condition_None_level_level_0_level_0_level_0_0) - OR (performed_event_condition_None_level_level_0_level_0_level_1_0)) - AND ((first_time_condition_None_level_level_0_level_1_level_0_0)))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohortQuery.test_cohort_filter_with_another_cohort_with_event_sequence - ''' - - SELECT person.person_id AS id - FROM - (SELECT person_id, - max(if(event = '$pageview' - AND event_0_latest_0 < event_0_latest_1 - AND event_0_latest_1 <= event_0_latest_0 + INTERVAL 3 day, 2, 1)) = 2 AS steps_0, - max(if(event = '$new_view' - AND event_1_latest_0 < event_1_latest_1 - AND event_1_latest_1 <= event_1_latest_0 + INTERVAL 8 day, 2, 1)) = 2 AS steps_1 - FROM - (SELECT person_id, - event, - properties, - distinct_id, timestamp, event_0_latest_0, - min(event_0_latest_1) over (PARTITION by person_id - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) event_0_latest_1, - event_1_latest_0, - min(event_1_latest_1) over (PARTITION by person_id - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) event_1_latest_1 - FROM - (SELECT pdi.person_id AS person_id, - event, - properties, - distinct_id, timestamp, if(event = '$pageview' - AND timestamp > now() - INTERVAL 8 day, 1, 0) AS event_0_step_0, - if(event_0_step_0 = 1, timestamp, null) AS event_0_latest_0, - if(event = '$pageview' - AND timestamp > now() - INTERVAL 8 day, 1, 0) AS event_0_step_1, - if(event_0_step_1 = 1, timestamp, null) AS event_0_latest_1, - if(event = '$new_view' - AND timestamp > now() - INTERVAL 8 day, 1, 0) AS event_1_step_0, - if(event_1_step_0 = 1, timestamp, null) AS event_1_latest_0, - if(event = '$new_view' - AND timestamp > now() - INTERVAL 8 day, 1, 0) AS event_1_step_1, - if(event_1_step_1 = 1, timestamp, null) AS event_1_latest_1 - FROM events AS e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview', '$pageview', '$new_view', '$new_view'] - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 8 day )) - GROUP BY person_id) funnel_query - INNER JOIN - (SELECT *, - id AS person_id - FROM - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (((has(['test'], replaceRegexpAll(JSONExtractRaw(properties, 'name'), '^"|"$', ''))))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (((has(['test'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'name'), '^"|"$', ''))))) SETTINGS optimize_aggregation_in_order = 1)) person ON person.person_id = funnel_query.person_id - WHERE 1 = 1 - AND ((((steps_0)) - AND (steps_1))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohortQuery.test_cohort_filter_with_extra - ''' - - SELECT person.person_id AS id - FROM - (SELECT pdi.person_id AS person_id, - countIf(timestamp > now() - INTERVAL 1 week - AND timestamp < now() - AND event = '$pageview' - AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_0 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview'] - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 1 week - GROUP BY person_id) behavior_query - INNER JOIN - (SELECT *, - id AS person_id - FROM - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND ((((has(['test'], replaceRegexpAll(JSONExtractRaw(properties, 'name'), '^"|"$', '')))))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND ((((has(['test'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'name'), '^"|"$', '')))))) SETTINGS optimize_aggregation_in_order = 1)) person ON person.person_id = behavior_query.person_id - WHERE 1 = 1 - AND (((performed_event_condition_None_level_level_0_level_0_0))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohortQuery.test_cohort_filter_with_extra.1 - ''' - - SELECT if(behavior_query.person_id = '00000000-0000-0000-0000-000000000000', person.person_id, behavior_query.person_id) AS id - FROM - (SELECT pdi.person_id AS person_id, - countIf(timestamp > now() - INTERVAL 1 week - AND timestamp < now() - AND event = '$pageview' - AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_1_level_0_0 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview'] - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 1 week - GROUP BY person_id) behavior_query - FULL OUTER JOIN - (SELECT *, - id AS person_id - FROM - (SELECT id, - argMax(properties, version) as person_props - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1)) person ON person.person_id = behavior_query.person_id - WHERE 1 = 1 - AND ((((has(['test'], replaceRegexpAll(JSONExtractRaw(person_props, 'name'), '^"|"$', '')))) - OR ((performed_event_condition_None_level_level_0_level_1_level_0_0)))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohortQuery.test_performed_event_sequence - ''' - - SELECT funnel_query.person_id AS id - FROM - (SELECT person_id, - max(if(event = '$pageview' - AND event_0_latest_0 < event_0_latest_1 - AND event_0_latest_1 <= event_0_latest_0 + INTERVAL 3 day, 2, 1)) = 2 AS steps_0 - FROM - (SELECT person_id, - event, - properties, - distinct_id, timestamp, event_0_latest_0, - min(event_0_latest_1) over (PARTITION by person_id - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) event_0_latest_1 - FROM - (SELECT pdi.person_id AS person_id, - event, - properties, - distinct_id, timestamp, if(event = '$pageview' - AND timestamp > now() - INTERVAL 7 day, 1, 0) AS event_0_step_0, - if(event_0_step_0 = 1, timestamp, null) AS event_0_latest_0, - if(event = '$pageview' - AND timestamp > now() - INTERVAL 7 day, 1, 0) AS event_0_step_1, - if(event_0_step_1 = 1, timestamp, null) AS event_0_latest_1 - FROM events AS e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview', '$pageview'] - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 7 day )) - GROUP BY person_id) funnel_query - WHERE 1 = 1 - AND (((steps_0))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohortQuery.test_performed_event_sequence_and_clause_with_additional_event - ''' - - SELECT funnel_query.person_id AS id - FROM - (SELECT person_id, - max(if(event = '$pageview' - AND event_0_latest_0 < event_0_latest_1 - AND event_0_latest_1 <= event_0_latest_0 + INTERVAL 3 day, 2, 1)) = 2 AS steps_0, - countIf(timestamp > now() - INTERVAL 1 week - AND timestamp < now() - AND event = '$new_view' - AND 1=1) >= 1 AS performed_event_multiple_condition_None_level_level_0_level_1_0 - FROM - (SELECT person_id, - event, - properties, - distinct_id, timestamp, event_0_latest_0, - min(event_0_latest_1) over (PARTITION by person_id - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) event_0_latest_1 - FROM - (SELECT pdi.person_id AS person_id, - event, - properties, - distinct_id, timestamp, if(event = '$pageview' - AND timestamp > now() - INTERVAL 7 day, 1, 0) AS event_0_step_0, - if(event_0_step_0 = 1, timestamp, null) AS event_0_latest_0, - if(event = '$pageview' - AND timestamp > now() - INTERVAL 7 day, 1, 0) AS event_0_step_1, - if(event_0_step_1 = 1, timestamp, null) AS event_0_latest_1 - FROM events AS e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$new_view', '$pageview', '$pageview'] - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 1 week )) - GROUP BY person_id) funnel_query - WHERE 1 = 1 - AND (((steps_0) - OR (performed_event_multiple_condition_None_level_level_0_level_1_0))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohortQuery.test_performed_event_sequence_with_person_properties - ''' - - SELECT person.person_id AS id - FROM - (SELECT person_id, - max(if(event = '$pageview' - AND event_0_latest_0 < event_0_latest_1 - AND event_0_latest_1 <= event_0_latest_0 + INTERVAL 3 day, 2, 1)) = 2 AS steps_0, - countIf(timestamp > now() - INTERVAL 1 week - AND timestamp < now() - AND event = '$pageview' - AND 1=1) >= 1 AS performed_event_multiple_condition_None_level_level_0_level_1_0 - FROM - (SELECT person_id, - event, - properties, - distinct_id, timestamp, event_0_latest_0, - min(event_0_latest_1) over (PARTITION by person_id - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) event_0_latest_1 - FROM - (SELECT pdi.person_id AS person_id, - event, - properties, - distinct_id, timestamp, if(event = '$pageview' - AND timestamp > now() - INTERVAL 7 day, 1, 0) AS event_0_step_0, - if(event_0_step_0 = 1, timestamp, null) AS event_0_latest_0, - if(event = '$pageview' - AND timestamp > now() - INTERVAL 7 day, 1, 0) AS event_0_step_1, - if(event_0_step_1 = 1, timestamp, null) AS event_0_latest_1 - FROM events AS e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview', '$pageview', '$pageview'] - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 1 week )) - GROUP BY person_id) funnel_query - INNER JOIN - (SELECT *, - id AS person_id - FROM - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND ((has(['test@posthog.com'], replaceRegexpAll(JSONExtractRaw(properties, 'email'), '^"|"$', '')))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND ((has(['test@posthog.com'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'email'), '^"|"$', '')))) SETTINGS optimize_aggregation_in_order = 1)) person ON person.person_id = funnel_query.person_id - WHERE 1 = 1 - AND (((steps_0) - AND (performed_event_multiple_condition_None_level_level_0_level_1_0))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohortQuery.test_performed_event_with_event_filters_and_explicit_date - ''' - - SELECT behavior_query.person_id AS id - FROM - (SELECT pdi.person_id AS person_id, - countIf(timestamp > 'explicit_timestamp' - AND timestamp < now() - AND event = '$pageview' - AND (has(['something'], replaceRegexpAll(JSONExtractRaw(properties, '$filter_prop'), '^"|"$', '')))) > 0 AS performed_event_condition_None_level_level_0_level_0_0 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview'] - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 4 day - GROUP BY person_id) behavior_query - WHERE 1 = 1 - AND (((performed_event_condition_None_level_level_0_level_0_0))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohortQuery.test_person - ''' - - SELECT if(behavior_query.person_id = '00000000-0000-0000-0000-000000000000', person.person_id, behavior_query.person_id) AS id - FROM - (SELECT pdi.person_id AS person_id, - countIf(timestamp > now() - INTERVAL 1 week - AND timestamp < now() - AND event = '$pageview' - AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_0 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview'] - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 1 week - GROUP BY person_id) behavior_query - FULL OUTER JOIN - (SELECT *, - id AS person_id - FROM - (SELECT id, - argMax(properties, version) as person_props - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1)) person ON person.person_id = behavior_query.person_id - WHERE 1 = 1 - AND (((performed_event_condition_None_level_level_0_level_0_0) - OR (has(['test@posthog.com'], replaceRegexpAll(JSONExtractRaw(person_props, '$sample_field'), '^"|"$', ''))))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohortQuery.test_person_materialized - ''' - - SELECT if(behavior_query.person_id = '00000000-0000-0000-0000-000000000000', person.person_id, behavior_query.person_id) AS id - FROM - (SELECT pdi.person_id AS person_id, - countIf(timestamp > now() - INTERVAL 1 week - AND timestamp < now() - AND event = '$pageview' - AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_0 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview'] - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 1 week - GROUP BY person_id) behavior_query - FULL OUTER JOIN - (SELECT *, - id AS person_id - FROM - (SELECT id, - argMax(pmat_$sample_field, version) as pmat_$sample_field - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1)) person ON person.person_id = behavior_query.person_id - WHERE 1 = 1 - AND (((performed_event_condition_None_level_level_0_level_0_0) - OR (has(['test@posthog.com'], "pmat_$sample_field")))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohortQuery.test_person_properties_with_pushdowns - ''' - - SELECT if(behavior_query.person_id = '00000000-0000-0000-0000-000000000000', person.person_id, behavior_query.person_id) AS id - FROM - (SELECT pdi.person_id AS person_id, - countIf(timestamp > now() - INTERVAL 1 day - AND timestamp < now() - AND event = '$pageview' - AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_level_0_0, - countIf(timestamp > now() - INTERVAL 2 week - AND timestamp < now() - AND event = '$pageview' - AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_level_1_0, - minIf(timestamp, ((replaceRegexpAll(JSONExtractRaw(properties, '$current_url'), '^"|"$', '') = 'https://posthog.com/feedback/123' - AND event = '$autocapture'))) >= now() - INTERVAL 2 week - AND minIf(timestamp, ((replaceRegexpAll(JSONExtractRaw(properties, '$current_url'), '^"|"$', '') = 'https://posthog.com/feedback/123' - AND event = '$autocapture'))) < now() as first_time_condition_None_level_level_0_level_1_level_0_0 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview', '$pageview', '$autocapture'] - GROUP BY person_id) behavior_query - FULL OUTER JOIN - (SELECT *, - id AS person_id - FROM - (SELECT id, - argMax(properties, version) as person_props - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (((has(['test@posthog.com'], replaceRegexpAll(JSONExtractRaw(properties, 'email'), '^"|"$', ''))))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (((has(['test@posthog.com'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'email'), '^"|"$', ''))))) SETTINGS optimize_aggregation_in_order = 1)) person ON person.person_id = behavior_query.person_id - WHERE 1 = 1 - AND ((((performed_event_condition_None_level_level_0_level_0_level_0_0) - OR (performed_event_condition_None_level_level_0_level_0_level_1_0) - OR (has(['special'], replaceRegexpAll(JSONExtractRaw(person_props, 'name'), '^"|"$', '')))) - AND ((first_time_condition_None_level_level_0_level_1_level_0_0)))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohortQuery.test_person_props_only - ''' - - SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (((has(['test1@posthog.com'], replaceRegexpAll(JSONExtractRaw(properties, 'email'), '^"|"$', ''))) - OR (has(['test2@posthog.com'], replaceRegexpAll(JSONExtractRaw(properties, 'email'), '^"|"$', '')))) - OR ((has(['test3'], replaceRegexpAll(JSONExtractRaw(properties, 'name'), '^"|"$', ''))) - AND (has(['test3@posthog.com'], replaceRegexpAll(JSONExtractRaw(properties, 'email'), '^"|"$', ''))))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (((has(['test1@posthog.com'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'email'), '^"|"$', ''))) - OR (has(['test2@posthog.com'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'email'), '^"|"$', '')))) - OR ((has(['test3'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'name'), '^"|"$', ''))) - AND (has(['test3@posthog.com'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'email'), '^"|"$', ''))))) SETTINGS optimize_aggregation_in_order = 1 - ''' -# --- -# name: TestCohortQuery.test_precalculated_cohort_filter_with_extra_filters - ''' - - SELECT count(DISTINCT person_id) - FROM cohortpeople - WHERE team_id = 2 - AND cohort_id = 2 - AND version = NULL - ''' -# --- -# name: TestCohortQuery.test_precalculated_cohort_filter_with_extra_filters.1 - ''' - /* cohort_calculation: */ - SELECT count(DISTINCT person_id) - FROM cohortpeople - WHERE team_id = 2 - AND cohort_id = 2 - AND version = 0 - ''' -# --- -# name: TestCohortQuery.test_precalculated_cohort_filter_with_extra_filters.2 - ''' - - SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND ((((has(['test'], replaceRegexpAll(JSONExtractRaw(properties, 'name'), '^"|"$', ''))))) - OR (has(['test2'], replaceRegexpAll(JSONExtractRaw(properties, 'name'), '^"|"$', '')))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND ((((has(['test'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'name'), '^"|"$', ''))))) - OR (has(['test2'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'name'), '^"|"$', '')))) SETTINGS optimize_aggregation_in_order = 1 - ''' -# --- -# name: TestCohortQuery.test_static_cohort_filter - ''' - - SELECT count(DISTINCT person_id) - FROM person_static_cohort - WHERE team_id = 2 - AND cohort_id = 2 - ''' -# --- -# name: TestCohortQuery.test_static_cohort_filter.1 - ''' - - SELECT person.person_id AS id - FROM - (SELECT *, - id AS person_id - FROM - (SELECT id - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1)) person - WHERE 1 = 1 - AND (((id IN - (SELECT person_id as id - FROM person_static_cohort - WHERE cohort_id = 2 - AND team_id = 2)))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohortQuery.test_static_cohort_filter_with_extra - ''' - - SELECT count(DISTINCT person_id) - FROM person_static_cohort - WHERE team_id = 2 - AND cohort_id = 2 - ''' -# --- -# name: TestCohortQuery.test_static_cohort_filter_with_extra.1 - ''' - - SELECT if(behavior_query.person_id = '00000000-0000-0000-0000-000000000000', person.person_id, behavior_query.person_id) AS id - FROM - (SELECT pdi.person_id AS person_id, - countIf(timestamp > now() - INTERVAL 1 week - AND timestamp < now() - AND event = '$pageview' - AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_1_0 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview'] - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 1 week - GROUP BY person_id) behavior_query - FULL OUTER JOIN - (SELECT *, - id AS person_id - FROM - (SELECT id - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1)) person ON person.person_id = behavior_query.person_id - WHERE 1 = 1 - AND (((id IN - (SELECT person_id as id - FROM person_static_cohort - WHERE cohort_id = 2 - AND team_id = 2)) - AND (performed_event_condition_None_level_level_0_level_1_0))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohortQuery.test_static_cohort_filter_with_extra.2 - ''' - - SELECT if(behavior_query.person_id = '00000000-0000-0000-0000-000000000000', person.person_id, behavior_query.person_id) AS id - FROM - (SELECT pdi.person_id AS person_id, - countIf(timestamp > now() - INTERVAL 1 week - AND timestamp < now() - AND event = '$pageview' - AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_1_level_0_0 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageview'] - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 1 week - GROUP BY person_id) behavior_query - FULL OUTER JOIN - (SELECT *, - id AS person_id - FROM - (SELECT id - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1)) person ON person.person_id = behavior_query.person_id - WHERE 1 = 1 - AND ((((id IN - (SELECT person_id as id - FROM person_static_cohort - WHERE cohort_id = 2 - AND team_id = 2))) - OR ((performed_event_condition_None_level_level_0_level_1_level_0_0)))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestCohortQuery.test_unwrapping_static_cohort_filter_hidden_in_layers_of_cohorts - ''' - - SELECT count(DISTINCT person_id) - FROM person_static_cohort - WHERE team_id = 2 - AND cohort_id = 2 - ''' -# --- -# name: TestCohortQuery.test_unwrapping_static_cohort_filter_hidden_in_layers_of_cohorts.1 - ''' - - SELECT if(behavior_query.person_id = '00000000-0000-0000-0000-000000000000', person.person_id, behavior_query.person_id) AS id - FROM - (SELECT pdi.person_id AS person_id, - countIf(timestamp > now() - INTERVAL 7 day - AND timestamp < now() - AND event = '$new_view' - AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_0_level_0_0, - countIf(timestamp > now() - INTERVAL 1 week - AND timestamp < now() - AND event = '$pageview' - AND 1=1) > 0 AS performed_event_condition_None_level_level_0_level_1_0 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$new_view', '$pageview'] - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 7 day - GROUP BY person_id) behavior_query - FULL OUTER JOIN - (SELECT *, - id AS person_id - FROM - (SELECT id - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1)) person ON person.person_id = behavior_query.person_id - WHERE 1 = 1 - AND ((((performed_event_condition_None_level_level_0_level_0_level_0_0) - AND (id NOT IN - (SELECT person_id as id - FROM person_static_cohort - WHERE cohort_id = 2 - AND team_id = 2))) - OR (performed_event_condition_None_level_level_0_level_1_0))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- diff --git a/ee/clickhouse/queries/test/__snapshots__/test_event_query.ambr b/ee/clickhouse/queries/test/__snapshots__/test_event_query.ambr deleted file mode 100644 index 7b40dbc6f08..00000000000 --- a/ee/clickhouse/queries/test/__snapshots__/test_event_query.ambr +++ /dev/null @@ -1,386 +0,0 @@ -# serializer version: 1 -# name: TestEventQuery.test_account_filters - ''' - - SELECT count(DISTINCT person_id) - FROM cohortpeople - WHERE team_id = 2 - AND cohort_id = 2 - AND version = NULL - ''' -# --- -# name: TestEventQuery.test_account_filters.1 - ''' - /* cohort_calculation: */ - SELECT count(DISTINCT person_id) - FROM cohortpeople - WHERE team_id = 2 - AND cohort_id = 2 - AND version = 0 - ''' -# --- -# name: TestEventQuery.test_account_filters.2 - ''' - SELECT e.timestamp as timestamp, - pdi.person_id as person_id - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'event_name' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2021-01-14 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-01-21 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['Jane'], replaceRegexpAll(JSONExtractRaw(properties, 'name'), '^"|"$', ''))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['Jane'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'name'), '^"|"$', ''))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event = 'event_name' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2021-01-14 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-01-21 23:59:59', 'UTC') - ''' -# --- -# name: TestEventQuery.test_basic_event_filter - ''' - SELECT e.timestamp as timestamp - FROM events e - WHERE team_id = 2 - AND event = 'viewed' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2021-05-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ''' -# --- -# name: TestEventQuery.test_cohort_filter - ''' - SELECT e.timestamp as timestamp, - pdi.person_id as person_id - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'viewed' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2021-05-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event = 'viewed' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2021-05-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (pdi.person_id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND ((has(['test'], replaceRegexpAll(JSONExtractRaw(properties, 'name'), '^"|"$', '')))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND ((has(['test'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'name'), '^"|"$', '')))) SETTINGS optimize_aggregation_in_order = 1)) - ''' -# --- -# name: TestEventQuery.test_denormalised_props - ''' - SELECT e.timestamp as timestamp, - e."mat_test_prop" as "mat_test_prop" - FROM events e - WHERE team_id = 2 - AND event = 'user signed up' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND ((has(['hi'], "mat_test_prop")) - AND (has(['hi'], "mat_test_prop"))) - ''' -# --- -# name: TestEventQuery.test_element - ''' - SELECT e.timestamp as timestamp - FROM events e - WHERE team_id = 2 - AND event = 'event_name' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2021-01-14 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-01-21 23:59:59', 'UTC') - AND ((match(elements_chain, '(^|;)label(\\.|$|;|:)'))) - ''' -# --- -# name: TestEventQuery.test_element.1 - ''' - SELECT e.timestamp as timestamp - FROM events e - WHERE team_id = 2 - AND event = 'event_name' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2021-01-14 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-01-21 23:59:59', 'UTC') - AND (0 = 191) - ''' -# --- -# name: TestEventQuery.test_entity_filtered_by_cohort - ''' - SELECT e.timestamp as timestamp, - pdi.person_id as person_id - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2021-05-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2021-05-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (pdi.person_id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND ((has(['test'], replaceRegexpAll(JSONExtractRaw(properties, 'name'), '^"|"$', '')))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND ((has(['test'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'name'), '^"|"$', '')))) SETTINGS optimize_aggregation_in_order = 1)) - ''' -# --- -# name: TestEventQuery.test_entity_filtered_by_multiple_session_duration_filters - ''' - SELECT e.timestamp as timestamp, - sessions.session_duration as session_duration, - sessions.$session_id as $session_id - FROM events e - INNER JOIN - (SELECT "$session_id", - dateDiff('second', min(timestamp), max(timestamp)) as session_duration - FROM events - WHERE "$session_id" != '' - AND team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-02 00:00:00', 'UTC') - INTERVAL 24 HOUR - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-03 23:59:59', 'UTC') + INTERVAL 24 HOUR - GROUP BY "$session_id") as sessions ON sessions."$session_id" = e."$session_id" - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-02 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-03 23:59:59', 'UTC') - AND (sessions.session_duration > 90.0 - AND sessions.session_duration < 150.0) - ''' -# --- -# name: TestEventQuery.test_entity_filtered_by_session_duration - ''' - SELECT e.timestamp as timestamp, - sessions.session_duration as session_duration, - sessions.$session_id as $session_id - FROM events e - INNER JOIN - (SELECT "$session_id", - dateDiff('second', min(timestamp), max(timestamp)) as session_duration - FROM events - WHERE "$session_id" != '' - AND team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-02 00:00:00', 'UTC') - INTERVAL 24 HOUR - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-03 23:59:59', 'UTC') + INTERVAL 24 HOUR - GROUP BY "$session_id") as sessions ON sessions."$session_id" = e."$session_id" - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-02 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-03 23:59:59', 'UTC') - AND (sessions.session_duration > 90.0) - ''' -# --- -# name: TestEventQuery.test_event_properties_filter - ''' - SELECT e.timestamp as timestamp, - e."properties" as "properties" - FROM events e - WHERE team_id = 2 - AND event = 'viewed' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2021-05-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (has(['test_val'], replaceRegexpAll(JSONExtractRaw(e.properties, 'some_key'), '^"|"$', ''))) - ''' -# --- -# name: TestEventQuery.test_event_properties_filter.1 - ''' - SELECT e.timestamp as timestamp - FROM events e - WHERE team_id = 2 - AND event = 'viewed' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2021-05-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (has(['test_val'], replaceRegexpAll(JSONExtractRaw(e.properties, 'some_key'), '^"|"$', ''))) - ''' -# --- -# name: TestEventQuery.test_groups_filters - ''' - SELECT e.timestamp as timestamp - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_1 - FROM groups - WHERE team_id = 2 - AND group_type_index = 1 - GROUP BY group_key) groups_1 ON "$group_1" == groups_1.group_key - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-12 23:59:59', 'UTC') - AND ((has(['finance'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND (has(['value'], replaceRegexpAll(JSONExtractRaw(group_properties_1, 'another'), '^"|"$', '')))) - ''' -# --- -# name: TestEventQuery.test_groups_filters_mixed - ''' - SELECT e.timestamp as timestamp, - pdi.person_id as person_id - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-12 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND ((has(['test'], replaceRegexpAll(JSONExtractRaw(properties, '$browser'), '^"|"$', '')))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND ((has(['test'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), '$browser'), '^"|"$', '')))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-12 23:59:59', 'UTC') - AND ((has(['finance'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', '')))) - ''' -# --- -# name: TestEventQuery.test_static_cohort_filter - ''' - SELECT e.timestamp as timestamp, - pdi.person_id as person_id - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'viewed' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2021-05-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event = 'viewed' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2021-05-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (pdi.person_id IN - (SELECT person_id as id - FROM person_static_cohort - WHERE cohort_id = 2 - AND team_id = 2)) - ''' -# --- -# name: TestEventQuery.test_unique_session_math_filtered_by_session_duration - ''' - SELECT e.timestamp as timestamp, - e."$session_id" as "$session_id", - sessions.session_duration as session_duration - FROM events e - INNER JOIN - (SELECT "$session_id", - dateDiff('second', min(timestamp), max(timestamp)) as session_duration - FROM events - WHERE "$session_id" != '' - AND team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-02 00:00:00', 'UTC') - INTERVAL 24 HOUR - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-03 23:59:59', 'UTC') + INTERVAL 24 HOUR - GROUP BY "$session_id") as sessions ON sessions."$session_id" = e."$session_id" - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-02 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-03 23:59:59', 'UTC') - AND (sessions.session_duration > 30.0) - ''' -# --- diff --git a/ee/clickhouse/queries/test/__snapshots__/test_groups_join_query.ambr b/ee/clickhouse/queries/test/__snapshots__/test_groups_join_query.ambr deleted file mode 100644 index 85b77e61624..00000000000 --- a/ee/clickhouse/queries/test/__snapshots__/test_groups_join_query.ambr +++ /dev/null @@ -1,55 +0,0 @@ -# serializer version: 1 -# name: test_groups_join_query_filtering - tuple( - ''' - - LEFT JOIN ( - SELECT - group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = %(team_id)s AND group_type_index = %(group_index_0)s - GROUP BY group_key - ) groups_0 - ON "$group_0" == groups_0.group_key - - ''', - dict({ - 'group_index_0': 0, - 'team_id': 2, - }), - ) -# --- -# name: test_groups_join_query_filtering_with_custom_key_names - tuple( - ''' - - LEFT JOIN ( - SELECT - group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = %(team_id)s AND group_type_index = %(group_index_0)s - GROUP BY group_key - ) groups_0 - ON call_me_industry == groups_0.group_key - - - LEFT JOIN ( - SELECT - group_key, - argMax(group_properties, _timestamp) AS group_properties_2 - FROM groups - WHERE team_id = %(team_id)s AND group_type_index = %(group_index_2)s - GROUP BY group_key - ) groups_2 - ON call_me_industry == groups_2.group_key - - ''', - dict({ - 'group_index_0': 0, - 'group_index_2': 2, - 'team_id': 2, - }), - ) -# --- diff --git a/ee/clickhouse/queries/test/__snapshots__/test_lifecycle.ambr b/ee/clickhouse/queries/test/__snapshots__/test_lifecycle.ambr deleted file mode 100644 index 4a1fa4a377e..00000000000 --- a/ee/clickhouse/queries/test/__snapshots__/test_lifecycle.ambr +++ /dev/null @@ -1,672 +0,0 @@ -# serializer version: 1 -# name: TestClickhouseLifecycle.test_interval_dates_days - ''' - WITH 'day' AS selected_period, - periods AS - (SELECT dateSub(day, number, dateTrunc(selected_period, toDateTime('2021-05-05 23:59:59', 'UTC'))) AS start_of_period - FROM numbers(dateDiff('day', dateTrunc('day', toDateTime('2021-04-28 00:00:00', 'UTC')), dateTrunc('day', toDateTime('2021-05-05 23:59:59', 'UTC') + INTERVAL 1 day)))) - SELECT groupArray(start_of_period) AS date, - groupArray(counts) AS total, - status - FROM - (SELECT if(status = 'dormant', toInt64(SUM(counts)) * toInt16(-1), toInt64(SUM(counts))) as counts, - start_of_period, - status - FROM - (SELECT periods.start_of_period as start_of_period, - toUInt16(0) AS counts, - status - FROM periods - CROSS JOIN - (SELECT status - FROM - (SELECT ['new', 'returning', 'resurrecting', 'dormant'] as status) ARRAY - JOIN status) as sec - ORDER BY status, - start_of_period - UNION ALL SELECT start_of_period, - count(DISTINCT person_id) counts, - status - FROM - (SELECT pdi.person_id as person_id, - arraySort(groupUniqArray(dateTrunc('day', toTimeZone(toDateTime(events.timestamp, 'UTC'), 'UTC')))) AS all_activity, - arrayPopBack(arrayPushFront(all_activity, dateTrunc('day', toTimeZone(toDateTime(min(person.created_at), 'UTC'), 'UTC')))) as previous_activity, - arrayPopFront(arrayPushBack(all_activity, dateTrunc('day', toDateTime('1970-01-01')))) as following_activity, - arrayMap((previous, current, index) -> if(previous = current, 'new', if(current - INTERVAL 1 day = previous - AND index != 1, 'returning', 'resurrecting')), previous_activity, all_activity, arrayEnumerate(all_activity)) as initial_status, - arrayMap((current, next) -> if(current + INTERVAL 1 day = next, '', 'dormant'), all_activity, following_activity) as dormant_status, - arrayMap(x -> x + INTERVAL 1 day, arrayFilter((current, is_dormant) -> is_dormant = 'dormant', all_activity, dormant_status)) as dormant_periods, - arrayMap(x -> 'dormant', dormant_periods) as dormant_label, - arrayConcat(arrayZip(all_activity, initial_status), arrayZip(dormant_periods, dormant_label)) as temp_concat, - arrayJoin(temp_concat) as period_status_pairs, - period_status_pairs.1 as start_of_period, - period_status_pairs.2 as status, - toDateTime(min(person.created_at), 'UTC') AS created_at - FROM events AS e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id, - argMax(created_at, version) as created_at - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event = '$pageview' - AND timestamp >= toDateTime(dateTrunc('day', toDateTime('2021-04-28 00:00:00', 'UTC'))) - INTERVAL 1 day - AND timestamp < toDateTime(dateTrunc('day', toDateTime('2021-05-05 23:59:59', 'UTC'))) + INTERVAL 1 day - GROUP BY pdi.person_id) - GROUP BY start_of_period, - status) - WHERE start_of_period <= dateTrunc('day', toDateTime('2021-05-05 23:59:59', 'UTC')) - AND start_of_period >= dateTrunc('day', toDateTime('2021-04-28 00:00:00', 'UTC')) - GROUP BY start_of_period, - status - ORDER BY start_of_period ASC) - GROUP BY status - ''' -# --- -# name: TestClickhouseLifecycle.test_interval_dates_months - ''' - WITH 'month' AS selected_period, - periods AS - (SELECT dateSub(month, number, dateTrunc(selected_period, toDateTime('2021-05-05 23:59:59', 'UTC'))) AS start_of_period - FROM numbers(dateDiff('month', dateTrunc('month', toDateTime('2021-02-04 00:00:00', 'UTC')), dateTrunc('month', toDateTime('2021-05-05 23:59:59', 'UTC') + INTERVAL 1 month)))) - SELECT groupArray(start_of_period) AS date, - groupArray(counts) AS total, - status - FROM - (SELECT if(status = 'dormant', toInt64(SUM(counts)) * toInt16(-1), toInt64(SUM(counts))) as counts, - start_of_period, - status - FROM - (SELECT periods.start_of_period as start_of_period, - toUInt16(0) AS counts, - status - FROM periods - CROSS JOIN - (SELECT status - FROM - (SELECT ['new', 'returning', 'resurrecting', 'dormant'] as status) ARRAY - JOIN status) as sec - ORDER BY status, - start_of_period - UNION ALL SELECT start_of_period, - count(DISTINCT person_id) counts, - status - FROM - (SELECT pdi.person_id as person_id, - arraySort(groupUniqArray(dateTrunc('month', toTimeZone(toDateTime(events.timestamp, 'UTC'), 'UTC')))) AS all_activity, - arrayPopBack(arrayPushFront(all_activity, dateTrunc('month', toTimeZone(toDateTime(min(person.created_at), 'UTC'), 'UTC')))) as previous_activity, - arrayPopFront(arrayPushBack(all_activity, dateTrunc('month', toDateTime('1970-01-01')))) as following_activity, - arrayMap((previous, current, index) -> if(previous = current, 'new', if(current - INTERVAL 1 month = previous - AND index != 1, 'returning', 'resurrecting')), previous_activity, all_activity, arrayEnumerate(all_activity)) as initial_status, - arrayMap((current, next) -> if(current + INTERVAL 1 month = next, '', 'dormant'), all_activity, following_activity) as dormant_status, - arrayMap(x -> x + INTERVAL 1 month, arrayFilter((current, is_dormant) -> is_dormant = 'dormant', all_activity, dormant_status)) as dormant_periods, - arrayMap(x -> 'dormant', dormant_periods) as dormant_label, - arrayConcat(arrayZip(all_activity, initial_status), arrayZip(dormant_periods, dormant_label)) as temp_concat, - arrayJoin(temp_concat) as period_status_pairs, - period_status_pairs.1 as start_of_period, - period_status_pairs.2 as status, - toDateTime(min(person.created_at), 'UTC') AS created_at - FROM events AS e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id, - argMax(created_at, version) as created_at - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event = '$pageview' - AND timestamp >= toDateTime(dateTrunc('month', toDateTime('2021-02-04 00:00:00', 'UTC'))) - INTERVAL 1 month - AND timestamp < toDateTime(dateTrunc('month', toDateTime('2021-05-05 23:59:59', 'UTC'))) + INTERVAL 1 month - GROUP BY pdi.person_id) - GROUP BY start_of_period, - status) - WHERE start_of_period <= dateTrunc('month', toDateTime('2021-05-05 23:59:59', 'UTC')) - AND start_of_period >= dateTrunc('month', toDateTime('2021-02-04 00:00:00', 'UTC')) - GROUP BY start_of_period, - status - ORDER BY start_of_period ASC) - GROUP BY status - ''' -# --- -# name: TestClickhouseLifecycle.test_interval_dates_weeks - ''' - WITH 'week' AS selected_period, - periods AS - (SELECT dateSub(week, number, dateTrunc(selected_period, toDateTime('2021-05-06 23:59:59', 'UTC'))) AS start_of_period - FROM numbers(dateDiff('week', dateTrunc('week', toDateTime('2021-04-06 00:00:00', 'UTC')), dateTrunc('week', toDateTime('2021-05-06 23:59:59', 'UTC') + INTERVAL 1 week)))) - SELECT groupArray(start_of_period) AS date, - groupArray(counts) AS total, - status - FROM - (SELECT if(status = 'dormant', toInt64(SUM(counts)) * toInt16(-1), toInt64(SUM(counts))) as counts, - start_of_period, - status - FROM - (SELECT periods.start_of_period as start_of_period, - toUInt16(0) AS counts, - status - FROM periods - CROSS JOIN - (SELECT status - FROM - (SELECT ['new', 'returning', 'resurrecting', 'dormant'] as status) ARRAY - JOIN status) as sec - ORDER BY status, - start_of_period - UNION ALL SELECT start_of_period, - count(DISTINCT person_id) counts, - status - FROM - (SELECT pdi.person_id as person_id, - arraySort(groupUniqArray(dateTrunc('week', toTimeZone(toDateTime(events.timestamp, 'UTC'), 'UTC')))) AS all_activity, - arrayPopBack(arrayPushFront(all_activity, dateTrunc('week', toTimeZone(toDateTime(min(person.created_at), 'UTC'), 'UTC')))) as previous_activity, - arrayPopFront(arrayPushBack(all_activity, dateTrunc('week', toDateTime('1970-01-01')))) as following_activity, - arrayMap((previous, current, index) -> if(previous = current, 'new', if(current - INTERVAL 1 week = previous - AND index != 1, 'returning', 'resurrecting')), previous_activity, all_activity, arrayEnumerate(all_activity)) as initial_status, - arrayMap((current, next) -> if(current + INTERVAL 1 week = next, '', 'dormant'), all_activity, following_activity) as dormant_status, - arrayMap(x -> x + INTERVAL 1 week, arrayFilter((current, is_dormant) -> is_dormant = 'dormant', all_activity, dormant_status)) as dormant_periods, - arrayMap(x -> 'dormant', dormant_periods) as dormant_label, - arrayConcat(arrayZip(all_activity, initial_status), arrayZip(dormant_periods, dormant_label)) as temp_concat, - arrayJoin(temp_concat) as period_status_pairs, - period_status_pairs.1 as start_of_period, - period_status_pairs.2 as status, - toDateTime(min(person.created_at), 'UTC') AS created_at - FROM events AS e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id, - argMax(created_at, version) as created_at - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event = '$pageview' - AND timestamp >= toDateTime(dateTrunc('week', toDateTime('2021-04-06 00:00:00', 'UTC'))) - INTERVAL 1 week - AND timestamp < toDateTime(dateTrunc('week', toDateTime('2021-05-06 23:59:59', 'UTC'))) + INTERVAL 1 week - GROUP BY pdi.person_id) - GROUP BY start_of_period, - status) - WHERE start_of_period <= dateTrunc('week', toDateTime('2021-05-06 23:59:59', 'UTC')) - AND start_of_period >= dateTrunc('week', toDateTime('2021-04-06 00:00:00', 'UTC')) - GROUP BY start_of_period, - status - ORDER BY start_of_period ASC) - GROUP BY status - ''' -# --- -# name: TestClickhouseLifecycle.test_lifecycle_edge_cases - ''' - WITH 'day' AS selected_period, - periods AS - (SELECT dateSub(day, number, dateTrunc(selected_period, toDateTime('2020-01-18 23:59:59', 'UTC'))) AS start_of_period - FROM numbers(dateDiff('day', dateTrunc('day', toDateTime('2020-01-11 00:00:00', 'UTC')), dateTrunc('day', toDateTime('2020-01-18 23:59:59', 'UTC') + INTERVAL 1 day)))) - SELECT groupArray(start_of_period) AS date, - groupArray(counts) AS total, - status - FROM - (SELECT if(status = 'dormant', toInt64(SUM(counts)) * toInt16(-1), toInt64(SUM(counts))) as counts, - start_of_period, - status - FROM - (SELECT periods.start_of_period as start_of_period, - toUInt16(0) AS counts, - status - FROM periods - CROSS JOIN - (SELECT status - FROM - (SELECT ['new', 'returning', 'resurrecting', 'dormant'] as status) ARRAY - JOIN status) as sec - ORDER BY status, - start_of_period - UNION ALL SELECT start_of_period, - count(DISTINCT person_id) counts, - status - FROM - (SELECT pdi.person_id as person_id, - arraySort(groupUniqArray(dateTrunc('day', toTimeZone(toDateTime(events.timestamp, 'UTC'), 'UTC')))) AS all_activity, - arrayPopBack(arrayPushFront(all_activity, dateTrunc('day', toTimeZone(toDateTime(min(person.created_at), 'UTC'), 'UTC')))) as previous_activity, - arrayPopFront(arrayPushBack(all_activity, dateTrunc('day', toDateTime('1970-01-01')))) as following_activity, - arrayMap((previous, current, index) -> if(previous = current, 'new', if(current - INTERVAL 1 day = previous - AND index != 1, 'returning', 'resurrecting')), previous_activity, all_activity, arrayEnumerate(all_activity)) as initial_status, - arrayMap((current, next) -> if(current + INTERVAL 1 day = next, '', 'dormant'), all_activity, following_activity) as dormant_status, - arrayMap(x -> x + INTERVAL 1 day, arrayFilter((current, is_dormant) -> is_dormant = 'dormant', all_activity, dormant_status)) as dormant_periods, - arrayMap(x -> 'dormant', dormant_periods) as dormant_label, - arrayConcat(arrayZip(all_activity, initial_status), arrayZip(dormant_periods, dormant_label)) as temp_concat, - arrayJoin(temp_concat) as period_status_pairs, - period_status_pairs.1 as start_of_period, - period_status_pairs.2 as status, - toDateTime(min(person.created_at), 'UTC') AS created_at - FROM events AS e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id, - argMax(created_at, version) as created_at - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event = '$pageview' - AND timestamp >= toDateTime(dateTrunc('day', toDateTime('2020-01-11 00:00:00', 'UTC'))) - INTERVAL 1 day - AND timestamp < toDateTime(dateTrunc('day', toDateTime('2020-01-18 23:59:59', 'UTC'))) + INTERVAL 1 day - GROUP BY pdi.person_id) - GROUP BY start_of_period, - status) - WHERE start_of_period <= dateTrunc('day', toDateTime('2020-01-18 23:59:59', 'UTC')) - AND start_of_period >= dateTrunc('day', toDateTime('2020-01-11 00:00:00', 'UTC')) - GROUP BY start_of_period, - status - ORDER BY start_of_period ASC) - GROUP BY status - ''' -# --- -# name: TestClickhouseLifecycle.test_lifecycle_hogql_event_properties - ''' - WITH 'day' AS selected_period, - periods AS - (SELECT dateSub(day, number, dateTrunc(selected_period, toDateTime('2021-05-05 23:59:59', 'UTC'))) AS start_of_period - FROM numbers(dateDiff('day', dateTrunc('day', toDateTime('2021-04-28 00:00:00', 'UTC')), dateTrunc('day', toDateTime('2021-05-05 23:59:59', 'UTC') + INTERVAL 1 day)))) - SELECT groupArray(start_of_period) AS date, - groupArray(counts) AS total, - status - FROM - (SELECT if(status = 'dormant', toInt64(SUM(counts)) * toInt16(-1), toInt64(SUM(counts))) as counts, - start_of_period, - status - FROM - (SELECT periods.start_of_period as start_of_period, - toUInt16(0) AS counts, - status - FROM periods - CROSS JOIN - (SELECT status - FROM - (SELECT ['new', 'returning', 'resurrecting', 'dormant'] as status) ARRAY - JOIN status) as sec - ORDER BY status, - start_of_period - UNION ALL SELECT start_of_period, - count(DISTINCT person_id) counts, - status - FROM - (SELECT pdi.person_id as person_id, - arraySort(groupUniqArray(dateTrunc('day', toTimeZone(toDateTime(events.timestamp, 'UTC'), 'UTC')))) AS all_activity, - arrayPopBack(arrayPushFront(all_activity, dateTrunc('day', toTimeZone(toDateTime(min(person.created_at), 'UTC'), 'UTC')))) as previous_activity, - arrayPopFront(arrayPushBack(all_activity, dateTrunc('day', toDateTime('1970-01-01')))) as following_activity, - arrayMap((previous, current, index) -> if(previous = current, 'new', if(current - INTERVAL 1 day = previous - AND index != 1, 'returning', 'resurrecting')), previous_activity, all_activity, arrayEnumerate(all_activity)) as initial_status, - arrayMap((current, next) -> if(current + INTERVAL 1 day = next, '', 'dormant'), all_activity, following_activity) as dormant_status, - arrayMap(x -> x + INTERVAL 1 day, arrayFilter((current, is_dormant) -> is_dormant = 'dormant', all_activity, dormant_status)) as dormant_periods, - arrayMap(x -> 'dormant', dormant_periods) as dormant_label, - arrayConcat(arrayZip(all_activity, initial_status), arrayZip(dormant_periods, dormant_label)) as temp_concat, - arrayJoin(temp_concat) as period_status_pairs, - period_status_pairs.1 as start_of_period, - period_status_pairs.2 as status, - toDateTime(min(person.created_at), 'UTC') AS created_at - FROM events AS e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id, - argMax(created_at, version) as created_at - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event = '$pageview' - AND timestamp >= toDateTime(dateTrunc('day', toDateTime('2021-04-28 00:00:00', 'UTC'))) - INTERVAL 1 day - AND timestamp < toDateTime(dateTrunc('day', toDateTime('2021-05-05 23:59:59', 'UTC'))) + INTERVAL 1 day - AND (and(ifNull(like(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), '%example%'), 0), 1)) - GROUP BY pdi.person_id) - GROUP BY start_of_period, - status) - WHERE start_of_period <= dateTrunc('day', toDateTime('2021-05-05 23:59:59', 'UTC')) - AND start_of_period >= dateTrunc('day', toDateTime('2021-04-28 00:00:00', 'UTC')) - GROUP BY start_of_period, - status - ORDER BY start_of_period ASC) - GROUP BY status - ''' -# --- -# name: TestClickhouseLifecycle.test_lifecycle_hogql_event_properties_materialized - ''' - WITH 'day' AS selected_period, - periods AS - (SELECT dateSub(day, number, dateTrunc(selected_period, toDateTime('2021-05-05 23:59:59', 'UTC'))) AS start_of_period - FROM numbers(dateDiff('day', dateTrunc('day', toDateTime('2021-04-28 00:00:00', 'UTC')), dateTrunc('day', toDateTime('2021-05-05 23:59:59', 'UTC') + INTERVAL 1 day)))) - SELECT groupArray(start_of_period) AS date, - groupArray(counts) AS total, - status - FROM - (SELECT if(status = 'dormant', toInt64(SUM(counts)) * toInt16(-1), toInt64(SUM(counts))) as counts, - start_of_period, - status - FROM - (SELECT periods.start_of_period as start_of_period, - toUInt16(0) AS counts, - status - FROM periods - CROSS JOIN - (SELECT status - FROM - (SELECT ['new', 'returning', 'resurrecting', 'dormant'] as status) ARRAY - JOIN status) as sec - ORDER BY status, - start_of_period - UNION ALL SELECT start_of_period, - count(DISTINCT person_id) counts, - status - FROM - (SELECT pdi.person_id as person_id, - arraySort(groupUniqArray(dateTrunc('day', toTimeZone(toDateTime(events.timestamp, 'UTC'), 'UTC')))) AS all_activity, - arrayPopBack(arrayPushFront(all_activity, dateTrunc('day', toTimeZone(toDateTime(min(person.created_at), 'UTC'), 'UTC')))) as previous_activity, - arrayPopFront(arrayPushBack(all_activity, dateTrunc('day', toDateTime('1970-01-01')))) as following_activity, - arrayMap((previous, current, index) -> if(previous = current, 'new', if(current - INTERVAL 1 day = previous - AND index != 1, 'returning', 'resurrecting')), previous_activity, all_activity, arrayEnumerate(all_activity)) as initial_status, - arrayMap((current, next) -> if(current + INTERVAL 1 day = next, '', 'dormant'), all_activity, following_activity) as dormant_status, - arrayMap(x -> x + INTERVAL 1 day, arrayFilter((current, is_dormant) -> is_dormant = 'dormant', all_activity, dormant_status)) as dormant_periods, - arrayMap(x -> 'dormant', dormant_periods) as dormant_label, - arrayConcat(arrayZip(all_activity, initial_status), arrayZip(dormant_periods, dormant_label)) as temp_concat, - arrayJoin(temp_concat) as period_status_pairs, - period_status_pairs.1 as start_of_period, - period_status_pairs.2 as status, - toDateTime(min(person.created_at), 'UTC') AS created_at - FROM events AS e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id, - argMax(created_at, version) as created_at - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event = '$pageview' - AND timestamp >= toDateTime(dateTrunc('day', toDateTime('2021-04-28 00:00:00', 'UTC'))) - INTERVAL 1 day - AND timestamp < toDateTime(dateTrunc('day', toDateTime('2021-05-05 23:59:59', 'UTC'))) + INTERVAL 1 day - AND (and(ifNull(like(nullIf(nullIf(events.`mat_$current_url`, ''), 'null'), '%example%'), 0), 1)) - GROUP BY pdi.person_id) - GROUP BY start_of_period, - status) - WHERE start_of_period <= dateTrunc('day', toDateTime('2021-05-05 23:59:59', 'UTC')) - AND start_of_period >= dateTrunc('day', toDateTime('2021-04-28 00:00:00', 'UTC')) - GROUP BY start_of_period, - status - ORDER BY start_of_period ASC) - GROUP BY status - ''' -# --- -# name: TestClickhouseLifecycle.test_lifecycle_hogql_person_properties - ''' - WITH 'day' AS selected_period, - periods AS - (SELECT dateSub(day, number, dateTrunc(selected_period, toDateTime('2021-05-05 23:59:59', 'UTC'))) AS start_of_period - FROM numbers(dateDiff('day', dateTrunc('day', toDateTime('2021-04-28 00:00:00', 'UTC')), dateTrunc('day', toDateTime('2021-05-05 23:59:59', 'UTC') + INTERVAL 1 day)))) - SELECT groupArray(start_of_period) AS date, - groupArray(counts) AS total, - status - FROM - (SELECT if(status = 'dormant', toInt64(SUM(counts)) * toInt16(-1), toInt64(SUM(counts))) as counts, - start_of_period, - status - FROM - (SELECT periods.start_of_period as start_of_period, - toUInt16(0) AS counts, - status - FROM periods - CROSS JOIN - (SELECT status - FROM - (SELECT ['new', 'returning', 'resurrecting', 'dormant'] as status) ARRAY - JOIN status) as sec - ORDER BY status, - start_of_period - UNION ALL SELECT start_of_period, - count(DISTINCT person_id) counts, - status - FROM - (SELECT pdi.person_id as person_id, - arraySort(groupUniqArray(dateTrunc('day', toTimeZone(toDateTime(events.timestamp, 'UTC'), 'UTC')))) AS all_activity, - arrayPopBack(arrayPushFront(all_activity, dateTrunc('day', toTimeZone(toDateTime(min(person.created_at), 'UTC'), 'UTC')))) as previous_activity, - arrayPopFront(arrayPushBack(all_activity, dateTrunc('day', toDateTime('1970-01-01')))) as following_activity, - arrayMap((previous, current, index) -> if(previous = current, 'new', if(current - INTERVAL 1 day = previous - AND index != 1, 'returning', 'resurrecting')), previous_activity, all_activity, arrayEnumerate(all_activity)) as initial_status, - arrayMap((current, next) -> if(current + INTERVAL 1 day = next, '', 'dormant'), all_activity, following_activity) as dormant_status, - arrayMap(x -> x + INTERVAL 1 day, arrayFilter((current, is_dormant) -> is_dormant = 'dormant', all_activity, dormant_status)) as dormant_periods, - arrayMap(x -> 'dormant', dormant_periods) as dormant_label, - arrayConcat(arrayZip(all_activity, initial_status), arrayZip(dormant_periods, dormant_label)) as temp_concat, - arrayJoin(temp_concat) as period_status_pairs, - period_status_pairs.1 as start_of_period, - period_status_pairs.2 as status, - toDateTime(min(person.created_at), 'UTC') AS created_at - FROM events AS e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id, - argMax(created_at, version) as created_at, - argMax(properties, version) as person_props - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event = '$pageview' - AND timestamp >= toDateTime(dateTrunc('day', toDateTime('2021-04-28 00:00:00', 'UTC'))) - INTERVAL 1 day - AND timestamp < toDateTime(dateTrunc('day', toDateTime('2021-05-05 23:59:59', 'UTC'))) + INTERVAL 1 day - AND (ifNull(like(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person_properties, 'email'), ''), 'null'), '^"|"$', ''), '%test.com'), 0)) - GROUP BY pdi.person_id) - GROUP BY start_of_period, - status) - WHERE start_of_period <= dateTrunc('day', toDateTime('2021-05-05 23:59:59', 'UTC')) - AND start_of_period >= dateTrunc('day', toDateTime('2021-04-28 00:00:00', 'UTC')) - GROUP BY start_of_period, - status - ORDER BY start_of_period ASC) - GROUP BY status - ''' -# --- -# name: TestClickhouseLifecycle.test_lifecycle_hogql_person_properties_materialized - ''' - WITH 'day' AS selected_period, - periods AS - (SELECT dateSub(day, number, dateTrunc(selected_period, toDateTime('2021-05-05 23:59:59', 'UTC'))) AS start_of_period - FROM numbers(dateDiff('day', dateTrunc('day', toDateTime('2021-04-28 00:00:00', 'UTC')), dateTrunc('day', toDateTime('2021-05-05 23:59:59', 'UTC') + INTERVAL 1 day)))) - SELECT groupArray(start_of_period) AS date, - groupArray(counts) AS total, - status - FROM - (SELECT if(status = 'dormant', toInt64(SUM(counts)) * toInt16(-1), toInt64(SUM(counts))) as counts, - start_of_period, - status - FROM - (SELECT periods.start_of_period as start_of_period, - toUInt16(0) AS counts, - status - FROM periods - CROSS JOIN - (SELECT status - FROM - (SELECT ['new', 'returning', 'resurrecting', 'dormant'] as status) ARRAY - JOIN status) as sec - ORDER BY status, - start_of_period - UNION ALL SELECT start_of_period, - count(DISTINCT person_id) counts, - status - FROM - (SELECT pdi.person_id as person_id, - arraySort(groupUniqArray(dateTrunc('day', toTimeZone(toDateTime(events.timestamp, 'UTC'), 'UTC')))) AS all_activity, - arrayPopBack(arrayPushFront(all_activity, dateTrunc('day', toTimeZone(toDateTime(min(person.created_at), 'UTC'), 'UTC')))) as previous_activity, - arrayPopFront(arrayPushBack(all_activity, dateTrunc('day', toDateTime('1970-01-01')))) as following_activity, - arrayMap((previous, current, index) -> if(previous = current, 'new', if(current - INTERVAL 1 day = previous - AND index != 1, 'returning', 'resurrecting')), previous_activity, all_activity, arrayEnumerate(all_activity)) as initial_status, - arrayMap((current, next) -> if(current + INTERVAL 1 day = next, '', 'dormant'), all_activity, following_activity) as dormant_status, - arrayMap(x -> x + INTERVAL 1 day, arrayFilter((current, is_dormant) -> is_dormant = 'dormant', all_activity, dormant_status)) as dormant_periods, - arrayMap(x -> 'dormant', dormant_periods) as dormant_label, - arrayConcat(arrayZip(all_activity, initial_status), arrayZip(dormant_periods, dormant_label)) as temp_concat, - arrayJoin(temp_concat) as period_status_pairs, - period_status_pairs.1 as start_of_period, - period_status_pairs.2 as status, - toDateTime(min(person.created_at), 'UTC') AS created_at - FROM events AS e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id, - argMax(created_at, version) as created_at, - argMax(pmat_email, version) as pmat_email - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event = '$pageview' - AND timestamp >= toDateTime(dateTrunc('day', toDateTime('2021-04-28 00:00:00', 'UTC'))) - INTERVAL 1 day - AND timestamp < toDateTime(dateTrunc('day', toDateTime('2021-05-05 23:59:59', 'UTC'))) + INTERVAL 1 day - AND (ifNull(like(nullIf(nullIf(mat_pp_email, ''), 'null'), '%test.com'), 0)) - GROUP BY pdi.person_id) - GROUP BY start_of_period, - status) - WHERE start_of_period <= dateTrunc('day', toDateTime('2021-05-05 23:59:59', 'UTC')) - AND start_of_period >= dateTrunc('day', toDateTime('2021-04-28 00:00:00', 'UTC')) - GROUP BY start_of_period, - status - ORDER BY start_of_period ASC) - GROUP BY status - ''' -# --- -# name: TestClickhouseLifecycle.test_test_account_filters_with_groups - ''' - WITH 'day' AS selected_period, - periods AS - (SELECT dateSub(day, number, dateTrunc(selected_period, toDateTime('2020-01-19 23:59:59', 'UTC'))) AS start_of_period - FROM numbers(dateDiff('day', dateTrunc('day', toDateTime('2020-01-12 00:00:00', 'UTC')), dateTrunc('day', toDateTime('2020-01-19 23:59:59', 'UTC') + INTERVAL 1 day)))) - SELECT groupArray(start_of_period) AS date, - groupArray(counts) AS total, - status - FROM - (SELECT if(status = 'dormant', toInt64(SUM(counts)) * toInt16(-1), toInt64(SUM(counts))) as counts, - start_of_period, - status - FROM - (SELECT periods.start_of_period as start_of_period, - toUInt16(0) AS counts, - status - FROM periods - CROSS JOIN - (SELECT status - FROM - (SELECT ['new', 'returning', 'resurrecting', 'dormant'] as status) ARRAY - JOIN status) as sec - ORDER BY status, - start_of_period - UNION ALL SELECT start_of_period, - count(DISTINCT person_id) counts, - status - FROM - (SELECT pdi.person_id as person_id, - arraySort(groupUniqArray(dateTrunc('day', toTimeZone(toDateTime(events.timestamp, 'UTC'), 'UTC')))) AS all_activity, - arrayPopBack(arrayPushFront(all_activity, dateTrunc('day', toTimeZone(toDateTime(min(person.created_at), 'UTC'), 'UTC')))) as previous_activity, - arrayPopFront(arrayPushBack(all_activity, dateTrunc('day', toDateTime('1970-01-01')))) as following_activity, - arrayMap((previous, current, index) -> if(previous = current, 'new', if(current - INTERVAL 1 day = previous - AND index != 1, 'returning', 'resurrecting')), previous_activity, all_activity, arrayEnumerate(all_activity)) as initial_status, - arrayMap((current, next) -> if(current + INTERVAL 1 day = next, '', 'dormant'), all_activity, following_activity) as dormant_status, - arrayMap(x -> x + INTERVAL 1 day, arrayFilter((current, is_dormant) -> is_dormant = 'dormant', all_activity, dormant_status)) as dormant_periods, - arrayMap(x -> 'dormant', dormant_periods) as dormant_label, - arrayConcat(arrayZip(all_activity, initial_status), arrayZip(dormant_periods, dormant_label)) as temp_concat, - arrayJoin(temp_concat) as period_status_pairs, - period_status_pairs.1 as start_of_period, - period_status_pairs.2 as status, - toDateTime(min(person.created_at), 'UTC') AS created_at - FROM events AS e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id, - argMax(created_at, version) as created_at - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event = '$pageview' - AND timestamp >= toDateTime(dateTrunc('day', toDateTime('2020-01-12 00:00:00', 'UTC'))) - INTERVAL 1 day - AND timestamp < toDateTime(dateTrunc('day', toDateTime('2020-01-19 23:59:59', 'UTC'))) + INTERVAL 1 day - AND (has(['value'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'key'), '^"|"$', ''))) - GROUP BY pdi.person_id) - GROUP BY start_of_period, - status) - WHERE start_of_period <= dateTrunc('day', toDateTime('2020-01-19 23:59:59', 'UTC')) - AND start_of_period >= dateTrunc('day', toDateTime('2020-01-12 00:00:00', 'UTC')) - GROUP BY start_of_period, - status - ORDER BY start_of_period ASC) - GROUP BY status - ''' -# --- diff --git a/ee/clickhouse/queries/test/__snapshots__/test_paths.ambr b/ee/clickhouse/queries/test/__snapshots__/test_paths.ambr deleted file mode 100644 index d1b8aac137c..00000000000 --- a/ee/clickhouse/queries/test/__snapshots__/test_paths.ambr +++ /dev/null @@ -1,9318 +0,0 @@ -# serializer version: 1 -# name: TestClickhousePaths.test_by_funnel_after_dropoff - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_2, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_0, - latest_2, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps = 1 - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.timestamp AS target_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND e.timestamp >= target_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_after_dropoff.1 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_2, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_0, - latest_2, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps = 1 - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.timestamp AS target_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND e.timestamp >= target_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '1_step one' - AND path_key = '2_step dropoff1' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_after_dropoff.2 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_2, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_0, - latest_2, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps = 1 - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.timestamp AS target_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND e.timestamp >= target_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '2_step dropoff1' - AND path_key = '3_step dropoff2' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_after_dropoff.3 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_2, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_0, - latest_2, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps = 1 - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.timestamp AS target_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND e.timestamp >= target_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '3_step dropoff2' - AND path_key = '4_step branch' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_after_dropoff.4 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_2, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_0, - latest_2, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps = 1 - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.timestamp AS target_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND e.timestamp >= target_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '4_step branch' - AND path_key = '3_step dropoff2' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_after_dropoff_with_group_filter - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_2, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_0, - latest_2, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps = 1 - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.timestamp AS target_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (has(['technology'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND e.timestamp >= target_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_after_dropoff_with_group_filter.1 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_2, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_0, - latest_2, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps = 1 - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.timestamp AS target_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (has(['technology'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND e.timestamp >= target_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '1_step one' - AND path_key = '2_step dropoff1' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_after_dropoff_with_group_filter.2 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_2, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_0, - latest_2, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps = 1 - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.timestamp AS target_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (has(['technology'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND e.timestamp >= target_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '2_step dropoff1' - AND path_key = '3_step dropoff2' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_after_dropoff_with_group_filter.3 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_2, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_0, - latest_2, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps = 1 - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.timestamp AS target_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (has(['technology'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND e.timestamp >= target_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '3_step dropoff2' - AND path_key = '4_step branch' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_after_dropoff_with_group_filter.4 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_0, steps) as timestamp, - argMax(latest_2, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_0, - latest_2, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps = 1 - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.timestamp AS target_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (has(['technology'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND e.timestamp >= target_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '4_step branch' - AND path_key = '3_step dropoff2' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_after_step - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_1, steps) as timestamp, - argMax(latest_2, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_1, - latest_2, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [2, 3] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.timestamp AS target_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND e.timestamp >= target_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_after_step_limit - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_1, steps) as timestamp, - argMax(latest_2, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_1, - latest_2, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [2, 3] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.timestamp AS target_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND e.timestamp >= target_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_before_dropoff - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_1, steps) as timestamp, - argMax(latest_2, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_1, - latest_2, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps = 2 - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.timestamp AS target_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND e.timestamp <= target_timestamp + INTERVAL 7 DAY - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_before_step - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id , timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_1, steps) as timestamp, - argMax(latest_2, steps) as final_timestamp, - argMax(latest_0, steps) as first_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_1, - latest_2, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [2, 3] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.timestamp AS target_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND e.timestamp <= target_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_between_step - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id, - max_timestamp, - min_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_1, steps) as max_timestamp, - argMax(latest_0, steps) as min_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [2, 3] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.min_timestamp as min_timestamp, - funnel_actors.max_timestamp as max_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND e.timestamp >= min_timestamp - AND e.timestamp <= max_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_between_step.1 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id, - max_timestamp, - min_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_1, steps) as max_timestamp, - argMax(latest_0, steps) as min_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [2, 3] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.min_timestamp as min_timestamp, - funnel_actors.max_timestamp as max_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND e.timestamp >= min_timestamp - AND e.timestamp <= max_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '1_step one' - AND path_key = '2_between_step_1_a' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_between_step.2 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id, - max_timestamp, - min_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_1, steps) as max_timestamp, - argMax(latest_0, steps) as min_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [2, 3] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.min_timestamp as min_timestamp, - funnel_actors.max_timestamp as max_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND e.timestamp >= min_timestamp - AND e.timestamp <= max_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '2_between_step_1_a' - AND path_key = '3_between_step_1_b' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_between_step.3 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id, - max_timestamp, - min_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_1, steps) as max_timestamp, - argMax(latest_0, steps) as min_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [2, 3] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.min_timestamp as min_timestamp, - funnel_actors.max_timestamp as max_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND e.timestamp >= min_timestamp - AND e.timestamp <= max_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '3_between_step_1_b' - AND path_key = '4_step two' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_between_step.4 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id, - max_timestamp, - min_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_1, steps) as max_timestamp, - argMax(latest_0, steps) as min_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [2, 3] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.min_timestamp as min_timestamp, - funnel_actors.max_timestamp as max_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND e.timestamp >= min_timestamp - AND e.timestamp <= max_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '3_between_step_1_b' - AND path_key = '4_between_step_1_c' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_by_funnel_between_step.5 - ''' - WITH funnel_actors AS - (SELECT aggregation_target AS actor_id, - max_timestamp, - min_timestamp - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner , - argMax(latest_1, steps) as max_timestamp, - argMax(latest_0, steps) as min_timestamp - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time , - latest_1, - latest_0 - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 7 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 7 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 7 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [2, 3] - ORDER BY aggregation_target SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000) - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(toDateTime('2018-01-01') + toIntervalSecond(x.3 / 1000) < toDateTime('2018-01-01') + INTERVAL 7 DAY, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - funnel_actors.min_timestamp as min_timestamp, - funnel_actors.max_timestamp as max_timestamp, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - JOIN funnel_actors ON funnel_actors.actor_id = pdi.person_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - AND e.timestamp >= min_timestamp - AND e.timestamp <= max_timestamp - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '4_between_step_1_c' - AND path_key = '5_step two' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_end - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, '/about') as target_index , - if(target_index > 0, arrayResize(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arrayResize(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, (-1) * 5) as limited_path , - arraySlice(filtered_timings, (-1) * 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index - WHERE target_index > 0 )) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_end.1 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, '/about') as target_index , - if(target_index > 0, arrayResize(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arrayResize(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, (-1) * 5) as limited_path , - arraySlice(filtered_timings, (-1) * 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index - WHERE target_index > 0 )) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_end_materialized - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, '/about') as target_index , - if(target_index > 0, arrayResize(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arrayResize(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, (-1) * 5) as limited_path , - arraySlice(filtered_timings, (-1) * 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), nullIf(nullIf(e.`mat_$screen_name`, ''), 'null'), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(nullIf(nullIf(e.`mat_$current_url`, ''), 'null'), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index - WHERE target_index > 0 )) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_end_materialized.1 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, '/about') as target_index , - if(target_index > 0, arrayResize(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arrayResize(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, (-1) * 5) as limited_path , - arraySlice(filtered_timings, (-1) * 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), nullIf(nullIf(e.`mat_$screen_name`, ''), 'null'), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(nullIf(nullIf(e.`mat_$current_url`, ''), 'null'), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index - WHERE target_index > 0 )) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_event_exclusion_filters_with_wildcard_groups - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, ['/bar/.*/foo']) AS group_index, - if(group_index > 0, ['/bar/*/foo'][group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND NOT path_item IN ['/bar/*/foo'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_event_exclusion_filters_with_wildcard_groups.1 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, ['/xxx/invalid/.*']) AS group_index, - if(group_index > 0, ['/xxx/invalid/*'][group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND NOT path_item IN ['/bar/*/foo'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_event_inclusion_exclusion_filters - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_event_inclusion_exclusion_filters.1 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), event), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$screen') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$screen') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_event_inclusion_exclusion_filters.2 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(event, '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_event_inclusion_exclusion_filters.3 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(event, '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event IN ['/custom1', '/custom2']) - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event IN ['/custom1', '/custom2']) - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_event_inclusion_exclusion_filters.4 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(event, '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event IN ['/custom3', 'blah']) - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event IN ['/custom3', 'blah']) - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_event_inclusion_exclusion_filters.5 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview' - OR event = '$screen' - OR NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview' - OR event = '$screen' - OR NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_event_inclusion_exclusion_filters.6 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview' - OR event = '$screen' - OR NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview' - OR event = '$screen' - OR NOT event LIKE '$%') - AND NOT path_item IN ['/custom1', '/1', '/2', '/3'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_event_ordering - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(event, '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-03 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-03 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_groups_filtering_person_on_events - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - e.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND (event = '$pageview' - OR event = '$screen' - OR NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-02-01 23:59:59', 'UTC') - AND (has(['finance'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - ORDER BY e.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_groups_filtering_person_on_events.1 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - e.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND (event = '$pageview' - OR event = '$screen' - OR NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-02-01 23:59:59', 'UTC') - AND (has(['technology'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - ORDER BY e.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_groups_filtering_person_on_events.2 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - e.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_1 - FROM groups - WHERE team_id = 2 - AND group_type_index = 1 - GROUP BY group_key) groups_1 ON "$group_1" == groups_1.group_key - WHERE team_id = 2 - AND (event = '$pageview' - OR event = '$screen' - OR NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-02-01 23:59:59', 'UTC') - AND (has(['technology'], replaceRegexpAll(JSONExtractRaw(group_properties_1, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - ORDER BY e.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_path_cleaning_rules_with_wildcard_groups - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, '/step1') as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - replaceRegexpAll(path_item_ungrouped, '\\?(.*)', '?') AS path_item_cleaned, - multiMatchAnyIndex(path_item_cleaned, ['/step1']) AS group_index, - if(group_index > 0, ['/step1'][group_index], path_item_cleaned) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index - WHERE target_index > 0 )) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_person_dropoffs - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(event, '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE path_dropoff_key = '2_step two' - AND path_dropoff_key = path_key - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_person_dropoffs.1 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(event, '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE path_key = '2_step two' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_person_dropoffs.2 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(event, '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '2_step two' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_person_dropoffs.3 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(event, '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE path_dropoff_key = '3_step three' - AND path_dropoff_key = path_key - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_person_dropoffs.4 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(event, '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE path_key = '3_step three' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_person_dropoffs.5 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(event, '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '3_step three' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_person_dropoffs.6 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(event, '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE path_dropoff_key = '4_step four' - AND path_dropoff_key = path_key - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_person_dropoffs.7 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(event, '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE path_key = '4_step four' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_person_dropoffs.8 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(event, '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '4_step four' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_person_on_events_v2 - ''' - - SELECT DISTINCT person_id - FROM events - WHERE team_id = 2 - AND distinct_id = 'poev2_p2' - ''' -# --- -# name: TestClickhousePaths.test_person_on_events_v2.1 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - WHERE team_id = 2 - AND (event = '$pageview' - OR event = '$screen' - OR NOT event LIKE '$%') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-02-01 23:59:59', 'UTC') - AND notEmpty(e.person_id) - ORDER BY if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id), - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_recording - ''' - - SELECT person_id AS actor_id , - groupUniqArray(100)((timestamp, uuid, - "$session_id", - "$window_id")) as matching_events - FROM - (SELECT person_id, - path, - final_uuid as uuid, - final_timestamp as timestamp, - final_$session_id as $session_id, - final_$window_id as $window_id, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - joined_path_tuple.4 as final_uuid, - joined_path_tuple.5 as final_timestamp, - joined_path_tuple.6 as final_$session_id, - joined_path_tuple.7 as final_$window_id , - arrayFilter((x, y)->y, uuid, mapping) as uuids, - arrayFilter((x, y)->y, timestamp, mapping) as timestamps, - arrayFilter((x, y)->y, $session_id, mapping) as $session_ids, - arrayFilter((x, y)->y, $window_id, mapping) as $window_ids , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - if(target_index > 0, arraySlice(uuids, target_index), uuids) as filtered_uuids , - arraySlice(filtered_uuids, 1, 5) as limited_uuids , - if(target_index > 0, arraySlice(timestamps, target_index), timestamps) as filtered_timestamps , - arraySlice(filtered_timestamps, 1, 5) as limited_timestamps , - if(target_index > 0, arraySlice($session_ids, target_index), $session_ids) as filtered_$session_ids , - arraySlice(filtered_$session_ids, 1, 5) as limited_$session_ids , - if(target_index > 0, arraySlice($window_ids, target_index), $window_ids) as filtered_$window_ids , - arraySlice(filtered_$window_ids, 1, 5) as limited_$window_ids , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, '')), limited_uuids, limited_timestamps, limited_$session_ids, limited_$window_ids) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - path_time_tuple.4 as uuid, - path_time_tuple.5 as timestamp, - path_time_tuple.6 as $session_id, - path_time_tuple.7 as $window_id , - session_index , - arrayZip(paths, timing, arrayDifference(timing), uuids, timestamps, $session_ids, $window_ids) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(uuid) as uuids, - groupArray(timestamp) as timestamps, - groupArray($session_id) as $session_ids, - groupArray($window_id) as $window_ids, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - e.uuid AS uuid, - e.timestamp AS timestamp, - e."$session_id" as $session_id, - e."$window_id" as $window_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-02 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-02 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE path_key = '2_/2' - GROUP BY person_id - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_recording.1 - ''' - - SELECT DISTINCT session_id - FROM session_replay_events - WHERE team_id = 2 - and session_id in ['s1', 's3', 's5'] - AND min_first_timestamp >= '2011-12-31 00:00:00' - AND max_last_timestamp <= '2012-01-03 00:00:00' - ''' -# --- -# name: TestClickhousePaths.test_recording_for_dropoff - ''' - - SELECT person_id AS actor_id , - groupUniqArray(100)((timestamp, uuid, - "$session_id", - "$window_id")) as matching_events - FROM - (SELECT person_id, - path, - final_uuid as uuid, - final_timestamp as timestamp, - final_$session_id as $session_id, - final_$window_id as $window_id, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - joined_path_tuple.4 as final_uuid, - joined_path_tuple.5 as final_timestamp, - joined_path_tuple.6 as final_$session_id, - joined_path_tuple.7 as final_$window_id , - arrayFilter((x, y)->y, uuid, mapping) as uuids, - arrayFilter((x, y)->y, timestamp, mapping) as timestamps, - arrayFilter((x, y)->y, $session_id, mapping) as $session_ids, - arrayFilter((x, y)->y, $window_id, mapping) as $window_ids , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - if(target_index > 0, arraySlice(uuids, target_index), uuids) as filtered_uuids , - arraySlice(filtered_uuids, 1, 5) as limited_uuids , - if(target_index > 0, arraySlice(timestamps, target_index), timestamps) as filtered_timestamps , - arraySlice(filtered_timestamps, 1, 5) as limited_timestamps , - if(target_index > 0, arraySlice($session_ids, target_index), $session_ids) as filtered_$session_ids , - arraySlice(filtered_$session_ids, 1, 5) as limited_$session_ids , - if(target_index > 0, arraySlice($window_ids, target_index), $window_ids) as filtered_$window_ids , - arraySlice(filtered_$window_ids, 1, 5) as limited_$window_ids , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, '')), limited_uuids, limited_timestamps, limited_$session_ids, limited_$window_ids) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - path_time_tuple.4 as uuid, - path_time_tuple.5 as timestamp, - path_time_tuple.6 as $session_id, - path_time_tuple.7 as $window_id , - session_index , - arrayZip(paths, timing, arrayDifference(timing), uuids, timestamps, $session_ids, $window_ids) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(uuid) as uuids, - groupArray(timestamp) as timestamps, - groupArray($session_id) as $session_ids, - groupArray($window_id) as $window_ids, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - e.uuid AS uuid, - e.timestamp AS timestamp, - e."$session_id" as $session_id, - e."$window_id" as $window_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-02 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-02 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE path_dropoff_key = '2_/2' - AND path_dropoff_key = path_key - GROUP BY person_id - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_recording_for_dropoff.1 - ''' - - SELECT DISTINCT session_id - FROM session_replay_events - WHERE team_id = 2 - and session_id in [] - AND min_first_timestamp >= '2011-12-31 00:00:00' - AND max_last_timestamp <= '2012-01-03 00:00:00' - ''' -# --- -# name: TestClickhousePaths.test_recording_for_dropoff.2 - ''' - - SELECT person_id AS actor_id , - groupUniqArray(100)((timestamp, uuid, - "$session_id", - "$window_id")) as matching_events - FROM - (SELECT person_id, - path, - final_uuid as uuid, - final_timestamp as timestamp, - final_$session_id as $session_id, - final_$window_id as $window_id, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - joined_path_tuple.4 as final_uuid, - joined_path_tuple.5 as final_timestamp, - joined_path_tuple.6 as final_$session_id, - joined_path_tuple.7 as final_$window_id , - arrayFilter((x, y)->y, uuid, mapping) as uuids, - arrayFilter((x, y)->y, timestamp, mapping) as timestamps, - arrayFilter((x, y)->y, $session_id, mapping) as $session_ids, - arrayFilter((x, y)->y, $window_id, mapping) as $window_ids , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - if(target_index > 0, arraySlice(uuids, target_index), uuids) as filtered_uuids , - arraySlice(filtered_uuids, 1, 5) as limited_uuids , - if(target_index > 0, arraySlice(timestamps, target_index), timestamps) as filtered_timestamps , - arraySlice(filtered_timestamps, 1, 5) as limited_timestamps , - if(target_index > 0, arraySlice($session_ids, target_index), $session_ids) as filtered_$session_ids , - arraySlice(filtered_$session_ids, 1, 5) as limited_$session_ids , - if(target_index > 0, arraySlice($window_ids, target_index), $window_ids) as filtered_$window_ids , - arraySlice(filtered_$window_ids, 1, 5) as limited_$window_ids , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, '')), limited_uuids, limited_timestamps, limited_$session_ids, limited_$window_ids) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - path_time_tuple.4 as uuid, - path_time_tuple.5 as timestamp, - path_time_tuple.6 as $session_id, - path_time_tuple.7 as $window_id , - session_index , - arrayZip(paths, timing, arrayDifference(timing), uuids, timestamps, $session_ids, $window_ids) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(uuid) as uuids, - groupArray(timestamp) as timestamps, - groupArray($session_id) as $session_ids, - groupArray($window_id) as $window_ids, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - e.uuid AS uuid, - e.timestamp AS timestamp, - e."$session_id" as $session_id, - e."$window_id" as $window_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-02 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-02 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE path_dropoff_key = '3_/3' - AND path_dropoff_key = path_key - GROUP BY person_id - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_recording_for_dropoff.3 - ''' - - SELECT DISTINCT session_id - FROM session_replay_events - WHERE team_id = 2 - and session_id in ['s1'] - AND min_first_timestamp >= '2011-12-31 00:00:00' - AND max_last_timestamp <= '2012-01-03 00:00:00' - ''' -# --- -# name: TestClickhousePaths.test_recording_with_no_window_or_session_id - ''' - - SELECT person_id AS actor_id , - groupUniqArray(100)((timestamp, uuid, - "$session_id", - "$window_id")) as matching_events - FROM - (SELECT person_id, - path, - final_uuid as uuid, - final_timestamp as timestamp, - final_$session_id as $session_id, - final_$window_id as $window_id, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - joined_path_tuple.4 as final_uuid, - joined_path_tuple.5 as final_timestamp, - joined_path_tuple.6 as final_$session_id, - joined_path_tuple.7 as final_$window_id , - arrayFilter((x, y)->y, uuid, mapping) as uuids, - arrayFilter((x, y)->y, timestamp, mapping) as timestamps, - arrayFilter((x, y)->y, $session_id, mapping) as $session_ids, - arrayFilter((x, y)->y, $window_id, mapping) as $window_ids , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - if(target_index > 0, arraySlice(uuids, target_index), uuids) as filtered_uuids , - arraySlice(filtered_uuids, 1, 5) as limited_uuids , - if(target_index > 0, arraySlice(timestamps, target_index), timestamps) as filtered_timestamps , - arraySlice(filtered_timestamps, 1, 5) as limited_timestamps , - if(target_index > 0, arraySlice($session_ids, target_index), $session_ids) as filtered_$session_ids , - arraySlice(filtered_$session_ids, 1, 5) as limited_$session_ids , - if(target_index > 0, arraySlice($window_ids, target_index), $window_ids) as filtered_$window_ids , - arraySlice(filtered_$window_ids, 1, 5) as limited_$window_ids , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, '')), limited_uuids, limited_timestamps, limited_$session_ids, limited_$window_ids) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - path_time_tuple.4 as uuid, - path_time_tuple.5 as timestamp, - path_time_tuple.6 as $session_id, - path_time_tuple.7 as $window_id , - session_index , - arrayZip(paths, timing, arrayDifference(timing), uuids, timestamps, $session_ids, $window_ids) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(uuid) as uuids, - groupArray(timestamp) as timestamps, - groupArray($session_id) as $session_ids, - groupArray($window_id) as $window_ids, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - e.uuid AS uuid, - e.timestamp AS timestamp, - e."$session_id" as $session_id, - e."$window_id" as $window_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-02 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-02 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE path_key = '2_/2' - GROUP BY person_id - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_recording_with_no_window_or_session_id.1 - ''' - - SELECT DISTINCT session_id - FROM session_replay_events - WHERE team_id = 2 - and session_id in [] - AND min_first_timestamp >= '2011-12-31 00:00:00' - AND max_last_timestamp <= '2012-01-03 00:00:00' - ''' -# --- -# name: TestClickhousePaths.test_recording_with_start_and_end - ''' - - SELECT person_id AS actor_id , - groupUniqArray(100)((timestamp, uuid, - "$session_id", - "$window_id")) as matching_events - FROM - (SELECT person_id, - path, - final_uuid as uuid, - final_timestamp as timestamp, - final_$session_id as $session_id, - final_$window_id as $window_id, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - joined_path_tuple.4 as final_uuid, - joined_path_tuple.5 as final_timestamp, - joined_path_tuple.6 as final_$session_id, - joined_path_tuple.7 as final_$window_id , - arrayFilter((x, y)->y, uuid, mapping) as uuids, - arrayFilter((x, y)->y, timestamp, mapping) as timestamps, - arrayFilter((x, y)->y, $session_id, mapping) as $session_ids, - arrayFilter((x, y)->y, $window_id, mapping) as $window_ids , - indexOf(compact_path, '/1') as start_target_index , - if(start_target_index > 0, arraySlice(compact_path, start_target_index), compact_path) as start_filtered_path , - if(start_target_index > 0, arraySlice(timings, start_target_index), timings) as start_filtered_timings , - indexOf(start_filtered_path, '/3') as end_target_index , - if(end_target_index > 0, arrayResize(start_filtered_path, end_target_index), start_filtered_path) as filtered_path , - if(end_target_index > 0, arrayResize(start_filtered_timings, end_target_index), start_filtered_timings) as filtered_timings , - if(length(filtered_path) > 5, arrayConcat(arraySlice(filtered_path, 1, intDiv(5, 2)), ['...'], arraySlice(filtered_path, (-1)*intDiv(5, 2), intDiv(5, 2))), filtered_path) AS limited_path , - if(length(filtered_timings) > 5, arrayConcat(arraySlice(filtered_timings, 1, intDiv(5, 2)), [filtered_timings[1+intDiv(5, 2)]], arraySlice(filtered_timings, (-1)*intDiv(5, 2), intDiv(5, 2))), filtered_timings) AS limited_timings , - if(start_target_index > 0, arraySlice(uuids, start_target_index), uuids) as start_filtered_uuids , - if(end_target_index > 0, arrayResize(start_filtered_uuids, end_target_index), start_filtered_uuids) as filtered_uuids , - if(length(filtered_uuids) > 5, arrayConcat(arraySlice(filtered_uuids, 1, intDiv(5, 2)), [filtered_uuids[1+intDiv(5, 2)]], arraySlice(filtered_uuids, (-1)*intDiv(5, 2), intDiv(5, 2))), filtered_uuids) AS limited_uuids , - if(start_target_index > 0, arraySlice(timestamps, start_target_index), timestamps) as start_filtered_timestamps , - if(end_target_index > 0, arrayResize(start_filtered_timestamps, end_target_index), start_filtered_timestamps) as filtered_timestamps , - if(length(filtered_timestamps) > 5, arrayConcat(arraySlice(filtered_timestamps, 1, intDiv(5, 2)), [filtered_timestamps[1+intDiv(5, 2)]], arraySlice(filtered_timestamps, (-1)*intDiv(5, 2), intDiv(5, 2))), filtered_timestamps) AS limited_timestamps , - if(start_target_index > 0, arraySlice($session_ids, start_target_index), $session_ids) as start_filtered_$session_ids , - if(end_target_index > 0, arrayResize(start_filtered_$session_ids, end_target_index), start_filtered_$session_ids) as filtered_$session_ids , - if(length(filtered_$session_ids) > 5, arrayConcat(arraySlice(filtered_$session_ids, 1, intDiv(5, 2)), [filtered_$session_ids[1+intDiv(5, 2)]], arraySlice(filtered_$session_ids, (-1)*intDiv(5, 2), intDiv(5, 2))), filtered_$session_ids) AS limited_$session_ids , - if(start_target_index > 0, arraySlice($window_ids, start_target_index), $window_ids) as start_filtered_$window_ids , - if(end_target_index > 0, arrayResize(start_filtered_$window_ids, end_target_index), start_filtered_$window_ids) as filtered_$window_ids , - if(length(filtered_$window_ids) > 5, arrayConcat(arraySlice(filtered_$window_ids, 1, intDiv(5, 2)), [filtered_$window_ids[1+intDiv(5, 2)]], arraySlice(filtered_$window_ids, (-1)*intDiv(5, 2), intDiv(5, 2))), filtered_$window_ids) AS limited_$window_ids , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, '')), limited_uuids, limited_timestamps, limited_$session_ids, limited_$window_ids) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - path_time_tuple.4 as uuid, - path_time_tuple.5 as timestamp, - path_time_tuple.6 as $session_id, - path_time_tuple.7 as $window_id , - session_index , - arrayZip(paths, timing, arrayDifference(timing), uuids, timestamps, $session_ids, $window_ids) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(uuid) as uuids, - groupArray(timestamp) as timestamps, - groupArray($session_id) as $session_ids, - groupArray($window_id) as $window_ids, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - e.uuid AS uuid, - e.timestamp AS timestamp, - e."$session_id" as $session_id, - e."$window_id" as $window_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-02 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-02 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index - WHERE start_target_index > 0 - AND end_target_index > 0 )) - WHERE path_key = '2_/2' - GROUP BY person_id - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_recording_with_start_and_end.1 - ''' - - SELECT DISTINCT session_id - FROM session_replay_events - WHERE team_id = 2 - and session_id in ['s1'] - AND min_first_timestamp >= '2011-12-31 00:00:00' - AND max_last_timestamp <= '2012-01-03 00:00:00' - ''' -# --- -# name: TestClickhousePaths.test_respect_session_limits - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_start_and_end - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, '/5') as start_target_index , - if(start_target_index > 0, arraySlice(compact_path, start_target_index), compact_path) as start_filtered_path , - if(start_target_index > 0, arraySlice(timings, start_target_index), timings) as start_filtered_timings , - indexOf(start_filtered_path, '/about') as end_target_index , - if(end_target_index > 0, arrayResize(start_filtered_path, end_target_index), start_filtered_path) as filtered_path , - if(end_target_index > 0, arrayResize(start_filtered_timings, end_target_index), start_filtered_timings) as filtered_timings , - if(length(filtered_path) > 5, arrayConcat(arraySlice(filtered_path, 1, intDiv(5, 2)), ['...'], arraySlice(filtered_path, (-1)*intDiv(5, 2), intDiv(5, 2))), filtered_path) AS limited_path , - if(length(filtered_timings) > 5, arrayConcat(arraySlice(filtered_timings, 1, intDiv(5, 2)), [filtered_timings[1+intDiv(5, 2)]], arraySlice(filtered_timings, (-1)*intDiv(5, 2), intDiv(5, 2))), filtered_timings) AS limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index - WHERE start_target_index > 0 - AND end_target_index > 0 )) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - ''' -# --- -# name: TestClickhousePaths.test_start_and_end.1 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, '/5') as start_target_index , - if(start_target_index > 0, arraySlice(compact_path, start_target_index), compact_path) as start_filtered_path , - if(start_target_index > 0, arraySlice(timings, start_target_index), timings) as start_filtered_timings , - indexOf(start_filtered_path, '/about') as end_target_index , - if(end_target_index > 0, arrayResize(start_filtered_path, end_target_index), start_filtered_path) as filtered_path , - if(end_target_index > 0, arrayResize(start_filtered_timings, end_target_index), start_filtered_timings) as filtered_timings , - if(length(filtered_path) > 5, arrayConcat(arraySlice(filtered_path, 1, intDiv(5, 2)), ['...'], arraySlice(filtered_path, (-1)*intDiv(5, 2), intDiv(5, 2))), filtered_path) AS limited_path , - if(length(filtered_timings) > 5, arrayConcat(arraySlice(filtered_timings, 1, intDiv(5, 2)), [filtered_timings[1+intDiv(5, 2)]], arraySlice(filtered_timings, (-1)*intDiv(5, 2), intDiv(5, 2))), filtered_timings) AS limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index - WHERE start_target_index > 0 - AND end_target_index > 0 )) - WHERE last_path_key = '1_/5' - AND path_key = '2_/about' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_start_and_end.2 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, '/2') as start_target_index , - if(start_target_index > 0, arraySlice(compact_path, start_target_index), compact_path) as start_filtered_path , - if(start_target_index > 0, arraySlice(timings, start_target_index), timings) as start_filtered_timings , - indexOf(start_filtered_path, '/about') as end_target_index , - if(end_target_index > 0, arrayResize(start_filtered_path, end_target_index), start_filtered_path) as filtered_path , - if(end_target_index > 0, arrayResize(start_filtered_timings, end_target_index), start_filtered_timings) as filtered_timings , - if(length(filtered_path) > 4, arrayConcat(arraySlice(filtered_path, 1, intDiv(4, 2)), ['...'], arraySlice(filtered_path, (-1)*intDiv(4, 2), intDiv(4, 2))), filtered_path) AS limited_path , - if(length(filtered_timings) > 4, arrayConcat(arraySlice(filtered_timings, 1, intDiv(4, 2)), [filtered_timings[1+intDiv(4, 2)]], arraySlice(filtered_timings, (-1)*intDiv(4, 2), intDiv(4, 2))), filtered_timings) AS limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index - WHERE start_target_index > 0 - AND end_target_index > 0 )) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - ''' -# --- -# name: TestClickhousePaths.test_start_and_end.3 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, '/2') as start_target_index , - if(start_target_index > 0, arraySlice(compact_path, start_target_index), compact_path) as start_filtered_path , - if(start_target_index > 0, arraySlice(timings, start_target_index), timings) as start_filtered_timings , - indexOf(start_filtered_path, '/about') as end_target_index , - if(end_target_index > 0, arrayResize(start_filtered_path, end_target_index), start_filtered_path) as filtered_path , - if(end_target_index > 0, arrayResize(start_filtered_timings, end_target_index), start_filtered_timings) as filtered_timings , - if(length(filtered_path) > 4, arrayConcat(arraySlice(filtered_path, 1, intDiv(4, 2)), ['...'], arraySlice(filtered_path, (-1)*intDiv(4, 2), intDiv(4, 2))), filtered_path) AS limited_path , - if(length(filtered_timings) > 4, arrayConcat(arraySlice(filtered_timings, 1, intDiv(4, 2)), [filtered_timings[1+intDiv(4, 2)]], arraySlice(filtered_timings, (-1)*intDiv(4, 2), intDiv(4, 2))), filtered_timings) AS limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index - WHERE start_target_index > 0 - AND end_target_index > 0 )) - WHERE last_path_key = '3_...' - AND path_key = '4_/5' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_start_and_end_materialized - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, '/5') as start_target_index , - if(start_target_index > 0, arraySlice(compact_path, start_target_index), compact_path) as start_filtered_path , - if(start_target_index > 0, arraySlice(timings, start_target_index), timings) as start_filtered_timings , - indexOf(start_filtered_path, '/about') as end_target_index , - if(end_target_index > 0, arrayResize(start_filtered_path, end_target_index), start_filtered_path) as filtered_path , - if(end_target_index > 0, arrayResize(start_filtered_timings, end_target_index), start_filtered_timings) as filtered_timings , - if(length(filtered_path) > 5, arrayConcat(arraySlice(filtered_path, 1, intDiv(5, 2)), ['...'], arraySlice(filtered_path, (-1)*intDiv(5, 2), intDiv(5, 2))), filtered_path) AS limited_path , - if(length(filtered_timings) > 5, arrayConcat(arraySlice(filtered_timings, 1, intDiv(5, 2)), [filtered_timings[1+intDiv(5, 2)]], arraySlice(filtered_timings, (-1)*intDiv(5, 2), intDiv(5, 2))), filtered_timings) AS limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), nullIf(nullIf(e.`mat_$screen_name`, ''), 'null'), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(nullIf(nullIf(e.`mat_$current_url`, ''), 'null'), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index - WHERE start_target_index > 0 - AND end_target_index > 0 )) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - ''' -# --- -# name: TestClickhousePaths.test_start_and_end_materialized.1 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, '/5') as start_target_index , - if(start_target_index > 0, arraySlice(compact_path, start_target_index), compact_path) as start_filtered_path , - if(start_target_index > 0, arraySlice(timings, start_target_index), timings) as start_filtered_timings , - indexOf(start_filtered_path, '/about') as end_target_index , - if(end_target_index > 0, arrayResize(start_filtered_path, end_target_index), start_filtered_path) as filtered_path , - if(end_target_index > 0, arrayResize(start_filtered_timings, end_target_index), start_filtered_timings) as filtered_timings , - if(length(filtered_path) > 5, arrayConcat(arraySlice(filtered_path, 1, intDiv(5, 2)), ['...'], arraySlice(filtered_path, (-1)*intDiv(5, 2), intDiv(5, 2))), filtered_path) AS limited_path , - if(length(filtered_timings) > 5, arrayConcat(arraySlice(filtered_timings, 1, intDiv(5, 2)), [filtered_timings[1+intDiv(5, 2)]], arraySlice(filtered_timings, (-1)*intDiv(5, 2), intDiv(5, 2))), filtered_timings) AS limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), nullIf(nullIf(e.`mat_$screen_name`, ''), 'null'), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(nullIf(nullIf(e.`mat_$current_url`, ''), 'null'), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index - WHERE start_target_index > 0 - AND end_target_index > 0 )) - WHERE last_path_key = '1_/5' - AND path_key = '2_/about' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_start_and_end_materialized.2 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, '/2') as start_target_index , - if(start_target_index > 0, arraySlice(compact_path, start_target_index), compact_path) as start_filtered_path , - if(start_target_index > 0, arraySlice(timings, start_target_index), timings) as start_filtered_timings , - indexOf(start_filtered_path, '/about') as end_target_index , - if(end_target_index > 0, arrayResize(start_filtered_path, end_target_index), start_filtered_path) as filtered_path , - if(end_target_index > 0, arrayResize(start_filtered_timings, end_target_index), start_filtered_timings) as filtered_timings , - if(length(filtered_path) > 4, arrayConcat(arraySlice(filtered_path, 1, intDiv(4, 2)), ['...'], arraySlice(filtered_path, (-1)*intDiv(4, 2), intDiv(4, 2))), filtered_path) AS limited_path , - if(length(filtered_timings) > 4, arrayConcat(arraySlice(filtered_timings, 1, intDiv(4, 2)), [filtered_timings[1+intDiv(4, 2)]], arraySlice(filtered_timings, (-1)*intDiv(4, 2), intDiv(4, 2))), filtered_timings) AS limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), nullIf(nullIf(e.`mat_$screen_name`, ''), 'null'), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(nullIf(nullIf(e.`mat_$current_url`, ''), 'null'), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index - WHERE start_target_index > 0 - AND end_target_index > 0 )) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - ''' -# --- -# name: TestClickhousePaths.test_start_and_end_materialized.3 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, '/2') as start_target_index , - if(start_target_index > 0, arraySlice(compact_path, start_target_index), compact_path) as start_filtered_path , - if(start_target_index > 0, arraySlice(timings, start_target_index), timings) as start_filtered_timings , - indexOf(start_filtered_path, '/about') as end_target_index , - if(end_target_index > 0, arrayResize(start_filtered_path, end_target_index), start_filtered_path) as filtered_path , - if(end_target_index > 0, arrayResize(start_filtered_timings, end_target_index), start_filtered_timings) as filtered_timings , - if(length(filtered_path) > 4, arrayConcat(arraySlice(filtered_path, 1, intDiv(4, 2)), ['...'], arraySlice(filtered_path, (-1)*intDiv(4, 2), intDiv(4, 2))), filtered_path) AS limited_path , - if(length(filtered_timings) > 4, arrayConcat(arraySlice(filtered_timings, 1, intDiv(4, 2)), [filtered_timings[1+intDiv(4, 2)]], arraySlice(filtered_timings, (-1)*intDiv(4, 2), intDiv(4, 2))), filtered_timings) AS limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), nullIf(nullIf(e.`mat_$screen_name`, ''), 'null'), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(nullIf(nullIf(e.`mat_$current_url`, ''), 'null'), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index - WHERE start_target_index > 0 - AND end_target_index > 0 )) - WHERE last_path_key = '3_...' - AND path_key = '4_/5' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_start_dropping_orphaned_edges - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, '/2') as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index - WHERE target_index > 0 )) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 6 - ''' -# --- -# name: TestClickhousePaths.test_step_conversion_times - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_step_limit - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 2) as limited_path , - arraySlice(filtered_timings, 1, 2) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_step_limit.1 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 2) as limited_path , - arraySlice(filtered_timings, 1, 2) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '1_/1' - AND path_key = '2_/2' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_step_limit.2 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 2) as limited_path , - arraySlice(filtered_timings, 1, 2) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '2_/2' - AND path_key = '3_/3' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_step_limit.3 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 3) as limited_path , - arraySlice(filtered_timings, 1, 3) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_step_limit.4 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 3) as limited_path , - arraySlice(filtered_timings, 1, 3) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '2_/2' - AND path_key = '3_/3' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_step_limit.5 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_step_limit.6 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '1_/1' - AND path_key = '2_/2' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_step_limit.7 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '2_/2' - AND path_key = '3_/3' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_step_limit.8 - ''' - - SELECT DISTINCT person_id AS actor_id - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2011-12-31 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE last_path_key = '3_/3' - AND path_key = '4_/4' - ORDER BY person_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhousePaths.test_team_and_local_path_cleaning_rules - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - replaceRegexpAll(path_item_ungrouped, '\\?(.*)', '?') AS path_item_0, - replaceRegexpAll(path_item_0, '/\\d+(/|\\?)?', '/') AS path_item_cleaned, - multiMatchAnyIndex(path_item_cleaned, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_cleaned) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_team_and_local_path_cleaning_rules.1 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - replaceRegexpAll(path_item_ungrouped, '\\?(.*)', '?') AS path_item_0, - replaceRegexpAll(path_item_0, '/\\d+(/|\\?)?', '/') AS path_item_cleaned, - multiMatchAnyIndex(path_item_cleaned, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_cleaned) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_team_and_local_path_cleaning_rules.2 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - replaceRegexpAll(path_item_ungrouped, '\\?(.*)', '?') AS path_item_0, - replaceRegexpAll(path_item_0, '/\\d+(/|\\?)?', '/') AS path_item_cleaned, - multiMatchAnyIndex(path_item_cleaned, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_cleaned) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_team_path_cleaning_rules - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_team_path_cleaning_rules.1 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - replaceRegexpAll(path_item_ungrouped, '\\?(.*)', '?') AS path_item_cleaned, - multiMatchAnyIndex(path_item_cleaned, NULL) AS group_index, - if(group_index > 0, NULL[group_index], path_item_cleaned) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_wildcard_groups - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, ['between_step_1_.*', 'between_step_2_.*', 'step\\ drop.*']) AS group_index, - if(group_index > 0, ['between_step_1_*', 'between_step_2_*', 'step drop*'][group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_wildcard_groups_across_people - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 4) as limited_path , - arraySlice(filtered_timings, 1, 4) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, ['/bar/.*/foo']) AS group_index, - if(group_index > 0, ['/bar/*/foo'][group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_wildcard_groups_and_min_edge_weight - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, ['between_step_1_.*', 'between_step_2_.*', 'step\\ drop.*']) AS group_index, - if(group_index > 0, ['between_step_1_*', 'between_step_2_*', 'step drop*'][group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - HAVING event_count >= 15 - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_wildcard_groups_and_min_edge_weight.1 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, ['between_step_1_.*', 'between_step_2_.*', 'step\\ drop.*']) AS group_index, - if(group_index > 0, ['between_step_1_*', 'between_step_2_*', 'step drop*'][group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - HAVING event_count >= 15 - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 2 - ''' -# --- -# name: TestClickhousePaths.test_wildcard_groups_and_min_edge_weight.2 - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, ['between_step_1_.*', 'between_step_2_.*', 'step\\ drop.*']) AS group_index, - if(group_index > 0, ['between_step_1_*', 'between_step_2_*', 'step drop*'][group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - HAVING event_count >= 6 - AND event_count <= 11 - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 20 - ''' -# --- -# name: TestClickhousePaths.test_wildcard_groups_evil_input - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, ['\\(a\\+\\)\\+', '\\[aaa\\|aaaa\\]\\+', '1\\..*', '\\..*', '/3\\?q=1', '/3.*']) AS group_index, - if(group_index > 0, ['(a+)+', '[aaa|aaaa]+', '1.*', '.*', '/3?q=1', '/3*'][group_index], path_item_ungrouped) AS path_item - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND (event = '$pageview') - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2023-05-23 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- -# name: TestClickhousePaths.test_wildcard_groups_with_sampling - ''' - - SELECT last_path_key as source_event, - path_key as target_event, - COUNT(*) AS event_count, - avg(conversion_time) AS average_conversion_time - FROM - (SELECT person_id, - path, - conversion_time, - event_in_session_index, - concat(toString(event_in_session_index), '_', path) as path_key, - if(event_in_session_index > 1, concat(toString(event_in_session_index-1), '_', prev_path), null) AS last_path_key, - path_dropoff_key - FROM - (SELECT person_id , - joined_path_tuple.1 as path , - joined_path_tuple.2 as conversion_time , - joined_path_tuple.3 as prev_path , - event_in_session_index , - session_index , - arrayPopFront(arrayPushBack(path_basic, '')) as path_basic_0 , - arrayMap((x, y) -> if(x=y, 0, 1), path_basic, path_basic_0) as mapping , - arrayFilter((x, y) -> y, time, mapping) as timings , - arrayFilter((x, y)->y, path_basic, mapping) as compact_path , - indexOf(compact_path, NULL) as target_index , - if(target_index > 0, arraySlice(compact_path, target_index), compact_path) as filtered_path , - if(target_index > 0, arraySlice(timings, target_index), timings) as filtered_timings , - arraySlice(filtered_path, 1, 5) as limited_path , - arraySlice(filtered_timings, 1, 5) as limited_timings , - arrayDifference(limited_timings) as timings_diff , - arrayZip(limited_path, timings_diff, arrayPopBack(arrayPushFront(limited_path, ''))) as limited_path_timings , - concat(toString(length(limited_path)), '_', limited_path[-1]) as path_dropoff_key - FROM - (SELECT person_id , - path_time_tuple.1 as path_basic , - path_time_tuple.2 as time , - session_index , - arrayZip(paths, timing, arrayDifference(timing)) as paths_tuple , - arraySplit(x -> if(x.3 < 1800000, 0, 1), paths_tuple) as session_paths - FROM - (SELECT person_id, - groupArray(toUnixTimestamp64Milli(timestamp)) as timing, - groupArray(path_item) as paths - FROM - (SELECT e.timestamp AS timestamp, - pdi.person_id AS person_id, - ifNull(if(equals(event, '$screen'), replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$screen_name'), ''), 'null'), '^"|"$', ''), if(equals(event, '$pageview'), replaceRegexpAll(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), ''), '(.)/$', '\\1'), event)), '') AS path_item_ungrouped, - multiMatchAnyIndex(path_item_ungrouped, ['between_step_1_.*', 'between_step_2_.*', 'step\\ drop.*']) AS group_index, - if(group_index > 0, ['between_step_1_*', 'between_step_2_*', 'step drop*'][group_index], path_item_ungrouped) AS path_item - FROM events e SAMPLE 1.0 - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-07 23:59:59', 'UTC') - ORDER BY pdi.person_id, - e.timestamp) - GROUP BY person_id) ARRAY - JOIN session_paths AS path_time_tuple, - arrayEnumerate(session_paths) AS session_index) ARRAY - JOIN limited_path_timings AS joined_path_tuple, - arrayEnumerate(limited_path_timings) AS event_in_session_index)) - WHERE source_event IS NOT NULL - GROUP BY source_event, - target_event - ORDER BY event_count DESC, - source_event, - target_event - LIMIT 50 - ''' -# --- diff --git a/ee/clickhouse/queries/test/__snapshots__/test_person_distinct_id_query.ambr b/ee/clickhouse/queries/test/__snapshots__/test_person_distinct_id_query.ambr deleted file mode 100644 index 112bddef4e6..00000000000 --- a/ee/clickhouse/queries/test/__snapshots__/test_person_distinct_id_query.ambr +++ /dev/null @@ -1,13 +0,0 @@ -# serializer version: 1 -# name: test_person_distinct_id_query - ''' - - SELECT distinct_id, argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = %(team_id)s - - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0 - - ''' -# --- diff --git a/ee/clickhouse/queries/test/__snapshots__/test_person_query.ambr b/ee/clickhouse/queries/test/__snapshots__/test_person_query.ambr deleted file mode 100644 index 5f46bd1a814..00000000000 --- a/ee/clickhouse/queries/test/__snapshots__/test_person_query.ambr +++ /dev/null @@ -1,355 +0,0 @@ -# serializer version: 1 -# name: test_person_query - ''' - - SELECT id - FROM person - - WHERE team_id = %(team_id)s - - - GROUP BY id - HAVING max(is_deleted) = 0 - - - - - - SETTINGS optimize_aggregation_in_order = 1 - - ''' -# --- -# name: test_person_query.1 - ''' - - SELECT id - FROM person - - WHERE team_id = %(team_id)s - AND id IN ( - SELECT id FROM person - - WHERE team_id = %(team_id)s - AND ( "pmat_email" ILIKE %(vperson_filter_pre__0)s) - - ) - - - GROUP BY id - HAVING max(is_deleted) = 0 - - AND ( argMax(person."pmat_email", version) ILIKE %(vpersonquery_person_filter_fin__0)s) - - - - SETTINGS optimize_aggregation_in_order = 1 - - ''' -# --- -# name: test_person_query_with_and_and_or_property_groups - ''' - - SELECT id, argMax(properties, version) as person_props - FROM person - - WHERE team_id = %(team_id)s - AND id IN ( - SELECT id FROM person - - WHERE team_id = %(team_id)s - AND (( "pmat_email" ILIKE %(vperson_filter_pre__0_0)s OR replaceRegexpAll(JSONExtractRaw(properties, %(kperson_filter_pre__0_1)s), '^"|"$', '') ILIKE %(vperson_filter_pre__0_1)s)) - - ) - - - GROUP BY id - HAVING max(is_deleted) = 0 - - AND (( argMax(person."pmat_email", version) ILIKE %(vpersonquery_person_filter_fin__0_0)s OR replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), %(kpersonquery_person_filter_fin__0_1)s), '^"|"$', '') ILIKE %(vpersonquery_person_filter_fin__0_1)s)) - - - - SETTINGS optimize_aggregation_in_order = 1 - - ''' -# --- -# name: test_person_query_with_anded_property_groups - ''' - - SELECT id - FROM person - - WHERE team_id = %(team_id)s - AND id IN ( - SELECT id FROM person - - WHERE team_id = %(team_id)s - AND ( "pmat_email" ILIKE %(vperson_filter_pre__0)s AND has(%(vperson_filter_pre__1)s, replaceRegexpAll(JSONExtractRaw(properties, %(kperson_filter_pre__1)s), '^"|"$', '')) AND has(%(vperson_filter_pre__2)s, replaceRegexpAll(JSONExtractRaw(properties, %(kperson_filter_pre__2)s), '^"|"$', ''))) - - ) - - - GROUP BY id - HAVING max(is_deleted) = 0 - - AND ( argMax(person."pmat_email", version) ILIKE %(vpersonquery_person_filter_fin__0)s AND has(%(vpersonquery_person_filter_fin__1)s, replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), %(kpersonquery_person_filter_fin__1)s), '^"|"$', '')) AND has(%(vpersonquery_person_filter_fin__2)s, replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), %(kpersonquery_person_filter_fin__2)s), '^"|"$', ''))) - - - - SETTINGS optimize_aggregation_in_order = 1 - - ''' -# --- -# name: test_person_query_with_entity_filters - ''' - - SELECT id, argMax(pmat_email, version) as pmat_email - FROM person - - WHERE team_id = %(team_id)s - - - GROUP BY id - HAVING max(is_deleted) = 0 - - - - - - SETTINGS optimize_aggregation_in_order = 1 - - ''' -# --- -# name: test_person_query_with_entity_filters.1 - ''' - - SELECT id - FROM person - - WHERE team_id = %(team_id)s - AND id IN ( - SELECT id FROM person - - WHERE team_id = %(team_id)s - AND ( "pmat_email" ILIKE %(vperson_filter_pre__0)s) - - ) - - - GROUP BY id - HAVING max(is_deleted) = 0 - - AND ( argMax(person."pmat_email", version) ILIKE %(vpersonquery_person_filter_fin__0)s) - - - - SETTINGS optimize_aggregation_in_order = 1 - - ''' -# --- -# name: test_person_query_with_entity_filters_and_property_group_filters - ''' - - SELECT id, argMax(pmat_email, version) as pmat_email , argMax(properties, version) as person_props - FROM person - - WHERE team_id = %(team_id)s - AND id IN ( - SELECT id FROM person - - WHERE team_id = %(team_id)s - AND (( "pmat_email" ILIKE %(vperson_filter_pre__0_0)s OR replaceRegexpAll(JSONExtractRaw(properties, %(kperson_filter_pre__0_1)s), '^"|"$', '') ILIKE %(vperson_filter_pre__0_1)s)) - - ) - - - GROUP BY id - HAVING max(is_deleted) = 0 - - AND (( argMax(person."pmat_email", version) ILIKE %(vpersonquery_person_filter_fin__0_0)s OR replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), %(kpersonquery_person_filter_fin__0_1)s), '^"|"$', '') ILIKE %(vpersonquery_person_filter_fin__0_1)s)) - - - - SETTINGS optimize_aggregation_in_order = 1 - - ''' -# --- -# name: test_person_query_with_entity_filters_and_property_group_filters.1 - ''' - - SELECT id, argMax(properties, version) as person_props - FROM person - - WHERE team_id = %(team_id)s - AND id IN ( - SELECT id FROM person - - WHERE team_id = %(team_id)s - AND ((( "pmat_email" ILIKE %(vperson_filter_pre__0_0_0)s OR replaceRegexpAll(JSONExtractRaw(properties, %(kperson_filter_pre__0_0_1)s), '^"|"$', '') ILIKE %(vperson_filter_pre__0_0_1)s))AND ( "pmat_email" ILIKE %(vperson_filter_pre__1_0)s OR replaceRegexpAll(JSONExtractRaw(properties, %(kperson_filter_pre__1_1)s), '^"|"$', '') ILIKE %(vperson_filter_pre__1_1)s)) - - ) - - - GROUP BY id - HAVING max(is_deleted) = 0 - - AND ((( argMax(person."pmat_email", version) ILIKE %(vpersonquery_person_filter_fin__0_0_0)s OR replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), %(kpersonquery_person_filter_fin__0_0_1)s), '^"|"$', '') ILIKE %(vpersonquery_person_filter_fin__0_0_1)s))AND ( argMax(person."pmat_email", version) ILIKE %(vpersonquery_person_filter_fin__1_0)s OR replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), %(kpersonquery_person_filter_fin__1_1)s), '^"|"$', '') ILIKE %(vpersonquery_person_filter_fin__1_1)s)) - - - - SETTINGS optimize_aggregation_in_order = 1 - - ''' -# --- -# name: test_person_query_with_extra_fields - ''' - - SELECT id, argMax(pmat_email, version) as pmat_email , argMax(properties, version) as person_props - FROM person - - WHERE team_id = %(team_id)s - AND id IN ( - SELECT id FROM person - - WHERE team_id = %(team_id)s - AND ( "pmat_email" ILIKE %(vperson_filter_pre__0)s) - - ) - - - GROUP BY id - HAVING max(is_deleted) = 0 - - AND ( argMax(person."pmat_email", version) ILIKE %(vpersonquery_person_filter_fin__0)s) - - - - SETTINGS optimize_aggregation_in_order = 1 - - ''' -# --- -# name: test_person_query_with_extra_requested_fields - ''' - - SELECT id, argMax(properties, version) as person_props - FROM person - - WHERE team_id = %(team_id)s - AND id IN ( - SELECT id FROM person - - WHERE team_id = %(team_id)s - AND ( "pmat_email" ILIKE %(vperson_filter_pre__0)s) - - ) - - - GROUP BY id - HAVING max(is_deleted) = 0 - - AND ( argMax(person."pmat_email", version) ILIKE %(vpersonquery_person_filter_fin__0)s) - - - - SETTINGS optimize_aggregation_in_order = 1 - - ''' -# --- -# name: test_person_query_with_extra_requested_fields.1 - ''' - - SELECT id, argMax(pmat_email, version) as pmat_email - FROM person - - WHERE team_id = %(team_id)s - AND id IN ( - SELECT id FROM person - - WHERE team_id = %(team_id)s - AND ( "pmat_email" ILIKE %(vperson_filter_pre__0)s) - - ) - - - GROUP BY id - HAVING max(is_deleted) = 0 - - AND ( argMax(person."pmat_email", version) ILIKE %(vpersonquery_person_filter_fin__0)s) - - - - SETTINGS optimize_aggregation_in_order = 1 - - ''' -# --- -# name: test_person_query_with_multiple_cohorts - ''' - - SELECT id - FROM person - - WHERE team_id = %(team_id)s - AND id IN ( - SELECT id FROM person - - WHERE team_id = %(team_id)s - AND ( "pmat_email" ILIKE %(vperson_filter_pre__0)s) - - ) - - AND id in ( - SELECT DISTINCT person_id FROM cohortpeople WHERE team_id = %(team_id)s AND cohort_id = %(_cohort_id_0)s AND version = %(_version_0)s - ) AND id in ( - SELECT DISTINCT person_id FROM cohortpeople WHERE team_id = %(team_id)s AND cohort_id = %(_cohort_id_1)s AND version = %(_version_1)s - ) - GROUP BY id - HAVING max(is_deleted) = 0 - - AND ( argMax(person."pmat_email", version) ILIKE %(vpersonquery_person_filter_fin__0)s) - - - - SETTINGS optimize_aggregation_in_order = 1 - - ''' -# --- -# name: test_person_query_with_updated_after - ''' - - SELECT id - FROM person - - WHERE team_id = %(team_id)s - - - GROUP BY id - HAVING max(is_deleted) = 0 - and max(_timestamp) > parseDateTimeBestEffort(%(updated_after)s) - - - - - SETTINGS optimize_aggregation_in_order = 1 - - ''' -# --- -# name: test_person_query_with_updated_after.1 - ''' - - SELECT id - FROM person - - WHERE team_id = %(team_id)s - - - GROUP BY id - HAVING max(is_deleted) = 0 - and max(_timestamp) > parseDateTimeBestEffort(%(updated_after)s) - - - - - SETTINGS optimize_aggregation_in_order = 1 - - ''' -# --- diff --git a/ee/clickhouse/queries/test/__snapshots__/test_retention.ambr b/ee/clickhouse/queries/test/__snapshots__/test_retention.ambr deleted file mode 100644 index 779391e6ece..00000000000 --- a/ee/clickhouse/queries/test/__snapshots__/test_retention.ambr +++ /dev/null @@ -1,744 +0,0 @@ -# serializer version: 1 -# name: TestClickhouseRetention.test_groups_aggregating_person_on_events - ''' - WITH actor_query AS - (WITH 'Week' as period, - NULL as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_0" as target - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - GROUP BY target, - event_date), - target_event_query as - (SELECT DISTINCT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_0" as target, - [ - dateDiff( - 'Week', - toStartOfWeek(toDateTime('2020-06-07 00:00:00', 'UTC'), 0), - toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) - ) - ] as breakdown_values - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) ) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) - SELECT actor_activity.breakdown_values AS breakdown_values, - actor_activity.intervals_from_base AS intervals_from_base, - COUNT(DISTINCT actor_activity.actor_id) AS count - FROM actor_query AS actor_activity - GROUP BY breakdown_values, - intervals_from_base - ORDER BY breakdown_values, - intervals_from_base - ''' -# --- -# name: TestClickhouseRetention.test_groups_aggregating_person_on_events.1 - ''' - - SELECT actor_id, - groupArray(actor_activity.intervals_from_base) AS appearances - FROM - (WITH 'Week' as period, - [0] as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_0" as target - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - GROUP BY target, - event_date), - target_event_query as - (SELECT DISTINCT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_0" as target, - [ - dateDiff( - 'Week', - toStartOfWeek(toDateTime('2020-06-07 00:00:00', 'UTC'), 0), - toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) - ) - ] as breakdown_values - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) ) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) AS actor_activity - GROUP BY actor_id - ORDER BY length(appearances) DESC, actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseRetention.test_groups_aggregating_person_on_events.2 - ''' - WITH actor_query AS - (WITH 'Week' as period, - NULL as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_1" as target - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_1")) - AND notEmpty(e.person_id) - GROUP BY target, - event_date), - target_event_query as - (SELECT DISTINCT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_1" as target, - [ - dateDiff( - 'Week', - toStartOfWeek(toDateTime('2020-06-07 00:00:00', 'UTC'), 0), - toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) - ) - ] as breakdown_values - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_1")) - AND notEmpty(e.person_id) ) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) - SELECT actor_activity.breakdown_values AS breakdown_values, - actor_activity.intervals_from_base AS intervals_from_base, - COUNT(DISTINCT actor_activity.actor_id) AS count - FROM actor_query AS actor_activity - GROUP BY breakdown_values, - intervals_from_base - ORDER BY breakdown_values, - intervals_from_base - ''' -# --- -# name: TestClickhouseRetention.test_groups_aggregating_person_on_events_materialized - ''' - WITH actor_query AS - (WITH 'Week' as period, - NULL as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_0" as target - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - GROUP BY target, - event_date), - target_event_query as - (SELECT DISTINCT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_0" as target, - [ - dateDiff( - 'Week', - toStartOfWeek(toDateTime('2020-06-07 00:00:00', 'UTC'), 0), - toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) - ) - ] as breakdown_values - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) ) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) - SELECT actor_activity.breakdown_values AS breakdown_values, - actor_activity.intervals_from_base AS intervals_from_base, - COUNT(DISTINCT actor_activity.actor_id) AS count - FROM actor_query AS actor_activity - GROUP BY breakdown_values, - intervals_from_base - ORDER BY breakdown_values, - intervals_from_base - ''' -# --- -# name: TestClickhouseRetention.test_groups_aggregating_person_on_events_materialized.1 - ''' - - SELECT actor_id, - groupArray(actor_activity.intervals_from_base) AS appearances - FROM - (WITH 'Week' as period, - [0] as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_0" as target - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - GROUP BY target, - event_date), - target_event_query as - (SELECT DISTINCT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_0" as target, - [ - dateDiff( - 'Week', - toStartOfWeek(toDateTime('2020-06-07 00:00:00', 'UTC'), 0), - toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) - ) - ] as breakdown_values - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) ) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) AS actor_activity - GROUP BY actor_id - ORDER BY length(appearances) DESC, actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseRetention.test_groups_aggregating_person_on_events_materialized.2 - ''' - WITH actor_query AS - (WITH 'Week' as period, - NULL as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_1" as target - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_1")) - AND notEmpty(e.person_id) - GROUP BY target, - event_date), - target_event_query as - (SELECT DISTINCT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_1" as target, - [ - dateDiff( - 'Week', - toStartOfWeek(toDateTime('2020-06-07 00:00:00', 'UTC'), 0), - toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) - ) - ] as breakdown_values - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_1")) - AND notEmpty(e.person_id) ) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) - SELECT actor_activity.breakdown_values AS breakdown_values, - actor_activity.intervals_from_base AS intervals_from_base, - COUNT(DISTINCT actor_activity.actor_id) AS count - FROM actor_query AS actor_activity - GROUP BY breakdown_values, - intervals_from_base - ORDER BY breakdown_values, - intervals_from_base - ''' -# --- -# name: TestClickhouseRetention.test_groups_filtering_person_on_events - ''' - WITH actor_query AS - (WITH 'Week' as period, - NULL as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e.person_id as target - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (has(['technology'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) - GROUP BY target, - event_date), - target_event_query as - (SELECT DISTINCT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e.person_id as target, - [ - dateDiff( - 'Week', - toStartOfWeek(toDateTime('2020-06-07 00:00:00', 'UTC'), 0), - toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) - ) - ] as breakdown_values - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (has(['technology'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND notEmpty(e.person_id) ) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) - SELECT actor_activity.breakdown_values AS breakdown_values, - actor_activity.intervals_from_base AS intervals_from_base, - COUNT(DISTINCT actor_activity.actor_id) AS count - FROM actor_query AS actor_activity - GROUP BY breakdown_values, - intervals_from_base - ORDER BY breakdown_values, - intervals_from_base - ''' -# --- -# name: TestClickhouseRetention.test_groups_filtering_person_on_events.1 - ''' - WITH actor_query AS - (WITH 'Week' as period, - NULL as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e.person_id as target - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (JSONHas(group_properties_0, 'industry')) - AND notEmpty(e.person_id) - GROUP BY target, - event_date), - target_event_query as - (SELECT DISTINCT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e.person_id as target, - [ - dateDiff( - 'Week', - toStartOfWeek(toDateTime('2020-06-07 00:00:00', 'UTC'), 0), - toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) - ) - ] as breakdown_values - FROM events e - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (JSONHas(group_properties_0, 'industry')) - AND notEmpty(e.person_id) ) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) - SELECT actor_activity.breakdown_values AS breakdown_values, - actor_activity.intervals_from_base AS intervals_from_base, - COUNT(DISTINCT actor_activity.actor_id) AS count - FROM actor_query AS actor_activity - GROUP BY breakdown_values, - intervals_from_base - ORDER BY breakdown_values, - intervals_from_base - ''' -# --- -# name: TestClickhouseRetention.test_groups_filtering_person_on_events_v2 - ''' - - SELECT DISTINCT person_id - FROM events - WHERE team_id = 2 - AND distinct_id = 'person2' - ''' -# --- -# name: TestClickhouseRetention.test_groups_filtering_person_on_events_v2.1 - ''' - WITH actor_query AS - (WITH 'Day' as period, - NULL as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfDay(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC')) AS event_date, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as target - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-12 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-06-19 00:00:00', 'UTC') - AND (JSONHas(group_properties_0, 'industry')) - AND notEmpty(e.person_id) - GROUP BY target, - event_date), - target_event_query as - (SELECT DISTINCT toStartOfDay(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC')) AS event_date, - if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) as target, - [ - dateDiff( - 'Day', - toStartOfDay(toDateTime('2020-06-12 00:00:00', 'UTC')), - toStartOfDay(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC')) - ) - ] as breakdown_values - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-12 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-06-19 00:00:00', 'UTC') - AND (JSONHas(group_properties_0, 'industry')) - AND notEmpty(e.person_id) ) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) - SELECT actor_activity.breakdown_values AS breakdown_values, - actor_activity.intervals_from_base AS intervals_from_base, - COUNT(DISTINCT actor_activity.actor_id) AS count - FROM actor_query AS actor_activity - GROUP BY breakdown_values, - intervals_from_base - ORDER BY breakdown_values, - intervals_from_base - ''' -# --- -# name: TestClickhouseRetention.test_groups_in_period_person_on_events - ''' - - SELECT actor_id, - groupArray(actor_activity.intervals_from_base) AS appearances - FROM - (WITH 'Week' as period, - [0] as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_0" as target - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - GROUP BY target, - event_date), - target_event_query as - (SELECT DISTINCT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_0" as target, - [ - dateDiff( - 'Week', - toStartOfWeek(toDateTime('2020-06-07 00:00:00', 'UTC'), 0), - toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) - ) - ] as breakdown_values - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) ) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) AS actor_activity - GROUP BY actor_id - ORDER BY length(appearances) DESC, actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseRetention.test_groups_in_period_person_on_events_materialized - ''' - - SELECT actor_id, - groupArray(actor_activity.intervals_from_base) AS appearances - FROM - (WITH 'Week' as period, - [0] as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_0" as target - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) - GROUP BY target, - event_date), - target_event_query as - (SELECT DISTINCT toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) AS event_date, - e."$group_0" as target, - [ - dateDiff( - 'Week', - toStartOfWeek(toDateTime('2020-06-07 00:00:00', 'UTC'), 0), - toStartOfWeek(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'), 0) - ) - ] as breakdown_values - FROM events e - WHERE team_id = 2 - AND e.event = '$pageview' - AND toDateTime(e.timestamp) >= toDateTime('2020-06-07 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-07-27 00:00:00', 'UTC') - AND (NOT has([''], "$group_0")) - AND notEmpty(e.person_id) ) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) AS actor_activity - GROUP BY actor_id - ORDER BY length(appearances) DESC, actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- diff --git a/ee/clickhouse/queries/test/test_breakdown_props.py b/ee/clickhouse/queries/test/test_breakdown_props.py deleted file mode 100644 index bd398812bb9..00000000000 --- a/ee/clickhouse/queries/test/test_breakdown_props.py +++ /dev/null @@ -1,550 +0,0 @@ -import pytest -from freezegun import freeze_time - -from posthog.models.cohort import Cohort -from posthog.models.entity import Entity -from posthog.models.filters import Filter -from posthog.models.group.util import create_group -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.queries.breakdown_props import ( - _to_bucketing_expression, - get_breakdown_prop_values, -) -from posthog.queries.trends.util import process_math -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - _create_event, - _create_person, - also_test_with_materialized_columns, - snapshot_clickhouse_queries, -) - - -class TestBreakdownProps(ClickhouseTestMixin, APIBaseTest): - @also_test_with_materialized_columns( - event_properties=["$host", "distinct_id"], - person_properties=["$browser", "email"], - ) - @snapshot_clickhouse_queries - def test_breakdown_person_props(self): - _create_person(team_id=self.team.pk, distinct_ids=["p1"], properties={"$browser": "test"}) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp="2020-01-02T12:00:00Z", - properties={"key": "val"}, - ) - - self.team.test_account_filters = [ - { - "key": "email", - "type": "person", - "value": "posthog.com", - "operator": "not_icontains", - }, - { - "key": "$host", - "type": "event", - "value": [ - "127.0.0.1:3000", - "127.0.0.1:5000", - "localhost:5000", - "localhost:8000", - ], - "operator": "is_not", - }, - { - "key": "distinct_id", - "type": "event", - "value": "posthog.com", - "operator": "not_icontains", - }, - ] - self.team.save() - with freeze_time("2020-01-04T13:01:01Z"): - filter = Filter( - data={ - "insight": "FUNNELS", - "properties": [], - "filter_test_accounts": True, - "events": [ - { - "id": "$pageview", - "name": "$pageview", - "type": "events", - "order": 0, - } - ], - "actions": [], - "funnel_viz_type": "steps", - "display": "FunnelViz", - "interval": "day", - "breakdown": "$browser", - "breakdown_type": "person", - "breakdown_limit": 5, - "date_from": "-14d", - "funnel_window_days": 14, - } - ) - res = get_breakdown_prop_values( - filter, - Entity({"id": "$pageview", "type": "events"}), - "count(*)", - self.team, - ) - self.assertEqual(res[0], ["test"]) - - def test_breakdown_person_props_with_entity_filter(self): - _create_person(team_id=self.team.pk, distinct_ids=["p1"], properties={"$browser": "test"}) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp="2020-01-02T12:00:00Z", - properties={"key": "val"}, - ) - _create_person(team_id=self.team.pk, distinct_ids=["p2"], properties={"$browser": "test2"}) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2020-01-02T12:00:00Z", - properties={"key": "val"}, - ) - - cohort = Cohort.objects.create( - team=self.team, - name="a", - groups=[{"properties": [{"key": "$browser", "value": "test", "type": "person"}]}], - ) - cohort.calculate_people_ch(pending_version=0) - - entity_params = [ - { - "id": "$pageview", - "name": "$pageview", - "type": "events", - "order": 0, - "properties": [{"key": "id", "value": cohort.pk, "type": "cohort"}], - } - ] - with self.settings(USE_PRECALCULATED_CH_COHORT_PEOPLE=True): - with freeze_time("2020-01-04T13:01:01Z"): - filter = Filter( - data={ - "insight": "FUNNELS", - "properties": [], - "filter_test_accounts": False, - "events": entity_params, - "actions": [], - "funnel_viz_type": "steps", - "display": "FunnelViz", - "interval": "day", - "breakdown": "$browser", - "breakdown_type": "person", - "breakdown_limit": 5, - "date_from": "-14d", - "funnel_window_days": 14, - } - ) - res = get_breakdown_prop_values(filter, Entity(entity_params[0]), "count(*)", self.team) - self.assertEqual(res[0], ["test"]) - - @snapshot_clickhouse_queries - def test_breakdown_person_props_with_entity_filter_and_or_props_with_partial_pushdown(self): - _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"$browser": "test", "$os": "test"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp="2020-01-02T12:00:00Z", - properties={"key": "val"}, - ) - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"$browser": "test2", "$os": "test2"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2020-01-02T12:00:00Z", - properties={"key": "val2"}, - ) - _create_person( - team_id=self.team.pk, - distinct_ids=["p3"], - properties={"$browser": "test3", "$os": "test3"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p3", - timestamp="2020-01-02T12:00:00Z", - properties={"key": "val3"}, - ) - - entity_params = [ - { - "id": "$pageview", - "name": "$pageview", - "type": "events", - "order": 0, - "properties": [ - { - "key": "$browser", - "type": "person", - "value": "test", - "operator": "icontains", - } - ], - } - ] - with self.settings(USE_PRECALCULATED_CH_COHORT_PEOPLE=True): - with freeze_time("2020-01-04T13:01:01Z"): - filter = Filter( - data={ - "insight": "FUNNELS", - "properties": { - "type": "OR", - "values": [ - { - "key": "$os", - "type": "person", - "value": "test2", - "operator": "exact", - }, - { - "key": "key", - "type": "event", - "value": "val", - "operator": "exact", - }, - ], - }, - "filter_test_accounts": False, - "events": entity_params, - "actions": [], - "funnel_viz_type": "steps", - "display": "FunnelViz", - "interval": "day", - "breakdown": "$browser", - "breakdown_type": "person", - "breakdown_limit": 5, - "date_from": "-14d", - "funnel_window_days": 14, - } - ) - res = sorted(get_breakdown_prop_values(filter, Entity(entity_params[0]), "count(*)", self.team)[0]) - self.assertEqual(res, ["test", "test2"]) - - @snapshot_clickhouse_queries - def test_breakdown_group_props(self): - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - GroupTypeMapping.objects.create(team=self.team, group_type="company", group_type_index=1) - - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:5", - properties={"industry": "finance"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:6", - properties={"industry": "technology"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:7", - properties={"industry": "finance"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:8", - properties={"industry": "another", "out": 1}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:10", - properties={"industry": "foobar"}, - ) - # :TRICKY: Test group type overlapping - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="org:8", - properties={"industry": "foobar"}, - ) - - for org_index in range(5, 9): - _create_event( - event="$pageview", - distinct_id="person1", - team=self.team, - properties={"$group_0": f"org:{org_index}"}, - timestamp="2020-01-02T12:00:00Z", - ) - - filter = Filter( - data={ - "date_from": "2020-01-01T00:00:00Z", - "date_to": "2020-01-12T00:00:00Z", - "breakdown": "industry", - "breakdown_type": "group", - "breakdown_group_type_index": 0, - "breakdown_limit": 5, - "events": [{"id": "$pageview", "type": "events", "order": 0}], - "properties": [ - { - "key": "out", - "value": "", - "type": "group", - "group_type_index": 0, - "operator": "is_not_set", - } - ], - }, - team=self.team, - ) - result = get_breakdown_prop_values(filter, filter.entities[0], "count(*)", self.team) - self.assertEqual(result[0], ["finance", "technology"]) - - filter = Filter( - data={ - "date_from": "2020-01-01T00:00:00Z", - "date_to": "2020-01-12T00:00:00Z", - "breakdown": "industry", - "breakdown_type": "group", - "breakdown_group_type_index": 0, - "breakdown_limit": 5, - "events": [{"id": "$pageview", "type": "events", "order": 0}], - "properties": { - "type": "AND", - "values": [ - { - "key": "out", - "value": "", - "type": "group", - "group_type_index": 0, - "operator": "is_not_set", - } - ], - }, - } - ) - result = get_breakdown_prop_values(filter, filter.entities[0], "count(*)", self.team) - self.assertEqual(result[0], ["finance", "technology"]) - - @snapshot_clickhouse_queries - def test_breakdown_session_props(self): - _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"$browser": "test", "$os": "test"}, - ) - - # 20 second session that starts before the time range - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp="2020-01-01T23:59:50Z", - properties={"$session_id": "1"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp="2020-01-02T00:00:10Z", - properties={"$session_id": "1"}, - ) - - # 70 second session - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp="2020-01-02T12:00:00Z", - properties={"$session_id": "2"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp="2020-01-02T12:01:10Z", - properties={"$session_id": "2"}, - ) - - filter = Filter( - data={ - "date_from": "2020-01-02T00:00:00Z", - "date_to": "2020-01-12T00:00:00Z", - "breakdown": "$session_duration", - "breakdown_type": "session", - "events": [{"id": "$pageview", "type": "events", "order": 0}], - } - ) - result = get_breakdown_prop_values(filter, filter.entities[0], "count(*)", self.team) - self.assertEqual(result[0], [70, 20]) - - @snapshot_clickhouse_queries - def test_breakdown_with_math_property_session(self): - _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"$browser": "test", "$os": "test"}, - ) - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"$browser": "mac", "$os": "test"}, - ) - - # 20 second session that starts before the time range - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp="2020-01-01T23:59:50Z", - properties={"$session_id": "1"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp="2020-01-02T00:00:10Z", - properties={"$session_id": "1"}, - ) - - # 70 second session - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp="2020-01-02T12:00:00Z", - properties={"$session_id": "2"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp="2020-01-02T12:01:10Z", - properties={"$session_id": "2"}, - ) - - # 10 second session for second person with different browser, but more absolute - # events than first person - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2020-01-02T12:00:00Z", - properties={"$session_id": "3"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2020-01-02T12:00:01Z", - properties={"$session_id": "3"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2020-01-02T12:00:02Z", - properties={"$session_id": "3"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2020-01-02T12:00:03Z", - properties={"$session_id": "3"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2020-01-02T12:00:04Z", - properties={"$session_id": "3"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2020-01-02T12:00:10Z", - properties={"$session_id": "3"}, - ) - - filter = Filter( - data={ - "date_from": "2020-01-02T00:00:00Z", - "date_to": "2020-01-12T00:00:00Z", - "breakdown": "$browser", - "breakdown_type": "person", - "events": [ - { - "id": "$pageview", - "type": "events", - "order": 0, - "math": "sum", - "math_property": "$session_duration", - } - ], - } - ) - aggregate_operation, _, _ = process_math(filter.entities[0], self.team, filter=filter) - - result = get_breakdown_prop_values(filter, filter.entities[0], aggregate_operation, self.team) - # test should come first, based on aggregate operation, even if absolute count of events for - # mac is higher - self.assertEqual(result[0], ["test", "mac"]) - - result = get_breakdown_prop_values(filter, filter.entities[0], "count(*)", self.team) - self.assertEqual(result[0], ["mac", "test"]) - - -@pytest.mark.parametrize( - "test_input,expected", - [ - (0, "arrayCompact(arrayMap(x -> floor(x, 2), quantiles(0,1)(value)))"), - (1, "arrayCompact(arrayMap(x -> floor(x, 2), quantiles(0,1)(value)))"), - ( - 2, - "arrayCompact(arrayMap(x -> floor(x, 2), quantiles(0.00,0.50,1.00)(value)))", - ), - ( - 3, - "arrayCompact(arrayMap(x -> floor(x, 2), quantiles(0.00,0.33,0.67,1.00)(value)))", - ), - ( - 5, - "arrayCompact(arrayMap(x -> floor(x, 2), quantiles(0.00,0.20,0.40,0.60,0.80,1.00)(value)))", - ), - ( - 7, - "arrayCompact(arrayMap(x -> floor(x, 2), quantiles(0.00,0.14,0.29,0.43,0.57,0.71,0.86,1.00)(value)))", - ), - ( - 10, - "arrayCompact(arrayMap(x -> floor(x, 2), quantiles(0.00,0.10,0.20,0.30,0.40,0.50,0.60,0.70,0.80,0.90,1.00)(value)))", - ), - ], -) -def test_bucketing_expression(test_input, expected): - result = _to_bucketing_expression(test_input) - - assert result == expected diff --git a/ee/clickhouse/queries/test/test_cohort_query.py b/ee/clickhouse/queries/test/test_cohort_query.py deleted file mode 100644 index 9d07d9378d5..00000000000 --- a/ee/clickhouse/queries/test/test_cohort_query.py +++ /dev/null @@ -1,3273 +0,0 @@ -from datetime import datetime, timedelta - - -from ee.clickhouse.queries.enterprise_cohort_query import check_negation_clause -from posthog.client import sync_execute -from posthog.constants import PropertyOperatorType -from posthog.models.action import Action -from posthog.models.cohort import Cohort -from posthog.models.filters.filter import Filter -from posthog.models.property import Property, PropertyGroup -from posthog.queries.cohort_query import CohortQuery -from posthog.test.base import ( - BaseTest, - ClickhouseTestMixin, - _create_event, - _create_person, - also_test_with_materialized_columns, - flush_persons_and_events, - snapshot_clickhouse_queries, -) - - -def _make_event_sequence( - team, - distinct_id, - interval_days, - period_event_counts, - event="$pageview", - properties=None, -): - if properties is None: - properties = {} - for period_index, event_count in enumerate(period_event_counts): - for i in range(event_count): - _create_event( - team=team, - event=event, - properties=properties, - distinct_id=distinct_id, - timestamp=datetime.now() - timedelta(days=interval_days * period_index, hours=1, minutes=i), - ) - - -def _create_cohort(**kwargs): - team = kwargs.pop("team") - name = kwargs.pop("name") - groups = kwargs.pop("groups") - is_static = kwargs.pop("is_static", False) - cohort = Cohort.objects.create(team=team, name=name, groups=groups, is_static=is_static) - return cohort - - -class TestCohortQuery(ClickhouseTestMixin, BaseTest): - @snapshot_clickhouse_queries - def test_basic_query(self): - action1 = Action.objects.create( - team=self.team, - name="action1", - steps_json=[ - { - "event": "$autocapture", - "url": "https://posthog.com/feedback/123", - "url_matching": "exact", - } - ], - ) - - # satiesfies all conditions - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$autocapture", - properties={"$current_url": "https://posthog.com/feedback/123"}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=2), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=1), - ) - - # doesn't satisfy action - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$autocapture", - properties={"$current_url": "https://posthog.com/feedback/123"}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(weeks=3), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=1), - ) - - # doesn't satisfy property condition - _create_person( - team_id=self.team.pk, - distinct_ids=["p3"], - properties={"name": "test", "email": "testXX@posthog.com"}, - ) - _create_event( - team=self.team, - event="$autocapture", - properties={"$current_url": "https://posthog.com/feedback/123"}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=2), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=1), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "day", - "value": "performed_event", - "type": "behavioral", - }, - { - "key": "$pageview", - "event_type": "events", - "time_value": 2, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - }, - ], - }, - { - "type": "AND", - "values": [ - { - "key": action1.pk, - "event_type": "actions", - "time_value": 2, - "time_interval": "week", - "value": "performed_event_first_time", - "type": "behavioral", - }, - { - "key": "email", - "value": "test@posthog.com", - "type": "person", - }, - ], - }, - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - # Since all props should be pushed down here, there should be no full outer join! - self.assertTrue("FULL OUTER JOIN" not in q) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - def test_performed_event(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=2), - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=9), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "explicit_datetime": "-1w", - "value": "performed_event", - "type": "behavioral", - } - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - @snapshot_clickhouse_queries - def test_performed_event_with_event_filters_and_explicit_date(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={"$filter_prop": "something"}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=2), - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=2), - ) - _create_event( - team=self.team, - event="$pageview", - properties={"$filter_prop": "something"}, - distinct_id="p2", - # rejected because explicit datetime is set to 3 days ago - timestamp=datetime.now() - timedelta(days=5), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "explicit_datetime": str( - datetime.now() - timedelta(days=3) - ), # overrides time_value and time_interval - "time_value": 1, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - "event_filters": [ - {"key": "$filter_prop", "value": "something", "operator": "exact", "type": "event"} - ], - } - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - def test_performed_event_multiple(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=2), - ) - - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=4), - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=9), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "operator": "gte", - "operator_value": 1, - "time_value": 1, - "time_interval": "week", - "value": "performed_event_multiple", - "type": "behavioral", - } - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - def test_performed_event_multiple_with_event_filters(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={"$filter_prop": "something"}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=2), - ) - - _create_event( - team=self.team, - event="$pageview", - properties={"$filter_prop": "something"}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=4), - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=2), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=4), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "operator": "gte", - "operator_value": 1, - "time_value": 1, - "time_interval": "week", - "value": "performed_event_multiple", - "type": "behavioral", - "event_filters": [ - {"key": "$filter_prop", "value": "something", "operator": "exact", "type": "event"}, - {"key": "$filter_prop", "value": "some", "operator": "icontains", "type": "event"}, - ], - } - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - def test_performed_event_lte_1_times(self): - _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - p2 = _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(hours=9), - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p3"], - properties={"name": "test3", "email": "test3@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(hours=9), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(hours=8), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "operator": "lte", - "operator_value": 1, - "time_value": 1, - "time_interval": "week", - "value": "performed_event_multiple", - "type": "behavioral", - } - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual({p2.uuid}, {r[0] for r in res}) - - def test_can_handle_many_performed_multiple_filters(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(hours=9), - ) - - p2 = _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(hours=9), - ) - - p3 = _create_person( - team_id=self.team.pk, - distinct_ids=["p3"], - properties={"name": "test3", "email": "test3@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(hours=9), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(hours=8), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "operator": "eq", - "operator_value": 1, - "time_value": 1, - "time_interval": "week", - "value": "performed_event_multiple", - "type": "behavioral", - }, - { - "key": "$pageview", - "event_type": "events", - "operator": "eq", - "operator_value": 2, - "time_value": 1, - "time_interval": "week", - "value": "performed_event_multiple", - "type": "behavioral", - }, - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual({p1.uuid, p2.uuid, p3.uuid}, {r[0] for r in res}) - - def test_performed_event_zero_times_(self): - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "operator": "eq", - "operator_value": 0, - "time_value": 1, - "time_interval": "week", - "value": "performed_event_multiple", - "type": "behavioral", - } - ], - } - } - ) - with self.assertRaises(ValueError): - CohortQuery(filter=filter, team=self.team).get_query() - - def test_stopped_performing_event(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=10), - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=3), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_value": 2, - "time_interval": "week", - "seq_time_value": 1, - "seq_time_interval": "week", - "value": "stopped_performing_event", - "type": "behavioral", - } - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - def test_stopped_performing_event_raises_if_seq_date_later_than_date(self): - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "day", - "seq_time_value": 2, - "seq_time_interval": "day", - "value": "stopped_performing_event", - "type": "behavioral", - } - ], - } - } - ) - - with self.assertRaises(ValueError): - CohortQuery(filter=filter, team=self.team).get_query() - - def test_restarted_performing_event(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test2", "email": "test2@posthog.com"}, - ) - _create_person( - team_id=self.team.pk, - distinct_ids=["p3"], - properties={"name": "test3", "email": "test3@posthog.com"}, - ) - - # P1 events (proper restarting sequence) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=20), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=1), - ) - - # P2 events (an event occurs in the middle of the sequence, so the event never "stops") - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=20), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=5), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=1), - ) - - # P3 events (the event just started, so it isn't considered a restart) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=1), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "week", - "seq_time_value": 2, - "seq_time_interval": "day", - "value": "restarted_performing_event", - "type": "behavioral", - } - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - def test_restarted_performing_event_raises_if_seq_date_later_than_date(self): - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "day", - "seq_time_value": 2, - "seq_time_interval": "day", - "value": "restarted_performing_event", - "type": "behavioral", - } - ], - } - } - ) - - with self.assertRaises(ValueError): - CohortQuery(filter=filter, team=self.team).get_query() - - def test_performed_event_first_time(self): - _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - p2 = _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test2", "email": "test2@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=20), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=4), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=4), - ) - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "week", - "value": "performed_event_first_time", - "type": "behavioral", - } - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p2.uuid], [r[0] for r in res]) - - def test_performed_event_regularly(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _make_event_sequence(self.team, "p1", 3, [1, 1, 1]) - flush_persons_and_events() - # Filter for: - # Regularly completed [$pageview] [at least] [1] times per - # [3][day] period for at least [3] of the last [3] periods - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "operator": "gte", - "operator_value": 1, - "time_interval": "day", - "time_value": 3, - "total_periods": 3, - "min_periods": 3, - "value": "performed_event_regularly", - "type": "behavioral", - } - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - def test_performed_event_regularly_with_variable_event_counts_in_each_period(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - p2 = _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test2", "email": "test2@posthog.com"}, - ) - # p1 gets variable number of events in each period - _make_event_sequence(self.team, "p1", 3, [0, 1, 2]) - # p2 gets 10 events in each period - _make_event_sequence(self.team, "p2", 3, [1, 2, 2]) - - # Filter for: - # Regularly completed [$pageview] [at least] [2] times per - # [3][day] period for at least [2] of the last [3] periods - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "operator": "gte", - "operator_value": 2, - "time_interval": "day", - "time_value": 3, - "total_periods": 3, - "min_periods": 2, - "value": "performed_event_regularly", - "type": "behavioral", - } - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - self.assertEqual([p2.uuid], [r[0] for r in res]) - flush_persons_and_events() - - # Filter for: - # Regularly completed [$pageview] [at least] [1] times per - # [3][day] period for at least [2] of the last [3] periods - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "operator": "gte", - "operator_value": 1, - "time_interval": "day", - "time_value": 3, - "total_periods": 3, - "min_periods": 2, - "value": "performed_event_regularly", - "type": "behavioral", - } - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - self.assertEqual({p1.uuid, p2.uuid}, {r[0] for r in res}) - - @snapshot_clickhouse_queries - def test_person_props_only(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test1@posthog.com"}, - ) - p2 = _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test2@posthog.com"}, - ) - p3 = _create_person( - team_id=self.team.pk, - distinct_ids=["p3"], - properties={"name": "test3", "email": "test3@posthog.com"}, - ) - # doesn't match - _create_person( - team_id=self.team.pk, - distinct_ids=["p4"], - properties={"name": "test3", "email": "test4@posthog.com"}, - ) - - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "email", - "value": "test1@posthog.com", - "type": "person", - }, - { - "key": "email", - "value": "test2@posthog.com", - "type": "person", - }, - ], - }, - { - "type": "AND", - "values": [ - {"key": "name", "value": "test3", "type": "person"}, - { - "key": "email", - "value": "test3@posthog.com", - "type": "person", - }, - ], - }, - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - # Since all props should be pushed down here, there should be no full outer join! - self.assertTrue("FULL OUTER JOIN" not in q) - - self.assertCountEqual([p1.uuid, p2.uuid, p3.uuid], [r[0] for r in res]) - - @snapshot_clickhouse_queries - def test_person_properties_with_pushdowns(self): - action1 = Action.objects.create( - team=self.team, - name="action1", - steps_json=[ - { - "event": "$autocapture", - "url": "https://posthog.com/feedback/123", - "url_matching": "exact", - } - ], - ) - - # satiesfies all conditions - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$autocapture", - properties={"$current_url": "https://posthog.com/feedback/123"}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=2), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=1), - ) - - # doesn't satisfy action - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$autocapture", - properties={"$current_url": "https://posthog.com/feedback/123"}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(weeks=3), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=1), - ) - - # satisfies special condition (not pushed down person property in OR group) - p3 = _create_person( - team_id=self.team.pk, - distinct_ids=["p3"], - properties={"name": "special", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$autocapture", - properties={"$current_url": "https://posthog.com/feedback/123"}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=2), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "day", - "value": "performed_event", - "type": "behavioral", - }, - { - "key": "$pageview", - "event_type": "events", - "time_value": 2, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - }, - { - "key": "name", - "value": "special", - "type": "person", - }, # this is NOT pushed down - ], - }, - { - "type": "AND", - "values": [ - { - "key": action1.pk, - "event_type": "actions", - "time_value": 2, - "time_interval": "week", - "value": "performed_event_first_time", - "type": "behavioral", - }, - { - "key": "email", - "value": "test@posthog.com", - "type": "person", - }, # this is pushed down - ], - }, - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertCountEqual([p1.uuid, p3.uuid], [r[0] for r in res]) - - @also_test_with_materialized_columns(person_properties=["$sample_field"]) - @snapshot_clickhouse_queries - def test_person(self): - # satiesfies all conditions - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "$sample_field": "test@posthog.com"}, - ) - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - }, - { - "key": "$sample_field", - "value": "test@posthog.com", - "type": "person", - }, - ], - } - } - ) - flush_persons_and_events() - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - def test_earliest_date_clause(self): - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - }, - { - "key": "$pageview", - "event_type": "events", - "time_value": 2, - "time_interval": "week", - "value": "performed_event_multiple", - "operator_value": 1, - "type": "behavioral", - }, - { - "key": "$pageview", - "event_type": "events", - "time_value": 4, - "time_interval": "week", - "seq_time_value": 1, - "seq_time_interval": "week", - "value": "stopped_performing_event", - "type": "behavioral", - }, - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - }, - { - "key": "$pageview", - "event_type": "events", - "operator": "gte", - "operator_value": 2, - "time_interval": "week", - "time_value": 3, - "total_periods": 3, - "min_periods": 2, - "value": "performed_event_regularly", - "type": "behavioral", - }, - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertTrue("timestamp >= now() - INTERVAL 9 week" in (q % params)) - - def test_earliest_date_clause_removed_for_started_at_query(self): - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_value": 2, - "time_interval": "week", - "value": "performed_event_first_time", - "type": "behavioral", - }, - { - "key": "$pageview", - "event_type": "events", - "operator": "gte", - "operator_value": 2, - "time_interval": "week", - "time_value": 3, - "total_periods": 3, - "min_periods": 2, - "value": "performed_event_regularly", - "type": "behavioral", - }, - ], - } - } - ) - query_class = CohortQuery(filter=filter, team=self.team) - q, params = query_class.get_query() - self.assertFalse(query_class._restrict_event_query_by_time) - sync_execute(q, {**params, **filter.hogql_context.values}) - - def test_negation(self): - _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=2), - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=10), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - "negation": True, - } - ], - } - } - ) - - self.assertRaises(ValueError, lambda: CohortQuery(filter=filter, team=self.team)) - - def test_negation_with_simplify_filters(self): - _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=2), - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=10), - ) - - p3 = _create_person( - team_id=self.team.pk, - distinct_ids=["p3"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$feature_flag_called", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=10), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "type": "behavioral", - "value": "performed_event", - "negation": True, - "event_type": "events", - "time_value": "30", - "time_interval": "day", - }, - { - "key": "$feature_flag_called", - "type": "behavioral", - "value": "performed_event", - "negation": False, - "event_type": "events", - "time_value": "30", - "time_interval": "day", - }, - ], - } - }, - team=self.team, - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - self.assertCountEqual([p3.uuid], [r[0] for r in res]) - - def test_negation_dynamic_time_bound_with_performed_event(self): - # invalid dude because $pageview happened too early - _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=2), - ) - - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=4), - ) - - # invalid dude because no new_view event - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=4), - ) - - # valid dude because $pageview happened a long time ago - p3 = _create_person( - team_id=self.team.pk, - distinct_ids=["p3"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=35), - ) - - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=4), - ) - - # valid dude because $pageview did not happen - p4 = _create_person( - team_id=self.team.pk, - distinct_ids=["p4"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p4", - timestamp=datetime.now() - timedelta(days=4), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$new_view", - "event_type": "events", - "time_value": 1, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - }, - { - "key": "$pageview", - "event_type": "events", - "time_value": 2, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - "negation": True, - }, - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertCountEqual([p3.uuid, p4.uuid], [r[0] for r in res]) - - def test_negation_dynamic_time_bound_with_performed_event_sequence(self): - # invalid dude because $pageview sequence happened too early - _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - # pageview sequence that happens today, and 2 days ago - _make_event_sequence(self.team, "p1", 2, [1, 1]) - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=4), - ) - - # invalid dude because no new_view event - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _make_event_sequence(self.team, "p2", 2, [1, 1]) - - # valid dude because $pageview sequence happened a long time ago - p3 = _create_person( - team_id=self.team.pk, - distinct_ids=["p3"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=35), - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=37), - ) - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=4), - ) - - # valid dude because $pageview sequence did not happen - p4 = _create_person( - team_id=self.team.pk, - distinct_ids=["p4"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p4", - timestamp=datetime.now() - timedelta(days=4), - ) - - # valid dude because $pageview sequence did not complete, even if one pageview happened - p5 = _create_person( - team_id=self.team.pk, - distinct_ids=["p5"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p5", - timestamp=datetime.now() - timedelta(days=5), - ) - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p5", - timestamp=datetime.now() - timedelta(days=4), - ) - - # valid dude because $pageview sequence delay was long enough, even if it happened too early - p6 = _create_person( - team_id=self.team.pk, - distinct_ids=["p6"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - # pageview sequence that happens today, and 4 days ago - _make_event_sequence(self.team, "p6", 4, [1, 1]) - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p6", - timestamp=datetime.now() - timedelta(days=4), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$new_view", - "event_type": "events", - "time_value": 1, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - }, - { - "key": "$pageview", - "event_type": "events", - "time_interval": "day", - "time_value": 8, - "seq_time_interval": "day", - "seq_time_value": 3, - "seq_event": "$pageview", - "seq_event_type": "events", - "value": "performed_event_sequence", - "type": "behavioral", - "negation": True, - }, - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - self.assertCountEqual([p3.uuid, p4.uuid, p5.uuid, p6.uuid], [r[0] for r in res]) - - def test_cohort_filter(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "name": "test"}, - ) - cohort = _create_cohort( - team=self.team, - name="cohort1", - groups=[{"properties": [{"key": "name", "value": "test", "type": "person"}]}], - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [{"key": "id", "value": cohort.pk, "type": "cohort"}], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - def test_faulty_type(self): - cohort = _create_cohort( - team=self.team, - name="cohort1", - groups=[ - { - "properties": [ - { - "key": "email", - "type": "event", - "value": ["fake@test.com"], - "operator": "exact", - } - ] - } - ], - ) - - self.assertEqual( - cohort.properties.to_dict(), - { - "type": "OR", - "values": [ - { - "type": "AND", - "values": [ - { - "key": "email", - "value": ["fake@test.com"], - "operator": "exact", - "type": "person", - } - ], - } - ], - }, - ) - - def test_missing_type(self): - cohort = _create_cohort( - team=self.team, - name="cohort1", - groups=[ - { - "properties": [ - { - "key": "email", - "value": ["fake@test.com"], - "operator": "exact", - } - ] - } - ], - ) - - self.assertEqual( - cohort.properties.to_dict(), - { - "type": "OR", - "values": [ - { - "type": "AND", - "values": [ - { - "key": "email", - "value": ["fake@test.com"], - "operator": "exact", - "type": "person", - } - ], - } - ], - }, - ) - - def test_old_old_style_properties(self): - cohort = _create_cohort( - team=self.team, - name="cohort1", - groups=[ - { - "properties": [ - { - "key": "email", - "value": ["fake@test.com"], - "operator": "exact", - } - ] - }, - {"properties": {"abra": "cadabra", "name": "alakazam"}}, - ], - ) - - self.assertEqual( - cohort.properties.to_dict(), - { - "type": "OR", - "values": [ - { - "type": "AND", - "values": [ - { - "key": "email", - "value": ["fake@test.com"], - "operator": "exact", - "type": "person", - } - ], - }, - { - "type": "AND", - "values": [ - {"key": "abra", "value": "cadabra", "type": "person"}, - {"key": "name", "value": "alakazam", "type": "person"}, - ], - }, - ], - }, - ) - - def test_precalculated_cohort_filter(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "name": "test"}, - ) - cohort = _create_cohort( - team=self.team, - name="cohort1", - groups=[{"properties": [{"key": "name", "value": "test", "type": "person"}]}], - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [ - { - "key": "id", - "value": cohort.pk, - "type": "precalculated-cohort", - } - ], - } - } - ) - - cohort.calculate_people_ch(pending_version=0) - - with self.settings(USE_PRECALCULATED_CH_COHORT_PEOPLE=True): - q, params = CohortQuery(filter=filter, team=self.team).get_query() - # Precalculated cohorts should not be used as is - # since we want cohort calculation with cohort properties to not be out of sync - self.assertTrue("cohortpeople" not in q) - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - @snapshot_clickhouse_queries - def test_precalculated_cohort_filter_with_extra_filters(self): - p1 = _create_person(team_id=self.team.pk, distinct_ids=["p1"], properties={"name": "test"}) - p2 = _create_person(team_id=self.team.pk, distinct_ids=["p2"], properties={"name": "test2"}) - _create_person(team_id=self.team.pk, distinct_ids=["p3"], properties={"name": "test3"}) - - cohort = _create_cohort( - team=self.team, - name="cohort1", - groups=[{"properties": [{"key": "name", "value": "test", "type": "person"}]}], - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [ - { - "key": "id", - "value": cohort.pk, - "type": "precalculated-cohort", - }, - {"key": "name", "value": "test2", "type": "person"}, - ], - } - } - ) - - # makes sure cohort is precalculated - cohort.calculate_people_ch(pending_version=0) - - with self.settings(USE_PRECALCULATED_CH_COHORT_PEOPLE=True): - q, params = CohortQuery(filter=filter, team=self.team).get_query() - self.assertTrue("cohortpeople" not in q) - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertCountEqual([p1.uuid, p2.uuid], [r[0] for r in res]) - - @snapshot_clickhouse_queries - def test_cohort_filter_with_extra(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "name": "test"}, - ) - cohort = _create_cohort( - team=self.team, - name="cohort1", - groups=[{"properties": [{"key": "name", "value": "test", "type": "person"}]}], - ) - - p2 = _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=2), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - {"key": "id", "value": cohort.pk, "type": "cohort"}, - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - }, - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p2.uuid], [r[0] for r in res]) - - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [ - {"key": "id", "value": cohort.pk, "type": "cohort"}, - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - }, - ], - } - }, - team=self.team, - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertCountEqual([p1.uuid, p2.uuid], [r[0] for r in res]) - - @snapshot_clickhouse_queries - def test_cohort_filter_with_another_cohort_with_event_sequence(self): - # passes filters for cohortCeption, but not main cohort - _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@gmail.com"}, - ) - _make_event_sequence(self.team, "p1", 2, [1, 1]) - - # passes filters for cohortCeption and main cohort - p2 = _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _make_event_sequence(self.team, "p2", 2, [1, 1]) - _make_event_sequence(self.team, "p2", 6, [1, 1], event="$new_view") - - # passes filters for neither cohortCeption nor main cohort - _create_person( - team_id=self.team.pk, - distinct_ids=["p3"], - properties={"email": "test@posthog.com"}, - ) - _make_event_sequence(self.team, "p3", 2, [1, 1]) - - # passes filters for mainCohort but not cohortCeption - _create_person( - team_id=self.team.pk, - distinct_ids=["p4"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _make_event_sequence(self.team, "p4", 6, [1, 1]) - _make_event_sequence(self.team, "p4", 6, [1, 1], event="$new_view") - flush_persons_and_events() - - cohort = Cohort.objects.create( - team=self.team, - name="cohortCeption", - filters={ - "properties": { - "type": "AND", - "values": [ - {"key": "name", "value": "test", "type": "person"}, - { - "key": "$pageview", - "event_type": "events", - "time_interval": "day", - "time_value": 8, - "seq_time_interval": "day", - "seq_time_value": 3, - "seq_event": "$pageview", - "seq_event_type": "events", - "value": "performed_event_sequence", - "type": "behavioral", - }, - ], - } - }, - ) - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - {"key": "id", "value": cohort.pk, "type": "cohort"}, - { - "key": "$new_view", - "event_type": "events", - "time_interval": "day", - "time_value": 8, - "seq_time_interval": "day", - "seq_time_value": 8, - "seq_event": "$new_view", - "seq_event_type": "events", - "value": "performed_event_sequence", - "type": "behavioral", - }, - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p2.uuid], [r[0] for r in res]) - - @snapshot_clickhouse_queries - def test_static_cohort_filter(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "name": "test"}, - ) - cohort = _create_cohort(team=self.team, name="cohort1", groups=[], is_static=True) - flush_persons_and_events() - cohort.insert_users_by_list(["p1"]) - - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [{"key": "id", "value": cohort.pk, "type": "static-cohort"}], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - @snapshot_clickhouse_queries - def test_static_cohort_filter_with_extra(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "name": "test"}, - ) - cohort = _create_cohort(team=self.team, name="cohort1", groups=[], is_static=True) - - p2 = _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=2), - ) - flush_persons_and_events() - cohort.insert_users_by_list(["p1", "p2"]) - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - {"key": "id", "value": cohort.pk, "type": "cohort"}, - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - }, - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p2.uuid], [r[0] for r in res]) - - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [ - {"key": "id", "value": cohort.pk, "type": "cohort"}, - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - }, - ], - } - }, - team=self.team, - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertCountEqual([p1.uuid, p2.uuid], [r[0] for r in res]) - - @snapshot_clickhouse_queries - def test_performed_event_sequence(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _make_event_sequence(self.team, "p1", 2, [1, 1]) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=2), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_interval": "day", - "time_value": 7, - "seq_time_interval": "day", - "seq_time_value": 3, - "seq_event": "$pageview", - "seq_event_type": "events", - "value": "performed_event_sequence", - "type": "behavioral", - } - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - @also_test_with_materialized_columns(event_properties=["$current_url"]) - def test_performed_event_sequence_with_action(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - action1 = Action.objects.create( - team=self.team, - name="action1", - steps_json=[ - { - "event": "$pageview", - "url": "https://posthog.com/feedback/123", - "url_matching": "exact", - } - ], - ) - - _make_event_sequence( - self.team, - "p1", - 2, - [1, 1], - properties={"$current_url": "https://posthog.com/feedback/123"}, - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _create_event( - team=self.team, - event="$pageview", - properties={"$current_url": "https://posthog.com/feedback/123"}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=2), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": action1.pk, - "event_type": "actions", - "time_interval": "day", - "time_value": 7, - "seq_time_interval": "day", - "seq_time_value": 3, - "seq_event": action1.pk, - "seq_event_type": "actions", - "value": "performed_event_sequence", - "type": "behavioral", - } - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - def test_performed_event_sequence_with_restarted(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _make_event_sequence(self.team, "p1", 2, [1, 1]) - - p2 = _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=18), - ) - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=5), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_interval": "day", - "time_value": 7, - "seq_time_interval": "day", - "seq_time_value": 3, - "seq_event": "$pageview", - "seq_event_type": "events", - "value": "performed_event_sequence", - "type": "behavioral", - }, - { - "key": "$new_view", - "event_type": "events", - "time_value": 2, - "time_interval": "week", - "seq_time_value": 1, - "seq_time_interval": "week", - "value": "restarted_performing_event", - "type": "behavioral", - }, - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual(sorted([p1.uuid, p2.uuid]), sorted([r[0] for r in res])) - - def test_performed_event_sequence_with_extra_conditions(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _make_event_sequence(self.team, "p1", 2, [1, 1]) - - _create_event( - team=self.team, - event="$some_event", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=2), - ) - - _create_event( - team=self.team, - event="$some_event", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=4), - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=2), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_interval": "day", - "time_value": 7, - "seq_time_interval": "day", - "seq_time_value": 3, - "seq_event": "$pageview", - "seq_event_type": "events", - "value": "performed_event_sequence", - "type": "behavioral", - }, - { - "key": "$pageview", - "event_type": "events", - "operator": "gte", - "operator_value": 1, - "time_value": 1, - "time_interval": "week", - "value": "performed_event_multiple", - "type": "behavioral", - }, - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - @snapshot_clickhouse_queries - def test_performed_event_sequence_with_person_properties(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _make_event_sequence(self.team, "p1", 2, [1, 1]) - - _create_event( - team=self.team, - event="$some_event", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=2), - ) - - _create_event( - team=self.team, - event="$some_event", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=4), - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=2), - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p3"], - properties={"name": "test22", "email": "test22@posthog.com"}, - ) - - _make_event_sequence(self.team, "p3", 2, [1, 1]) - - _create_event( - team=self.team, - event="$some_event", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=2), - ) - - _create_event( - team=self.team, - event="$some_event", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=4), - ) - - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_interval": "day", - "time_value": 7, - "seq_time_interval": "day", - "seq_time_value": 3, - "seq_event": "$pageview", - "seq_event_type": "events", - "value": "performed_event_sequence", - "type": "behavioral", - }, - { - "key": "$pageview", - "event_type": "events", - "operator": "gte", - "operator_value": 1, - "time_value": 1, - "time_interval": "week", - "value": "performed_event_multiple", - "type": "behavioral", - }, - { - "key": "email", - "value": "test@posthog.com", - "type": "person", - }, # pushed down - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - def test_multiple_performed_event_sequence(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _make_event_sequence(self.team, "p1", 2, [1, 1]) - - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=10), - ) - - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=9), - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=10), - ) - - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=9), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_interval": "day", - "time_value": 7, - "seq_time_interval": "day", - "seq_time_value": 3, - "seq_event": "$pageview", - "seq_event_type": "events", - "value": "performed_event_sequence", - "type": "behavioral", - }, - { - "key": "$pageview", - "event_type": "events", - "time_interval": "week", - "time_value": 2, - "seq_time_interval": "day", - "seq_time_value": 2, - "seq_event": "$new_view", - "seq_event_type": "events", - "value": "performed_event_sequence", - "type": "behavioral", - }, - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual([p1.uuid], [r[0] for r in res]) - - @snapshot_clickhouse_queries - def test_performed_event_sequence_and_clause_with_additional_event(self): - p1 = _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=6), - ) - - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=5), - ) - - p2 = _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=3), - ) - flush_persons_and_events() - - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_interval": "day", - "time_value": 7, - "seq_time_interval": "day", - "seq_time_value": 3, - "seq_event": "$pageview", - "seq_event_type": "events", - "value": "performed_event_sequence", - "type": "behavioral", - }, - { - "key": "$new_view", - "event_type": "events", - "operator": "gte", - "operator_value": 1, - "time_value": 1, - "time_interval": "week", - "value": "performed_event_multiple", - "type": "behavioral", - }, - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertEqual({p1.uuid, p2.uuid}, {r[0] for r in res}) - - @snapshot_clickhouse_queries - def test_unwrapping_static_cohort_filter_hidden_in_layers_of_cohorts(self): - _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test", "name": "test"}, - ) - cohort_static = _create_cohort(team=self.team, name="cohort static", groups=[], is_static=True) - - p2 = _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - _create_event( - team=self.team, - event="$pageview", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=2), - ) - - p3 = _create_person(team_id=self.team.pk, distinct_ids=["p3"], properties={"name": "test"}) - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=1), - ) - - _create_person(team_id=self.team.pk, distinct_ids=["p4"], properties={"name": "test"}) - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p4", - timestamp=datetime.now() - timedelta(days=1), - ) - _create_person(team_id=self.team.pk, distinct_ids=["p5"], properties={"name": "test"}) - flush_persons_and_events() - cohort_static.insert_users_by_list(["p4", "p5"]) - - other_cohort = Cohort.objects.create( - team=self.team, - name="cohort other", - is_static=False, - filters={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$new_view", - "event_type": "events", - "time_interval": "day", - "time_value": 7, - "value": "performed_event", - "type": "behavioral", - # p3, p4 fits in here - }, - { - "key": "id", - "value": cohort_static.pk, - "type": "cohort", - "negation": True, - # p4, p5 fits in here - }, - ], - } - }, - ) - - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [ - { - "key": "id", - "value": other_cohort.pk, - "type": "cohort", - }, # p3 fits in here - { - "key": "$pageview", - "event_type": "events", - "time_value": 1, - "time_interval": "week", - "value": "performed_event", - "type": "behavioral", - # p2 fits in here - }, - ], - } - } - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertCountEqual([p2.uuid, p3.uuid], [r[0] for r in res]) - - def test_unwrap_with_negated_cohort(self): - _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test2", "email": "test@posthog.com"}, - ) - - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=6), - ) - _create_event( - team=self.team, - event="$some_event", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=6), - ) - - p2 = _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _create_event( - team=self.team, - event="$some_event", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=6), - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p3"], - properties={"name": "test2", "email": "test@posthog.com"}, - ) - - _create_event( - team=self.team, - event="$some_event", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=6), - ) - - cohort1 = Cohort.objects.create( - team=self.team, - name="cohort 1", - is_static=False, - filters={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$new_view", - "event_type": "events", - "time_interval": "day", - "time_value": 7, - "value": "performed_event", - "type": "behavioral", - } - ], - } - }, - ) - cohort2 = Cohort.objects.create( - team=self.team, - name="cohort 2", - is_static=False, - filters={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$some_event", - "event_type": "events", - "time_interval": "day", - "time_value": 7, - "value": "performed_event", - "type": "behavioral", - }, - { - "key": "name", - "value": "test2", - "type": "person", - "negation": True, - }, - { - "key": "id", - "value": cohort1.pk, - "type": "cohort", - "negation": True, - }, - ], - } - }, - ) - - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [{"key": "id", "value": cohort2.pk, "type": "cohort"}], # p3 fits in here - } - }, - team=self.team, - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertCountEqual([p2.uuid], [r[0] for r in res]) - - def test_unwrap_multiple_levels(self): - _create_person( - team_id=self.team.pk, - distinct_ids=["p1"], - properties={"name": "test2", "email": "test@posthog.com"}, - ) - - _create_event( - team=self.team, - event="$new_view", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=6), - ) - _create_event( - team=self.team, - event="$some_event", - properties={}, - distinct_id="p1", - timestamp=datetime.now() - timedelta(days=6), - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p2"], - properties={"name": "test", "email": "test@posthog.com"}, - ) - - _create_event( - team=self.team, - event="$some_event", - properties={}, - distinct_id="p2", - timestamp=datetime.now() - timedelta(days=6), - ) - - _create_person( - team_id=self.team.pk, - distinct_ids=["p3"], - properties={"name": "test2", "email": "test@posthog.com"}, - ) - - _create_event( - team=self.team, - event="$some_event", - properties={}, - distinct_id="p3", - timestamp=datetime.now() - timedelta(days=6), - ) - - p4 = _create_person( - team_id=self.team.pk, - distinct_ids=["p4"], - properties={"name": "test3", "email": "test@posthog.com"}, - ) - - _create_event( - team=self.team, - event="$target_event", - properties={}, - distinct_id="p4", - timestamp=datetime.now() - timedelta(days=6), - ) - - cohort1 = Cohort.objects.create( - team=self.team, - name="cohort 1", - is_static=False, - filters={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$new_view", - "event_type": "events", - "time_interval": "day", - "time_value": 7, - "value": "performed_event", - "type": "behavioral", - } - ], - } - }, - ) - cohort2 = Cohort.objects.create( - team=self.team, - name="cohort 2", - is_static=False, - filters={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$some_event", - "event_type": "events", - "time_interval": "day", - "time_value": 7, - "value": "performed_event", - "type": "behavioral", - }, - { - "key": "name", - "value": "test2", - "type": "person", - "negation": True, - }, - { - "key": "id", - "value": cohort1.pk, - "type": "cohort", - "negation": True, - }, - ], - } - }, - ) - - cohort3 = Cohort.objects.create( - team=self.team, - name="cohort 3", - is_static=False, - filters={ - "properties": { - "type": "AND", - "values": [ - {"key": "name", "value": "test3", "type": "person"}, - { - "key": "id", - "value": cohort2.pk, - "type": "cohort", - "negation": True, - }, - ], - } - }, - ) - - filter = Filter( - data={ - "properties": { - "type": "OR", - "values": [{"key": "id", "value": cohort3.pk, "type": "cohort"}], - } - }, - team=self.team, - ) - - q, params = CohortQuery(filter=filter, team=self.team).get_query() - res = sync_execute(q, {**params, **filter.hogql_context.values}) - - self.assertCountEqual([p4.uuid], [r[0] for r in res]) - - -class TestCohortNegationValidation(BaseTest): - def test_basic_valid_negation_tree(self): - property_group = PropertyGroup( - type=PropertyOperatorType.AND, - values=[ - Property(key="name", value="test", type="person"), - Property(key="email", value="xxx", type="person", negation=True), - ], - ) - - has_pending_neg, has_reg = check_negation_clause(property_group) - self.assertEqual(has_pending_neg, False) - self.assertEqual(has_reg, True) - - def test_valid_negation_tree_with_extra_layers(self): - property_group = PropertyGroup( - type=PropertyOperatorType.OR, - values=[ - PropertyGroup( - type=PropertyOperatorType.AND, - values=[Property(key="name", value="test", type="person")], - ), - PropertyGroup( - type=PropertyOperatorType.AND, - values=[ - PropertyGroup( - type=PropertyOperatorType.OR, - values=[ - Property( - key="email", - value="xxx", - type="person", - negation=True, - ) - ], - ), - PropertyGroup( - type=PropertyOperatorType.OR, - values=[Property(key="email", value="xxx", type="person")], - ), - ], - ), - ], - ) - - has_pending_neg, has_reg = check_negation_clause(property_group) - self.assertEqual(has_pending_neg, False) - self.assertEqual(has_reg, True) - - def test_invalid_negation_tree_with_extra_layers(self): - property_group = PropertyGroup( - type=PropertyOperatorType.OR, - values=[ - PropertyGroup( - type=PropertyOperatorType.AND, - values=[Property(key="name", value="test", type="person")], - ), - PropertyGroup( - type=PropertyOperatorType.AND, - values=[ - PropertyGroup( - type=PropertyOperatorType.OR, - values=[ - Property( - key="email", - value="xxx", - type="person", - negation=True, - ) - ], - ), - PropertyGroup( - type=PropertyOperatorType.OR, - values=[ - Property( - key="email", - value="xxx", - type="person", - negation=True, - ) - ], - ), - ], - ), - ], - ) - - has_pending_neg, has_reg = check_negation_clause(property_group) - self.assertEqual(has_pending_neg, True) - self.assertEqual(has_reg, True) - - def test_valid_negation_tree_with_extra_layers_recombining_at_top(self): - property_group = PropertyGroup( - type=PropertyOperatorType.AND, # top level AND protects the 2 negations from being invalid - values=[ - PropertyGroup( - type=PropertyOperatorType.OR, - values=[Property(key="name", value="test", type="person")], - ), - PropertyGroup( - type=PropertyOperatorType.AND, - values=[ - PropertyGroup( - type=PropertyOperatorType.OR, - values=[ - Property( - key="email", - value="xxx", - type="person", - negation=True, - ) - ], - ), - PropertyGroup( - type=PropertyOperatorType.OR, - values=[ - Property( - key="email", - value="xxx", - type="person", - negation=True, - ) - ], - ), - ], - ), - ], - ) - - has_pending_neg, has_reg = check_negation_clause(property_group) - self.assertEqual(has_pending_neg, False) - self.assertEqual(has_reg, True) - - def test_invalid_negation_tree_no_positive_filter(self): - property_group = PropertyGroup( - type=PropertyOperatorType.AND, - values=[ - PropertyGroup( - type=PropertyOperatorType.OR, - values=[Property(key="name", value="test", type="person", negation=True)], - ), - PropertyGroup( - type=PropertyOperatorType.AND, - values=[ - PropertyGroup( - type=PropertyOperatorType.OR, - values=[ - Property( - key="email", - value="xxx", - type="person", - negation=True, - ) - ], - ), - PropertyGroup( - type=PropertyOperatorType.OR, - values=[ - Property( - key="email", - value="xxx", - type="person", - negation=True, - ) - ], - ), - ], - ), - ], - ) - - has_pending_neg, has_reg = check_negation_clause(property_group) - self.assertEqual(has_pending_neg, True) - self.assertEqual(has_reg, False) - - def test_empty_property_group(self): - property_group = PropertyGroup(type=PropertyOperatorType.AND, values=[]) # type: ignore - - has_pending_neg, has_reg = check_negation_clause(property_group) - self.assertEqual(has_pending_neg, False) - self.assertEqual(has_reg, False) - - def test_basic_invalid_negation_tree(self): - property_group = PropertyGroup( - type=PropertyOperatorType.AND, - values=[Property(key="email", value="xxx", type="person", negation=True)], - ) - - has_pending_neg, has_reg = check_negation_clause(property_group) - self.assertEqual(has_pending_neg, True) - self.assertEqual(has_reg, False) - - def test_basic_valid_negation_tree_with_no_negations(self): - property_group = PropertyGroup( - type=PropertyOperatorType.AND, - values=[Property(key="name", value="test", type="person")], - ) - - has_pending_neg, has_reg = check_negation_clause(property_group) - self.assertEqual(has_pending_neg, False) - self.assertEqual(has_reg, True) diff --git a/ee/clickhouse/queries/test/test_column_optimizer.py b/ee/clickhouse/queries/test/test_column_optimizer.py deleted file mode 100644 index 296f3d18b35..00000000000 --- a/ee/clickhouse/queries/test/test_column_optimizer.py +++ /dev/null @@ -1,260 +0,0 @@ -from ee.clickhouse.materialized_columns.columns import materialize -from ee.clickhouse.queries.column_optimizer import EnterpriseColumnOptimizer -from posthog.models import Action -from posthog.models.filters import Filter, RetentionFilter -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - cleanup_materialized_columns, -) - -PROPERTIES_OF_ALL_TYPES = [ - {"key": "event_prop", "value": ["foo", "bar"], "type": "event"}, - {"key": "person_prop", "value": "efg", "type": "person"}, - {"key": "id", "value": 1, "type": "cohort"}, - {"key": "tag_name", "value": ["label"], "operator": "exact", "type": "element"}, - { - "key": "group_prop", - "value": ["value"], - "operator": "exact", - "type": "group", - "group_type_index": 2, - }, -] - -BASE_FILTER = Filter({"events": [{"id": "$pageview", "type": "events", "order": 0}]}) -FILTER_WITH_PROPERTIES = BASE_FILTER.shallow_clone({"properties": PROPERTIES_OF_ALL_TYPES}) -FILTER_WITH_GROUPS = BASE_FILTER.shallow_clone({"properties": {"type": "AND", "values": PROPERTIES_OF_ALL_TYPES}}) - - -class TestColumnOptimizer(ClickhouseTestMixin, APIBaseTest): - def setUp(self): - super().setUp() - self.team.test_account_filters = PROPERTIES_OF_ALL_TYPES - self.team.save() - - cleanup_materialized_columns() - - def test_properties_used_in_filter(self): - properties_used_in_filter = lambda filter: EnterpriseColumnOptimizer( - filter, self.team.id - ).properties_used_in_filter - - self.assertEqual(properties_used_in_filter(BASE_FILTER), {}) - self.assertEqual( - properties_used_in_filter(FILTER_WITH_PROPERTIES), - { - ("event_prop", "event", None): 1, - ("person_prop", "person", None): 1, - ("id", "cohort", None): 1, - ("tag_name", "element", None): 1, - ("group_prop", "group", 2): 1, - }, - ) - self.assertEqual( - properties_used_in_filter(FILTER_WITH_GROUPS), - { - ("event_prop", "event", None): 1, - ("person_prop", "person", None): 1, - ("id", "cohort", None): 1, - ("tag_name", "element", None): 1, - ("group_prop", "group", 2): 1, - }, - ) - - # Breakdown cases - filter = BASE_FILTER.shallow_clone({"breakdown": "some_prop", "breakdown_type": "person"}) - self.assertEqual(properties_used_in_filter(filter), {("some_prop", "person", None): 1}) - - filter = BASE_FILTER.shallow_clone({"breakdown": "some_prop", "breakdown_type": "event"}) - self.assertEqual(properties_used_in_filter(filter), {("some_prop", "event", None): 1}) - - filter = BASE_FILTER.shallow_clone({"breakdown": [11], "breakdown_type": "cohort"}) - self.assertEqual(properties_used_in_filter(filter), {}) - - filter = BASE_FILTER.shallow_clone( - { - "breakdown": "some_prop", - "breakdown_type": "group", - "breakdown_group_type_index": 1, - } - ) - self.assertEqual(properties_used_in_filter(filter), {("some_prop", "group", 1): 1}) - - # Funnel Correlation cases - filter = BASE_FILTER.shallow_clone( - { - "funnel_correlation_type": "events", - "funnel_correlation_names": ["random_column"], - } - ) - self.assertEqual(properties_used_in_filter(filter), {}) - - filter = BASE_FILTER.shallow_clone( - { - "funnel_correlation_type": "properties", - "funnel_correlation_names": ["random_column", "$browser"], - } - ) - self.assertEqual( - properties_used_in_filter(filter), - {("random_column", "person", None): 1, ("$browser", "person", None): 1}, - ) - - filter = BASE_FILTER.shallow_clone( - { - "funnel_correlation_type": "properties", - "funnel_correlation_names": ["random_column", "$browser"], - "aggregation_group_type_index": 2, - } - ) - self.assertEqual( - properties_used_in_filter(filter), - {("random_column", "group", 2): 1, ("$browser", "group", 2): 1}, - ) - - filter = BASE_FILTER.shallow_clone({"funnel_correlation_type": "properties"}) - self.assertEqual(properties_used_in_filter(filter), {}) - - filter = Filter( - data={ - "events": [ - { - "id": "$pageview", - "type": "events", - "order": 0, - "math": "sum", - "math_property": "numeric_prop", - "properties": PROPERTIES_OF_ALL_TYPES, - } - ] - } - ) - self.assertEqual( - properties_used_in_filter(filter), - { - ("numeric_prop", "event", None): 1, - ("event_prop", "event", None): 1, - ("person_prop", "person", None): 1, - ("id", "cohort", None): 1, - ("tag_name", "element", None): 1, - ("group_prop", "group", 2): 1, - }, - ) - - filter = Filter( - data={ - "events": [ - { - "id": "$pageview", - "type": "events", - "order": 0, - "math": "unique_group", - "math_group_type_index": 1, - } - ] - } - ) - self.assertEqual(properties_used_in_filter(filter), {("$group_1", "event", None): 1}) - - filter = Filter( - data={ - "events": [ - { - "id": "$pageview", - "type": "events", - "order": 0, - "math": "unique_session", - } - ] - } - ) - self.assertEqual(properties_used_in_filter(filter), {("$session_id", "event", None): 1}) - - def test_properties_used_in_filter_with_actions(self): - action = Action.objects.create( - team=self.team, - steps_json=[ - { - "event": "$autocapture", - "url": "https://example.com/donate", - "url_matching": "exact", - }, - { - "event": "$autocapture", - "tag_name": "button", - "text": "Pay $10", - "properties": [{"key": "$browser", "value": "Chrome", "type": "person"}], - }, - ], - ) - - filter = Filter(data={"actions": [{"id": action.id, "math": "dau"}]}) - self.assertEqual( - EnterpriseColumnOptimizer(filter, self.team.id).properties_used_in_filter, - {("$current_url", "event", None): 1, ("$browser", "person", None): 1}, - ) - - filter = BASE_FILTER.shallow_clone({"exclusions": [{"id": action.id, "type": "actions"}]}) - self.assertEqual( - EnterpriseColumnOptimizer(filter, self.team.id).properties_used_in_filter, - {("$current_url", "event", None): 1, ("$browser", "person", None): 1}, - ) - - retention_filter = RetentionFilter(data={"target_entity": {"id": action.id, "type": "actions"}}) - self.assertEqual( - EnterpriseColumnOptimizer(retention_filter, self.team.id).properties_used_in_filter, - {("$current_url", "event", None): 2, ("$browser", "person", None): 2}, - ) - - def test_materialized_columns_checks(self): - optimizer = lambda: EnterpriseColumnOptimizer(FILTER_WITH_PROPERTIES, self.team.id) - optimizer_groups = lambda: EnterpriseColumnOptimizer(FILTER_WITH_GROUPS, self.team.id) - - self.assertEqual(optimizer().event_columns_to_query, {"properties"}) - self.assertEqual(optimizer().person_columns_to_query, {"properties"}) - self.assertEqual(optimizer_groups().event_columns_to_query, {"properties"}) - self.assertEqual(optimizer_groups().person_columns_to_query, {"properties"}) - - materialize("events", "event_prop") - materialize("person", "person_prop") - - self.assertEqual(optimizer().event_columns_to_query, {"mat_event_prop"}) - self.assertEqual(optimizer().person_columns_to_query, {"pmat_person_prop"}) - self.assertEqual(optimizer_groups().event_columns_to_query, {"mat_event_prop"}) - self.assertEqual(optimizer_groups().person_columns_to_query, {"pmat_person_prop"}) - - def test_materialized_columns_checks_person_on_events(self): - optimizer = lambda: EnterpriseColumnOptimizer( - BASE_FILTER.shallow_clone( - { - "properties": [ - { - "key": "person_prop", - "value": ["value"], - "operator": "exact", - "type": "person", - }, - ] - } - ), - self.team.id, - ) - - self.assertEqual(optimizer().person_on_event_columns_to_query, {"person_properties"}) - - # materialising the props on `person` table should make no difference - materialize("person", "person_prop") - - self.assertEqual(optimizer().person_on_event_columns_to_query, {"person_properties"}) - - materialize("events", "person_prop", table_column="person_properties") - - self.assertEqual(optimizer().person_on_event_columns_to_query, {"mat_pp_person_prop"}) - - def test_group_types_to_query(self): - group_types_to_query = lambda filter: EnterpriseColumnOptimizer(filter, self.team.id).group_types_to_query - - self.assertEqual(group_types_to_query(BASE_FILTER), set()) - self.assertEqual(group_types_to_query(FILTER_WITH_PROPERTIES), {2}) - self.assertEqual(group_types_to_query(FILTER_WITH_GROUPS), {2}) diff --git a/ee/clickhouse/queries/test/test_event_query.py b/ee/clickhouse/queries/test/test_event_query.py deleted file mode 100644 index 216f8471a33..00000000000 --- a/ee/clickhouse/queries/test/test_event_query.py +++ /dev/null @@ -1,744 +0,0 @@ -from freezegun import freeze_time - -from ee.clickhouse.materialized_columns.columns import materialize -from posthog.client import sync_execute -from posthog.models import Action -from posthog.models.cohort import Cohort -from posthog.models.element import Element -from posthog.models.entity import Entity -from posthog.models.filters import Filter -from posthog.models.group.util import create_group -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.models.person import Person -from posthog.queries.trends.trends_event_query import TrendsEventQuery -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - _create_event, - _create_person, - snapshot_clickhouse_queries, -) - - -def _create_cohort(**kwargs): - team = kwargs.pop("team") - name = kwargs.pop("name") - groups = kwargs.pop("groups") - is_static = kwargs.pop("is_static", False) - cohort = Cohort.objects.create(team=team, name=name, groups=groups, is_static=is_static) - return cohort - - -class TestEventQuery(ClickhouseTestMixin, APIBaseTest): - def setUp(self): - super().setUp() - self._create_sample_data() - - def _create_sample_data(self): - distinct_id = "user_one_{}".format(self.team.pk) - _create_person(distinct_ids=[distinct_id], team=self.team) - - _create_event( - event="viewed", - distinct_id=distinct_id, - team=self.team, - timestamp="2021-05-01 00:00:00", - ) - - def _run_query(self, filter: Filter, entity=None): - entity = entity or filter.entities[0] - - query, params = TrendsEventQuery( - filter=filter, - entity=entity, - team=self.team, - person_on_events_mode=self.team.person_on_events_mode, - ).get_query() - - result = sync_execute(query, {**params, **filter.hogql_context.values}) - - return result, query - - @snapshot_clickhouse_queries - def test_basic_event_filter(self): - self._run_query( - Filter( - data={ - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "events": [{"id": "viewed", "order": 0}], - } - ) - ) - - def test_person_properties_filter(self): - filter = Filter( - data={ - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "events": [{"id": "viewed", "order": 0}], - "properties": [ - { - "key": "email", - "value": "@posthog.com", - "operator": "not_icontains", - "type": "person", - }, - {"key": "key", "value": "val"}, - ], - } - ) - - entity = Entity({"id": "viewed", "type": "events"}) - - self._run_query(filter, entity) - - entity = Entity( - { - "id": "viewed", - "type": "events", - "properties": [ - { - "key": "email", - "value": "@posthog.com", - "operator": "not_icontains", - "type": "person", - }, - {"key": "key", "value": "val"}, - ], - } - ) - - filter = Filter( - data={ - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "events": [entity.to_dict()], - } - ) - - self._run_query(filter, entity) - - @snapshot_clickhouse_queries - def test_event_properties_filter(self): - filter = Filter( - data={ - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "events": [{"id": "viewed", "order": 0}], - "properties": [ - { - "key": "some_key", - "value": "test_val", - "operator": "exact", - "type": "event", - } - ], - } - ) - - entity = Entity({"id": "viewed", "type": "events"}) - - self._run_query(filter, entity) - - filter = Filter( - data={ - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "events": [{"id": "viewed", "order": 0}], - } - ) - - entity = Entity( - { - "id": "viewed", - "type": "events", - "properties": [ - { - "key": "some_key", - "value": "test_val", - "operator": "exact", - "type": "event", - } - ], - } - ) - - self._run_query(filter, entity) - - # just smoke test making sure query runs because no new functions are used here - @snapshot_clickhouse_queries - def test_cohort_filter(self): - cohort = _create_cohort( - team=self.team, - name="cohort1", - groups=[{"properties": [{"key": "name", "value": "test", "type": "person"}]}], - ) - - filter = Filter( - data={ - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "events": [{"id": "viewed", "order": 0}], - "properties": [{"key": "id", "value": cohort.pk, "type": "cohort"}], - } - ) - - self._run_query(filter) - - # just smoke test making sure query runs because no new functions are used here - @snapshot_clickhouse_queries - def test_entity_filtered_by_cohort(self): - cohort = _create_cohort( - team=self.team, - name="cohort1", - groups=[{"properties": [{"key": "name", "value": "test", "type": "person"}]}], - ) - - filter = Filter( - data={ - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "events": [ - { - "id": "$pageview", - "order": 0, - "properties": [{"key": "id", "type": "cohort", "value": cohort.pk}], - } - ], - } - ) - - Person.objects.create(team_id=self.team.pk, distinct_ids=["p1"], properties={"name": "test"}) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp="2020-01-02T12:00:00Z", - ) - - Person.objects.create(team_id=self.team.pk, distinct_ids=["p2"], properties={"name": "foo"}) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2020-01-02T12:01:00Z", - ) - - self._run_query(filter) - - # smoke test make sure query is formatted and runs - @snapshot_clickhouse_queries - def test_static_cohort_filter(self): - cohort = _create_cohort(team=self.team, name="cohort1", groups=[], is_static=True) - - filter = Filter( - data={ - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "events": [{"id": "viewed", "order": 0}], - "properties": [{"key": "id", "value": cohort.pk, "type": "cohort"}], - }, - team=self.team, - ) - - self._run_query(filter) - - @snapshot_clickhouse_queries - @freeze_time("2021-01-21") - def test_account_filters(self): - Person.objects.create(team_id=self.team.pk, distinct_ids=["person_1"], properties={"name": "John"}) - Person.objects.create(team_id=self.team.pk, distinct_ids=["person_2"], properties={"name": "Jane"}) - - _create_event(event="event_name", team=self.team, distinct_id="person_1") - _create_event(event="event_name", team=self.team, distinct_id="person_2") - _create_event(event="event_name", team=self.team, distinct_id="person_2") - - cohort = Cohort.objects.create( - team=self.team, - name="cohort1", - groups=[{"properties": [{"key": "name", "value": "Jane", "type": "person"}]}], - ) - cohort.calculate_people_ch(pending_version=0) - - self.team.test_account_filters = [{"key": "id", "value": cohort.pk, "type": "cohort"}] - self.team.save() - - filter = Filter( - data={ - "events": [{"id": "event_name", "order": 0}], - "filter_test_accounts": True, - }, - team=self.team, - ) - - self._run_query(filter) - - def test_action_with_person_property_filter(self): - Person.objects.create(team_id=self.team.pk, distinct_ids=["person_1"], properties={"name": "John"}) - Person.objects.create(team_id=self.team.pk, distinct_ids=["person_2"], properties={"name": "Jane"}) - - _create_event(event="event_name", team=self.team, distinct_id="person_1") - _create_event(event="event_name", team=self.team, distinct_id="person_2") - _create_event(event="event_name", team=self.team, distinct_id="person_2") - - action = Action.objects.create( - team=self.team, - name="action1", - steps_json=[{"event": "event_name", "properties": [{"key": "name", "type": "person", "value": "John"}]}], - ) - - filter = Filter(data={"actions": [{"id": action.id, "type": "actions", "order": 0}]}) - - self._run_query(filter) - - @snapshot_clickhouse_queries - def test_denormalised_props(self): - filters = { - "events": [ - { - "id": "user signed up", - "type": "events", - "order": 0, - "properties": [{"key": "test_prop", "value": "hi"}], - } - ], - "date_from": "2020-01-01", - "properties": [{"key": "test_prop", "value": "hi"}], - "date_to": "2020-01-14", - } - - materialize("events", "test_prop") - - Person.objects.create(team_id=self.team.pk, distinct_ids=["p1"], properties={"key": "value"}) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp="2020-01-02T12:00:00Z", - properties={"test_prop": "hi"}, - ) - - Person.objects.create(team_id=self.team.pk, distinct_ids=["p2"], properties={"key_2": "value_2"}) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2020-01-02T12:00:00Z", - properties={"test_prop": "hi"}, - ) - - filter = Filter(data=filters) - _, query = self._run_query(filter) - self.assertIn("mat_test_prop", query) - - @snapshot_clickhouse_queries - @freeze_time("2021-01-21") - def test_element(self): - _create_event( - event="$autocapture", - team=self.team, - distinct_id="whatever", - properties={"attr": "some_other_val"}, - elements=[ - Element( - tag_name="a", - href="/a-url", - attr_class=["small"], - text="bla bla", - attributes={}, - nth_child=1, - nth_of_type=0, - ), - Element( - tag_name="button", - attr_class=["btn", "btn-primary"], - nth_child=0, - nth_of_type=0, - ), - Element(tag_name="div", nth_child=0, nth_of_type=0), - Element(tag_name="label", nth_child=0, nth_of_type=0, attr_id="nested"), - ], - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="whatever", - properties={"attr": "some_val"}, - elements=[ - Element( - tag_name="a", - href="/a-url", - attr_class=["small"], - text="bla bla", - attributes={}, - nth_child=1, - nth_of_type=0, - ), - Element( - tag_name="button", - attr_class=["btn", "btn-secondary"], - nth_child=0, - nth_of_type=0, - ), - Element(tag_name="div", nth_child=0, nth_of_type=0), - Element(tag_name="img", nth_child=0, nth_of_type=0, attr_id="nested"), - ], - ) - - filter = Filter( - data={ - "events": [{"id": "event_name", "order": 0}], - "properties": [ - { - "key": "tag_name", - "value": ["label"], - "operator": "exact", - "type": "element", - } - ], - } - ) - - self._run_query(filter) - - self._run_query( - filter.shallow_clone( - { - "properties": [ - { - "key": "tag_name", - "value": [], - "operator": "exact", - "type": "element", - } - ] - } - ) - ) - - def _create_groups_test_data(self): - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - GroupTypeMapping.objects.create(team=self.team, group_type="company", group_type_index=1) - - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:5", - properties={"industry": "finance"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:6", - properties={"industry": "technology"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:1", - properties={"another": "value"}, - ) - - Person.objects.create(team_id=self.team.pk, distinct_ids=["p1"], properties={"$browser": "test"}) - Person.objects.create(team_id=self.team.pk, distinct_ids=["p2"], properties={"$browser": "foobar"}) - Person.objects.create(team_id=self.team.pk, distinct_ids=["p3"], properties={"$browser": "test"}) - - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp="2020-01-02T12:00:00Z", - properties={"$group_0": "org:5", "$group_1": "company:1"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2020-01-02T12:00:00Z", - properties={"$group_0": "org:6", "$group_1": "company:1"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp="2020-01-02T12:00:00Z", - properties={"$group_0": "org:6"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p3", - timestamp="2020-01-02T12:00:00Z", - properties={"$group_0": "org:5"}, - ) - - @snapshot_clickhouse_queries - def test_groups_filters(self): - self._create_groups_test_data() - - filter = Filter( - { - "date_from": "2020-01-01T00:00:00Z", - "date_to": "2020-01-12T00:00:00Z", - "events": [{"id": "$pageview", "type": "events", "order": 0}], - "properties": [ - { - "key": "industry", - "value": "finance", - "type": "group", - "group_type_index": 0, - }, - { - "key": "another", - "value": "value", - "type": "group", - "group_type_index": 1, - }, - ], - }, - team=self.team, - ) - - results, _ = self._run_query(filter) - self.assertEqual(len(results), 1) - - @snapshot_clickhouse_queries - def test_groups_filters_mixed(self): - self._create_groups_test_data() - - filter = Filter( - { - "date_from": "2020-01-01T00:00:00Z", - "date_to": "2020-01-12T00:00:00Z", - "events": [{"id": "$pageview", "type": "events", "order": 0}], - "properties": [ - { - "key": "industry", - "value": "finance", - "type": "group", - "group_type_index": 0, - }, - {"key": "$browser", "value": "test", "type": "person"}, - ], - }, - team=self.team, - ) - - results, _ = self._run_query(filter) - self.assertEqual(len(results), 2) - - @snapshot_clickhouse_queries - def test_entity_filtered_by_session_duration(self): - filter = Filter( - data={ - "date_from": "2021-05-02 00:00:00", - "date_to": "2021-05-03 00:00:00", - "events": [ - { - "id": "$pageview", - "order": 0, - "properties": [ - { - "key": "$session_duration", - "type": "session", - "operator": "gt", - "value": 90, - } - ], - } - ], - } - ) - - event_timestamp_str = "2021-05-02 00:01:00" - - # Session starts before the date_from - _create_event( - team=self.team, - event="start", - distinct_id="p1", - timestamp="2021-05-01 23:59:00", - properties={"$session_id": "1abc"}, - ) - # Event that should be returned - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp=event_timestamp_str, - properties={"$session_id": "1abc"}, - ) - - # Event in a session that's too short - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2021-05-02 00:02:00", - properties={"$session_id": "2abc"}, - ) - _create_event( - team=self.team, - event="final_event", - distinct_id="p2", - timestamp="2021-05-02 00:02:01", - properties={"$session_id": "2abc"}, - ) - - # Event with no session - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2021-05-02 00:02:00", - ) - - results, _ = self._run_query(filter) - self.assertEqual(len(results), 1) - self.assertEqual(results[0][0].strftime("%Y-%m-%d %H:%M:%S"), event_timestamp_str) - - @snapshot_clickhouse_queries - def test_entity_filtered_by_multiple_session_duration_filters(self): - filter = Filter( - data={ - "date_from": "2021-05-02 00:00:00", - "date_to": "2021-05-03 00:00:00", - "events": [ - { - "id": "$pageview", - "order": 0, - "properties": [ - { - "key": "$session_duration", - "type": "session", - "operator": "gt", - "value": 90, - }, - { - "key": "$session_duration", - "type": "session", - "operator": "lt", - "value": 150, - }, - ], - } - ], - } - ) - - event_timestamp_str = "2021-05-02 00:01:00" - - # 120s session - _create_event( - team=self.team, - event="start", - distinct_id="p1", - timestamp="2021-05-01 23:59:00", - properties={"$session_id": "1abc"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp=event_timestamp_str, - properties={"$session_id": "1abc"}, - ) - - # 1s session (too short) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2021-05-02 00:02:00", - properties={"$session_id": "2abc"}, - ) - _create_event( - team=self.team, - event="final_event", - distinct_id="p2", - timestamp="2021-05-02 00:02:01", - properties={"$session_id": "2abc"}, - ) - - # 600s session (too long) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2021-05-02 00:02:00", - properties={"$session_id": "3abc"}, - ) - _create_event( - team=self.team, - event="final_event", - distinct_id="p2", - timestamp="2021-05-02 00:07:00", - properties={"$session_id": "3abc"}, - ) - - results, _ = self._run_query(filter) - self.assertEqual(len(results), 1) - self.assertEqual(results[0][0].strftime("%Y-%m-%d %H:%M:%S"), event_timestamp_str) - - @snapshot_clickhouse_queries - def test_unique_session_math_filtered_by_session_duration(self): - filter = Filter( - data={ - "date_from": "2021-05-02 00:00:00", - "date_to": "2021-05-03 00:00:00", - "events": [ - { - "id": "$pageview", - "math": "unique_session", - "order": 0, - "properties": [ - { - "key": "$session_duration", - "type": "session", - "operator": "gt", - "value": 30, - } - ], - } - ], - } - ) - - event_timestamp_str = "2021-05-02 00:01:00" - - # Session that should be returned - _create_event( - team=self.team, - event="start", - distinct_id="p1", - timestamp="2021-05-02 00:00:00", - properties={"$session_id": "1abc"}, - ) - _create_event( - team=self.team, - event="$pageview", - distinct_id="p1", - timestamp=event_timestamp_str, - properties={"$session_id": "1abc"}, - ) - - # Session that's too short - _create_event( - team=self.team, - event="$pageview", - distinct_id="p2", - timestamp="2021-05-02 00:02:00", - properties={"$session_id": "2abc"}, - ) - _create_event( - team=self.team, - event="final_event", - distinct_id="p2", - timestamp="2021-05-02 00:02:01", - properties={"$session_id": "2abc"}, - ) - - results, _ = self._run_query(filter) - self.assertEqual(len(results), 1) - self.assertEqual(results[0][0].strftime("%Y-%m-%d %H:%M:%S"), event_timestamp_str) diff --git a/ee/clickhouse/queries/test/test_experiments.py b/ee/clickhouse/queries/test/test_experiments.py deleted file mode 100644 index 95aacc8ca0d..00000000000 --- a/ee/clickhouse/queries/test/test_experiments.py +++ /dev/null @@ -1,235 +0,0 @@ -import json -import unittest -from ee.clickhouse.queries.experiments.funnel_experiment_result import ( - validate_event_variants as validate_funnel_event_variants, -) -from ee.clickhouse.queries.experiments.trend_experiment_result import ( - validate_event_variants as validate_trend_event_variants, -) -from rest_framework.exceptions import ValidationError - -from posthog.constants import ExperimentNoResultsErrorKeys - - -class TestFunnelExperiments(unittest.TestCase): - def test_validate_event_variants_no_events(self): - funnel_results = [] - - expected_errors = json.dumps( - { - ExperimentNoResultsErrorKeys.NO_EVENTS: True, - ExperimentNoResultsErrorKeys.NO_FLAG_INFO: True, - ExperimentNoResultsErrorKeys.NO_CONTROL_VARIANT: True, - ExperimentNoResultsErrorKeys.NO_TEST_VARIANT: True, - } - ) - - with self.assertRaises(ValidationError) as context: - validate_funnel_event_variants(funnel_results, ["test", "control"]) - - self.assertEqual(context.exception.detail[0], expected_errors) - - def test_validate_event_variants_no_control(self): - funnel_results = [ - [ - { - "action_id": "funnel-step-1", - "name": "funnel-step-1", - "order": 0, - "breakdown": ["test"], - "breakdown_value": ["test"], - }, - { - "action_id": "funnel-step-2", - "name": "funnel-step-2", - "order": 1, - "breakdown": ["test"], - "breakdown_value": ["test"], - }, - ] - ] - - expected_errors = json.dumps( - { - ExperimentNoResultsErrorKeys.NO_EVENTS: False, - ExperimentNoResultsErrorKeys.NO_FLAG_INFO: False, - ExperimentNoResultsErrorKeys.NO_CONTROL_VARIANT: True, - ExperimentNoResultsErrorKeys.NO_TEST_VARIANT: False, - } - ) - - with self.assertRaises(ValidationError) as context: - validate_funnel_event_variants(funnel_results, ["test", "control"]) - - self.assertEqual(context.exception.detail[0], expected_errors) - - def test_validate_event_variants_no_test(self): - funnel_results = [ - [ - { - "action_id": "funnel-step-1", - "name": "funnel-step-1", - "order": 0, - "breakdown": ["control"], - "breakdown_value": ["control"], - }, - { - "action_id": "funnel-step-2", - "name": "funnel-step-2", - "order": 1, - "breakdown": ["control"], - "breakdown_value": ["control"], - }, - ] - ] - - expected_errors = json.dumps( - { - ExperimentNoResultsErrorKeys.NO_EVENTS: False, - ExperimentNoResultsErrorKeys.NO_FLAG_INFO: False, - ExperimentNoResultsErrorKeys.NO_CONTROL_VARIANT: False, - ExperimentNoResultsErrorKeys.NO_TEST_VARIANT: True, - } - ) - - with self.assertRaises(ValidationError) as context: - validate_funnel_event_variants(funnel_results, ["test", "control"]) - - self.assertEqual(context.exception.detail[0], expected_errors) - - def test_validate_event_variants_no_flag_info(self): - funnel_results = [ - [ - { - "action_id": "funnel-step-1", - "name": "funnel-step-1", - "order": 0, - "breakdown": [""], - "breakdown_value": [""], - }, - { - "action_id": "funnel-step-2", - "name": "funnel-step-2", - "order": 1, - "breakdown": [""], - "breakdown_value": [""], - }, - ] - ] - - expected_errors = json.dumps( - { - ExperimentNoResultsErrorKeys.NO_EVENTS: False, - ExperimentNoResultsErrorKeys.NO_FLAG_INFO: True, - ExperimentNoResultsErrorKeys.NO_CONTROL_VARIANT: True, - ExperimentNoResultsErrorKeys.NO_TEST_VARIANT: True, - } - ) - - with self.assertRaises(ValidationError) as context: - validate_funnel_event_variants(funnel_results, ["test", "control"]) - - self.assertEqual(context.exception.detail[0], expected_errors) - - -class TestTrendExperiments(unittest.TestCase): - def test_validate_event_variants_no_events(self): - trend_results = [] - - expected_errors = json.dumps( - { - ExperimentNoResultsErrorKeys.NO_EVENTS: True, - ExperimentNoResultsErrorKeys.NO_FLAG_INFO: True, - ExperimentNoResultsErrorKeys.NO_CONTROL_VARIANT: True, - ExperimentNoResultsErrorKeys.NO_TEST_VARIANT: True, - } - ) - - with self.assertRaises(ValidationError) as context: - validate_trend_event_variants(trend_results, ["test", "control"]) - - self.assertEqual(context.exception.detail[0], expected_errors) - - def test_validate_event_variants_no_control(self): - trend_results = [ - { - "action": { - "id": "trend-event", - "type": "events", - "order": 0, - "name": "trend-event", - }, - "label": "test_1", - "breakdown_value": "test_1", - } - ] - - expected_errors = json.dumps( - { - ExperimentNoResultsErrorKeys.NO_EVENTS: False, - ExperimentNoResultsErrorKeys.NO_FLAG_INFO: False, - ExperimentNoResultsErrorKeys.NO_CONTROL_VARIANT: True, - ExperimentNoResultsErrorKeys.NO_TEST_VARIANT: False, - } - ) - - with self.assertRaises(ValidationError) as context: - validate_trend_event_variants(trend_results, ["control", "test_1", "test_2"]) - - self.assertEqual(context.exception.detail[0], expected_errors) - - def test_validate_event_variants_no_test(self): - trend_results = [ - { - "action": { - "id": "trend-event", - "type": "events", - "order": 0, - "name": "trend-event", - }, - "label": "control", - "breakdown_value": "control", - } - ] - - expected_errors = json.dumps( - { - ExperimentNoResultsErrorKeys.NO_EVENTS: False, - ExperimentNoResultsErrorKeys.NO_FLAG_INFO: False, - ExperimentNoResultsErrorKeys.NO_CONTROL_VARIANT: False, - ExperimentNoResultsErrorKeys.NO_TEST_VARIANT: True, - } - ) - - with self.assertRaises(ValidationError) as context: - validate_trend_event_variants(trend_results, ["control", "test_1", "test_2"]) - - self.assertEqual(context.exception.detail[0], expected_errors) - - def test_validate_event_variants_no_flag_info(self): - trend_results = [ - { - "action": { - "id": "trend-event", - "type": "events", - "order": 0, - "name": "trend-event", - }, - "label": "", - "breakdown_value": "", - } - ] - - expected_errors = json.dumps( - { - ExperimentNoResultsErrorKeys.NO_EVENTS: False, - ExperimentNoResultsErrorKeys.NO_FLAG_INFO: True, - ExperimentNoResultsErrorKeys.NO_CONTROL_VARIANT: True, - ExperimentNoResultsErrorKeys.NO_TEST_VARIANT: True, - } - ) - - with self.assertRaises(ValidationError) as context: - validate_trend_event_variants(trend_results, ["control", "test_1", "test_2"]) - - self.assertEqual(context.exception.detail[0], expected_errors) diff --git a/ee/clickhouse/queries/test/test_groups_join_query.py b/ee/clickhouse/queries/test/test_groups_join_query.py deleted file mode 100644 index 1564cf8f50a..00000000000 --- a/ee/clickhouse/queries/test/test_groups_join_query.py +++ /dev/null @@ -1,48 +0,0 @@ -from ee.clickhouse.queries.groups_join_query import GroupsJoinQuery -from posthog.models.filters import Filter - - -def test_groups_join_query_blank(): - filter = Filter(data={"properties": []}) - - assert GroupsJoinQuery(filter, 2).get_join_query() == ("", {}) - - -def test_groups_join_query_filtering(snapshot): - filter = Filter( - data={ - "properties": [ - { - "key": "industry", - "value": "finance", - "type": "group", - "group_type_index": 0, - } - ] - } - ) - - assert GroupsJoinQuery(filter, 2).get_join_query() == snapshot - - -def test_groups_join_query_filtering_with_custom_key_names(snapshot): - filter = Filter( - data={ - "properties": [ - { - "key": "industry", - "value": "finance", - "type": "group", - "group_type_index": 0, - }, - { - "key": "company", - "value": "crashed", - "type": "group", - "group_type_index": 2, - }, - ] - } - ) - - assert GroupsJoinQuery(filter, 2, join_key="call_me_industry").get_join_query() == snapshot diff --git a/ee/clickhouse/queries/test/test_lifecycle.py b/ee/clickhouse/queries/test/test_lifecycle.py deleted file mode 100644 index 550505a9429..00000000000 --- a/ee/clickhouse/queries/test/test_lifecycle.py +++ /dev/null @@ -1,296 +0,0 @@ -from datetime import datetime, timedelta - -from django.utils.timezone import now -from freezegun.api import freeze_time - -from posthog.constants import FILTER_TEST_ACCOUNTS, TRENDS_LIFECYCLE -from posthog.models.filters.filter import Filter -from posthog.models.group.util import create_group -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.models.person import Person -from posthog.queries.test.test_lifecycle import TestLifecycleBase -from posthog.queries.trends.trends import Trends -from posthog.test.base import ( - also_test_with_materialized_columns, - snapshot_clickhouse_queries, -) -from posthog.test.test_journeys import journeys_for - - -class TestClickhouseLifecycle(TestLifecycleBase): - @snapshot_clickhouse_queries - def test_test_account_filters_with_groups(self): - self.team.test_account_filters = [{"key": "key", "type": "group", "value": "value", "group_type_index": 0}] - self.team.save() - - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - create_group( - self.team.pk, - group_type_index=0, - group_key="in", - properties={"key": "value"}, - ) - create_group( - self.team.pk, - group_type_index=0, - group_key="out", - properties={"key": "othervalue"}, - ) - - with freeze_time("2020-01-11T12:00:00Z"): - Person.objects.create(distinct_ids=["person1"], team_id=self.team.pk) - - with freeze_time("2020-01-09T12:00:00Z"): - Person.objects.create(distinct_ids=["person2"], team_id=self.team.pk) - - journeys_for( - { - "person1": [ - { - "event": "$pageview", - "timestamp": datetime(2020, 1, 11, 12), - "properties": {"$group_0": "out"}, - } - ], - "person2": [ - { - "event": "$pageview", - "timestamp": datetime(2020, 1, 9, 12), - "properties": {"$group_0": "in"}, - }, - { - "event": "$pageview", - "timestamp": datetime(2020, 1, 12, 12), - "properties": {"$group_0": "in"}, - }, - { - "event": "$pageview", - "timestamp": datetime(2020, 1, 15, 12), - "properties": {"$group_0": "in"}, - }, - ], - }, - self.team, - ) - result = Trends().run( - Filter( - data={ - "date_from": "2020-01-12T00:00:00Z", - "date_to": "2020-01-19T00:00:00Z", - "events": [{"id": "$pageview", "type": "events", "order": 0}], - "shown_as": TRENDS_LIFECYCLE, - FILTER_TEST_ACCOUNTS: True, - }, - team=self.team, - ), - self.team, - ) - - self.assertLifecycleResults( - result, - [ - {"status": "dormant", "data": [0, -1, 0, 0, -1, 0, 0, 0]}, - {"status": "new", "data": [0, 0, 0, 0, 0, 0, 0, 0]}, - {"status": "resurrecting", "data": [1, 0, 0, 1, 0, 0, 0, 0]}, - {"status": "returning", "data": [0, 0, 0, 0, 0, 0, 0, 0]}, - ], - ) - - @snapshot_clickhouse_queries - def test_lifecycle_edge_cases(self): - # This test tests behavior when created_at is different from first matching event and dormant/resurrecting/returning logic - with freeze_time("2020-01-11T12:00:00Z"): - Person.objects.create(distinct_ids=["person1"], team_id=self.team.pk) - - journeys_for( - { - "person1": [ - {"event": "$pageview", "timestamp": datetime(2020, 1, 12, 12)}, - {"event": "$pageview", "timestamp": datetime(2020, 1, 13, 12)}, - {"event": "$pageview", "timestamp": datetime(2020, 1, 15, 12)}, - {"event": "$pageview", "timestamp": datetime(2020, 1, 16, 12)}, - ] - }, - self.team, - ) - - result = Trends().run( - Filter( - data={ - "date_from": "2020-01-11T00:00:00Z", - "date_to": "2020-01-18T00:00:00Z", - "events": [{"id": "$pageview", "type": "events", "order": 0}], - "shown_as": TRENDS_LIFECYCLE, - }, - team=self.team, - ), - self.team, - ) - - self.assertLifecycleResults( - result, - [ - {"status": "dormant", "data": [0, 0, 0, -1, 0, 0, -1, 0]}, - {"status": "new", "data": [0, 0, 0, 0, 0, 0, 0, 0]}, - {"status": "resurrecting", "data": [0, 1, 0, 0, 1, 0, 0, 0]}, - {"status": "returning", "data": [0, 0, 1, 0, 0, 1, 0, 0]}, - ], - ) - - @snapshot_clickhouse_queries - def test_interval_dates_days(self): - with freeze_time("2021-05-05T12:00:00Z"): - self._setup_returning_lifecycle_data(20) - - result = self._run_lifecycle({"date_from": "-7d", "interval": "day"}) - - self.assertLifecycleResults( - result, - [ - {"status": "dormant", "data": [0] * 8}, - {"status": "new", "data": [0] * 8}, - {"status": "resurrecting", "data": [0] * 8}, - {"status": "returning", "data": [1] * 8}, - ], - ) - self.assertEqual( - result[0]["days"], - [ - "2021-04-28", - "2021-04-29", - "2021-04-30", - "2021-05-01", - "2021-05-02", - "2021-05-03", - "2021-05-04", - "2021-05-05", - ], - ) - - @snapshot_clickhouse_queries - def test_interval_dates_weeks(self): - with freeze_time("2021-05-06T12:00:00Z"): - self._setup_returning_lifecycle_data(50) - - result = self._run_lifecycle({"date_from": "-30d", "interval": "week"}) - - self.assertLifecycleResults( - result, - [ - {"status": "dormant", "data": [0] * 5}, - {"status": "new", "data": [0] * 5}, - {"status": "resurrecting", "data": [0] * 5}, - {"status": "returning", "data": [1] * 5}, - ], - ) - self.assertEqual( - result[0]["days"], - ["2021-04-05", "2021-04-12", "2021-04-19", "2021-04-26", "2021-05-03"], - ) - - @snapshot_clickhouse_queries - def test_interval_dates_months(self): - with freeze_time("2021-05-05T12:00:00Z"): - self._setup_returning_lifecycle_data(120) - - result = self._run_lifecycle({"date_from": "-90d", "interval": "month"}) - - self.assertLifecycleResults( - result, - [ - {"status": "dormant", "data": [0] * 4}, - {"status": "new", "data": [0] * 4}, - {"status": "resurrecting", "data": [0] * 4}, - {"status": "returning", "data": [1] * 4}, - ], - ) - self.assertEqual(result[0]["days"], ["2021-02-01", "2021-03-01", "2021-04-01", "2021-05-01"]) - - @also_test_with_materialized_columns(event_properties=["$current_url"]) - @snapshot_clickhouse_queries - def test_lifecycle_hogql_event_properties(self): - with freeze_time("2021-05-05T12:00:00Z"): - self._setup_returning_lifecycle_data(20) - result = self._run_lifecycle( - { - "date_from": "-7d", - "interval": "day", - "properties": [ - { - "key": "like(properties.$current_url, '%example%') and 'bla' != 'a%sd'", - "type": "hogql", - }, - ], - } - ) - self.assertLifecycleResults( - result, - [ - {"status": "dormant", "data": [0] * 8}, - {"status": "new", "data": [0] * 8}, - {"status": "resurrecting", "data": [0] * 8}, - {"status": "returning", "data": [1] * 8}, - ], - ) - - @also_test_with_materialized_columns(event_properties=[], person_properties=["email"]) - @snapshot_clickhouse_queries - def test_lifecycle_hogql_person_properties(self): - with freeze_time("2021-05-05T12:00:00Z"): - self._setup_returning_lifecycle_data(20) - result = self._run_lifecycle( - { - "date_from": "-7d", - "interval": "day", - "properties": [ - { - "key": "like(person.properties.email, '%test.com')", - "type": "hogql", - }, - ], - } - ) - - self.assertLifecycleResults( - result, - [ - {"status": "dormant", "data": [0] * 8}, - {"status": "new", "data": [0] * 8}, - {"status": "resurrecting", "data": [0] * 8}, - {"status": "returning", "data": [1] * 8}, - ], - ) - - def _setup_returning_lifecycle_data(self, days): - with freeze_time("2019-01-01T12:00:00Z"): - Person.objects.create( - distinct_ids=["person1"], - team_id=self.team.pk, - properties={"email": "person@test.com"}, - ) - - journeys_for( - { - "person1": [ - { - "event": "$pageview", - "timestamp": (now() - timedelta(days=n)).strftime("%Y-%m-%d %H:%M:%S.%f"), - "properties": {"$current_url": "http://example.com"}, - } - for n in range(days) - ] - }, - self.team, - create_people=False, - ) - - def _run_lifecycle(self, data): - filter = Filter( - data={ - "events": [{"id": "$pageview", "type": "events", "order": 0}], - "shown_as": TRENDS_LIFECYCLE, - **data, - }, - team=self.team, - ) - return Trends().run(filter, self.team) diff --git a/ee/clickhouse/queries/test/test_paths.py b/ee/clickhouse/queries/test/test_paths.py deleted file mode 100644 index 1a17f578b7e..00000000000 --- a/ee/clickhouse/queries/test/test_paths.py +++ /dev/null @@ -1,4492 +0,0 @@ -from datetime import timedelta -from unittest.mock import MagicMock -from uuid import UUID - -from django.test import TestCase -from django.utils import timezone -from freezegun import freeze_time - -from posthog.constants import ( - FUNNEL_PATH_AFTER_STEP, - FUNNEL_PATH_BEFORE_STEP, - FUNNEL_PATH_BETWEEN_STEPS, - INSIGHT_FUNNELS, -) -from posthog.models.filters import Filter, PathFilter -from posthog.models.group.util import create_group -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.models.instance_setting import override_instance_config -from posthog.queries.paths import Paths, PathsActors -from posthog.queries.paths.paths_event_query import PathEventQuery -from posthog.session_recordings.queries.test.session_replay_sql import ( - produce_replay_summary, -) -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - _create_event, - _create_person, - also_test_with_materialized_columns, - snapshot_clickhouse_queries, - create_person_id_override_by_distinct_id, -) -import uuid -from django.test import override_settings - -ONE_MINUTE = 60_000 # 1 minute in milliseconds - - -class TestClickhousePaths(ClickhouseTestMixin, APIBaseTest): - maxDiff = None - - def _create_groups(self): - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - GroupTypeMapping.objects.create(team=self.team, group_type="company", group_type_index=1) - - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:5", - properties={"industry": "finance"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:6", - properties={"industry": "technology"}, - ) - - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:1", - properties={"industry": "technology"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:2", - properties={}, - ) - - def _get_people_at_path( - self, - filter, - path_start=None, - path_end=None, - funnel_filter=None, - path_dropoff=None, - ): - person_filter = filter.shallow_clone( - { - "path_start_key": path_start, - "path_end_key": path_end, - "path_dropoff_key": path_dropoff, - } - ) - _, serialized_actors, _ = PathsActors(person_filter, self.team, funnel_filter).get_actors() - return [row["id"] for row in serialized_actors] - - @snapshot_clickhouse_queries - def test_step_limit(self): - p1 = _create_person(team_id=self.team.pk, distinct_ids=["fake"]) - - ( - _create_event( - properties={"$current_url": "/1"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/2"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/3"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/4"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:27:34", - ), - ) - - with freeze_time("2012-01-7T03:21:34.000Z"): - filter = PathFilter(team=self.team, data={"step_limit": 2}) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - } - ], - ) - self.assertEqual([p1.uuid], self._get_people_at_path(filter, "1_/1", "2_/2")) - self.assertEqual([], self._get_people_at_path(filter, "2_/2", "3_/3")) - - with freeze_time("2012-01-7T03:21:34.000Z"): - filter = PathFilter(team=self.team, data={"step_limit": 3}) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/2", - "target": "3_/3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - self.assertEqual([p1.uuid], self._get_people_at_path(filter, "2_/2", "3_/3")) - - with freeze_time("2012-01-7T03:21:34.000Z"): - filter = PathFilter(team=self.team, data={"step_limit": 4}) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/2", - "target": "3_/3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - { - "source": "3_/3", - "target": "4_/4", - "value": 1, - "average_conversion_time": 3 * ONE_MINUTE, - }, - ], - ) - self.assertEqual([p1.uuid], self._get_people_at_path(filter, "1_/1", "2_/2")) - self.assertEqual([p1.uuid], self._get_people_at_path(filter, "2_/2", "3_/3")) - self.assertEqual([p1.uuid], self._get_people_at_path(filter, "3_/3", "4_/4")) - - @snapshot_clickhouse_queries - @freeze_time("2023-05-23T11:00:00.000Z") - def test_step_conversion_times(self): - _create_person(team_id=self.team.pk, distinct_ids=["fake"]) - - ( - _create_event( - properties={"$current_url": "/1"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/2"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/3"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/4"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:27:34", - ), - ) - - _create_person(team_id=self.team.pk, distinct_ids=["fake2"]) - - ( - _create_event( - properties={"$current_url": "/1"}, - distinct_id="fake2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/2"}, - distinct_id="fake2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:23:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/3"}, - distinct_id="fake2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:27:34", - ), - ) - - filter = PathFilter( - team=self.team, - data={ - "step_limit": 4, - "date_from": "2012-01-01", - "include_event_types": ["$pageview"], - }, - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/2", - "value": 2, - "average_conversion_time": 1.5 * ONE_MINUTE, - }, - { - "source": "2_/2", - "target": "3_/3", - "value": 2, - "average_conversion_time": 3 * ONE_MINUTE, - }, - { - "source": "3_/3", - "target": "4_/4", - "value": 1, - "average_conversion_time": 3 * ONE_MINUTE, - }, - ], - ) - - @snapshot_clickhouse_queries - def test_event_ordering(self): - # this tests to make sure that paths don't get scrambled when there are several similar variations - events = [] - for i in range(50): - _create_person(distinct_ids=[f"user_{i}"], team=self.team) - person_events = [ - _create_event( - event="step one", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:00:00", - properties={}, - ), - _create_event( - event="step two", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:01:00", - properties={}, - ), - _create_event( - event="step three", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:02:00", - properties={}, - ), - ] - events.extend(person_events) - - if i % 2 == 0: - events.append( - _create_event( - event="step branch", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:03:00", - properties={}, - ) - ) - - filter = PathFilter( - team=self.team, - data={ - "date_from": "2021-05-01", - "date_to": "2021-05-03", - "include_event_types": ["custom_event"], - }, - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - self.assertEqual( - response, - [ - { - "source": "1_step one", - "target": "2_step two", - "value": 50, - "average_conversion_time": 60000.0, - }, - { - "source": "2_step two", - "target": "3_step three", - "value": 50, - "average_conversion_time": 60000.0, - }, - { - "source": "3_step three", - "target": "4_step branch", - "value": 25, - "average_conversion_time": 60000.0, - }, - ], - ) - - def _create_sample_data_multiple_dropoffs(self, use_groups=False): - events = [] - if use_groups: - self._create_groups() - - for i in range(5): - _create_person(distinct_ids=[f"user_{i}"], team=self.team) - full_funnel = [ - _create_event( - event="step one", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:00:00", - properties={"$group_0": "org:5"} if use_groups else {}, - ), - _create_event( - event="between_step_1_a", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:01:00", - properties={"$group_0": "org:5"} if use_groups else {}, - ), - _create_event( - event="between_step_1_b", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:02:00", - properties={"$group_0": "org:5"} if use_groups else {}, - ), - _create_event( - event="between_step_1_c", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:03:00", - properties={"$group_0": "org:5"} if use_groups else {}, - ), - _create_event( - event="step two", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:04:00", - properties={"$group_0": "org:5"} if use_groups else {}, - ), - _create_event( - event="step three", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:05:00", - properties={"$group_0": "org:5"} if use_groups else {}, - ), - ] - events.extend(full_funnel) - - for i in range(5, 15): - _create_person(distinct_ids=[f"user_{i}"], team=self.team) - two_step_funnel = [ - _create_event( - event="step one", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:00:00", - properties={}, - ), - _create_event( - event="between_step_1_a", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:01:00", - properties={}, - ), - _create_event( - event="between_step_1_b", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:02:00", - properties={}, - ), - _create_event( - event="step two", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:03:00", - properties={}, - ), - _create_event( - event="between_step_2_a", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:04:20", - properties={}, - ), - _create_event( - event="between_step_2_b", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:05:40", - properties={}, - ), - ] - events.extend(two_step_funnel) - - for i in range(15, 35): - _create_person(distinct_ids=[f"user_{i}"], team=self.team) - funnel_branching = [ - _create_event( - event="step one", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:00:00", - properties={"$group_0": "org:6"} if use_groups else {}, - ), - _create_event( - event="step dropoff1", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:01:00", - properties={"$group_0": "org:6"} if use_groups else {}, - ), - _create_event( - event="step dropoff2", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:02:00", - properties={"$group_0": "org:6"} if use_groups else {}, - ), - ] - if i % 2 == 0: - funnel_branching.append( - _create_event( - event="step branch", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:03:00", - properties={"$group_0": "org:6"} if use_groups else {}, - ) - ) - events.extend(funnel_branching) - - @snapshot_clickhouse_queries - def test_wildcard_groups(self): - self._create_sample_data_multiple_dropoffs() - data = { - "insight": INSIGHT_FUNNELS, - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "path_groupings": ["between_step_1_*", "between_step_2_*", "step drop*"], - } - path_filter = PathFilter(data=data, team=self.team) - response = Paths(team=self.team, filter=path_filter).run() - self.assertCountEqual( - response, - [ - { - "source": "1_step one", - "target": "2_step drop*", - "value": 20, - "average_conversion_time": 2 * ONE_MINUTE, - }, - # when we group events for a single user, these effectively become duplicate events, and we choose the last event from - # a list of duplicate events. - { - "source": "1_step one", - "target": "2_between_step_1_*", - "value": 15, - "average_conversion_time": (5 * 3 + 10 * 2) - * ONE_MINUTE - / 15, # first 5 go till between_step_1_c, next 10 go till between_step_1_b - }, - { - "source": "2_between_step_1_*", - "target": "3_step two", - "value": 15, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_step drop*", - "target": "3_step branch", - "value": 10, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "3_step two", - "target": "4_between_step_2_*", - "value": 10, - "average_conversion_time": 160000, - }, - { - "source": "3_step two", - "target": "4_step three", - "value": 5, - "average_conversion_time": ONE_MINUTE, - }, - ], - ) - - @snapshot_clickhouse_queries - def test_team_path_cleaning_rules(self): - _create_person(distinct_ids=[f"user_1"], team=self.team) - _create_person(distinct_ids=[f"user_2"], team=self.team) - _create_person(distinct_ids=[f"user_3"], team=self.team) - - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_1", - "team": self.team, - "timestamp": "2021-05-01 00:00:00", - "properties": {"$current_url": "test.com/step1"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_1", - "team": self.team, - "timestamp": "2021-05-01 00:01:00", - "properties": {"$current_url": "test.com/step2"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_1", - "team": self.team, - "timestamp": "2021-05-01 00:02:00", - "properties": {"$current_url": "test.com/step3?key=value1"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_2", - "team": self.team, - "timestamp": "2021-05-01 00:00:00", - "properties": {"$current_url": "test.com/step1"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_2", - "team": self.team, - "timestamp": "2021-05-01 00:01:00", - "properties": {"$current_url": "test.com/step2"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_2", - "team": self.team, - "timestamp": "2021-05-01 00:02:00", - "properties": {"$current_url": "test.com/step3?key=value2"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_3", - "team": self.team, - "timestamp": "2021-05-01 00:00:00", - "properties": {"$current_url": "test.com/step1"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_3", - "team": self.team, - "timestamp": "2021-05-01 00:01:00", - "properties": {"$current_url": "test.com/step2"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_3", - "team": self.team, - "timestamp": "2021-05-01 00:02:00", - "properties": {"$current_url": "test.com/step3?key=value3"}, - } - ), - ) - - self.team.path_cleaning_filters = [{"alias": "?", "regex": "\\?(.*)"}] - self.team.save() - - data = { - "insight": INSIGHT_FUNNELS, - "include_event_types": ["$pageview"], - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - } - path_filter = PathFilter(data=data, team=self.team) - response_no_flag = Paths(team=self.team, filter=path_filter).run() - - self.assertNotEqual( - response_no_flag, - [ - { - "source": "1_test.com/step1", - "target": "2_test.com/step2", - "value": 3, - "average_conversion_time": 60000.0, - }, - { - "source": "2_test.com/step2", - "target": "3_test.com/step3?", - "value": 3, - "average_conversion_time": 60000.0, - }, - ], - ) - - # data.update({"path_replacements": "true"}) - path_filter = path_filter.shallow_clone({"path_replacements": "true"}) - response = Paths(team=self.team, filter=path_filter).run() - - self.assertEqual( - response, - [ - { - "source": "1_test.com/step1", - "target": "2_test.com/step2", - "value": 3, - "average_conversion_time": 60000.0, - }, - { - "source": "2_test.com/step2", - "target": "3_test.com/step3?", - "value": 3, - "average_conversion_time": 60000.0, - }, - ], - ) - - @snapshot_clickhouse_queries - def test_team_and_local_path_cleaning_rules(self): - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_1", - "team": self.team, - "timestamp": "2021-05-01 00:00:00", - "properties": {"$current_url": "test.com/step1"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_1", - "team": self.team, - "timestamp": "2021-05-01 00:01:00", - "properties": {"$current_url": "test.com/step2/5"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_1", - "team": self.team, - "timestamp": "2021-05-01 00:02:00", - "properties": {"$current_url": "test.com/step2/5?key=value1"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_2", - "team": self.team, - "timestamp": "2021-05-01 00:00:00", - "properties": {"$current_url": "test.com/step1"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_2", - "team": self.team, - "timestamp": "2021-05-01 00:01:00", - "properties": {"$current_url": "test.com/step2/5"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_2", - "team": self.team, - "timestamp": "2021-05-01 00:02:00", - "properties": {"$current_url": "test.com/step2/5?key=value2"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_3", - "team": self.team, - "timestamp": "2021-05-01 00:00:00", - "properties": {"$current_url": "test.com/step1"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_3", - "team": self.team, - "timestamp": "2021-05-01 00:01:00", - "properties": {"$current_url": "test.com/step2/5"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_3", - "team": self.team, - "timestamp": "2021-05-01 00:02:00", - "properties": {"$current_url": "test.com/step2/5?key=value3"}, - } - ), - ) - - _create_person(distinct_ids=[f"user_1"], team=self.team) - - _create_person(distinct_ids=[f"user_2"], team=self.team) - - _create_person(distinct_ids=[f"user_3"], team=self.team) - - correct_response = [ - { - "source": "1_test.com/step1", - "target": "2_test.com/step2/", - "value": 3, - "average_conversion_time": 60000.0, - }, - { - "source": "2_test.com/step2/", - "target": "3_test.com/step2/", - "value": 3, - "average_conversion_time": 60000.0, - }, - ] - - self.team.path_cleaning_filters = [ - {"alias": "?", "regex": "\\?(.*)"}, - {"alias": "/", "regex": "/\\d+(/|\\?)?"}, - ] - self.team.save() - - data = { - "insight": INSIGHT_FUNNELS, - "include_event_types": ["$pageview"], - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "path_replacements": True, - } - path_filter = PathFilter(data=data, team=self.team) - response = Paths(team=self.team, filter=path_filter).run() - self.assertEqual(response, correct_response) - - self.team.path_cleaning_filters = [{"alias": "?", "regex": "\\?(.*)"}] - self.team.save() - - data.update({"local_path_cleaning_filters": [{"alias": "/", "regex": "/\\d+(/|\\?)?"}]}) - path_filter = PathFilter(data=data, team=self.team) - response = Paths(team=self.team, filter=path_filter).run() - self.assertEqual(response, correct_response) - - # overriding team filters - data.update( - { - "path_replacements": False, - "local_path_cleaning_filters": [ - {"alias": "?", "regex": "\\?(.*)"}, - {"alias": "/", "regex": "/\\d+(/|\\?)?"}, - ], - } - ) - path_filter = PathFilter(data=data, team=self.team) - response = Paths(team=self.team, filter=path_filter).run() - self.assertEqual(response, correct_response) - - @snapshot_clickhouse_queries - @freeze_time("2023-05-23T11:00:00.000Z") - def test_path_cleaning_rules_with_wildcard_groups(self): - _create_person(distinct_ids=[f"user_1"], team=self.team) - _create_person(distinct_ids=[f"user_2"], team=self.team) - _create_person(distinct_ids=[f"user_3"], team=self.team) - - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_1", - "team": self.team, - "timestamp": "2021-05-01 00:00:00", - "properties": {"$current_url": "test.com/step1/foo"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_1", - "team": self.team, - "timestamp": "2021-05-01 00:01:00", - "properties": {"$current_url": "test.com/step2"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_1", - "team": self.team, - "timestamp": "2021-05-01 00:02:00", - "properties": {"$current_url": "test.com/step3?key=value1"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_2", - "team": self.team, - "timestamp": "2021-05-01 00:00:00", - "properties": {"$current_url": "test.com/step1/bar"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_2", - "team": self.team, - "timestamp": "2021-05-01 00:01:00", - "properties": {"$current_url": "test.com/step2"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_2", - "team": self.team, - "timestamp": "2021-05-01 00:02:00", - "properties": {"$current_url": "test.com/step3?key=value2"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_3", - "team": self.team, - "timestamp": "2021-05-01 00:00:00", - "properties": {"$current_url": "test.com/step1/baz"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_3", - "team": self.team, - "timestamp": "2021-05-01 00:01:00", - "properties": {"$current_url": "test.com/step2"}, - } - ), - ) - ( - _create_event( - **{ - "event": "$pageview", - "distinct_id": f"user_3", - "team": self.team, - "timestamp": "2021-05-01 00:02:00", - "properties": {"$current_url": "test.com/step3?key=value3"}, - } - ), - ) - - data = { - "insight": INSIGHT_FUNNELS, - "include_event_types": ["$pageview"], - "path_groupings": ["/step1"], - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "local_path_cleaning_filters": [{"alias": "?", "regex": "\\?(.*)"}], - "start_point": "/step1", - } - path_filter = PathFilter(data=data, team=self.team) - response = Paths(team=self.team, filter=path_filter).run() - - self.assertEqual( - response, - [ - { - "source": "1_/step1", - "target": "2_test.com/step2", - "value": 3, - "average_conversion_time": 60000.0, - }, - { - "source": "2_test.com/step2", - "target": "3_test.com/step3?", - "value": 3, - "average_conversion_time": 60000.0, - }, - ], - ) - - @snapshot_clickhouse_queries - def test_by_funnel_after_dropoff(self): - self._create_sample_data_multiple_dropoffs() - data = { - "insight": INSIGHT_FUNNELS, - "funnel_paths": FUNNEL_PATH_AFTER_STEP, - "interval": "day", - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "funnel_window_interval": 7, - "funnel_window_interval_unit": "day", - "funnel_step": -2, - "events": [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ], - } - funnel_filter = Filter(data=data, team=self.team) - path_filter = PathFilter(data=data, team=self.team) - response = Paths(team=self.team, filter=path_filter, funnel_filter=funnel_filter).run() - self.assertEqual( - response, - [ - { - "source": "1_step one", - "target": "2_step dropoff1", - "value": 20, - "average_conversion_time": 60000.0, - }, - { - "source": "2_step dropoff1", - "target": "3_step dropoff2", - "value": 20, - "average_conversion_time": 60000.0, - }, - { - "source": "3_step dropoff2", - "target": "4_step branch", - "value": 10, - "average_conversion_time": 60000.0, - }, - ], - ) - self.assertEqual( - 20, - len(self._get_people_at_path(path_filter, "1_step one", "2_step dropoff1", funnel_filter)), - ) - self.assertEqual( - 20, - len(self._get_people_at_path(path_filter, "2_step dropoff1", "3_step dropoff2", funnel_filter)), - ) - self.assertEqual( - 10, - len(self._get_people_at_path(path_filter, "3_step dropoff2", "4_step branch", funnel_filter)), - ) - self.assertEqual( - 0, - len(self._get_people_at_path(path_filter, "4_step branch", "3_step dropoff2", funnel_filter)), - ) - - @snapshot_clickhouse_queries - def test_by_funnel_after_dropoff_with_group_filter(self): - # complex case, joins funnel_actors and groups - self._create_sample_data_multiple_dropoffs(use_groups=True) - data = { - "insight": INSIGHT_FUNNELS, - "funnel_paths": FUNNEL_PATH_AFTER_STEP, - "interval": "day", - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "funnel_window_interval": 7, - "funnel_window_interval_unit": "day", - "funnel_step": -2, - "events": [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ], - } - funnel_filter = Filter(data=data, team=self.team) - # passing group properties to funnel filter defeats purpose of test - path_filter = PathFilter(data=data, team=self.team).shallow_clone( - { - "properties": [ - { - "key": "industry", - "value": "technology", - "type": "group", - "group_type_index": 0, - } - ] - } - ) - response = Paths(team=self.team, filter=path_filter, funnel_filter=funnel_filter).run() - self.assertEqual( - response, - [ - { - "source": "1_step one", - "target": "2_step dropoff1", - "value": 20, - "average_conversion_time": 60000.0, - }, - { - "source": "2_step dropoff1", - "target": "3_step dropoff2", - "value": 20, - "average_conversion_time": 60000.0, - }, - { - "source": "3_step dropoff2", - "target": "4_step branch", - "value": 10, - "average_conversion_time": 60000.0, - }, - ], - ) - self.assertEqual( - 20, - len(self._get_people_at_path(path_filter, "1_step one", "2_step dropoff1", funnel_filter)), - ) - self.assertEqual( - 20, - len(self._get_people_at_path(path_filter, "2_step dropoff1", "3_step dropoff2", funnel_filter)), - ) - self.assertEqual( - 10, - len(self._get_people_at_path(path_filter, "3_step dropoff2", "4_step branch", funnel_filter)), - ) - self.assertEqual( - 0, - len(self._get_people_at_path(path_filter, "4_step branch", "3_step dropoff2", funnel_filter)), - ) - - def test_by_funnel_after_step_respects_conversion_window(self): - # note events happen after 1 day - events = [] - for i in range(5): - _create_person(distinct_ids=[f"user_{i}"], team=self.team) - events.extend( - [ - _create_event( - **{ - "event": "step one", - "distinct_id": f"user_{i}", - "team": self.team, - "timestamp": "2021-05-01 00:00:00", - "properties": {}, - } - ), - _create_event( - **{ - "event": "between_step_1_a", - "distinct_id": f"user_{i}", - "team": self.team, - "timestamp": "2021-05-02 00:00:00", - "properties": {}, - } - ), - _create_event( - **{ - "event": "between_step_1_b", - "distinct_id": f"user_{i}", - "team": self.team, - "timestamp": "2021-05-03 00:00:00", - "properties": {}, - } - ), - _create_event( - **{ - "event": "between_step_1_c", - "distinct_id": f"user_{i}", - "team": self.team, - "timestamp": "2021-05-04 00:00:00", - "properties": {}, - } - ), - _create_event( - **{ - "event": "step two", - "distinct_id": f"user_{i}", - "team": self.team, - "timestamp": "2021-05-05 00:00:00", - "properties": {}, - } - ), - _create_event( - **{ - "event": "step three", - "distinct_id": f"user_{i}", - "team": self.team, - "timestamp": "2021-05-06 00:00:00", - "properties": {}, - } - ), - ] - ) - for i in range(15, 35): - _create_person(distinct_ids=[f"user_{i}"], team=self.team) - events.extend( - [ - _create_event( - **{ - "event": "step one", - "distinct_id": f"user_{i}", - "team": self.team, - "timestamp": "2021-05-01 00:00:00", - "properties": {}, - } - ), - _create_event( - **{ - "event": "step dropoff1", - "distinct_id": f"user_{i}", - "team": self.team, - "timestamp": "2021-05-02 00:00:00", - "properties": {}, - } - ), - _create_event( - **{ - "event": "step dropoff2", - "distinct_id": f"user_{i}", - "team": self.team, - "timestamp": "2021-05-03 00:00:00", - "properties": {}, - } - ), - ] - ) - if i % 2 == 0: - events.extend( - [ - _create_event( - **{ - "event": "step branch", - "distinct_id": f"user_{i}", - "team": self.team, - "timestamp": "2021-05-04 00:00:00", - "properties": {}, - } - ) - ] - ) - - data = { - "insight": INSIGHT_FUNNELS, - "funnel_paths": FUNNEL_PATH_AFTER_STEP, - "interval": "day", - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "funnel_window_interval": 7, - "funnel_window_interval_unit": "day", - "funnel_step": -2, - "events": [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ], - } - funnel_filter = Filter(data=data, team=self.team) - path_filter = PathFilter(data=data, team=self.team) - response = Paths(team=self.team, filter=path_filter, funnel_filter=funnel_filter).run() - self.assertEqual( - response, - [ - { - "source": "1_step one", - "target": "2_step dropoff1", - "value": 20, - "average_conversion_time": ONE_MINUTE * 60 * 24, - }, - { - "source": "2_step dropoff1", - "target": "3_step dropoff2", - "value": 20, - "average_conversion_time": ONE_MINUTE * 60 * 24, - }, - { - "source": "3_step dropoff2", - "target": "4_step branch", - "value": 10, - "average_conversion_time": ONE_MINUTE * 60 * 24, - }, - ], - ) - self.assertEqual( - 20, - len(self._get_people_at_path(path_filter, "1_step one", "2_step dropoff1", funnel_filter)), - ) - self.assertEqual( - 20, - len(self._get_people_at_path(path_filter, "2_step dropoff1", "3_step dropoff2", funnel_filter)), - ) - self.assertEqual( - 10, - len(self._get_people_at_path(path_filter, "3_step dropoff2", "4_step branch", funnel_filter)), - ) - self.assertEqual( - 0, - len(self._get_people_at_path(path_filter, "4_step branch", "3_step dropoff2", funnel_filter)), - ) - - @snapshot_clickhouse_queries - def test_by_funnel_after_step(self): - self._create_sample_data_multiple_dropoffs() - data = { - "insight": INSIGHT_FUNNELS, - "funnel_paths": FUNNEL_PATH_AFTER_STEP, - "interval": "day", - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "funnel_window_interval": 7, - "funnel_window_interval_unit": "day", - "funnel_step": 2, - "events": [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ], - } - funnel_filter = Filter(data=data, team=self.team) - path_filter = PathFilter(data=data, team=self.team) - response = Paths(team=self.team, filter=path_filter, funnel_filter=funnel_filter).run() - self.assertEqual( - response, - [ - { - "source": "1_step two", - "target": "2_between_step_2_a", - "value": 10, - "average_conversion_time": 80000.0, - }, - { - "source": "2_between_step_2_a", - "target": "3_between_step_2_b", - "value": 10, - "average_conversion_time": 80000.0, - }, - { - "source": "1_step two", - "target": "2_step three", - "value": 5, - "average_conversion_time": 60000.0, - }, - ], - ) - - @snapshot_clickhouse_queries - def test_by_funnel_after_step_limit(self): - self._create_sample_data_multiple_dropoffs() - events = [] - # add more than 100. Previously, the funnel limit at 100 was stopping all users from showing up - for i in range(100, 200): - _create_person(distinct_ids=[f"user_{i}"], team=self.team) - person_events = [ - _create_event( - event="step one", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:00:00", - properties={}, - ), - _create_event( - event="between_step_1_a", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:01:00", - properties={}, - ), - _create_event( - event="between_step_1_b", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:02:00", - properties={}, - ), - _create_event( - event="between_step_1_c", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:03:00", - properties={}, - ), - _create_event( - event="step two", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:04:00", - properties={}, - ), - _create_event( - event="step three", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:05:00", - properties={}, - ), - ] - events.extend(person_events) - - data = { - "insight": INSIGHT_FUNNELS, - "funnel_paths": FUNNEL_PATH_AFTER_STEP, - "interval": "day", - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "funnel_window_interval": 7, - "funnel_window_interval_unit": "day", - "funnel_step": 2, - "events": [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ], - } - funnel_filter = Filter(data=data, team=self.team) - path_filter = PathFilter(data=data, team=self.team) - response = Paths(team=self.team, filter=path_filter, funnel_filter=funnel_filter).run() - self.assertEqual( - response, - [ - { - "source": "1_step two", - "target": "2_step three", - "value": 105, - "average_conversion_time": 60000.0, - }, - { - "source": "1_step two", - "target": "2_between_step_2_a", - "value": 10, - "average_conversion_time": 80000.0, - }, - { - "source": "2_between_step_2_a", - "target": "3_between_step_2_b", - "value": 10, - "average_conversion_time": 80000.0, - }, - ], - ) - - @snapshot_clickhouse_queries - def test_by_funnel_before_dropoff(self): - self._create_sample_data_multiple_dropoffs() - data = { - "insight": INSIGHT_FUNNELS, - "funnel_paths": FUNNEL_PATH_BEFORE_STEP, - "interval": "day", - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "funnel_window_interval": 7, - "funnel_window_interval_unit": "day", - "funnel_step": -3, - "events": [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ], - } - funnel_filter = Filter(data=data, team=self.team) - path_filter = PathFilter(data=data, team=self.team) - response = Paths(team=self.team, filter=path_filter, funnel_filter=funnel_filter).run() - self.assertEqual( - response, - [ - { - "source": "1_step one", - "target": "2_between_step_1_a", - "value": 10, - "average_conversion_time": 60000.0, - }, - { - "source": "2_between_step_1_a", - "target": "3_between_step_1_b", - "value": 10, - "average_conversion_time": 60000.0, - }, - { - "source": "3_between_step_1_b", - "target": "4_step two", - "value": 10, - "average_conversion_time": 60000.0, - }, - { - "source": "4_step two", - "target": "5_between_step_2_a", - "value": 10, - "average_conversion_time": 80000.0, - }, - ], - ) - - @snapshot_clickhouse_queries - def test_by_funnel_before_step(self): - self._create_sample_data_multiple_dropoffs() - data = { - "insight": INSIGHT_FUNNELS, - "funnel_paths": FUNNEL_PATH_BEFORE_STEP, - "interval": "day", - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "funnel_window_interval": 7, - "funnel_window_interval_unit": "day", - "funnel_step": 2, - "events": [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ], - } - funnel_filter = Filter(data=data, team=self.team) - path_filter = PathFilter(data=data, team=self.team) - response = Paths(team=self.team, filter=path_filter, funnel_filter=funnel_filter).run() - self.assertEqual( - response, - [ - { - "source": "1_step one", - "target": "2_between_step_1_a", - "value": 15, - "average_conversion_time": 60000.0, - }, - { - "source": "2_between_step_1_a", - "target": "3_between_step_1_b", - "value": 15, - "average_conversion_time": 60000.0, - }, - { - "source": "3_between_step_1_b", - "target": "4_step two", - "value": 10, - "average_conversion_time": 60000.0, - }, - { - "source": "3_between_step_1_b", - "target": "4_between_step_1_c", - "value": 5, - "average_conversion_time": 60000.0, - }, - { - "source": "4_between_step_1_c", - "target": "5_step two", - "value": 5, - "average_conversion_time": 60000.0, - }, - ], - ) - - @snapshot_clickhouse_queries - def test_by_funnel_between_step(self): - self._create_sample_data_multiple_dropoffs() - data = { - "insight": INSIGHT_FUNNELS, - "funnel_paths": FUNNEL_PATH_BETWEEN_STEPS, - "interval": "day", - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "funnel_window_interval": 7, - "funnel_window_interval_unit": "day", - "funnel_step": 2, - "events": [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ], - } - funnel_filter = Filter(data=data, team=self.team) - path_filter = PathFilter(data=data, team=self.team) - response = Paths(team=self.team, filter=path_filter, funnel_filter=funnel_filter).run() - self.assertEqual( - response, - [ - { - "source": "1_step one", - "target": "2_between_step_1_a", - "value": 15, - "average_conversion_time": 60000.0, - }, - { - "source": "2_between_step_1_a", - "target": "3_between_step_1_b", - "value": 15, - "average_conversion_time": 60000.0, - }, - { - "source": "3_between_step_1_b", - "target": "4_step two", - "value": 10, - "average_conversion_time": 60000.0, - }, - { - "source": "3_between_step_1_b", - "target": "4_between_step_1_c", - "value": 5, - "average_conversion_time": 60000.0, - }, - { - "source": "4_between_step_1_c", - "target": "5_step two", - "value": 5, - "average_conversion_time": 60000.0, - }, - ], - ) - self.assertEqual( - 15, - len(self._get_people_at_path(path_filter, "1_step one", "2_between_step_1_a", funnel_filter)), - ) - self.assertEqual( - 15, - len( - self._get_people_at_path( - path_filter, - "2_between_step_1_a", - "3_between_step_1_b", - funnel_filter, - ) - ), - ) - self.assertEqual( - 10, - len(self._get_people_at_path(path_filter, "3_between_step_1_b", "4_step two", funnel_filter)), - ) - self.assertEqual( - 5, - len( - self._get_people_at_path( - path_filter, - "3_between_step_1_b", - "4_between_step_1_c", - funnel_filter, - ) - ), - ) - self.assertEqual( - 5, - len(self._get_people_at_path(path_filter, "4_between_step_1_c", "5_step two", funnel_filter)), - ) - - @also_test_with_materialized_columns(["$current_url", "$screen_name"]) - @snapshot_clickhouse_queries - def test_end(self): - _create_person(team_id=self.team.pk, distinct_ids=["person_1"]) - p1 = [ - _create_event( - properties={"$current_url": "/1"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:01:00", - ), - _create_event( - properties={"$current_url": "/2"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:02:00", - ), - _create_event( - properties={"$current_url": "/3/"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:03:00", - ), - _create_event( - properties={"$current_url": "/4/"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:04:00", - ), - _create_event( - properties={"$current_url": "/5"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:05:00", - ), - _create_event( - properties={"$current_url": "/about"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:06:00", - ), - _create_event( - properties={"$current_url": "/after/"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:07:00", - ), - ] - - _create_person(team_id=self.team.pk, distinct_ids=["person_2"]) - p2 = [ - _create_event( - properties={"$current_url": "/5"}, - distinct_id="person_2", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:01:00", - ), - _create_event( - properties={"$current_url": "/about"}, - distinct_id="person_2", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:02:00", - ), - ] - - _create_person(team_id=self.team.pk, distinct_ids=["person_3"]) - p3 = [ - _create_event( - properties={"$current_url": "/3/"}, - distinct_id="person_3", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:01:00", - ), - _create_event( - properties={"$current_url": "/4"}, - distinct_id="person_3", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:02:00", - ), - _create_event( - properties={"$current_url": "/about/"}, - distinct_id="person_3", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:03:00", - ), - _create_event( - properties={"$current_url": "/after"}, - distinct_id="person_3", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:04:00", - ), - ] - - _ = [*p1, *p2, *p3] - - filter = PathFilter( - team=self.team, - data={ - "path_type": "$pageview", - "end_point": "/about", - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - }, - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - self.assertEqual( - response, - [ - { - "source": "1_/2", - "target": "2_/3", - "value": 1, - "average_conversion_time": 60000.0, - }, - { - "source": "1_/3", - "target": "2_/4", - "value": 1, - "average_conversion_time": 60000.0, - }, - { - "source": "1_/5", - "target": "2_/about", - "value": 1, - "average_conversion_time": 60000.0, - }, - { - "source": "2_/3", - "target": "3_/4", - "value": 1, - "average_conversion_time": 60000.0, - }, - { - "source": "2_/4", - "target": "3_/about", - "value": 1, - "average_conversion_time": 60000.0, - }, - { - "source": "3_/4", - "target": "4_/5", - "value": 1, - "average_conversion_time": 60000.0, - }, - { - "source": "4_/5", - "target": "5_/about", - "value": 1, - "average_conversion_time": 60000.0, - }, - ], - ) - - # ensure trailing slashes don't change results - filter = PathFilter( - team=self.team, - data={ - "path_type": "$pageview", - "end_point": "/about/", - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - }, - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - self.assertEqual( - response, - [ - { - "source": "1_/2", - "target": "2_/3", - "value": 1, - "average_conversion_time": 60000.0, - }, - { - "source": "1_/3", - "target": "2_/4", - "value": 1, - "average_conversion_time": 60000.0, - }, - { - "source": "1_/5", - "target": "2_/about", - "value": 1, - "average_conversion_time": 60000.0, - }, - { - "source": "2_/3", - "target": "3_/4", - "value": 1, - "average_conversion_time": 60000.0, - }, - { - "source": "2_/4", - "target": "3_/about", - "value": 1, - "average_conversion_time": 60000.0, - }, - { - "source": "3_/4", - "target": "4_/5", - "value": 1, - "average_conversion_time": 60000.0, - }, - { - "source": "4_/5", - "target": "5_/about", - "value": 1, - "average_conversion_time": 60000.0, - }, - ], - ) - - @snapshot_clickhouse_queries - @freeze_time("2023-05-23T11:00:00.000Z") - def test_event_inclusion_exclusion_filters(self): - # P1 for pageview event - _create_person(team_id=self.team.pk, distinct_ids=["p1"]) - p1 = [ - _create_event( - properties={"$current_url": "/1"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - _create_event( - properties={"$current_url": "/2/"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - _create_event( - properties={"$current_url": "/3"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ] - - # P2 for screen event - _create_person(team_id=self.team.pk, distinct_ids=["p2"]) - p2 = [ - _create_event( - properties={"$screen_name": "/screen1"}, - distinct_id="p2", - event="$screen", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - _create_event( - properties={"$screen_name": "/screen2"}, - distinct_id="p2", - event="$screen", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - _create_event( - properties={"$screen_name": "/screen3"}, - distinct_id="p2", - event="$screen", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ] - - # P3 for custom event - _create_person(team_id=self.team.pk, distinct_ids=["p3"]) - p3 = [ - _create_event( - distinct_id="p3", - event="/custom1", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - _create_event( - distinct_id="p3", - event="/custom2", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - _create_event( - distinct_id="p3", - event="/custom3", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ] - - _ = [*p1, *p2, *p3] - - filter = PathFilter( - team=self.team, - data={ - "step_limit": 4, - "date_from": "2012-01-01", - "include_event_types": ["$pageview"], - }, - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/2", - "target": "3_/3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - filter = filter.shallow_clone({"include_event_types": ["$screen"]}) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/screen1", - "target": "2_/screen2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/screen2", - "target": "3_/screen3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - filter = filter.shallow_clone({"include_event_types": ["custom_event"]}) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/custom1", - "target": "2_/custom2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/custom2", - "target": "3_/custom3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - filter = filter.shallow_clone( - { - "include_event_types": [], - "include_custom_events": ["/custom1", "/custom2"], - } - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/custom1", - "target": "2_/custom2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - } - ], - ) - - filter = filter.shallow_clone({"include_event_types": [], "include_custom_events": ["/custom3", "blah"]}) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual(response, []) - - filter = filter.shallow_clone( - { - "include_event_types": ["$pageview", "$screen", "custom_event"], - "include_custom_events": [], - } - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "1_/custom1", - "target": "2_/custom2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "1_/screen1", - "target": "2_/screen2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/2", - "target": "3_/3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - { - "source": "2_/custom2", - "target": "3_/custom3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - { - "source": "2_/screen2", - "target": "3_/screen3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - filter = filter.shallow_clone( - { - "include_event_types": ["$pageview", "$screen", "custom_event"], - "include_custom_events": [], - "exclude_events": ["/custom1", "/1", "/2", "/3"], - } - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - self.assertEqual( - response, - [ - { - "source": "1_/custom2", - "target": "2_/custom3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - { - "source": "1_/screen1", - "target": "2_/screen2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/screen2", - "target": "3_/screen3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - @snapshot_clickhouse_queries - @freeze_time("2023-05-23T11:00:00.000Z") - def test_event_exclusion_filters_with_wildcard_groups(self): - # P1 for pageview event /2/bar/1/foo - _create_person(team_id=self.team.pk, distinct_ids=["p1"]) - p1 = [ - _create_event( - properties={"$current_url": "/1"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - _create_event( - properties={"$current_url": "/2/bar/1/foo"}, # regex matches, despite beginning with `/2/` - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - _create_event( - properties={"$current_url": "/3"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ] - - # P2 for pageview event /bar/2/foo - _create_person(team_id=self.team.pk, distinct_ids=["p2"]) - p2 = [ - _create_event( - properties={"$current_url": "/1"}, - distinct_id="p2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - _create_event( - properties={"$current_url": "/bar/2/foo"}, - distinct_id="p2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - _create_event( - properties={"$current_url": "/3"}, - distinct_id="p2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ] - - # P3 for pageview event /bar/3/foo - _create_person(team_id=self.team.pk, distinct_ids=["p3"]) - p3 = [ - _create_event( - properties={"$current_url": "/1"}, - distinct_id="p3", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - _create_event( - properties={"$current_url": "/bar/33/foo"}, - distinct_id="p3", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - _create_event( - properties={"$current_url": "/3"}, - distinct_id="p3", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ] - - _ = [*p1, *p2, *p3] - - filter = PathFilter( - team=self.team, - data={ - "step_limit": 4, - "date_from": "2012-01-01", - "exclude_events": ["/bar/*/foo"], - "include_event_types": ["$pageview"], - "path_groupings": ["/bar/*/foo"], - }, - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/3", - "value": 3, - "average_conversion_time": 3 * ONE_MINUTE, - } - ], - ) - - filter = filter.shallow_clone({"path_groupings": ["/xxx/invalid/*"]}) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual(len(response), 6) - - def test_event_inclusion_exclusion_filters_across_single_person(self): - # P1 for pageview event, screen event, and custom event all together - _create_person(team_id=self.team.pk, distinct_ids=["p1"]) - - ( - _create_event( - properties={"$current_url": "/1"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/2"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/3"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ) - ( - _create_event( - properties={"$screen_name": "/screen1"}, - distinct_id="p1", - event="$screen", - team=self.team, - timestamp="2012-01-01 03:25:34", - ), - ) - ( - _create_event( - properties={"$screen_name": "/screen2"}, - distinct_id="p1", - event="$screen", - team=self.team, - timestamp="2012-01-01 03:26:34", - ), - ) - ( - _create_event( - properties={"$screen_name": "/screen3"}, - distinct_id="p1", - event="$screen", - team=self.team, - timestamp="2012-01-01 03:28:34", - ), - ) - ( - _create_event( - distinct_id="p1", - event="/custom1", - team=self.team, - timestamp="2012-01-01 03:29:34", - ), - ) - ( - _create_event( - distinct_id="p1", - event="/custom2", - team=self.team, - timestamp="2012-01-01 03:30:34", - ), - ) - ( - _create_event( - distinct_id="p1", - event="/custom3", - team=self.team, - timestamp="2012-01-01 03:32:34", - ), - ) - - filter = PathFilter( - team=self.team, data={"step_limit": 10, "date_from": "2012-01-01"} - ) # include everything, exclude nothing - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/2", - "target": "3_/3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - { - "source": "3_/3", - "target": "4_/screen1", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "4_/screen1", - "target": "5_/screen2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "5_/screen2", - "target": "6_/screen3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - { - "source": "6_/screen3", - "target": "7_/custom1", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "7_/custom1", - "target": "8_/custom2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "8_/custom2", - "target": "9_/custom3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - filter = filter.shallow_clone({"include_event_types": ["$pageview", "$screen"]}) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/2", - "target": "3_/3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - { - "source": "3_/3", - "target": "4_/screen1", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "4_/screen1", - "target": "5_/screen2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "5_/screen2", - "target": "6_/screen3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - filter = filter.shallow_clone( - { - "include_event_types": ["$pageview", "$screen"], - "include_custom_events": ["/custom2"], - } - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/2", - "target": "3_/3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - { - "source": "3_/3", - "target": "4_/screen1", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "4_/screen1", - "target": "5_/screen2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "5_/screen2", - "target": "6_/screen3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - { - "source": "6_/screen3", - "target": "7_/custom2", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - filter = filter.shallow_clone( - { - "include_event_types": ["$pageview", "custom_event"], - "include_custom_events": [], - "exclude_events": ["/custom1", "/custom3"], - } - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/2", - "target": "3_/3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - { - "source": "3_/3", - "target": "4_/custom2", - "value": 1, - "average_conversion_time": 6 * ONE_MINUTE, - }, - ], - ) - - @snapshot_clickhouse_queries - @freeze_time("2023-05-23T11:00:00.000Z") - def test_respect_session_limits(self): - _create_person(team_id=self.team.pk, distinct_ids=["fake"]) - - ( - _create_event( - properties={"$current_url": "/1"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/2"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/3"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/1"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-02 03:21:54", # new day, new session - ), - ) - ( - _create_event( - properties={"$current_url": "/2/"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-02 03:22:54", - ), - ) - ( - _create_event( - properties={"$current_url": "/3"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-02 03:26:54", - ), - ) - - filter = PathFilter(team=self.team, data={"date_from": "2012-01-01"}) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/2", - "value": 2, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/2", - "target": "3_/3", - "value": 2, - "average_conversion_time": 3 * ONE_MINUTE, - }, - ], - ) - - def test_removes_duplicates(self): - _create_person(team_id=self.team.pk, distinct_ids=["fake"]) - - ( - _create_event( - properties={"$current_url": "/1"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/1"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:54", - ), - ) - ( - _create_event( - properties={"$current_url": "/2"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/2/"}, # trailing slash should be removed - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:54", - ), - ) - ( - _create_event( - properties={"$current_url": "/3"}, - distinct_id="fake", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:54", - ), - ) - - _create_person(team_id=self.team.pk, distinct_ids=["fake2"]) - - ( - _create_event( - properties={"$current_url": "/1"}, - distinct_id="fake2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/2/"}, - distinct_id="fake2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:23:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/3"}, - distinct_id="fake2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:27:34", - ), - ) - - filter = PathFilter(team=self.team, data={"date_from": "2012-01-01"}) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/2", - "value": 2, - "average_conversion_time": 1.5 * ONE_MINUTE, - }, - { - "source": "2_/2", - "target": "3_/3", - "value": 2, - "average_conversion_time": 3 * ONE_MINUTE, - }, - ], - ) - - @also_test_with_materialized_columns(["$current_url", "$screen_name"]) - @snapshot_clickhouse_queries - def test_start_and_end(self): - p1 = _create_person(team_id=self.team.pk, distinct_ids=["person_1"]) - - ( - _create_event( - properties={"$current_url": "/1"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:01:00", - ), - ) - ( - _create_event( - properties={"$current_url": "/2"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:02:00", - ), - ) - ( - _create_event( - properties={"$current_url": "/3"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:03:00", - ), - ) - ( - _create_event( - properties={"$current_url": "/4"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:04:00", - ), - ) - ( - _create_event( - properties={"$current_url": "/5"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:05:00", - ), - ) - ( - _create_event( - properties={"$current_url": "/about"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:06:00", - ), - ) - ( - _create_event( - properties={"$current_url": "/after"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:07:00", - ), - ) - - p2 = _create_person(team_id=self.team.pk, distinct_ids=["person_2"]) - - ( - _create_event( - properties={"$current_url": "/5"}, - distinct_id="person_2", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:01:00", - ), - ) - ( - _create_event( - properties={"$current_url": "/about"}, - distinct_id="person_2", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:02:00", - ), - ) - - _create_person(team_id=self.team.pk, distinct_ids=["person_3"]) - - ( - _create_event( - properties={"$current_url": "/3"}, - distinct_id="person_3", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:01:00", - ), - ) - ( - _create_event( - properties={"$current_url": "/4"}, - distinct_id="person_3", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:02:00", - ), - ) - ( - _create_event( - properties={"$current_url": "/about"}, - distinct_id="person_3", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:03:00", - ), - ) - ( - _create_event( - properties={"$current_url": "/after"}, - distinct_id="person_3", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:04:00", - ), - ) - - filter = PathFilter( - team=self.team, - data={ - "path_type": "$pageview", - "start_point": "/5", - "end_point": "/about", - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - }, - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - self.assertEqual( - response, - [ - { - "source": "1_/5", - "target": "2_/about", - "value": 2, - "average_conversion_time": 60000.0, - } - ], - ) - self.assertCountEqual(self._get_people_at_path(filter, "1_/5", "2_/about"), [p1.uuid, p2.uuid]) - - # test aggregation for long paths - filter = filter.shallow_clone({"start_point": "/2", "step_limit": 4}) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - self.assertEqual( - response, - [ - { - "source": "1_/2", - "target": "2_/3", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/3", - "target": "3_...", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "3_...", - "target": "4_/5", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "4_/5", - "target": "5_/about", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - ], - ) - self.assertCountEqual(self._get_people_at_path(filter, "3_...", "4_/5"), [p1.uuid]) - - @snapshot_clickhouse_queries - def test_properties_queried_using_path_filter(self): - def should_query_list(filter) -> tuple[bool, bool]: - path_query = PathEventQuery(filter, self.team) - return (path_query._should_query_url(), path_query._should_query_screen()) - - filter = PathFilter() - self.assertEqual(should_query_list(filter), (True, True)) - - filter = PathFilter({"include_event_types": ["$pageview"]}) - self.assertEqual(should_query_list(filter), (True, False)) - - filter = PathFilter({"include_event_types": ["$screen"]}) - self.assertEqual(should_query_list(filter), (False, True)) - - filter = filter.shallow_clone( - { - "include_event_types": [], - "include_custom_events": ["/custom1", "/custom2"], - } - ) - self.assertEqual(should_query_list(filter), (False, False)) - - filter = filter.shallow_clone( - { - "include_event_types": ["$pageview", "$screen", "custom_event"], - "include_custom_events": [], - } - ) - self.assertEqual(should_query_list(filter), (True, True)) - - filter = filter.shallow_clone( - { - "include_event_types": ["$pageview", "$screen", "custom_event"], - "include_custom_events": [], - "exclude_events": ["/custom1"], - } - ) - self.assertEqual(should_query_list(filter), (True, True)) - - filter = filter.shallow_clone( - { - "include_event_types": [], - "include_custom_events": [], - "exclude_events": ["$pageview"], - } - ) - self.assertEqual(should_query_list(filter), (False, True)) - - @snapshot_clickhouse_queries - @freeze_time("2023-05-23T11:00:00.000Z") - def test_wildcard_groups_across_people(self): - # P1 for pageview event /2/bar/1/foo - _create_person(team_id=self.team.pk, distinct_ids=["p1"]) - - ( - _create_event( - properties={"$current_url": "/1"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/2/bar/1/foo"}, # regex matches, despite beginning with `/2/` - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/3"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ) - - # P2 for pageview event /bar/2/foo - _create_person(team_id=self.team.pk, distinct_ids=["p2"]) - - ( - _create_event( - properties={"$current_url": "/1"}, - distinct_id="p2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/bar/2/foo"}, - distinct_id="p2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/3"}, - distinct_id="p2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ) - - # P3 for pageview event /bar/3/foo - _create_person(team_id=self.team.pk, distinct_ids=["p3"]) - - ( - _create_event( - properties={"$current_url": "/1"}, - distinct_id="p3", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/bar/33/foo"}, - distinct_id="p3", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/3"}, - distinct_id="p3", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ) - - filter = PathFilter( - team=self.team, - data={ - "step_limit": 4, - "date_from": "2012-01-01", - "include_event_types": ["$pageview"], - "path_groupings": ["/bar/*/foo"], - }, - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/bar/*/foo", - "value": 3, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/bar/*/foo", - "target": "3_/3", - "value": 3, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - @snapshot_clickhouse_queries - @freeze_time("2023-05-23T11:00:00.000Z") - def test_wildcard_groups_evil_input(self): - evil_string = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" - # P1 for pageview event /2/bar/1/foo - _create_person(team_id=self.team.pk, distinct_ids=["p1"]) - - ( - _create_event( - properties={"$current_url": evil_string}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/2/bar/aaa"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/3"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ) - - # P2 for pageview event /2/bar/2/foo - _create_person(team_id=self.team.pk, distinct_ids=["p2"]) - - ( - _create_event( - properties={"$current_url": "/1"}, - distinct_id="p2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/2/3?q=1"}, - distinct_id="p2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - ) - ( - _create_event( - properties={"$current_url": "/3?q=1"}, - distinct_id="p2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ) - - filter = PathFilter( - team=self.team, - data={ - "date_from": "2012-01-01", - "include_event_types": ["$pageview"], - "path_groupings": [ - "(a+)+", - "[aaa|aaaa]+", - "1.*", - ".*", - "/3?q=1", - "/3*", - ], - }, - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/3*", - "value": 1, - "average_conversion_time": 3 * ONE_MINUTE, - }, - { - "source": f"1_{evil_string}", - "target": "2_/2/bar/aaa", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/2/bar/aaa", - "target": "3_/3*", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - @snapshot_clickhouse_queries - def test_person_dropoffs(self): - events = [] - - # 5 people do 2 events - for i in range(5): - _create_person(distinct_ids=[f"user_{i}"], team=self.team) - two_step = [ - _create_event( - event="step one", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:00:00", - properties={}, - ), - _create_event( - event="step two", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:04:00", - properties={}, - ), - ] - events.extend(two_step) - - # 10 people do 3 events - for i in range(5, 15): - _create_person(distinct_ids=[f"user_{i}"], team=self.team) - three_step = [ - _create_event( - event="step one", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:00:00", - properties={}, - ), - _create_event( - event="step two", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:04:00", - properties={}, - ), - _create_event( - event="step three", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:05:00", - properties={}, - ), - ] - events.extend(three_step) - - # 20 people do 4 events - for i in range(15, 35): - _create_person(distinct_ids=[f"user_{i}"], team=self.team) - four_step = [ - _create_event( - event="step one", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:00:00", - properties={}, - ), - _create_event( - event="step two", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:04:00", - properties={}, - ), - _create_event( - event="step three", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:05:00", - properties={}, - ), - _create_event( - event="step four", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:06:00", - properties={}, - ), - ] - events.extend(four_step) - - filter = PathFilter( - team=self.team, - data={ - "include_event_types": ["custom_event"], - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - }, - ) - self.assertEqual(5, len(self._get_people_at_path(filter, path_dropoff="2_step two"))) # 5 dropoff at step 2 - self.assertEqual(35, len(self._get_people_at_path(filter, path_end="2_step two"))) # 35 total reach step 2 - self.assertEqual( - 30, len(self._get_people_at_path(filter, path_start="2_step two")) - ) # 30 total reach after step 2 - - self.assertEqual(10, len(self._get_people_at_path(filter, path_dropoff="3_step three"))) # 10 dropoff at step 3 - self.assertEqual(30, len(self._get_people_at_path(filter, path_end="3_step three"))) # 30 total reach step 3 - self.assertEqual( - 20, len(self._get_people_at_path(filter, path_start="3_step three")) - ) # 20 total reach after step 3 - - self.assertEqual(20, len(self._get_people_at_path(filter, path_dropoff="4_step four"))) # 20 dropoff at step 4 - self.assertEqual(20, len(self._get_people_at_path(filter, path_end="4_step four"))) # 20 total reach step 4 - self.assertEqual( - 0, len(self._get_people_at_path(filter, path_start="4_step four")) - ) # 0 total reach after step 4 - - @snapshot_clickhouse_queries - def test_start_dropping_orphaned_edges(self): - events = [] - for i in range(5): - # 5 people going through this route to increase weights - _create_person(team_id=self.team.pk, distinct_ids=[f"person_{i}"]) - special_route = [ - _create_event( - properties={"$current_url": "/1"}, - distinct_id=f"person_{i}", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:01:00", - ), - _create_event( - properties={"$current_url": "/2"}, - distinct_id=f"person_{i}", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:02:00", - ), - _create_event( - properties={"$current_url": "/3"}, - distinct_id=f"person_{i}", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:03:00", - ), - _create_event( - properties={"$current_url": "/4"}, - distinct_id=f"person_{i}", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:04:00", - ), - _create_event( - properties={"$current_url": "/5"}, - distinct_id=f"person_{i}", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:05:00", - ), - _create_event( - properties={"$current_url": "/about"}, - distinct_id=f"person_{i}", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:06:00", - ), - _create_event( - properties={"$current_url": "/after"}, - distinct_id=f"person_{i}", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:07:00", - ), - ] - events.extend(special_route) - - _create_person(team_id=self.team.pk, distinct_ids=["person_r_2"]) - events_p2 = [ - _create_event( - properties={"$current_url": "/2"}, - distinct_id="person_r_2", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:01:00", - ), - _create_event( - properties={"$current_url": "/a"}, - distinct_id="person_r_2", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:01:30", - ), - _create_event( - properties={"$current_url": "/x"}, - distinct_id="person_r_2", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:02:00", - ), - _create_event( - properties={"$current_url": "/about"}, - distinct_id="person_r_2", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:03:00", - ), - ] - events.extend(events_p2) - - _create_person(team_id=self.team.pk, distinct_ids=["person_r_3"]) - event_p3 = [ - _create_event( - properties={"$current_url": "/2"}, - distinct_id="person_r_3", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:01:00", - ), - _create_event( - properties={"$current_url": "/b"}, - distinct_id="person_r_3", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:01:30", - ), - _create_event( - properties={"$current_url": "/x"}, - distinct_id="person_r_3", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:02:00", - ), - _create_event( - properties={"$current_url": "/about"}, - distinct_id="person_r_3", - event="$pageview", - team=self.team, - timestamp="2021-05-01 00:03:00", - ), - ] - - events.extend(event_p3) - - # /x -> /about has higher weight than /2 -> /a -> /x and /2 -> /b -> /x - - filter = PathFilter( - team=self.team, - data={ - "path_type": "$pageview", - "start_point": "/2", - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "edge_limit": "6", - }, - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - self.assertEqual( - response, - [ - { - "source": "1_/2", - "target": "2_/3", - "value": 5, - "average_conversion_time": 60000.0, - }, - { - "source": "2_/3", - "target": "3_/4", - "value": 5, - "average_conversion_time": 60000.0, - }, - { - "source": "3_/4", - "target": "4_/5", - "value": 5, - "average_conversion_time": 60000.0, - }, - { - "source": "4_/5", - "target": "5_/about", - "value": 5, - "average_conversion_time": 60000.0, - }, - # {'source': '3_/x', 'target': '4_/about', 'value': 2, 'average_conversion_time': 60000.0}, # gets deleted by validation since dangling - { - "source": "1_/2", - "target": "2_/a", - "value": 1, - "average_conversion_time": 30000.0, - }, - ], - ) - - @snapshot_clickhouse_queries - def test_wildcard_groups_and_min_edge_weight(self): - self._create_sample_data_multiple_dropoffs() - data = { - "insight": INSIGHT_FUNNELS, - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "min_edge_weight": 15, - "path_groupings": ["between_step_1_*", "between_step_2_*", "step drop*"], - } - path_filter = PathFilter(data=data, team=self.team) - response = Paths(team=self.team, filter=path_filter).run() - self.assertCountEqual( - response, - [ - { - "source": "1_step one", - "target": "2_step drop*", - "value": 20, - "average_conversion_time": 2 * ONE_MINUTE, - }, - # when we group events for a single user, these effectively become duplicate events, and we choose the last event from - # a list of duplicate events. - { - "source": "1_step one", - "target": "2_between_step_1_*", - "value": 15, - "average_conversion_time": (5 * 3 + 10 * 2) - * ONE_MINUTE - / 15, # first 5 go till between_step_1_c, next 10 go till between_step_1_b - }, - { - "source": "2_between_step_1_*", - "target": "3_step two", - "value": 15, - "average_conversion_time": ONE_MINUTE, - }, - ], - ) - - path_filter = path_filter.shallow_clone({"edge_limit": 2}) - response = Paths(team=self.team, filter=path_filter).run() - self.assertCountEqual( - response, - [ - { - "source": "1_step one", - "target": "2_step drop*", - "value": 20, - "average_conversion_time": 2 * ONE_MINUTE, - }, - # when we group events for a single user, these effectively become duplicate events, and we choose the last event from - # a list of duplicate events. - { - "source": "1_step one", - "target": "2_between_step_1_*", - "value": 15, - "average_conversion_time": (5 * 3 + 10 * 2) - * ONE_MINUTE - / 15, # first 5 go till between_step_1_c, next 10 go till between_step_1_b - }, - ], - ) - - path_filter = path_filter.shallow_clone({"edge_limit": 20, "max_edge_weight": 11, "min_edge_weight": 6}) - response = Paths(team=self.team, filter=path_filter).run() - self.assertCountEqual( - response, - [ - { - "source": "2_step drop*", - "target": "3_step branch", - "value": 10, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "3_step two", - "target": "4_between_step_2_*", - "value": 10, - "average_conversion_time": 160000, - }, - ], - ) - - # TODO: Delete this test when moved to person-on-events - def test_groups_filtering(self): - self._create_groups() - # P1 for pageview event, org:5 - _create_person(team_id=self.team.pk, distinct_ids=["p1"]) - p1 = [ - _create_event( - properties={"$current_url": "/1", "$group_0": "org:5"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - _create_event( - properties={"$current_url": "/2/", "$group_0": "org:5"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - _create_event( - properties={"$current_url": "/3", "$group_0": "org:5"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ] - - # P2 for screen event, org:6 - _create_person(team_id=self.team.pk, distinct_ids=["p2"]) - p2 = [ - _create_event( - properties={"$screen_name": "/screen1", "$group_0": "org:6"}, - distinct_id="p2", - event="$screen", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - _create_event( - properties={"$screen_name": "/screen2", "$group_0": "org:6"}, - distinct_id="p2", - event="$screen", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - _create_event( - properties={"$screen_name": "/screen3", "$group_0": "org:6"}, - distinct_id="p2", - event="$screen", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ] - - # P3 for custom event, group_0 doesnt' exist, group_1 = company:1 - _create_person(team_id=self.team.pk, distinct_ids=["p3"]) - p3 = [ - _create_event( - distinct_id="p3", - event="/custom1", - team=self.team, - timestamp="2012-01-01 03:21:34", - properties={"$group_1": "company:1"}, - ), - _create_event( - distinct_id="p3", - event="/custom2", - team=self.team, - timestamp="2012-01-01 03:22:34", - properties={"$group_1": "company:1"}, - ), - _create_event( - distinct_id="p3", - event="/custom3", - team=self.team, - timestamp="2012-01-01 03:24:34", - properties={"$group_1": "company:1"}, - ), - ] - - _ = [*p1, *p2, *p3] - - filter = PathFilter( - team=self.team, - data={ - "step_limit": 4, - "date_from": "2012-01-01", - "date_to": "2012-02-01", - "include_event_types": ["$pageview", "$screen", "custom_event"], - "properties": [ - { - "key": "industry", - "value": "finance", - "type": "group", - "group_type_index": 0, - } - ], - }, - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/2", - "target": "3_/3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - filter = filter.shallow_clone( - { - "properties": [ - { - "key": "industry", - "value": "technology", - "type": "group", - "group_type_index": 0, - } - ] - } - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/screen1", - "target": "2_/screen2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/screen2", - "target": "3_/screen3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - filter = filter.shallow_clone( - { - "properties": [ - { - "key": "industry", - "value": "technology", - "type": "group", - "group_type_index": 1, - } - ] - } - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/custom1", - "target": "2_/custom2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/custom2", - "target": "3_/custom3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - @snapshot_clickhouse_queries - def test_groups_filtering_person_on_events(self): - self._create_groups() - # P1 for pageview event, org:5 - _create_person(team_id=self.team.pk, distinct_ids=["p1"]) - p1 = [ - _create_event( - properties={"$current_url": "/1", "$group_0": "org:5"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - _create_event( - properties={"$current_url": "/2/", "$group_0": "org:5"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - _create_event( - properties={"$current_url": "/3", "$group_0": "org:5"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ] - - # P2 for screen event, org:6 - _create_person(team_id=self.team.pk, distinct_ids=["p2"]) - p2 = [ - _create_event( - properties={"$screen_name": "/screen1", "$group_0": "org:6"}, - distinct_id="p2", - event="$screen", - team=self.team, - timestamp="2012-01-01 03:21:34", - ), - _create_event( - properties={"$screen_name": "/screen2", "$group_0": "org:6"}, - distinct_id="p2", - event="$screen", - team=self.team, - timestamp="2012-01-01 03:22:34", - ), - _create_event( - properties={"$screen_name": "/screen3", "$group_0": "org:6"}, - distinct_id="p2", - event="$screen", - team=self.team, - timestamp="2012-01-01 03:24:34", - ), - ] - - # P3 for custom event, group_0 doesnt' exist, group_1 = company:1 - _create_person(team_id=self.team.pk, distinct_ids=["p3"]) - p3 = [ - _create_event( - distinct_id="p3", - event="/custom1", - team=self.team, - timestamp="2012-01-01 03:21:34", - properties={"$group_1": "company:1"}, - ), - _create_event( - distinct_id="p3", - event="/custom2", - team=self.team, - timestamp="2012-01-01 03:22:34", - properties={"$group_1": "company:1"}, - ), - _create_event( - distinct_id="p3", - event="/custom3", - team=self.team, - timestamp="2012-01-01 03:24:34", - properties={"$group_1": "company:1"}, - ), - ] - - _ = [*p1, *p2, *p3] - - filter = PathFilter( - team=self.team, - data={ - "step_limit": 4, - "date_from": "2012-01-01", - "date_to": "2012-02-01", - "include_event_types": ["$pageview", "$screen", "custom_event"], - "properties": [ - { - "key": "industry", - "value": "finance", - "type": "group", - "group_type_index": 0, - } - ], - }, - ) - with override_instance_config("PERSON_ON_EVENTS_ENABLED", True): - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/1", - "target": "2_/2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/2", - "target": "3_/3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - filter = filter.shallow_clone( - { - "properties": [ - { - "key": "industry", - "value": "technology", - "type": "group", - "group_type_index": 0, - } - ] - } - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/screen1", - "target": "2_/screen2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/screen2", - "target": "3_/screen3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - filter = filter.shallow_clone( - { - "properties": [ - { - "key": "industry", - "value": "technology", - "type": "group", - "group_type_index": 1, - } - ] - } - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - { - "source": "1_/custom1", - "target": "2_/custom2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/custom2", - "target": "3_/custom3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - @override_settings(PERSON_ON_EVENTS_V2_OVERRIDE=True) - @snapshot_clickhouse_queries - def test_person_on_events_v2(self): - self._create_groups() - # P1 for pageview event, org:5 - p1_person_id = str(uuid.uuid4()) - _create_person(team_id=self.team.pk, distinct_ids=["poev2_p1"]) - p1 = [ - _create_event( - properties={"$current_url": "/1", "$group_0": "org:5"}, - distinct_id="poev2_p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - person_id=p1_person_id, - ), - _create_event( - properties={"$current_url": "/2/", "$group_0": "org:5"}, - distinct_id="poev2_p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - person_id=p1_person_id, - ), - _create_event( - properties={"$current_url": "/3", "$group_0": "org:5"}, - distinct_id="poev2_p1", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - person_id=p1_person_id, - ), - ] - - # P2 for screen event, org:6 - p2_person_id = str(uuid.uuid4()) - _create_person(team_id=self.team.pk, distinct_ids=["poev2_p2"]) - p2 = [ - _create_event( - properties={"$current_url": "/1", "$group_0": "org:6"}, - distinct_id="poev2_p2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:21:34", - person_id=p2_person_id, - ), - _create_event( - properties={"$current_url": "/2/", "$group_0": "org:6"}, - distinct_id="poev2_p2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:22:34", - person_id=p2_person_id, - ), - _create_event( - properties={"$current_url": "/3", "$group_0": "org:6"}, - distinct_id="poev2_p2", - event="$pageview", - team=self.team, - timestamp="2012-01-01 03:24:34", - person_id=p2_person_id, - ), - ] - - _ = [*p1, *p2] - - create_person_id_override_by_distinct_id("poev2_p1", "poev2_p2", self.team.pk) - - filter = PathFilter( - team=self.team, - data={ - "step_limit": 4, - "date_from": "2012-01-01", - "date_to": "2012-02-01", - "include_event_types": ["$pageview", "$screen", "custom_event"], - }, - ) - response = Paths(team=self.team, filter=filter).run(team=self.team, filter=filter) - - self.assertEqual( - response, - [ - # we expect 1s for the "value"s because the two persons above are actually the same person - # due to the override - { - "source": "1_/1", - "target": "2_/2", - "value": 1, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_/2", - "target": "3_/3", - "value": 1, - "average_conversion_time": 2 * ONE_MINUTE, - }, - ], - ) - - @freeze_time("2012-01-01T03:21:34.000Z") - @snapshot_clickhouse_queries - def test_recording(self): - # User with 2 matching paths with recordings - p1 = _create_person(team_id=self.team.pk, distinct_ids=["p1"]) - events = [ - _create_event( - properties={ - "$current_url": "/1", - "$session_id": "s1", - "$window_id": "w1", - }, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp=timezone.now().strftime("%Y-%m-%d %H:%M:%S.%f"), - event_uuid="11111111-1111-1111-1111-111111111111", - ), - _create_event( - properties={ - "$current_url": "/2", - "$session_id": "s1", - "$window_id": "w1", - }, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=1)).strftime("%Y-%m-%d %H:%M:%S.%f"), - event_uuid="21111111-1111-1111-1111-111111111111", - ), - _create_event( - properties={ - "$current_url": "/1", - "$session_id": "s2", - "$window_id": "w2", - }, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=31)).strftime("%Y-%m-%d %H:%M:%S.%f"), - event_uuid="31111111-1111-1111-1111-111111111111", - ), - _create_event( - properties={ - "$current_url": "/2", - "$session_id": "s3", - "$window_id": "w3", - }, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=32)).strftime("%Y-%m-%d %H:%M:%S.%f"), - event_uuid="41111111-1111-1111-1111-111111111111", - ), - ] - timestamp = timezone.now() - produce_replay_summary( - team_id=self.team.pk, - session_id="s1", - distinct_id="p1", - first_timestamp=timestamp, - last_timestamp=timestamp, - ) - timestamp1 = timezone.now() - produce_replay_summary( - team_id=self.team.pk, - session_id="s3", - distinct_id="p1", - first_timestamp=timestamp1, - last_timestamp=timestamp1, - ) - - # User with path matches, but no recordings - p2 = _create_person(team_id=self.team.pk, distinct_ids=["p2"]) - events += [ - _create_event( - properties={ - "$current_url": "/1", - "$session_id": "s5", - "$window_id": "w1", - }, - distinct_id="p2", - event="$pageview", - team=self.team, - timestamp=timezone.now().strftime("%Y-%m-%d %H:%M:%S.%f"), - event_uuid="51111111-1111-1111-1111-111111111111", - ), - _create_event( - properties={ - "$current_url": "/2", - "$session_id": "s5", - "$window_id": "w1", - }, - distinct_id="p2", - event="$pageview", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=1)).strftime("%Y-%m-%d %H:%M:%S.%f"), - event_uuid="61111111-1111-1111-1111-111111111111", - ), - ] - - filter = PathFilter( - team=self.team, - data={ - "include_event_types": ["$pageview"], - "date_from": "2012-01-01 00:00:00", - "date_to": "2012-01-02 00:00:00", - "path_end_key": "2_/2", - "include_recordings": "true", - }, - ) - _, serialized_actors, _ = PathsActors(filter, self.team).get_actors() - self.assertCountEqual([p1.uuid, p2.uuid], [actor["id"] for actor in serialized_actors]) - matched_recordings = [actor["matched_recordings"] for actor in serialized_actors] - - self.assertCountEqual( - [ - { - "session_id": "s3", - "events": [ - { - "uuid": UUID("41111111-1111-1111-1111-111111111111"), - "timestamp": timezone.now() + timedelta(minutes=32), - "window_id": "w3", - } - ], - }, - { - "session_id": "s1", - "events": [ - { - "uuid": UUID("21111111-1111-1111-1111-111111111111"), - "timestamp": timezone.now() + timedelta(minutes=1), - "window_id": "w1", - } - ], - }, - ], - matched_recordings[0], - ) - self.assertEqual([], matched_recordings[1]) - - @snapshot_clickhouse_queries - @freeze_time("2012-01-01T03:21:34.000Z") - def test_recording_with_no_window_or_session_id(self): - p1 = _create_person(team_id=self.team.pk, distinct_ids=["p1"]) - - ( - _create_event( - properties={"$current_url": "/1"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp=timezone.now().strftime("%Y-%m-%d %H:%M:%S.%f"), - event_uuid="11111111-1111-1111-1111-111111111111", - ), - ) - ( - _create_event( - properties={"$current_url": "/2"}, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=1)).strftime("%Y-%m-%d %H:%M:%S.%f"), - event_uuid="21111111-1111-1111-1111-111111111111", - ), - ) - - filter = PathFilter( - team=self.team, - data={ - "include_event_types": ["$pageview"], - "date_from": "2012-01-01 00:00:00", - "date_to": "2012-01-02 00:00:00", - "path_end_key": "2_/2", - "include_recordings": "true", - }, - ) - _, serialized_actors, _ = PathsActors(filter, self.team).get_actors() - self.assertEqual([p1.uuid], [actor["id"] for actor in serialized_actors]) - self.assertEqual([[]], [actor["matched_recordings"] for actor in serialized_actors]) - - @snapshot_clickhouse_queries - @freeze_time("2012-01-01T03:21:34.000Z") - def test_recording_with_start_and_end(self): - p1 = _create_person(team_id=self.team.pk, distinct_ids=["p1"]) - - ( - _create_event( - properties={ - "$current_url": "/1", - "$session_id": "s1", - "$window_id": "w1", - }, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp=timezone.now().strftime("%Y-%m-%d %H:%M:%S.%f"), - event_uuid="11111111-1111-1111-1111-111111111111", - ), - ) - ( - _create_event( - properties={ - "$current_url": "/2", - "$session_id": "s1", - "$window_id": "w1", - }, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=1)).strftime("%Y-%m-%d %H:%M:%S.%f"), - event_uuid="21111111-1111-1111-1111-111111111111", - ), - ) - ( - _create_event( - properties={ - "$current_url": "/3", - "$session_id": "s1", - "$window_id": "w1", - }, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=2)).strftime("%Y-%m-%d %H:%M:%S.%f"), - event_uuid="31111111-1111-1111-1111-111111111111", - ), - ) - - timestamp = timezone.now() - produce_replay_summary( - team_id=self.team.pk, - session_id="s1", - distinct_id="p1", - first_timestamp=timestamp, - last_timestamp=timestamp, - ) - - filter = PathFilter( - team=self.team, - data={ - "include_event_types": ["$pageview"], - "date_from": "2012-01-01 00:00:00", - "date_to": "2012-01-02 00:00:00", - "path_end_key": "2_/2", - "start_point": "/1", - "end_point": "/3", - "include_recordings": "true", - }, - ) - _, serialized_actors, _ = PathsActors(filter, self.team).get_actors() - self.assertEqual([p1.uuid], [actor["id"] for actor in serialized_actors]) - self.assertEqual( - [ - [ - { - "session_id": "s1", - "events": [ - { - "uuid": UUID("21111111-1111-1111-1111-111111111111"), - "timestamp": timezone.now() + timedelta(minutes=1), - "window_id": "w1", - } - ], - } - ] - ], - [actor["matched_recordings"] for actor in serialized_actors], - ) - - @snapshot_clickhouse_queries - @freeze_time("2012-01-01T03:21:34.000Z") - def test_recording_for_dropoff(self): - p1 = _create_person(team_id=self.team.pk, distinct_ids=["p1"]) - - ( - _create_event( - properties={ - "$current_url": "/1", - "$session_id": "s1", - "$window_id": "w1", - }, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp=timezone.now().strftime("%Y-%m-%d %H:%M:%S.%f"), - event_uuid="11111111-1111-1111-1111-111111111111", - ), - ) - ( - _create_event( - properties={ - "$current_url": "/2", - "$session_id": "s1", - "$window_id": "w1", - }, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=1)).strftime("%Y-%m-%d %H:%M:%S.%f"), - event_uuid="21111111-1111-1111-1111-111111111111", - ), - ) - ( - _create_event( - properties={ - "$current_url": "/3", - "$session_id": "s1", - "$window_id": "w1", - }, - distinct_id="p1", - event="$pageview", - team=self.team, - timestamp=(timezone.now() + timedelta(minutes=2)).strftime("%Y-%m-%d %H:%M:%S.%f"), - event_uuid="31111111-1111-1111-1111-111111111111", - ), - ) - - timestamp = timezone.now() - produce_replay_summary( - team_id=self.team.pk, - session_id="s1", - distinct_id="p1", - first_timestamp=timestamp, - last_timestamp=timestamp, - ) - - # No matching events for dropoff - filter = PathFilter( - team=self.team, - data={ - "include_event_types": ["$pageview"], - "date_from": "2012-01-01 00:00:00", - "date_to": "2012-01-02 00:00:00", - "path_dropoff_key": "2_/2", - "include_recordings": "true", - }, - ) - _, serialized_actors, _ = PathsActors(filter, self.team).get_actors() - self.assertEqual([], [actor["id"] for actor in serialized_actors]) - self.assertEqual([], [actor["matched_recordings"] for actor in serialized_actors]) - - # Matching events for dropoff - filter = PathFilter( - team=self.team, - data={ - "include_event_types": ["$pageview"], - "date_from": "2012-01-01 00:00:00", - "date_to": "2012-01-02 00:00:00", - "path_dropoff_key": "3_/3", - "include_recordings": "true", - }, - ) - _, serialized_actors, _ = PathsActors(filter, self.team).get_actors() - self.assertEqual([p1.uuid], [actor["id"] for actor in serialized_actors]) - self.assertEqual( - [ - [ - { - "session_id": "s1", - "events": [ - { - "uuid": UUID("31111111-1111-1111-1111-111111111111"), - "timestamp": timezone.now() + timedelta(minutes=2), - "window_id": "w1", - } - ], - } - ] - ], - [actor["matched_recordings"] for actor in serialized_actors], - ) - - @snapshot_clickhouse_queries - def test_wildcard_groups_with_sampling(self): - self._create_sample_data_multiple_dropoffs() - data = { - "insight": INSIGHT_FUNNELS, - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "path_groupings": ["between_step_1_*", "between_step_2_*", "step drop*"], - "sampling_factor": 1, - } - path_filter = PathFilter(data=data, team=self.team) - response = Paths(team=self.team, filter=path_filter).run() - self.assertCountEqual( - response, - [ - { - "source": "1_step one", - "target": "2_step drop*", - "value": 20, - "average_conversion_time": 2 * ONE_MINUTE, - }, - # when we group events for a single user, these effectively become duplicate events, and we choose the last event from - # a list of duplicate events. - { - "source": "1_step one", - "target": "2_between_step_1_*", - "value": 15, - "average_conversion_time": (5 * 3 + 10 * 2) - * ONE_MINUTE - / 15, # first 5 go till between_step_1_c, next 10 go till between_step_1_b - }, - { - "source": "2_between_step_1_*", - "target": "3_step two", - "value": 15, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "2_step drop*", - "target": "3_step branch", - "value": 10, - "average_conversion_time": ONE_MINUTE, - }, - { - "source": "3_step two", - "target": "4_between_step_2_*", - "value": 10, - "average_conversion_time": 160000, - }, - { - "source": "3_step two", - "target": "4_step three", - "value": 5, - "average_conversion_time": ONE_MINUTE, - }, - ], - ) - - -class TestClickhousePathsEdgeValidation(TestCase): - BASIC_PATH = [("1_a", "2_b"), ("2_b", "3_c"), ("3_c", "4_d")] # a->b->c->d - BASIC_PATH_2 = [("1_x", "2_y"), ("2_y", "3_z")] # x->y->z - - def test_basic_forest(self): - edges = self.BASIC_PATH + self.BASIC_PATH_2 - - results = Paths(PathFilter(), MagicMock()).validate_results(edges) - - self.assertCountEqual(results, self.BASIC_PATH + self.BASIC_PATH_2) - - def test_basic_forest_with_dangling_edges(self): - edges = self.BASIC_PATH + self.BASIC_PATH_2 + [("2_w", "3_z"), ("3_x", "4_d"), ("2_xxx", "3_yyy")] - - results = Paths(PathFilter(), MagicMock()).validate_results(edges) - - self.assertCountEqual(results, self.BASIC_PATH + self.BASIC_PATH_2) - - def test_basic_forest_with_dangling_and_cross_edges(self): - edges = self.BASIC_PATH + self.BASIC_PATH_2 + [("2_w", "3_z"), ("3_x", "4_d"), ("2_y", "3_c")] - - results = Paths(PathFilter(), MagicMock()).validate_results(edges) - - self.assertCountEqual(results, self.BASIC_PATH + self.BASIC_PATH_2 + [("2_y", "3_c")]) - - def test_no_start_point(self): - edges = set(self.BASIC_PATH + self.BASIC_PATH_2 + [("2_w", "3_z"), ("3_x", "4_d")]) - edges.remove(("1_a", "2_b")) # remove first start point - edges = list(edges) # type: ignore - - results = Paths(PathFilter(), MagicMock()).validate_results(edges) - - self.assertCountEqual(results, self.BASIC_PATH_2) diff --git a/ee/clickhouse/queries/test/test_person_distinct_id_query.py b/ee/clickhouse/queries/test/test_person_distinct_id_query.py deleted file mode 100644 index 52d75ffdd35..00000000000 --- a/ee/clickhouse/queries/test/test_person_distinct_id_query.py +++ /dev/null @@ -1,5 +0,0 @@ -from posthog.queries import person_distinct_id_query - - -def test_person_distinct_id_query(db, snapshot): - assert person_distinct_id_query.get_team_distinct_ids_query(2) == snapshot diff --git a/ee/clickhouse/queries/test/test_person_query.py b/ee/clickhouse/queries/test/test_person_query.py deleted file mode 100644 index bd2a280d40f..00000000000 --- a/ee/clickhouse/queries/test/test_person_query.py +++ /dev/null @@ -1,405 +0,0 @@ -import pytest - -from ee.clickhouse.materialized_columns.columns import materialize -from posthog.client import sync_execute -from posthog.models.filters import Filter -from posthog.models.team import Team -from posthog.queries.person_query import PersonQuery -from posthog.test.base import _create_person -from posthog.models.cohort import Cohort -from posthog.models.property import Property - - -def person_query(team: Team, filter: Filter, **kwargs): - return PersonQuery(filter, team.pk, **kwargs).get_query()[0] - - -def run_query(team: Team, filter: Filter, **kwargs): - query, params = PersonQuery(filter, team.pk, **kwargs).get_query() - rows = sync_execute(query, {**params, **filter.hogql_context.values, "team_id": team.pk}) - - if len(rows) > 0: - return {"rows": len(rows), "columns": len(rows[0])} - else: - return {"rows": 0} - - -@pytest.fixture -def testdata(db, team): - materialize("person", "email") - _create_person( - distinct_ids=["1"], - team_id=team.pk, - properties={"email": "tim@posthog.com", "$os": "windows", "$browser": "chrome"}, - ) - _create_person( - distinct_ids=["2"], - team_id=team.pk, - properties={"email": "marius@posthog.com", "$os": "Mac", "$browser": "firefox"}, - ) - _create_person( - distinct_ids=["3"], - team_id=team.pk, - properties={ - "email": "karl@example.com", - "$os": "windows", - "$browser": "mozilla", - }, - ) - - -def test_person_query(testdata, team, snapshot): - filter = Filter(data={"properties": []}) - - assert person_query(team, filter) == snapshot - assert run_query(team, filter) == {"rows": 3, "columns": 1} - - filter = Filter( - data={ - "properties": [ - {"key": "event_prop", "value": "value"}, - { - "key": "email", - "type": "person", - "value": "posthog", - "operator": "icontains", - }, - ] - } - ) - - assert person_query(team, filter) == snapshot - assert run_query(team, filter) == {"rows": 2, "columns": 1} - - -def test_person_query_with_multiple_cohorts(testdata, team, snapshot): - filter = Filter(data={"properties": []}) - - for i in range(10): - _create_person( - team_id=team.pk, - distinct_ids=[f"person{i}"], - properties={"group": i, "email": f"{i}@hey.com"}, - ) - - cohort1 = Cohort.objects.create( - team=team, - filters={ - "properties": { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - {"key": "group", "value": "none", "type": "person"}, - {"key": "group", "value": [1, 2, 3], "type": "person"}, - ], - } - ], - } - }, - name="cohort1", - ) - - cohort2 = Cohort.objects.create( - team=team, - filters={ - "properties": { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "group", - "value": [1, 2, 3, 4, 5, 6], - "type": "person", - }, - ], - } - ], - } - }, - name="cohort2", - ) - - cohort1.calculate_people_ch(pending_version=0) - cohort2.calculate_people_ch(pending_version=0) - - cohort_filters = [ - Property(key="id", type="cohort", value=cohort1.pk), - Property(key="id", type="cohort", value=cohort2.pk), - ] - - filter = Filter( - data={ - "properties": [ - { - "key": "email", - "type": "person", - "value": "posthog", - "operator": "icontains", - }, - ] - } - ) - - filter2 = Filter( - data={ - "properties": [ - { - "key": "email", - "type": "person", - "value": "hey", - "operator": "icontains", - }, - ] - } - ) - - assert run_query(team, filter) == {"rows": 2, "columns": 1} - - # 3 rows because the intersection between cohorts 1 and 2 is person1, person2, and person3, - # with their respective group properties - assert run_query(team, filter2, cohort_filters=cohort_filters) == { - "rows": 3, - "columns": 1, - } - assert person_query(team, filter2, cohort_filters=cohort_filters) == snapshot - - -def test_person_query_with_anded_property_groups(testdata, team, snapshot): - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - {"key": "event_prop", "value": "value"}, - { - "key": "email", - "type": "person", - "value": "posthog", - "operator": "icontains", - }, - { - "key": "$os", - "type": "person", - "value": "windows", - "operator": "exact", - }, - { - "key": "$browser", - "type": "person", - "value": "chrome", - "operator": "exact", - }, - ], - } - } - ) - - assert person_query(team, filter) == snapshot - assert run_query(team, filter) == {"rows": 1, "columns": 1} - - -def test_person_query_with_and_and_or_property_groups(testdata, team, snapshot): - filter = Filter( - data={ - "properties": { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "email", - "type": "person", - "value": "posthog", - "operator": "icontains", - }, - { - "key": "$browser", - "type": "person", - "value": "karl", - "operator": "icontains", - }, - ], - }, - { - "type": "OR", - "values": [ - {"key": "event_prop", "value": "value"}, - { - "key": "$os", - "type": "person", - "value": "windows", - "operator": "exact", - }, # this can't be pushed down - # so person query should return only rows from the first OR group - ], - }, - ], - } - } - ) - - assert person_query(team, filter) == snapshot - assert run_query(team, filter) == {"rows": 2, "columns": 2} - - -def test_person_query_with_extra_requested_fields(testdata, team, snapshot): - filter = Filter( - data={ - "properties": [ - { - "key": "email", - "type": "person", - "value": "posthog", - "operator": "icontains", - } - ], - "breakdown": "person_prop_4326", - "breakdown_type": "person", - } - ) - - assert person_query(team, filter) == snapshot - assert run_query(team, filter) == {"rows": 2, "columns": 2} - - filter = filter.shallow_clone({"breakdown": "email", "breakdown_type": "person"}) - assert person_query(team, filter) == snapshot - assert run_query(team, filter) == {"rows": 2, "columns": 2} - - -def test_person_query_with_entity_filters(testdata, team, snapshot): - filter = Filter( - data={ - "events": [ - { - "id": "$pageview", - "properties": [ - { - "key": "email", - "type": "person", - "value": "karl", - "operator": "icontains", - } - ], - } - ] - } - ) - - assert person_query(team, filter) == snapshot - assert run_query(team, filter) == {"rows": 3, "columns": 2} - - assert person_query(team, filter, entity=filter.entities[0]) == snapshot - assert run_query(team, filter, entity=filter.entities[0]) == { - "rows": 1, - "columns": 1, - } - - -def test_person_query_with_extra_fields(testdata, team, snapshot): - filter = Filter( - data={ - "properties": [ - { - "key": "email", - "type": "person", - "value": "posthog", - "operator": "icontains", - } - ] - } - ) - - assert person_query(team, filter, extra_fields=["person_props", "pmat_email"]) == snapshot - assert run_query(team, filter, extra_fields=["person_props", "pmat_email"]) == { - "rows": 2, - "columns": 3, - } - - -def test_person_query_with_entity_filters_and_property_group_filters(testdata, team, snapshot): - filter = Filter( - data={ - "events": [ - { - "id": "$pageview", - "properties": { - "type": "OR", - "values": [ - { - "key": "email", - "type": "person", - "value": "marius", - "operator": "icontains", - }, - { - "key": "$os", - "type": "person", - "value": "windows", - "operator": "icontains", - }, - ], - }, - } - ], - "properties": { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "email", - "type": "person", - "value": "posthog", - "operator": "icontains", - }, - { - "key": "$browser", - "type": "person", - "value": "karl", - "operator": "icontains", - }, - ], - }, - { - "type": "OR", - "values": [ - {"key": "event_prop", "value": "value"}, - { - "key": "$os", - "type": "person", - "value": "windows", - "operator": "exact", - }, - ], - }, - ], - }, - } - ) - - assert person_query(team, filter) == snapshot - assert run_query(team, filter) == {"rows": 2, "columns": 3} - - assert person_query(team, filter, entity=filter.entities[0]) == snapshot - assert run_query(team, filter, entity=filter.entities[0]) == { - "rows": 2, - "columns": 2, - } - - -def test_person_query_with_updated_after(testdata, team, snapshot): - filter = Filter(data={"updated_after": "2023-04-04"}) - - assert person_query(team, filter) == snapshot - assert run_query(team, filter) == {"rows": 3, "columns": 1} - - filter = Filter(data={"updated_after": "2055-04-04"}) - - assert person_query(team, filter) == snapshot - assert run_query(team, filter) == {"rows": 0} diff --git a/ee/clickhouse/queries/test/test_property_optimizer.py b/ee/clickhouse/queries/test/test_property_optimizer.py deleted file mode 100644 index 907c035b64b..00000000000 --- a/ee/clickhouse/queries/test/test_property_optimizer.py +++ /dev/null @@ -1,552 +0,0 @@ -import unittest - -from posthog.models.filters import Filter -from posthog.queries.property_optimizer import PropertyOptimizer - -PROPERTIES_OF_ALL_TYPES = [ - {"key": "event_prop", "value": ["foo", "bar"], "type": "event"}, - {"key": "person_prop", "value": "efg", "type": "person"}, - {"key": "id", "value": 1, "type": "cohort"}, - {"key": "tag_name", "value": ["label"], "operator": "exact", "type": "element"}, - { - "key": "group_prop", - "value": ["value"], - "operator": "exact", - "type": "group", - "group_type_index": 2, - }, -] - -BASE_FILTER = Filter({"events": [{"id": "$pageview", "type": "events", "order": 0}]}) -FILTER_WITH_GROUPS = BASE_FILTER.shallow_clone({"properties": {"type": "AND", "values": PROPERTIES_OF_ALL_TYPES}}) -TEAM_ID = 3 - - -class TestPersonPropertySelector(unittest.TestCase): - def test_basic_selector(self): - filter = BASE_FILTER.shallow_clone( - { - "properties": { - "type": "OR", - "values": [ - {"key": "person_prop", "value": "efg", "type": "person"}, - {"key": "person_prop2", "value": "efg2", "type": "person"}, - ], - } - } - ) - self.assertTrue(PropertyOptimizer.using_only_person_properties(filter.property_groups)) - - def test_multilevel_selector(self): - filter = BASE_FILTER.shallow_clone( - { - "properties": { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "event_prop2", - "value": ["foo2", "bar2"], - "type": "event", - }, - { - "key": "person_prop2", - "value": "efg2", - "type": "person", - }, - ], - }, - { - "type": "AND", - "values": [ - { - "key": "event_prop", - "value": ["foo", "bar"], - "type": "event", - }, - { - "key": "person_prop", - "value": "efg", - "type": "person", - }, - ], - }, - ], - } - } - ) - - self.assertFalse(PropertyOptimizer.using_only_person_properties(filter.property_groups)) - - def test_multilevel_selector_with_valid_OR_persons(self): - filter = BASE_FILTER.shallow_clone( - { - "properties": { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "event_prop2", - "value": ["foo2", "bar2"], - "type": "person", - }, - { - "key": "person_prop2", - "value": "efg2", - "type": "person", - }, - ], - }, - { - "type": "AND", - "values": [ - { - "key": "event_prop", - "value": ["foo", "bar"], - "type": "person", - }, - { - "key": "person_prop", - "value": "efg", - "type": "person", - }, - ], - }, - ], - } - } - ) - - self.assertTrue(PropertyOptimizer.using_only_person_properties(filter.property_groups)) - - -class TestPersonPushdown(unittest.TestCase): - maxDiff = None - - def test_basic_pushdowns(self): - property_groups = PropertyOptimizer().parse_property_groups(FILTER_WITH_GROUPS.property_groups) - inner = property_groups.inner - outer = property_groups.outer - - assert inner is not None - assert outer is not None - - self.assertEqual( - inner.to_dict(), - { - "type": "AND", - "values": [{"key": "person_prop", "value": "efg", "type": "person"}], - }, - ) - - self.assertEqual( - outer.to_dict(), - { - "type": "AND", - "values": [ - {"key": "event_prop", "value": ["foo", "bar"], "type": "event"}, - {"key": "id", "value": 1, "type": "cohort"}, - { - "key": "tag_name", - "value": ["label"], - "operator": "exact", - "type": "element", - }, - { - "key": "group_prop", - "value": ["value"], - "operator": "exact", - "type": "group", - "group_type_index": 2, - }, - ], - }, - ) - - def test_person_properties_mixed_with_event_properties(self): - filter = BASE_FILTER.shallow_clone( - { - "properties": { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "event_prop2", - "value": ["foo2", "bar2"], - "type": "event", - }, - { - "key": "person_prop2", - "value": "efg2", - "type": "person", - }, - ], - }, - { - "type": "AND", - "values": [ - { - "key": "event_prop", - "value": ["foo", "bar"], - "type": "event", - }, - { - "key": "person_prop", - "value": "efg", - "type": "person", - }, - ], - }, - ], - } - } - ) - - property_groups = PropertyOptimizer().parse_property_groups(filter.property_groups) - inner = property_groups.inner - outer = property_groups.outer - - assert inner is not None - assert outer is not None - - self.assertEqual( - inner.to_dict(), - { - "type": "AND", - "values": [ - { - "type": "AND", - "values": [{"key": "person_prop", "value": "efg", "type": "person"}], - } - ], - }, - ) - - self.assertEqual( - outer.to_dict(), - { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "event_prop2", - "value": ["foo2", "bar2"], - "type": "event", - }, - {"key": "person_prop2", "value": "efg2", "type": "person"}, - ], - }, - { - "type": "AND", - "values": [ - { - "key": "event_prop", - "value": ["foo", "bar"], - "type": "event", - }, - # {"key": "person_prop", "value": "efg", "type": "person", }, # this was pushed down - ], - }, - ], - }, - ) - - def test_person_properties_with_or_not_mixed_with_event_properties(self): - filter = BASE_FILTER.shallow_clone( - { - "properties": { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "person_prop2", - "value": ["foo2", "bar2"], - "type": "person", - }, - { - "key": "person_prop2", - "value": "efg2", - "type": "person", - }, - ], - }, - { - "type": "AND", - "values": [ - { - "key": "event_prop", - "value": ["foo", "bar"], - "type": "event", - }, - { - "key": "person_prop", - "value": "efg", - "type": "person", - }, - ], - }, - ], - } - } - ) - - property_groups = PropertyOptimizer().parse_property_groups(filter.property_groups) - inner = property_groups.inner - outer = property_groups.outer - - assert inner is not None - assert outer is not None - - self.assertEqual( - inner.to_dict(), - { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "person_prop2", - "value": ["foo2", "bar2"], - "type": "person", - }, - {"key": "person_prop2", "value": "efg2", "type": "person"}, - ], - }, - { - "type": "AND", - "values": [{"key": "person_prop", "value": "efg", "type": "person"}], - }, - ], - }, - ) - - self.assertEqual( - outer.to_dict(), - { - "type": "AND", - "values": [ - # OR group was pushed down, so not here anymore - { - "type": "AND", - "values": [ - { - "key": "event_prop", - "value": ["foo", "bar"], - "type": "event", - }, - # {"key": "person_prop", "value": "efg", "type": "person", }, # this was pushed down - ], - } - ], - }, - ) - - def test_person_properties_mixed_with_event_properties_with_misdirection_using_nested_groups(self): - filter = BASE_FILTER.shallow_clone( - { - "properties": { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "event_prop2", - "value": ["foo2", "bar2"], - "type": "event", - } - ], - } - ], - }, - { - "type": "AND", - "values": [ - { - "key": "person_prop2", - "value": "efg2", - "type": "person", - } - ], - }, - ], - } - ], - }, - { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "type": "AND", - "values": [ - { - "key": "event_prop", - "value": ["foo", "bar"], - "type": "event", - } - ], - } - ], - }, - { - "type": "OR", - "values": [ - { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "person_prop", - "value": "efg", - "type": "person", - } - ], - } - ], - } - ], - }, - ], - }, - ], - } - } - ) - - property_groups = PropertyOptimizer().parse_property_groups(filter.property_groups) - inner = property_groups.inner - outer = property_groups.outer - - assert inner is not None - assert outer is not None - - self.assertEqual( - inner.to_dict(), - { - "type": "AND", - "values": [ - { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "person_prop", - "value": "efg", - "type": "person", - } - ], - } - ], - } - ], - } - ], - } - ], - }, - ) - - self.assertEqual( - outer.to_dict(), - { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - { - "key": "event_prop2", - "value": ["foo2", "bar2"], - "type": "event", - } - ], - } - ], - }, - { - "type": "AND", - "values": [ - { - "key": "person_prop2", - "value": "efg2", - "type": "person", - } - ], - }, - ], - } - ], - }, - { - "type": "AND", - "values": [ - { - "type": "OR", - "values": [ - { - "type": "AND", - "values": [ - { - "key": "event_prop", - "value": ["foo", "bar"], - "type": "event", - } - ], - } - ], - }, - # {"type": "OR", "values": [ - # {"type": "AND", "values": [ - # {"type": "OR", "values": [{"key": "person_prop", "value": "efg", "type": "person"}]}] - # }]} - # this was pushed down - ], - }, - ], - }, - ) - - -# TODO: add macobo-groups in mixture to tests as well diff --git a/ee/clickhouse/queries/test/test_retention.py b/ee/clickhouse/queries/test/test_retention.py deleted file mode 100644 index 45e6503fc28..00000000000 --- a/ee/clickhouse/queries/test/test_retention.py +++ /dev/null @@ -1,530 +0,0 @@ -import uuid - -from django.test import override_settings - -from posthog.models.filters.retention_filter import RetentionFilter -from posthog.models.group.util import create_group -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.models.instance_setting import override_instance_config -from posthog.models.person import Person -from posthog.queries.retention import Retention -from posthog.queries.test.test_retention import ( - _create_event, - _create_events, - _date, - pluck, -) -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - create_person_id_override_by_distinct_id, - snapshot_clickhouse_queries, -) - - -class TestClickhouseRetention(ClickhouseTestMixin, APIBaseTest): - def _create_groups_and_events(self): - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - GroupTypeMapping.objects.create(team=self.team, group_type="company", group_type_index=1) - - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:5", - properties={"industry": "finance"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:6", - properties={"industry": "technology"}, - ) - - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:1", - properties={}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:2", - properties={}, - ) - - Person.objects.create(team=self.team, distinct_ids=["person1", "alias1"]) - Person.objects.create(team=self.team, distinct_ids=["person2"]) - Person.objects.create(team=self.team, distinct_ids=["person3"]) - - _create_events( - self.team, - [ - ("person1", _date(0), {"$group_0": "org:5", "$group_1": "company:1"}), - ("person2", _date(0), {"$group_0": "org:6"}), - ("person3", _date(0)), - ("person1", _date(1), {"$group_0": "org:5"}), - ("person2", _date(1), {"$group_0": "org:6"}), - ("person1", _date(7), {"$group_0": "org:5"}), - ("person2", _date(7), {"$group_0": "org:6"}), - ("person1", _date(14), {"$group_0": "org:5"}), - ( - "person1", - _date(month=1, day=-6), - {"$group_0": "org:5", "$group_1": "company:1"}, - ), - ("person2", _date(month=1, day=-6), {"$group_0": "org:6"}), - ("person2", _date(month=1, day=1), {"$group_0": "org:6"}), - ("person1", _date(month=1, day=1), {"$group_0": "org:5"}), - ( - "person2", - _date(month=1, day=15), - {"$group_0": "org:6", "$group_1": "company:1"}, - ), - ], - ) - - # TODO: Delete this test when moved to person-on-events - def test_groups_filtering(self): - self._create_groups_and_events() - - result = Retention().run( - RetentionFilter( - data={ - "date_to": _date(10, month=1, hour=0), - "period": "Week", - "total_intervals": 7, - "properties": [ - { - "key": "industry", - "value": "technology", - "type": "group", - "group_type_index": 0, - } - ], - }, - team=self.team, - ), - self.team, - ) - - self.assertEqual( - pluck(result, "values", "count"), - [ - [1, 1, 0, 1, 1, 0, 1], - [1, 0, 1, 1, 0, 1], - [0, 0, 0, 0, 0], - [1, 1, 0, 1], - [1, 0, 1], - [0, 0], - [1], - ], - ) - - result = Retention().run( - RetentionFilter( - data={ - "date_to": _date(10, month=1, hour=0), - "period": "Week", - "total_intervals": 7, - "properties": [ - { - "key": "industry", - "value": "", - "type": "group", - "group_type_index": 0, - "operator": "is_set", - } - ], - }, - team=self.team, - ), - self.team, - ) - - self.assertEqual( - pluck(result, "values", "count"), - [ - [2, 2, 1, 2, 2, 0, 1], - [2, 1, 2, 2, 0, 1], - [1, 1, 1, 0, 0], - [2, 2, 0, 1], - [2, 0, 1], - [0, 0], - [1], - ], - ) - - # TODO: Delete this test when moved to person-on-events - def test_groups_aggregating(self): - self._create_groups_and_events() - - filter = RetentionFilter( - data={ - "date_to": _date(10, month=1, hour=0), - "period": "Week", - "total_intervals": 7, - "aggregation_group_type_index": 0, - }, - team=self.team, - ) - - result = Retention().run(filter, self.team) - self.assertEqual( - pluck(result, "values", "count"), - [ - [2, 2, 1, 2, 2, 0, 1], - [2, 1, 2, 2, 0, 1], - [1, 1, 1, 0, 0], - [2, 2, 0, 1], - [2, 0, 1], - [0, 0], - [1], - ], - ) - - actor_result, _ = Retention().actors_in_period(filter.shallow_clone({"selected_interval": 0}), self.team) - self.assertCountEqual([actor["person"]["id"] for actor in actor_result], ["org:5", "org:6"]) - - filter = RetentionFilter( - data={ - "date_to": _date(10, month=1, hour=0), - "period": "Week", - "total_intervals": 7, - "aggregation_group_type_index": 1, - }, - team=self.team, - ) - - result = Retention().run(filter, self.team) - self.assertEqual( - pluck(result, "values", "count"), - [ - [1, 0, 0, 1, 0, 0, 1], - [0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [1, 0, 0, 1], - [0, 0, 0], - [0, 0], - [1], - ], - ) - - # TODO: Delete this test when moved to person-on-events - def test_groups_in_period(self): - self._create_groups_and_events() - - filter = RetentionFilter( - data={ - "date_to": _date(10, month=1, hour=0), - "period": "Week", - "total_intervals": 7, - "aggregation_group_type_index": 0, - }, - team=self.team, - ) - - actor_result, _ = Retention().actors_in_period(filter.shallow_clone({"selected_interval": 0}), self.team) - - self.assertEqual(actor_result[0]["person"]["id"], "org:5") - self.assertEqual(actor_result[0]["appearances"], [1, 1, 1, 1, 1, 0, 0]) - - self.assertEqual(actor_result[1]["person"]["id"], "org:6") - self.assertEqual(actor_result[1]["appearances"], [1, 1, 0, 1, 1, 0, 1]) - - @snapshot_clickhouse_queries - def test_groups_filtering_person_on_events(self): - self._create_groups_and_events() - - with override_instance_config("PERSON_ON_EVENTS_ENABLED", True): - result = Retention().run( - RetentionFilter( - data={ - "date_to": _date(10, month=1, hour=0), - "period": "Week", - "total_intervals": 7, - "properties": [ - { - "key": "industry", - "value": "technology", - "type": "group", - "group_type_index": 0, - } - ], - }, - team=self.team, - ), - self.team, - ) - - self.assertEqual( - pluck(result, "values", "count"), - [ - [1, 1, 0, 1, 1, 0, 1], - [1, 0, 1, 1, 0, 1], - [0, 0, 0, 0, 0], - [1, 1, 0, 1], - [1, 0, 1], - [0, 0], - [1], - ], - ) - - result = Retention().run( - RetentionFilter( - data={ - "date_to": _date(10, month=1, hour=0), - "period": "Week", - "total_intervals": 7, - "properties": [ - { - "key": "industry", - "value": "", - "type": "group", - "group_type_index": 0, - "operator": "is_set", - } - ], - }, - team=self.team, - ), - self.team, - ) - - self.assertEqual( - pluck(result, "values", "count"), - [ - [2, 2, 1, 2, 2, 0, 1], - [2, 1, 2, 2, 0, 1], - [1, 1, 1, 0, 0], - [2, 2, 0, 1], - [2, 0, 1], - [0, 0], - [1], - ], - ) - - @override_settings(PERSON_ON_EVENTS_V2_OVERRIDE=True) - @snapshot_clickhouse_queries - def test_groups_filtering_person_on_events_v2(self): - self._create_groups_and_events() - - person_id1 = str(uuid.uuid4()) - person_id2 = str(uuid.uuid4()) - person_id3 = str(uuid.uuid4()) - _create_event( - event="$pageview", - team=self.team, - distinct_id="person1", - person_id=person_id1, - timestamp=_date(1), - properties={"$group_0": "org:5"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="person2", - person_id=person_id2, - timestamp=_date(2), - properties={"$group_0": "org:5"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="person1", - person_id=person_id1, - timestamp=_date(3), - properties={"$group_0": "org:5"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="person2", - person_id=person_id2, - timestamp=_date(4), - properties={"$group_0": "org:5"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="person1", - person_id=person_id1, - timestamp=_date(5), - properties={"$group_0": "org:5"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="person2", - person_id=person_id2, - timestamp=_date(6), - properties={"$group_0": "org:5"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="person1", - person_id=person_id1, - timestamp=_date(7), - properties={"$group_0": "org:5"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="person2", - person_id=person_id2, - timestamp=_date(8), - properties={"$group_0": "org:5"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="person3", - person_id=person_id3, - timestamp=_date(1), - properties={"$group_1": "project:4"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="person3", - person_id=person_id3, - timestamp=_date(1), - properties={"$group_1": "project:4"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="person3", - person_id=person_id3, - timestamp=_date(1), - properties={"$group_1": "project:4"}, - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id="person3", - person_id=person_id3, - timestamp=_date(1), - properties={"$group_1": "project:4"}, - ) - - create_person_id_override_by_distinct_id("person1", "person2", self.team.pk) - - result = Retention().run( - RetentionFilter( - data={ - "date_to": _date(8), - "period": "Day", - "total_intervals": 7, - "properties": [ - { - "key": "industry", - "value": "", - "type": "group", - "group_type_index": 0, - "operator": "is_set", - } - ], - }, - team=self.team, - ), - self.team, - ) - - # We expect 1s across the board due to the override set up from person1 to person2, making them the same person - self.assertEqual( - pluck(result, "values", "count"), - [ - [1, 1, 1, 1, 1, 1, 1], - [1, 1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 1], - [1, 1, 1], - [1, 1], - [1], - ], - ) - - @snapshot_clickhouse_queries - def test_groups_aggregating_person_on_events(self): - self._create_groups_and_events() - - filter = RetentionFilter( - data={ - "date_to": _date(10, month=1, hour=0), - "period": "Week", - "total_intervals": 7, - "aggregation_group_type_index": 0, - }, - team=self.team, - ) - - with override_instance_config("PERSON_ON_EVENTS_ENABLED", True): - result = Retention().run(filter, self.team) - self.assertEqual( - pluck(result, "values", "count"), - [ - [2, 2, 1, 2, 2, 0, 1], - [2, 1, 2, 2, 0, 1], - [1, 1, 1, 0, 0], - [2, 2, 0, 1], - [2, 0, 1], - [0, 0], - [1], - ], - ) - - actor_result, _ = Retention().actors_in_period(filter.shallow_clone({"selected_interval": 0}), self.team) - - self.assertCountEqual([actor["person"]["id"] for actor in actor_result], ["org:5", "org:6"]) - - filter = RetentionFilter( - data={ - "date_to": _date(10, month=1, hour=0), - "period": "Week", - "total_intervals": 7, - "aggregation_group_type_index": 1, - }, - team=self.team, - ) - - result = Retention().run(filter, self.team) - self.assertEqual( - pluck(result, "values", "count"), - [ - [1, 0, 0, 1, 0, 0, 1], - [0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [1, 0, 0, 1], - [0, 0, 0], - [0, 0], - [1], - ], - ) - - @snapshot_clickhouse_queries - def test_groups_in_period_person_on_events(self): - from posthog.models.team import util - - util.can_enable_actor_on_events = True - self._create_groups_and_events() - - filter = RetentionFilter( - data={ - "date_to": _date(10, month=1, hour=0), - "period": "Week", - "total_intervals": 7, - "aggregation_group_type_index": 0, - }, - team=self.team, - ) - - with override_instance_config("PERSON_ON_EVENTS_ENABLED", True): - actor_result, _ = Retention().actors_in_period(filter.shallow_clone({"selected_interval": 0}), self.team) - - self.assertTrue(actor_result[0]["person"]["id"] == "org:5") - self.assertEqual(actor_result[0]["appearances"], [1, 1, 1, 1, 1, 0, 0]) - - self.assertTrue(actor_result[1]["person"]["id"] == "org:6") - self.assertEqual(actor_result[1]["appearances"], [1, 1, 0, 1, 1, 0, 1]) diff --git a/ee/clickhouse/queries/test/test_util.py b/ee/clickhouse/queries/test/test_util.py deleted file mode 100644 index 54124fde3e2..00000000000 --- a/ee/clickhouse/queries/test/test_util.py +++ /dev/null @@ -1,65 +0,0 @@ -from datetime import datetime, timedelta - -from zoneinfo import ZoneInfo -from freezegun.api import freeze_time - -from posthog.client import sync_execute -from posthog.hogql.hogql import HogQLContext -from posthog.models.action import Action -from posthog.models.cohort import Cohort -from posthog.queries.breakdown_props import _parse_breakdown_cohorts -from posthog.queries.util import get_earliest_timestamp -from posthog.test.base import _create_event - - -def test_get_earliest_timestamp(db, team): - with freeze_time("2021-01-21") as frozen_time: - _create_event( - team=team, - event="sign up", - distinct_id="1", - timestamp="2020-01-04T14:10:00Z", - ) - _create_event( - team=team, - event="sign up", - distinct_id="1", - timestamp="2020-01-06T14:10:00Z", - ) - - assert get_earliest_timestamp(team.id) == datetime(2020, 1, 4, 14, 10, tzinfo=ZoneInfo("UTC")) - - frozen_time.tick(timedelta(seconds=1)) - _create_event( - team=team, - event="sign up", - distinct_id="1", - timestamp="1984-01-06T14:10:00Z", - ) - _create_event( - team=team, - event="sign up", - distinct_id="1", - timestamp="2014-01-01T01:00:00Z", - ) - _create_event( - team=team, - event="sign up", - distinct_id="1", - timestamp="2015-01-01T01:00:00Z", - ) - - assert get_earliest_timestamp(team.id) == datetime(2015, 1, 1, 1, tzinfo=ZoneInfo("UTC")) - - -@freeze_time("2021-01-21") -def test_get_earliest_timestamp_with_no_events(db, team): - assert get_earliest_timestamp(team.id) == datetime(2021, 1, 14, tzinfo=ZoneInfo("UTC")) - - -def test_parse_breakdown_cohort_query(db, team): - action = Action.objects.create(team=team, name="$pageview", steps_json=[{"event": "$pageview"}]) - cohort1 = Cohort.objects.create(team=team, groups=[{"action_id": action.pk, "days": 3}], name="cohort1") - queries, params = _parse_breakdown_cohorts([cohort1], HogQLContext(team_id=team.pk)) - assert len(queries) == 1 - sync_execute(queries[0], params) diff --git a/ee/clickhouse/test/__init__.py b/ee/clickhouse/test/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/clickhouse/test/test_error.py b/ee/clickhouse/test/test_error.py deleted file mode 100644 index 983e37b1454..00000000000 --- a/ee/clickhouse/test/test_error.py +++ /dev/null @@ -1,50 +0,0 @@ -import pytest -from clickhouse_driver.errors import ServerException - -from posthog.errors import wrap_query_error - - -@pytest.mark.parametrize( - "error,expected_type,expected_message,expected_code", - [ - (AttributeError("Foobar"), "AttributeError", "Foobar", None), - ( - ServerException("Estimated query execution time (34.5 seconds) is too long. Aborting query"), - "EstimatedQueryExecutionTimeTooLong", - "Estimated query execution time (34.5 seconds) is too long. Try reducing its scope by changing the time range.", - None, - ), - ( - ServerException("Syntax error", code=62), - "CHQueryErrorSyntaxError", - "Code: 62.\nSyntax error", - 62, - ), - ( - ServerException("Syntax error", code=9999), - "CHQueryErrorUnknownException", - "Code: 9999.\nSyntax error", - 9999, - ), - ( - ServerException( - "Memory limit (for query) exceeded: would use 42.00 GiB (attempt to allocate chunk of 16757643 bytes), maximum: 42.00 GiB.", - code=241, - ), - "CHQueryErrorMemoryLimitExceeded", - "Query exceeds memory limits. Try reducing its scope by changing the time range.", - 241, - ), - ( - ServerException("Too many simultaneous queries. Maximum: 100.", code=202), - "CHQueryErrorTooManySimultaneousQueries", - "Code: 202.\nToo many simultaneous queries. Try again later.", - 202, - ), - ], -) -def test_wrap_query_error(error, expected_type, expected_message, expected_code): - new_error = wrap_query_error(error) - assert type(new_error).__name__ == expected_type - assert str(new_error) == expected_message - assert getattr(new_error, "code", None) == expected_code diff --git a/ee/clickhouse/test/test_system_status.py b/ee/clickhouse/test/test_system_status.py deleted file mode 100644 index 80a5b692c6a..00000000000 --- a/ee/clickhouse/test/test_system_status.py +++ /dev/null @@ -1,23 +0,0 @@ -def test_system_status(db): - from posthog.clickhouse.system_status import system_status - - results = list(system_status()) - assert [row["key"] for row in results] == [ - "clickhouse_alive", - "clickhouse_event_count", - "clickhouse_event_count_last_month", - "clickhouse_event_count_month_to_date", - "clickhouse_session_recordings_count_month_to_date", - "clickhouse_session_recordings_events_count_month_to_date", - "clickhouse_session_recordings_events_size_ingested", - "clickhouse_disk_0_free_space", - "clickhouse_disk_0_total_space", - "clickhouse_table_sizes", - "clickhouse_system_metrics", - "last_event_ingested_timestamp", - "dead_letter_queue_size", - "dead_letter_queue_events_last_day", - "dead_letter_queue_ratio_ok", - ] - assert len(results[9]["subrows"]["rows"]) > 0 - assert len(results[10]["subrows"]["rows"]) > 0 diff --git a/ee/clickhouse/views/__init__.py b/ee/clickhouse/views/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/clickhouse/views/experiments.py b/ee/clickhouse/views/experiments.py deleted file mode 100644 index 13ca4beb7f7..00000000000 --- a/ee/clickhouse/views/experiments.py +++ /dev/null @@ -1,492 +0,0 @@ -from typing import Any, Optional -from collections.abc import Callable - -from django.utils.timezone import now -from rest_framework import serializers, viewsets -from rest_framework.decorators import action -from rest_framework.exceptions import ValidationError -from rest_framework.request import Request -from rest_framework.response import Response -from statshog.defaults.django import statsd -import posthoganalytics - -from ee.clickhouse.queries.experiments.funnel_experiment_result import ( - ClickhouseFunnelExperimentResult, -) -from ee.clickhouse.queries.experiments.secondary_experiment_result import ( - ClickhouseSecondaryExperimentResult, -) -from ee.clickhouse.queries.experiments.trend_experiment_result import ( - ClickhouseTrendExperimentResult, -) -from ee.clickhouse.queries.experiments.utils import requires_flag_warning -from posthog.api.cohort import CohortSerializer -from posthog.api.feature_flag import FeatureFlagSerializer, MinimalFeatureFlagSerializer -from posthog.api.routing import TeamAndOrgViewSetMixin -from posthog.api.shared import UserBasicSerializer -from posthog.caching.insight_cache import update_cached_state -from posthog.clickhouse.query_tagging import tag_queries -from posthog.constants import INSIGHT_TRENDS -from posthog.models.experiment import Experiment -from posthog.models.filters.filter import Filter -from posthog.utils import generate_cache_key, get_safe_cache - -EXPERIMENT_RESULTS_CACHE_DEFAULT_TTL = 60 * 60 # 1 hour - - -def _calculate_experiment_results(experiment: Experiment, refresh: bool = False): - # :TRICKY: Don't run any filter simplification on the experiment filter yet - filter = Filter({**experiment.filters, "is_simplified": True}, team=experiment.team) - - exposure_filter_data = (experiment.parameters or {}).get("custom_exposure_filter") - exposure_filter = None - if exposure_filter_data: - exposure_filter = Filter(data={**exposure_filter_data, "is_simplified": True}, team=experiment.team) - - if filter.insight == INSIGHT_TRENDS: - calculate_func = lambda: ClickhouseTrendExperimentResult( - filter, - experiment.team, - experiment.feature_flag, - experiment.start_date, - experiment.end_date, - custom_exposure_filter=exposure_filter, - ).get_results() - else: - calculate_func = lambda: ClickhouseFunnelExperimentResult( - filter, - experiment.team, - experiment.feature_flag, - experiment.start_date, - experiment.end_date, - ).get_results() - - return _experiment_results_cached( - experiment, - "primary", - filter, - calculate_func, - refresh=refresh, - exposure_filter=exposure_filter, - ) - - -def _calculate_secondary_experiment_results(experiment: Experiment, parsed_id: int, refresh: bool = False): - filter = Filter(experiment.secondary_metrics[parsed_id]["filters"], team=experiment.team) - - calculate_func = lambda: ClickhouseSecondaryExperimentResult( - filter, - experiment.team, - experiment.feature_flag, - experiment.start_date, - experiment.end_date, - ).get_results() - return _experiment_results_cached(experiment, "secondary", filter, calculate_func, refresh=refresh) - - -def _experiment_results_cached( - experiment: Experiment, - results_type: str, - filter: Filter, - calculate_func: Callable, - refresh: bool, - exposure_filter: Optional[Filter] = None, -): - cache_filter = filter.shallow_clone( - { - "date_from": experiment.start_date, - "date_to": experiment.end_date if experiment.end_date else None, - } - ) - - exposure_suffix = "" if not exposure_filter else f"_{exposure_filter.toJSON()}" - - cache_key = generate_cache_key( - f"experiment_{results_type}_{cache_filter.toJSON()}_{experiment.team.pk}_{experiment.pk}{exposure_suffix}" - ) - - tag_queries(cache_key=cache_key) - - cached_result_package = get_safe_cache(cache_key) - - if cached_result_package and cached_result_package.get("result") and not refresh: - cached_result_package["is_cached"] = True - statsd.incr( - "posthog_cached_function_cache_hit", - tags={"route": "/projects/:id/experiments/:experiment_id/results"}, - ) - return cached_result_package - - statsd.incr( - "posthog_cached_function_cache_miss", - tags={"route": "/projects/:id/experiments/:experiment_id/results"}, - ) - - result = calculate_func() - - timestamp = now() - fresh_result_package = {"result": result, "last_refresh": now(), "is_cached": False} - - # Event to detect experiment significance flip-flopping - posthoganalytics.capture( - experiment.created_by.email, - "experiment result calculated", - properties={ - "experiment_id": experiment.id, - "name": experiment.name, - "goal_type": experiment.filters.get("insight", "FUNNELS"), - "significant": result.get("significant"), - "significance_code": result.get("significance_code"), - "probability": result.get("probability"), - }, - ) - - update_cached_state( - experiment.team.pk, - cache_key, - timestamp, - fresh_result_package, - ttl=EXPERIMENT_RESULTS_CACHE_DEFAULT_TTL, - ) - - return fresh_result_package - - -class ExperimentSerializer(serializers.ModelSerializer): - feature_flag_key = serializers.CharField(source="get_feature_flag_key") - created_by = UserBasicSerializer(read_only=True) - feature_flag = MinimalFeatureFlagSerializer(read_only=True) - - class Meta: - model = Experiment - fields = [ - "id", - "name", - "description", - "start_date", - "end_date", - "feature_flag_key", - "feature_flag", - "exposure_cohort", - "parameters", - "secondary_metrics", - "filters", - "archived", - "created_by", - "created_at", - "updated_at", - ] - read_only_fields = [ - "id", - "created_by", - "created_at", - "updated_at", - "feature_flag", - "exposure_cohort", - ] - - def validate_parameters(self, value): - if not value: - return value - - variants = value.get("feature_flag_variants", []) - - if len(variants) >= 11: - raise ValidationError("Feature flag variants must be less than 11") - elif len(variants) > 0: - if "control" not in [variant["key"] for variant in variants]: - raise ValidationError("Feature flag variants must contain a control variant") - - return value - - def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> Experiment: - if not validated_data.get("filters"): - raise ValidationError("Filters are required to create an Experiment") - - variants = [] - aggregation_group_type_index = None - if validated_data["parameters"]: - variants = validated_data["parameters"].get("feature_flag_variants", []) - aggregation_group_type_index = validated_data["parameters"].get("aggregation_group_type_index") - - request = self.context["request"] - validated_data["created_by"] = request.user - - feature_flag_key = validated_data.pop("get_feature_flag_key") - - is_draft = "start_date" not in validated_data or validated_data["start_date"] is None - - properties = validated_data["filters"].get("properties", []) - - if properties: - raise ValidationError("Experiments do not support global filter properties") - - default_variants = [ - {"key": "control", "name": "Control Group", "rollout_percentage": 50}, - {"key": "test", "name": "Test Variant", "rollout_percentage": 50}, - ] - - filters = { - "groups": [{"properties": properties, "rollout_percentage": 100}], - "multivariate": {"variants": variants or default_variants}, - "aggregation_group_type_index": aggregation_group_type_index, - } - - feature_flag_serializer = FeatureFlagSerializer( - data={ - "key": feature_flag_key, - "name": f'Feature Flag for Experiment {validated_data["name"]}', - "filters": filters, - "active": not is_draft, - }, - context=self.context, - ) - - feature_flag_serializer.is_valid(raise_exception=True) - feature_flag = feature_flag_serializer.save() - - experiment = Experiment.objects.create( - team_id=self.context["team_id"], feature_flag=feature_flag, **validated_data - ) - return experiment - - def update(self, instance: Experiment, validated_data: dict, *args: Any, **kwargs: Any) -> Experiment: - has_start_date = validated_data.get("start_date") is not None - feature_flag = instance.feature_flag - - expected_keys = { - "name", - "description", - "start_date", - "end_date", - "filters", - "parameters", - "archived", - "secondary_metrics", - } - given_keys = set(validated_data.keys()) - extra_keys = given_keys - expected_keys - - if feature_flag.key == validated_data.get("get_feature_flag_key"): - extra_keys.remove("get_feature_flag_key") - - if extra_keys: - raise ValidationError(f"Can't update keys: {', '.join(sorted(extra_keys))} on Experiment") - - # if an experiment has launched, we cannot edit its variants anymore. - if not instance.is_draft: - if "feature_flag_variants" in validated_data.get("parameters", {}): - if len(validated_data["parameters"]["feature_flag_variants"]) != len(feature_flag.variants): - raise ValidationError("Can't update feature_flag_variants on Experiment") - - for variant in validated_data["parameters"]["feature_flag_variants"]: - if ( - len([ff_variant for ff_variant in feature_flag.variants if ff_variant["key"] == variant["key"]]) - != 1 - ): - raise ValidationError("Can't update feature_flag_variants on Experiment") - - properties = validated_data.get("filters", {}).get("properties") - if properties: - raise ValidationError("Experiments do not support global filter properties") - - if instance.is_draft: - # if feature flag variants have changed, update the feature flag. - if validated_data.get("parameters"): - variants = validated_data["parameters"].get("feature_flag_variants", []) - aggregation_group_type_index = validated_data["parameters"].get("aggregation_group_type_index") - - global_filters = validated_data.get("filters") - properties = [] - if global_filters: - properties = global_filters.get("properties", []) - if properties: - raise ValidationError("Experiments do not support global filter properties") - - default_variants = [ - {"key": "control", "name": "Control Group", "rollout_percentage": 50}, - {"key": "test", "name": "Test Variant", "rollout_percentage": 50}, - ] - - filters = { - "groups": [{"properties": properties, "rollout_percentage": 100}], - "multivariate": {"variants": variants or default_variants}, - "aggregation_group_type_index": aggregation_group_type_index, - } - - existing_flag_serializer = FeatureFlagSerializer( - feature_flag, - data={"filters": filters}, - partial=True, - context=self.context, - ) - existing_flag_serializer.is_valid(raise_exception=True) - existing_flag_serializer.save() - - if instance.is_draft and has_start_date: - feature_flag.active = True - feature_flag.save() - return super().update(instance, validated_data) - else: - # Not a draft, doesn't have start date - # Or draft without start date - return super().update(instance, validated_data) - - -class ClickhouseExperimentsViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): - scope_object = "experiment" - serializer_class = ExperimentSerializer - queryset = Experiment.objects.prefetch_related("feature_flag", "created_by").all() - ordering = "-created_at" - - # ****************************************** - # /projects/:id/experiments/:experiment_id/results - # - # Returns current results of an experiment, and graphs - # 1. Probability of success - # 2. Funnel breakdown graph to display - # ****************************************** - @action(methods=["GET"], detail=True, required_scopes=["experiment:read"]) - def results(self, request: Request, *args: Any, **kwargs: Any) -> Response: - experiment: Experiment = self.get_object() - - refresh = request.query_params.get("refresh") is not None - - if not experiment.filters: - raise ValidationError("Experiment has no target metric") - - result = _calculate_experiment_results(experiment, refresh) - - return Response(result) - - # ****************************************** - # /projects/:id/experiments/:experiment_id/secondary_results?id= - # - # Returns values for secondary experiment metrics, broken down by variants - # ****************************************** - @action(methods=["GET"], detail=True, required_scopes=["experiment:read"]) - def secondary_results(self, request: Request, *args: Any, **kwargs: Any) -> Response: - experiment: Experiment = self.get_object() - - refresh = request.query_params.get("refresh") is not None - - if not experiment.secondary_metrics: - raise ValidationError("Experiment has no secondary metrics") - - metric_id = request.query_params.get("id") - - if not metric_id: - raise ValidationError("Secondary metric id is required") - - try: - parsed_id = int(metric_id) - except ValueError: - raise ValidationError("Secondary metric id must be an integer") - - if parsed_id > len(experiment.secondary_metrics): - raise ValidationError("Invalid metric ID") - - result = _calculate_secondary_experiment_results(experiment, parsed_id, refresh) - - return Response(result) - - # ****************************************** - # /projects/:id/experiments/requires_flag_implementation - # - # Returns current results of an experiment, and graphs - # 1. Probability of success - # 2. Funnel breakdown graph to display - # ****************************************** - @action(methods=["GET"], detail=False, required_scopes=["experiment:read"]) - def requires_flag_implementation(self, request: Request, *args: Any, **kwargs: Any) -> Response: - filter = Filter(request=request, team=self.team).shallow_clone({"date_from": "-7d", "date_to": ""}) - - warning = requires_flag_warning(filter, self.team) - - return Response({"result": warning}) - - @action(methods=["POST"], detail=True, required_scopes=["experiment:write"]) - def create_exposure_cohort_for_experiment(self, request: Request, *args: Any, **kwargs: Any) -> Response: - experiment = self.get_object() - flag = getattr(experiment, "feature_flag", None) - if not flag: - raise ValidationError("Experiment does not have a feature flag") - - if not experiment.start_date: - raise ValidationError("Experiment does not have a start date") - - if experiment.exposure_cohort: - raise ValidationError("Experiment already has an exposure cohort") - - exposure_filter_data = (experiment.parameters or {}).get("custom_exposure_filter") - exposure_filter = None - if exposure_filter_data: - exposure_filter = Filter(data={**exposure_filter_data, "is_simplified": True}, team=experiment.team) - - target_entity: int | str = "$feature_flag_called" - target_entity_type = "events" - target_filters = [ - { - "key": "$feature_flag", - "value": [flag.key], - "operator": "exact", - "type": "event", - } - ] - - if exposure_filter: - entity = exposure_filter.entities[0] - if entity.id: - target_entity_type = entity.type if entity.type in ["events", "actions"] else "events" - target_entity = entity.id - if entity.type == "actions": - try: - target_entity = int(target_entity) - except ValueError: - raise ValidationError("Invalid action ID") - - target_filters = [ - prop.to_dict() - for prop in entity.property_groups.flat - if prop.type in ("event", "feature", "element", "hogql") - ] - - cohort_serializer = CohortSerializer( - data={ - "is_static": False, - "name": f'Users exposed to experiment "{experiment.name}"', - "is_calculating": True, - "filters": { - "properties": { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - { - "type": "behavioral", - "value": "performed_event", - "key": target_entity, - "negation": False, - "event_type": target_entity_type, - "event_filters": target_filters, - "explicit_datetime": experiment.start_date.isoformat(), - } - ], - } - ], - } - }, - }, - context={ - "request": request, - "team": self.team, - "team_id": self.team_id, - }, - ) - - cohort_serializer.is_valid(raise_exception=True) - cohort = cohort_serializer.save() - experiment.exposure_cohort = cohort - experiment.save(update_fields=["exposure_cohort"]) - return Response({"cohort": cohort_serializer.data}, status=201) diff --git a/ee/clickhouse/views/groups.py b/ee/clickhouse/views/groups.py deleted file mode 100644 index 950fa114fb9..00000000000 --- a/ee/clickhouse/views/groups.py +++ /dev/null @@ -1,214 +0,0 @@ -from collections import defaultdict -from typing import cast - -from django.db.models import Q -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import OpenApiParameter -from rest_framework import mixins, request, response, serializers, viewsets -from rest_framework.decorators import action -from rest_framework.exceptions import NotFound, ValidationError -from rest_framework.pagination import CursorPagination - -from ee.clickhouse.queries.related_actors_query import RelatedActorsQuery -from posthog.api.documentation import extend_schema -from posthog.api.routing import TeamAndOrgViewSetMixin -from posthog.clickhouse.kafka_engine import trim_quotes_expr -from posthog.client import sync_execute -from posthog.models.group import Group -from posthog.models.group_type_mapping import GroupTypeMapping - - -class GroupTypeSerializer(serializers.ModelSerializer): - class Meta: - model = GroupTypeMapping - fields = ["group_type", "group_type_index", "name_singular", "name_plural"] - read_only_fields = ["group_type", "group_type_index"] - - -class ClickhouseGroupsTypesView(TeamAndOrgViewSetMixin, mixins.ListModelMixin, viewsets.GenericViewSet): - scope_object = "group" - serializer_class = GroupTypeSerializer - queryset = GroupTypeMapping.objects.all().order_by("group_type_index") - pagination_class = None - sharing_enabled_actions = ["list"] - - @action(detail=False, methods=["PATCH"], name="Update group types metadata") - def update_metadata(self, request: request.Request, *args, **kwargs): - for row in cast(list[dict], request.data): - instance = GroupTypeMapping.objects.get(team=self.team, group_type_index=row["group_type_index"]) - serializer = self.get_serializer(instance, data=row) - serializer.is_valid(raise_exception=True) - serializer.save() - - return self.list(request, *args, **kwargs) - - -class GroupCursorPagination(CursorPagination): - ordering = "-created_at" - page_size = 100 - - -class GroupSerializer(serializers.HyperlinkedModelSerializer): - class Meta: - model = Group - fields = ["group_type_index", "group_key", "group_properties", "created_at"] - - -class ClickhouseGroupsView(TeamAndOrgViewSetMixin, mixins.ListModelMixin, viewsets.GenericViewSet): - scope_object = "group" - serializer_class = GroupSerializer - queryset = Group.objects.all() - pagination_class = GroupCursorPagination - - def safely_get_queryset(self, queryset): - return queryset.filter( - group_type_index=self.request.GET["group_type_index"], - group_key__icontains=self.request.GET.get("group_key", ""), - ) - - @extend_schema( - parameters=[ - OpenApiParameter( - "group_type_index", - OpenApiTypes.INT, - description="Specify the group type to list", - required=True, - ), - OpenApiParameter( - "search", - OpenApiTypes.STR, - description="Search the group name", - required=True, - ), - ] - ) - def list(self, request, *args, **kwargs): - """ - List all groups of a specific group type. You must pass ?group_type_index= in the URL. To get a list of valid group types, call /api/:project_id/groups_types/ - """ - if not self.request.GET.get("group_type_index"): - raise ValidationError( - { - "group_type_index": [ - "You must pass ?group_type_index= in this URL. To get a list of valid group types, call /api/:project_id/groups_types/." - ] - } - ) - queryset = self.filter_queryset(self.get_queryset()) - - group_search = self.request.GET.get("search") - if group_search is not None: - queryset = queryset.filter(Q(group_properties__icontains=group_search) | Q(group_key__iexact=group_search)) - - page = self.paginate_queryset(queryset) - if page is not None: - serializer = self.get_serializer(page, many=True) - return self.get_paginated_response(serializer.data) - - serializer = self.get_serializer(queryset, many=True) - return response.Response(serializer.data) - - @extend_schema( - parameters=[ - OpenApiParameter( - "group_type_index", - OpenApiTypes.INT, - description="Specify the group type to find", - required=True, - ), - OpenApiParameter( - "group_key", - OpenApiTypes.STR, - description="Specify the key of the group to find", - required=True, - ), - ] - ) - @action(methods=["GET"], detail=False) - def find(self, request: request.Request, **kw) -> response.Response: - try: - group = self.get_queryset().get(group_key=request.GET["group_key"]) - data = self.get_serializer(group).data - return response.Response(data) - except Group.DoesNotExist: - raise NotFound() - - @extend_schema( - parameters=[ - OpenApiParameter( - "group_type_index", - OpenApiTypes.INT, - description="Specify the group type to find", - required=True, - ), - OpenApiParameter( - "id", - OpenApiTypes.STR, - description="Specify the id of the user to find groups for", - required=True, - ), - ] - ) - @action(methods=["GET"], detail=False) - def related(self, request: request.Request, pk=None, **kw) -> response.Response: - group_type_index = request.GET.get("group_type_index") - id = request.GET["id"] - - results = RelatedActorsQuery(self.team, group_type_index, id).run() - return response.Response(results) - - @action(methods=["GET"], detail=False) - def property_definitions(self, request: request.Request, **kw): - rows = sync_execute( - f""" - SELECT group_type_index, tupleElement(keysAndValues, 1) as key, count(*) as count - FROM groups - ARRAY JOIN JSONExtractKeysAndValuesRaw(group_properties) as keysAndValues - WHERE team_id = %(team_id)s - GROUP BY group_type_index, tupleElement(keysAndValues, 1) - ORDER BY group_type_index ASC, count DESC, key ASC - """, - {"team_id": self.team.pk}, - ) - - group_type_index_to_properties = defaultdict(list) - for group_type_index, key, count in rows: - group_type_index_to_properties[str(group_type_index)].append({"name": key, "count": count}) - - return response.Response(group_type_index_to_properties) - - @extend_schema( - parameters=[ - OpenApiParameter( - "group_type_index", - OpenApiTypes.INT, - description="Specify the group type to find property values of", - required=True, - ), - OpenApiParameter( - "key", - OpenApiTypes.STR, - description="Specify the property key to find values for", - required=True, - ), - ] - ) - @action(methods=["GET"], detail=False) - def property_values(self, request: request.Request, **kw): - rows = sync_execute( - f""" - SELECT {trim_quotes_expr("tupleElement(keysAndValues, 2)")} as value - FROM groups - ARRAY JOIN JSONExtractKeysAndValuesRaw(group_properties) as keysAndValues - WHERE team_id = %(team_id)s AND group_type_index = %(group_type_index)s AND tupleElement(keysAndValues, 1) = %(key)s - GROUP BY tupleElement(keysAndValues, 2) - ORDER BY value ASC - """, - { - "team_id": self.team.pk, - "group_type_index": request.GET["group_type_index"], - "key": request.GET["key"], - }, - ) - - return response.Response([{"name": name[0]} for name in rows]) diff --git a/ee/clickhouse/views/insights.py b/ee/clickhouse/views/insights.py deleted file mode 100644 index e6adf49e7ff..00000000000 --- a/ee/clickhouse/views/insights.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import Any - -from rest_framework.decorators import action -from rest_framework.permissions import SAFE_METHODS, BasePermission -from rest_framework.request import Request -from rest_framework.response import Response - -from ee.clickhouse.queries.funnels.funnel_correlation import FunnelCorrelation -from ee.clickhouse.queries.paths import ClickhousePaths -from ee.clickhouse.queries.retention import ClickhouseRetention -from ee.clickhouse.queries.stickiness import ClickhouseStickiness -from posthog.api.insight import InsightViewSet -from posthog.decorators import cached_by_filters -from posthog.models import Insight -from posthog.models.dashboard import Dashboard -from posthog.models.filters import Filter - - -class CanEditInsight(BasePermission): - message = "This insight is on a dashboard that can only be edited by its owner, team members invited to editing the dashboard, and project admins." - - def has_object_permission(self, request: Request, view, insight: Insight) -> bool: - if request.method in SAFE_METHODS: - return True - - return view.user_permissions.insight(insight).effective_privilege_level == Dashboard.PrivilegeLevel.CAN_EDIT - - -class ClickhouseInsightsViewSet(InsightViewSet): - permission_classes = [CanEditInsight] - retention_query_class = ClickhouseRetention - stickiness_query_class = ClickhouseStickiness - paths_query_class = ClickhousePaths - - # ****************************************** - # /projects/:id/insights/funnel/correlation - # - # params: - # - params are the same as for funnel - # - # Returns significant events, i.e. those that are correlated with a person - # making it through a funnel - # ****************************************** - @action(methods=["GET", "POST"], url_path="funnel/correlation", detail=False) - def funnel_correlation(self, request: Request, *args: Any, **kwargs: Any) -> Response: - result = self.calculate_funnel_correlation(request) - return Response(result) - - @cached_by_filters - def calculate_funnel_correlation(self, request: Request) -> dict[str, Any]: - team = self.team - filter = Filter(request=request, team=team) - - base_uri = request.build_absolute_uri("/") - result = FunnelCorrelation(filter=filter, team=team, base_uri=base_uri).run() - - return {"result": result} diff --git a/ee/clickhouse/views/person.py b/ee/clickhouse/views/person.py deleted file mode 100644 index f3f8432ad68..00000000000 --- a/ee/clickhouse/views/person.py +++ /dev/null @@ -1,65 +0,0 @@ -from typing import Optional - -from rest_framework import request, response -from rest_framework.decorators import action - -from ee.clickhouse.queries.funnels.funnel_correlation_persons import ( - FunnelCorrelationActors, -) -from posthog.api.person import PersonViewSet -from posthog.constants import ( - FUNNEL_CORRELATION_PERSON_LIMIT, - FUNNEL_CORRELATION_PERSON_OFFSET, - INSIGHT_FUNNELS, -) -from posthog.decorators import cached_by_filters -from posthog.models import Filter -from posthog.utils import format_query_params_absolute_url - - -class EnterprisePersonViewSet(PersonViewSet): - @action(methods=["GET", "POST"], url_path="funnel/correlation", detail=False) - def funnel_correlation(self, request: request.Request, **kwargs) -> response.Response: - if request.user.is_anonymous or not self.team: - return response.Response(data=[]) - - return self._respond_with_cached_results(self.calculate_funnel_correlation_persons(request)) - - @cached_by_filters - def calculate_funnel_correlation_persons( - self, request: request.Request - ) -> dict[str, tuple[list, Optional[str], Optional[str], int]]: - filter = Filter(request=request, data={"insight": INSIGHT_FUNNELS}, team=self.team) - if not filter.correlation_person_limit: - filter = filter.shallow_clone({FUNNEL_CORRELATION_PERSON_LIMIT: 100}) - base_uri = request.build_absolute_uri("/") - actors, serialized_actors, raw_count = FunnelCorrelationActors( - filter=filter, team=self.team, base_uri=base_uri - ).get_actors() - _should_paginate = raw_count >= filter.correlation_person_limit - - next_url = ( - format_query_params_absolute_url( - request, - filter.correlation_person_offset + filter.correlation_person_limit, - offset_alias=FUNNEL_CORRELATION_PERSON_OFFSET, - limit_alias=FUNNEL_CORRELATION_PERSON_LIMIT, - ) - if _should_paginate - else None - ) - initial_url = format_query_params_absolute_url(request, 0) - - # cached_function expects a dict with the key result - return { - "result": ( - serialized_actors, - next_url, - initial_url, - raw_count - len(serialized_actors), - ) - } - - -class LegacyEnterprisePersonViewSet(EnterprisePersonViewSet): - derive_current_team_from_user_only = True diff --git a/ee/clickhouse/views/test/__init__.py b/ee/clickhouse/views/test/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr deleted file mode 100644 index b9dc9a44b8c..00000000000 --- a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr +++ /dev/null @@ -1,186 +0,0 @@ -# serializer version: 1 -# name: ClickhouseTestExperimentSecondaryResults.test_basic_secondary_metric_results - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - AND (has(['control', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature/a-b-test'), '^"|"$', ''))) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestExperimentSecondaryResults.test_basic_secondary_metric_results.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total, - breakdown_value - FROM - (SELECT SUM(total) as count, - day_start, - breakdown_value - FROM - (SELECT * - FROM - (SELECT toUInt16(0) AS total, - ticks.day_start as day_start, - breakdown_value - FROM - (SELECT toStartOfDay(toDateTime('2020-01-06 00:00:00', 'UTC')) - toIntervalDay(number) as day_start - FROM numbers(6) - UNION ALL SELECT toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')) as day_start) as ticks - CROSS JOIN - (SELECT breakdown_value - FROM - (SELECT ['control', 'test'] as breakdown_value) ARRAY - JOIN breakdown_value) as sec - ORDER BY breakdown_value, - day_start - UNION ALL SELECT count(*) as total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) as day_start, - transform(ifNull(nullIf(replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', ''), ''), '$$_posthog_breakdown_null_$$'), (['control', 'test']), (['control', 'test']), '$$_posthog_breakdown_other_$$') as breakdown_value - FROM events e - WHERE e.team_id = 2 - AND event = '$pageview' - AND (has(['control', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature/a-b-test'), '^"|"$', ''))) - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - GROUP BY day_start, - breakdown_value)) - GROUP BY day_start, - breakdown_value - ORDER BY breakdown_value, - day_start) - GROUP BY breakdown_value - ORDER BY breakdown_value - ''' -# --- -# name: ClickhouseTestExperimentSecondaryResults.test_basic_secondary_metric_results.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature_flag_response'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event = '$feature_flag_called' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - AND ((has(['control', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag_response'), '^"|"$', ''))) - AND (has(['a-b-test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag'), '^"|"$', '')))) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestExperimentSecondaryResults.test_basic_secondary_metric_results.3 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT [now()] AS date, - [0] AS total, - '' AS breakdown_value - LIMIT 0 - ''' -# --- -# name: ClickhouseTestExperimentSecondaryResults.test_basic_secondary_metric_results.4 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT array(replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '')) AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event IN ['$pageleave_funnel', '$pageview_funnel'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestExperimentSecondaryResults.test_basic_secondary_metric_results.5 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time, - prop - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 , - if(has([['test'], ['control'], ['']], prop), prop, ['Other']) as prop - FROM - (SELECT *, - if(notEmpty(arrayFilter(x -> notEmpty(x), prop_vals)), prop_vals, ['']) as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = '$pageview_funnel', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = '$pageleave_funnel', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - array(replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '')) AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, notEmpty(arrayFilter(x -> notEmpty(x), prop))) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['$pageleave_funnel', '$pageview_funnel'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageleave_funnel', '$pageview_funnel'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) ))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - GROUP BY prop - ''' -# --- diff --git a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiments.ambr b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiments.ambr deleted file mode 100644 index 97d5902e16e..00000000000 --- a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiments.ambr +++ /dev/null @@ -1,1114 +0,0 @@ -# serializer version: 1 -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results - ''' - /* user_id:0 celery:posthog.tasks.tasks.sync_insight_caching_state */ - SELECT team_id, - date_diff('second', max(timestamp), now()) AS age - FROM events - WHERE timestamp > date_sub(DAY, 3, now()) - AND timestamp < now() - GROUP BY team_id - ORDER BY age; - ''' -# --- -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT array(replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '')) AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event IN ['$pageleave', '$pageview'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time, - prop - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 , - if(has([['test'], ['control'], ['']], prop), prop, ['Other']) as prop - FROM - (SELECT *, - if(notEmpty(arrayFilter(x -> notEmpty(x), prop_vals)), prop_vals, ['']) as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = '$pageview', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = '$pageleave', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - array(replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '')) AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, notEmpty(arrayFilter(x -> notEmpty(x), prop))) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['$pageleave', '$pageview'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageleave', '$pageview'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) ))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - GROUP BY prop - ''' -# --- -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results_and_events_out_of_time_range_timezones - ''' - /* user_id:0 celery:posthog.tasks.tasks.sync_insight_caching_state */ - SELECT team_id, - date_diff('second', max(timestamp), now()) AS age - FROM events - WHERE timestamp > date_sub(DAY, 3, now()) - AND timestamp < now() - GROUP BY team_id - ORDER BY age; - ''' -# --- -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results_and_events_out_of_time_range_timezones.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT array(replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '')) AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event IN ['$pageleave', '$pageview'] - AND toTimeZone(timestamp, 'Europe/Amsterdam') >= toDateTime('2020-01-01 14:20:21', 'Europe/Amsterdam') - AND toTimeZone(timestamp, 'Europe/Amsterdam') <= toDateTime('2020-01-06 10:00:00', 'Europe/Amsterdam') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results_and_events_out_of_time_range_timezones.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time, - prop - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 , - if(has([['test'], ['control']], prop), prop, ['Other']) as prop - FROM - (SELECT *, - if(notEmpty(arrayFilter(x -> notEmpty(x), prop_vals)), prop_vals, ['']) as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = '$pageview', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = '$pageleave', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - array(replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '')) AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, notEmpty(arrayFilter(x -> notEmpty(x), prop))) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['$pageleave', '$pageview'] - AND toTimeZone(timestamp, 'Europe/Amsterdam') >= toDateTime('2020-01-01 14:20:21', 'Europe/Amsterdam') - AND toTimeZone(timestamp, 'Europe/Amsterdam') <= toDateTime('2020-01-06 10:00:00', 'Europe/Amsterdam') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageleave', '$pageview'] - AND toTimeZone(timestamp, 'Europe/Amsterdam') >= toDateTime('2020-01-01 14:20:21', 'Europe/Amsterdam') - AND toTimeZone(timestamp, 'Europe/Amsterdam') <= toDateTime('2020-01-06 10:00:00', 'Europe/Amsterdam') - AND (step_0 = 1 - OR step_1 = 1) ))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - GROUP BY prop - ''' -# --- -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results_for_three_test_variants - ''' - /* user_id:0 celery:posthog.tasks.tasks.sync_insight_caching_state */ - SELECT team_id, - date_diff('second', max(timestamp), now()) AS age - FROM events - WHERE timestamp > date_sub(DAY, 3, now()) - AND timestamp < now() - GROUP BY team_id - ORDER BY age; - ''' -# --- -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results_for_three_test_variants.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT array(replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '')) AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event IN ['$pageleave', '$pageview'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results_for_three_test_variants.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time, - prop - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 , - if(has([[''], ['test_1'], ['test'], ['control'], ['unknown_3'], ['unknown_2'], ['unknown_1'], ['test_2']], prop), prop, ['Other']) as prop - FROM - (SELECT *, - if(notEmpty(arrayFilter(x -> notEmpty(x), prop_vals)), prop_vals, ['']) as prop - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = '$pageview', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = '$pageleave', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - array(replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '')) AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, notEmpty(arrayFilter(x -> notEmpty(x), prop))) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['$pageleave', '$pageview'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageleave', '$pageview'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) ))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - GROUP BY prop - ''' -# --- -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results_with_hogql_aggregation - ''' - /* user_id:0 celery:posthog.tasks.tasks.sync_insight_caching_state */ - SELECT team_id, - date_diff('second', max(timestamp), now()) AS age - FROM events - WHERE timestamp > date_sub(DAY, 3, now()) - AND timestamp < now() - GROUP BY team_id - ORDER BY age; - ''' -# --- -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results_with_hogql_aggregation.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT array(replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '')) AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event IN ['$pageleave', '$pageview'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results_with_hogql_aggregation.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time, - prop - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner , - prop - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target, - prop) as max_steps, - step_1_conversion_time , - prop - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - prop - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target, - prop - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 , - if(has([['test'], ['control'], ['']], prop), prop, ['Other']) as prop - FROM - (SELECT *, - if(notEmpty(arrayFilter(x -> notEmpty(x), prop_vals)), prop_vals, ['']) as prop - FROM - (SELECT e.timestamp as timestamp, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$account_id'), ''), 'null'), '^"|"$', '') as aggregation_target, - pdi.person_id as person_id, - if(event = '$pageview', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = '$pageleave', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - array(replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '')) AS prop_basic, - prop_basic as prop, - argMinIf(prop, timestamp, notEmpty(arrayFilter(x -> notEmpty(x), prop))) over (PARTITION by aggregation_target) as prop_vals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['$pageleave', '$pageview'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['$pageleave', '$pageview'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) ))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps, - prop - HAVING steps = max_steps) - GROUP BY prop - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results - ''' - /* user_id:0 celery:posthog.tasks.tasks.sync_insight_caching_state */ - SELECT team_id, - date_diff('second', max(timestamp), now()) AS age - FROM events - WHERE timestamp > date_sub(DAY, 3, now()) - AND timestamp < now() - GROUP BY team_id - ORDER BY age; - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - AND (((isNull(replaceRegexpAll(JSONExtractRaw(e.properties, 'exclude'), '^"|"$', '')) - OR NOT JSONHas(e.properties, 'exclude'))) - AND (has(['control', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature/a-b-test'), '^"|"$', '')))) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total, - breakdown_value - FROM - (SELECT SUM(total) as count, - day_start, - breakdown_value - FROM - (SELECT * - FROM - (SELECT toUInt16(0) AS total, - ticks.day_start as day_start, - breakdown_value - FROM - (SELECT toStartOfDay(toDateTime('2020-01-06 00:00:00', 'UTC')) - toIntervalDay(number) as day_start - FROM numbers(6) - UNION ALL SELECT toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')) as day_start) as ticks - CROSS JOIN - (SELECT breakdown_value - FROM - (SELECT ['test', 'control'] as breakdown_value) ARRAY - JOIN breakdown_value) as sec - ORDER BY breakdown_value, - day_start - UNION ALL SELECT count(*) as total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) as day_start, - transform(ifNull(nullIf(replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', ''), ''), '$$_posthog_breakdown_null_$$'), (['test', 'control']), (['test', 'control']), '$$_posthog_breakdown_other_$$') as breakdown_value - FROM events e - WHERE e.team_id = 2 - AND event = '$pageview' - AND (((isNull(replaceRegexpAll(JSONExtractRaw(e.properties, 'exclude'), '^"|"$', '')) - OR NOT JSONHas(e.properties, 'exclude'))) - AND (has(['control', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature/a-b-test'), '^"|"$', '')))) - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - GROUP BY day_start, - breakdown_value)) - GROUP BY day_start, - breakdown_value - ORDER BY breakdown_value, - day_start) - GROUP BY breakdown_value - ORDER BY breakdown_value - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results.3 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature_flag_response'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event = '$feature_flag_called' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - AND (((isNull(replaceRegexpAll(JSONExtractRaw(e.properties, 'exclude'), '^"|"$', '')) - OR NOT JSONHas(e.properties, 'exclude'))) - AND ((has(['control', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag_response'), '^"|"$', ''))) - AND (has(['a-b-test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag'), '^"|"$', ''))))) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results.4 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total, - breakdown_value - FROM - (SELECT SUM(total) as count, - day_start, - breakdown_value - FROM - (SELECT * - FROM - (SELECT toUInt16(0) AS total, - ticks.day_start as day_start, - breakdown_value - FROM - (SELECT toStartOfDay(toDateTime('2020-01-06 00:00:00', 'UTC')) - toIntervalDay(number) as day_start - FROM numbers(6) - UNION ALL SELECT toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')) as day_start) as ticks - CROSS JOIN - (SELECT breakdown_value - FROM - (SELECT ['control', 'test'] as breakdown_value) ARRAY - JOIN breakdown_value) as sec - ORDER BY breakdown_value, - day_start - UNION ALL SELECT count(DISTINCT person_id) as total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) as day_start, - breakdown_value - FROM - (SELECT person_id, - min(timestamp) as timestamp, - breakdown_value - FROM - (SELECT pdi.person_id as person_id, timestamp, transform(ifNull(nullIf(replaceRegexpAll(JSONExtractRaw(properties, '$feature_flag_response'), '^"|"$', ''), ''), '$$_posthog_breakdown_null_$$'), (['control', 'test']), (['control', 'test']), '$$_posthog_breakdown_other_$$') as breakdown_value - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as pdi ON events.distinct_id = pdi.distinct_id - WHERE e.team_id = 2 - AND event = '$feature_flag_called' - AND (((isNull(replaceRegexpAll(JSONExtractRaw(e.properties, 'exclude'), '^"|"$', '')) - OR NOT JSONHas(e.properties, 'exclude'))) - AND ((has(['control', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag_response'), '^"|"$', ''))) - AND (has(['a-b-test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag'), '^"|"$', ''))))) - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') ) - GROUP BY person_id, - breakdown_value) AS pdi - GROUP BY day_start, - breakdown_value)) - GROUP BY day_start, - breakdown_value - ORDER BY breakdown_value, - day_start) - GROUP BY breakdown_value - ORDER BY breakdown_value - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results_for_three_test_variants - ''' - /* user_id:0 celery:posthog.tasks.tasks.sync_insight_caching_state */ - SELECT team_id, - date_diff('second', max(timestamp), now()) AS age - FROM events - WHERE timestamp > date_sub(DAY, 3, now()) - AND timestamp < now() - GROUP BY team_id - ORDER BY age; - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results_for_three_test_variants.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event = '$pageview1' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - AND (has(['control', 'test_1', 'test_2', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature/a-b-test'), '^"|"$', ''))) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results_for_three_test_variants.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total, - breakdown_value - FROM - (SELECT SUM(total) as count, - day_start, - breakdown_value - FROM - (SELECT * - FROM - (SELECT toUInt16(0) AS total, - ticks.day_start as day_start, - breakdown_value - FROM - (SELECT toStartOfDay(toDateTime('2020-01-06 00:00:00', 'UTC')) - toIntervalDay(number) as day_start - FROM numbers(6) - UNION ALL SELECT toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')) as day_start) as ticks - CROSS JOIN - (SELECT breakdown_value - FROM - (SELECT ['control', 'test_1', 'test_2'] as breakdown_value) ARRAY - JOIN breakdown_value) as sec - ORDER BY breakdown_value, - day_start - UNION ALL SELECT count(*) as total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) as day_start, - transform(ifNull(nullIf(replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', ''), ''), '$$_posthog_breakdown_null_$$'), (['control', 'test_1', 'test_2']), (['control', 'test_1', 'test_2']), '$$_posthog_breakdown_other_$$') as breakdown_value - FROM events e - WHERE e.team_id = 2 - AND event = '$pageview1' - AND (has(['control', 'test_1', 'test_2', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature/a-b-test'), '^"|"$', ''))) - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - GROUP BY day_start, - breakdown_value)) - GROUP BY day_start, - breakdown_value - ORDER BY breakdown_value, - day_start) - GROUP BY breakdown_value - ORDER BY breakdown_value - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results_for_three_test_variants.3 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature_flag_response'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event = '$feature_flag_called' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - AND ((has(['control', 'test_1', 'test_2', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag_response'), '^"|"$', ''))) - AND (has(['a-b-test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag'), '^"|"$', '')))) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results_for_three_test_variants.4 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT [now()] AS date, - [0] AS total, - '' AS breakdown_value - LIMIT 0 - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results_out_of_timerange_timezone - ''' - /* user_id:0 celery:posthog.tasks.tasks.sync_insight_caching_state */ - SELECT team_id, - date_diff('second', max(timestamp), now()) AS age - FROM events - WHERE timestamp > date_sub(DAY, 3, now()) - AND timestamp < now() - GROUP BY team_id - ORDER BY age; - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results_out_of_timerange_timezone.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'US/Pacific') >= toDateTime('2020-01-01 02:10:00', 'US/Pacific') - AND toTimeZone(timestamp, 'US/Pacific') <= toDateTime('2020-01-06 07:00:00', 'US/Pacific') - AND (has(['control', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature/a-b-test'), '^"|"$', ''))) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results_out_of_timerange_timezone.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total, - breakdown_value - FROM - (SELECT SUM(total) as count, - day_start, - breakdown_value - FROM - (SELECT * - FROM - (SELECT toUInt16(0) AS total, - ticks.day_start as day_start, - breakdown_value - FROM - (SELECT toStartOfDay(toDateTime('2020-01-06 07:00:00', 'US/Pacific')) - toIntervalDay(number) as day_start - FROM numbers(6) - UNION ALL SELECT toStartOfDay(toDateTime('2020-01-01 02:10:00', 'US/Pacific')) as day_start) as ticks - CROSS JOIN - (SELECT breakdown_value - FROM - (SELECT ['test', 'control'] as breakdown_value) ARRAY - JOIN breakdown_value) as sec - ORDER BY breakdown_value, - day_start - UNION ALL SELECT count(*) as total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'US/Pacific')) as day_start, - transform(ifNull(nullIf(replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', ''), ''), '$$_posthog_breakdown_null_$$'), (['test', 'control']), (['test', 'control']), '$$_posthog_breakdown_other_$$') as breakdown_value - FROM events e - WHERE e.team_id = 2 - AND event = '$pageview' - AND (has(['control', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature/a-b-test'), '^"|"$', ''))) - AND toTimeZone(timestamp, 'US/Pacific') >= toDateTime('2020-01-01 02:10:00', 'US/Pacific') - AND toTimeZone(timestamp, 'US/Pacific') <= toDateTime('2020-01-06 07:00:00', 'US/Pacific') - GROUP BY day_start, - breakdown_value)) - GROUP BY day_start, - breakdown_value - ORDER BY breakdown_value, - day_start) - GROUP BY breakdown_value - ORDER BY breakdown_value - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results_out_of_timerange_timezone.3 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature_flag_response'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event = '$feature_flag_called' - AND toTimeZone(timestamp, 'US/Pacific') >= toDateTime('2020-01-01 02:10:00', 'US/Pacific') - AND toTimeZone(timestamp, 'US/Pacific') <= toDateTime('2020-01-06 07:00:00', 'US/Pacific') - AND ((has(['control', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag_response'), '^"|"$', ''))) - AND (has(['a-b-test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag'), '^"|"$', '')))) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results_out_of_timerange_timezone.4 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total, - breakdown_value - FROM - (SELECT SUM(total) as count, - day_start, - breakdown_value - FROM - (SELECT * - FROM - (SELECT toUInt16(0) AS total, - ticks.day_start as day_start, - breakdown_value - FROM - (SELECT toStartOfDay(toDateTime('2020-01-06 07:00:00', 'US/Pacific')) - toIntervalDay(number) as day_start - FROM numbers(6) - UNION ALL SELECT toStartOfDay(toDateTime('2020-01-01 02:10:00', 'US/Pacific')) as day_start) as ticks - CROSS JOIN - (SELECT breakdown_value - FROM - (SELECT ['control', 'test'] as breakdown_value) ARRAY - JOIN breakdown_value) as sec - ORDER BY breakdown_value, - day_start - UNION ALL SELECT count(DISTINCT person_id) as total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'US/Pacific')) as day_start, - breakdown_value - FROM - (SELECT person_id, - min(timestamp) as timestamp, - breakdown_value - FROM - (SELECT pdi.person_id as person_id, timestamp, transform(ifNull(nullIf(replaceRegexpAll(JSONExtractRaw(properties, '$feature_flag_response'), '^"|"$', ''), ''), '$$_posthog_breakdown_null_$$'), (['control', 'test']), (['control', 'test']), '$$_posthog_breakdown_other_$$') as breakdown_value - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as pdi ON events.distinct_id = pdi.distinct_id - WHERE e.team_id = 2 - AND event = '$feature_flag_called' - AND ((has(['control', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag_response'), '^"|"$', ''))) - AND (has(['a-b-test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag'), '^"|"$', '')))) - AND toTimeZone(timestamp, 'US/Pacific') >= toDateTime('2020-01-01 02:10:00', 'US/Pacific') - AND toTimeZone(timestamp, 'US/Pacific') <= toDateTime('2020-01-06 07:00:00', 'US/Pacific') ) - GROUP BY person_id, - breakdown_value) AS pdi - GROUP BY day_start, - breakdown_value)) - GROUP BY day_start, - breakdown_value - ORDER BY breakdown_value, - day_start) - GROUP BY breakdown_value - ORDER BY breakdown_value - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results_with_hogql_filter - ''' - /* user_id:0 celery:posthog.tasks.tasks.sync_insight_caching_state */ - SELECT team_id, - date_diff('second', max(timestamp), now()) AS age - FROM events - WHERE timestamp > date_sub(DAY, 3, now()) - AND timestamp < now() - GROUP BY team_id - ORDER BY age; - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results_with_hogql_filter.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - AND ((has(['control', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature/a-b-test'), '^"|"$', ''))) - AND (ifNull(ilike(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, 'hogql'), ''), 'null'), '^"|"$', ''), 'true'), 0))) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results_with_hogql_filter.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total, - breakdown_value - FROM - (SELECT SUM(total) as count, - day_start, - breakdown_value - FROM - (SELECT * - FROM - (SELECT toUInt16(0) AS total, - ticks.day_start as day_start, - breakdown_value - FROM - (SELECT toStartOfDay(toDateTime('2020-01-06 00:00:00', 'UTC')) - toIntervalDay(number) as day_start - FROM numbers(6) - UNION ALL SELECT toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')) as day_start) as ticks - CROSS JOIN - (SELECT breakdown_value - FROM - (SELECT ['test', 'control'] as breakdown_value) ARRAY - JOIN breakdown_value) as sec - ORDER BY breakdown_value, - day_start - UNION ALL SELECT count(*) as total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) as day_start, - transform(ifNull(nullIf(replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', ''), ''), '$$_posthog_breakdown_null_$$'), (['test', 'control']), (['test', 'control']), '$$_posthog_breakdown_other_$$') as breakdown_value - FROM events e - WHERE e.team_id = 2 - AND event = '$pageview' - AND ((has(['control', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature/a-b-test'), '^"|"$', ''))) - AND (ifNull(ilike(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, 'hogql'), ''), 'null'), '^"|"$', ''), 'true'), 0))) - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - GROUP BY day_start, - breakdown_value)) - GROUP BY day_start, - breakdown_value - ORDER BY breakdown_value, - day_start) - GROUP BY breakdown_value - ORDER BY breakdown_value - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results_with_hogql_filter.3 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature_flag_response'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event = '$feature_flag_called' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') - AND ((has(['control', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag_response'), '^"|"$', ''))) - AND (has(['a-b-test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag'), '^"|"$', '')))) - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_results_with_hogql_filter.4 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total, - breakdown_value - FROM - (SELECT SUM(total) as count, - day_start, - breakdown_value - FROM - (SELECT * - FROM - (SELECT toUInt16(0) AS total, - ticks.day_start as day_start, - breakdown_value - FROM - (SELECT toStartOfDay(toDateTime('2020-01-06 00:00:00', 'UTC')) - toIntervalDay(number) as day_start - FROM numbers(6) - UNION ALL SELECT toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')) as day_start) as ticks - CROSS JOIN - (SELECT breakdown_value - FROM - (SELECT ['control', 'test'] as breakdown_value) ARRAY - JOIN breakdown_value) as sec - ORDER BY breakdown_value, - day_start - UNION ALL SELECT count(DISTINCT person_id) as total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) as day_start, - breakdown_value - FROM - (SELECT person_id, - min(timestamp) as timestamp, - breakdown_value - FROM - (SELECT pdi.person_id as person_id, timestamp, transform(ifNull(nullIf(replaceRegexpAll(JSONExtractRaw(properties, '$feature_flag_response'), '^"|"$', ''), ''), '$$_posthog_breakdown_null_$$'), (['control', 'test']), (['control', 'test']), '$$_posthog_breakdown_other_$$') as breakdown_value - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as pdi ON events.distinct_id = pdi.distinct_id - WHERE e.team_id = 2 - AND event = '$feature_flag_called' - AND ((has(['control', 'test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag_response'), '^"|"$', ''))) - AND (has(['a-b-test'], replaceRegexpAll(JSONExtractRaw(e.properties, '$feature_flag'), '^"|"$', '')))) - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-06 00:00:00', 'UTC') ) - GROUP BY person_id, - breakdown_value) AS pdi - GROUP BY day_start, - breakdown_value)) - GROUP BY day_start, - breakdown_value - ORDER BY breakdown_value, - day_start) - GROUP BY breakdown_value - ORDER BY breakdown_value - ''' -# --- -# name: TestExperimentAuxiliaryEndpoints.test_create_exposure_cohort_for_experiment_with_custom_action_filters_exposure - ''' - /* cohort_calculation: */ - INSERT INTO cohortpeople - SELECT id, - 2 as cohort_id, - 2 as team_id, - 1 AS sign, - 1 AS version - FROM - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND ((has(['http://example.com'], replaceRegexpAll(JSONExtractRaw(properties, '$pageview'), '^"|"$', '')))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND ((has(['http://example.com'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), '$pageview'), '^"|"$', '')))) SETTINGS optimize_aggregation_in_order = 1) as person - UNION ALL - SELECT person_id, - cohort_id, - team_id, - -1, - version - FROM cohortpeople - WHERE team_id = 2 - AND cohort_id = 2 - AND version < 1 - AND sign = 1 SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- -# name: TestExperimentAuxiliaryEndpoints.test_create_exposure_cohort_for_experiment_with_custom_action_filters_exposure.1 - ''' - /* user_id:0 cohort_calculation:posthog.tasks.calculate_cohort.calculate_cohort_ch */ - INSERT INTO cohortpeople - SELECT id, - 2 as cohort_id, - 2 as team_id, - 1 AS sign, - 1 AS version - FROM - (SELECT behavior_query.person_id AS id - FROM - (SELECT pdi.person_id AS person_id, - countIf(timestamp > 'explicit_timestamp' - AND timestamp < now() - AND ((event = 'insight viewed' - AND (has(['RETENTION'], replaceRegexpAll(JSONExtractRaw(properties, 'insight'), '^"|"$', '')) - AND distinct_id IN - (SELECT distinct_id - FROM - (SELECT distinct_id, argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) - WHERE person_id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND ((has(['http://example.com'], replaceRegexpAll(JSONExtractRaw(properties, '$pageview'), '^"|"$', '')))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND ((has(['http://example.com'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), '$pageview'), '^"|"$', '')))) SETTINGS optimize_aggregation_in_order = 1) ))) - OR (event = 'insight viewed' - AND (toFloat64OrNull(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(JSONExtractRaw(properties, 'filters_count'), '^"|"$', ''), ' ', ''), '^"|"$', '')) > '1')) - OR (match(replaceRegexpAll(JSONExtractRaw(properties, '$current_url'), '^"|"$', ''), '/123') - AND event = '$autocapture')) - AND (has(['bonk'], replaceRegexpAll(JSONExtractRaw(properties, 'bonk'), '^"|"$', '')) - AND ifNull(in(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(properties, '$current_url'), ''), 'null'), '^"|"$', ''), tuple('x', 'y')), 0))) > 0 AS performed_event_condition_X_level_level_0_level_0_0 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['insight viewed', 'insight viewed', '$autocapture'] - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 6 day - GROUP BY person_id) behavior_query - WHERE 1 = 1 - AND (((performed_event_condition_X_level_level_0_level_0_0))) SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' ) as person - UNION ALL - SELECT person_id, - cohort_id, - team_id, - -1, - version - FROM cohortpeople - WHERE team_id = 2 - AND cohort_id = 2 - AND version < 1 - AND sign = 1 SETTINGS optimize_aggregation_in_order = 1, - join_algorithm = 'auto' - ''' -# --- diff --git a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_groups.ambr b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_groups.ambr deleted file mode 100644 index 6518393db20..00000000000 --- a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_groups.ambr +++ /dev/null @@ -1,90 +0,0 @@ -# serializer version: 1 -# name: ClickhouseTestGroupsApi.test_related_groups - ''' - /* user_id:0 request:_snapshot_ */ - SELECT DISTINCT pdi.person_id - FROM events e - JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) pdi on e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND timestamp > '2021-02-09T00:00:00.000000' - AND timestamp < '2021-05-10T00:00:00.000000' - AND $group_0 = '0::0' - ''' -# --- -# name: ClickhouseTestGroupsApi.test_related_groups.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT DISTINCT $group_1 AS group_key - FROM events e - JOIN - (SELECT group_key - FROM groups - WHERE team_id = 2 - AND group_type_index = 1 - GROUP BY group_key) groups ON $group_1 = groups.group_key - WHERE team_id = 2 - AND timestamp > '2021-02-09T00:00:00.000000' - AND timestamp < '2021-05-10T00:00:00.000000' - AND group_key != '' - AND $group_0 = '0::0' - ORDER BY group_key - ''' -# --- -# name: ClickhouseTestGroupsApi.test_related_groups_person - ''' - /* user_id:0 request:_snapshot_ */ - SELECT DISTINCT $group_0 AS group_key - FROM events e - JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) pdi on e.distinct_id = pdi.distinct_id - JOIN - (SELECT group_key - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups ON $group_0 = groups.group_key - WHERE team_id = 2 - AND timestamp > '2021-02-09T00:00:00.000000' - AND timestamp < '2021-05-10T00:00:00.000000' - AND group_key != '' - AND pdi.person_id = '01795392-cc00-0003-7dc7-67a694604d72' - ORDER BY group_key - ''' -# --- -# name: ClickhouseTestGroupsApi.test_related_groups_person.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT DISTINCT $group_1 AS group_key - FROM events e - JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) pdi on e.distinct_id = pdi.distinct_id - JOIN - (SELECT group_key - FROM groups - WHERE team_id = 2 - AND group_type_index = 1 - GROUP BY group_key) groups ON $group_1 = groups.group_key - WHERE team_id = 2 - AND timestamp > '2021-02-09T00:00:00.000000' - AND timestamp < '2021-05-10T00:00:00.000000' - AND group_key != '' - AND pdi.person_id = '01795392-cc00-0003-7dc7-67a694604d72' - ORDER BY group_key - ''' -# --- diff --git a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_retention.ambr b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_retention.ambr deleted file mode 100644 index 174634e3740..00000000000 --- a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_retention.ambr +++ /dev/null @@ -1,493 +0,0 @@ -# serializer version: 1 -# name: RetentionTests.test_retention_aggregation_by_distinct_id_and_retrieve_people - ''' - /* user_id:0 request:_snapshot_ */ WITH actor_query AS - (WITH 'Day' as period, - NULL as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfDay(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC')) AS event_date, - e.distinct_id AS target - FROM events e - WHERE team_id = 2 - AND e.event = 'target event' - AND toDateTime(e.timestamp) >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-01-04 00:00:00', 'UTC') - GROUP BY target, - event_date), - target_event_query as - (SELECT min(toStartOfDay(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'))) as event_date, - e.distinct_id AS target, - [ - dateDiff( - 'Day', - toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), - toStartOfDay(toTimeZone(toDateTime(min(e.timestamp), 'UTC'), 'UTC')) - ) - ] as breakdown_values - FROM events e - WHERE team_id = 2 - AND e.event = 'target event' - GROUP BY target - HAVING event_date >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND event_date <= toDateTime('2020-01-04 00:00:00', 'UTC')) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) - SELECT actor_activity.breakdown_values AS breakdown_values, - actor_activity.intervals_from_base AS intervals_from_base, - COUNT(DISTINCT actor_activity.actor_id) AS count - FROM actor_query AS actor_activity - GROUP BY breakdown_values, - intervals_from_base - ORDER BY breakdown_values, - intervals_from_base - ''' -# --- -# name: RetentionTests.test_retention_aggregation_by_distinct_id_and_retrieve_people.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT actor_id, - groupArray(actor_activity.intervals_from_base) AS appearances - FROM - (WITH 'Day' as period, - [0] as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfDay(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC')) AS event_date, - pdi.person_id as target - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND e.event = 'target event' - AND toDateTime(e.timestamp) >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-01-04 00:00:00', 'UTC') - GROUP BY target, - event_date), - target_event_query as - (SELECT min(toStartOfDay(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'))) as event_date, - pdi.person_id as target, - [ - dateDiff( - 'Day', - toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), - toStartOfDay(toTimeZone(toDateTime(min(e.timestamp), 'UTC'), 'UTC')) - ) - ] as breakdown_values - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND e.event = 'target event' - GROUP BY target - HAVING event_date >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND event_date <= toDateTime('2020-01-04 00:00:00', 'UTC')) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) AS actor_activity - GROUP BY actor_id - ORDER BY length(appearances) DESC, actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: RetentionTests.test_retention_aggregation_by_distinct_id_and_retrieve_people.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT actor_id, - groupArray(actor_activity.intervals_from_base) AS appearances - FROM - (WITH 'Day' as period, - [1] as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfDay(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC')) AS event_date, - pdi.person_id as target - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND e.event = 'target event' - AND toDateTime(e.timestamp) >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-01-04 00:00:00', 'UTC') - GROUP BY target, - event_date), - target_event_query as - (SELECT min(toStartOfDay(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'))) as event_date, - pdi.person_id as target, - [ - dateDiff( - 'Day', - toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), - toStartOfDay(toTimeZone(toDateTime(min(e.timestamp), 'UTC'), 'UTC')) - ) - ] as breakdown_values - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND e.event = 'target event' - GROUP BY target - HAVING event_date >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND event_date <= toDateTime('2020-01-04 00:00:00', 'UTC')) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) AS actor_activity - GROUP BY actor_id - ORDER BY length(appearances) DESC, actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: RetentionTests.test_retention_test_account_filters - ''' - /* user_id:0 request:_snapshot_ */ WITH actor_query AS - (WITH 'Day' as period, - NULL as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfDay(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC')) AS event_date, - pdi.person_id as target - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (NOT ("pmat_email" ILIKE '%posthog.com%')) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (NOT (argMax(person."pmat_email", version) ILIKE '%posthog.com%')) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND e.event = 'target event' - AND toDateTime(e.timestamp) >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-01-03 00:00:00', 'UTC') - GROUP BY target, - event_date), - target_event_query as - (SELECT min(toStartOfDay(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'))) as event_date, - pdi.person_id as target, - [ - dateDiff( - 'Day', - toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), - toStartOfDay(toTimeZone(toDateTime(min(e.timestamp), 'UTC'), 'UTC')) - ) - ] as breakdown_values - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (NOT ("pmat_email" ILIKE '%posthog.com%')) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (NOT (argMax(person."pmat_email", version) ILIKE '%posthog.com%')) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND e.event = 'target event' - GROUP BY target - HAVING event_date >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND event_date <= toDateTime('2020-01-03 00:00:00', 'UTC')) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) - SELECT actor_activity.breakdown_values AS breakdown_values, - actor_activity.intervals_from_base AS intervals_from_base, - COUNT(DISTINCT actor_activity.actor_id) AS count - FROM actor_query AS actor_activity - GROUP BY breakdown_values, - intervals_from_base - ORDER BY breakdown_values, - intervals_from_base - ''' -# --- -# name: RetentionTests.test_retention_test_account_filters.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT actor_id, - groupArray(actor_activity.intervals_from_base) AS appearances - FROM - (WITH 'Day' as period, - [0] as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfDay(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC')) AS event_date, - pdi.person_id as target - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (NOT ("pmat_email" ILIKE '%posthog.com%')) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (NOT (argMax(person."pmat_email", version) ILIKE '%posthog.com%')) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND e.event = 'target event' - AND toDateTime(e.timestamp) >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-01-03 00:00:00', 'UTC') - GROUP BY target, - event_date), - target_event_query as - (SELECT min(toStartOfDay(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'))) as event_date, - pdi.person_id as target, - [ - dateDiff( - 'Day', - toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), - toStartOfDay(toTimeZone(toDateTime(min(e.timestamp), 'UTC'), 'UTC')) - ) - ] as breakdown_values - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (NOT ("pmat_email" ILIKE '%posthog.com%')) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (NOT (argMax(person."pmat_email", version) ILIKE '%posthog.com%')) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND e.event = 'target event' - GROUP BY target - HAVING event_date >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND event_date <= toDateTime('2020-01-03 00:00:00', 'UTC')) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) AS actor_activity - GROUP BY actor_id - ORDER BY length(appearances) DESC, actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: RetentionTests.test_retention_test_account_filters.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT actor_id, - groupArray(actor_activity.intervals_from_base) AS appearances - FROM - (WITH 'Day' as period, - [1] as breakdown_values_filter, - NULL as selected_interval, - returning_event_query as - (SELECT toStartOfDay(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC')) AS event_date, - pdi.person_id as target - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (NOT ("pmat_email" ILIKE '%posthog.com%')) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (NOT (argMax(person."pmat_email", version) ILIKE '%posthog.com%')) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND e.event = 'target event' - AND toDateTime(e.timestamp) >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toDateTime(e.timestamp) <= toDateTime('2020-01-03 00:00:00', 'UTC') - GROUP BY target, - event_date), - target_event_query as - (SELECT min(toStartOfDay(toTimeZone(toDateTime(e.timestamp, 'UTC'), 'UTC'))) as event_date, - pdi.person_id as target, - [ - dateDiff( - 'Day', - toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), - toStartOfDay(toTimeZone(toDateTime(min(e.timestamp), 'UTC'), 'UTC')) - ) - ] as breakdown_values - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (NOT ("pmat_email" ILIKE '%posthog.com%')) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (NOT (argMax(person."pmat_email", version) ILIKE '%posthog.com%')) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND e.event = 'target event' - GROUP BY target - HAVING event_date >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND event_date <= toDateTime('2020-01-03 00:00:00', 'UTC')) SELECT DISTINCT breakdown_values, - intervals_from_base, - actor_id - FROM - (SELECT target_event.breakdown_values AS breakdown_values, - datediff(period, target_event.event_date, returning_event.event_date) AS intervals_from_base, - returning_event.target AS actor_id - FROM target_event_query AS target_event - JOIN returning_event_query AS returning_event ON returning_event.target = target_event.target - WHERE returning_event.event_date > target_event.event_date - UNION ALL SELECT target_event.breakdown_values AS breakdown_values, - 0 AS intervals_from_base, - target_event.target AS actor_id - FROM target_event_query AS target_event) - WHERE (breakdown_values_filter is NULL - OR breakdown_values = breakdown_values_filter) - AND (selected_interval is NULL - OR intervals_from_base = selected_interval) ) AS actor_activity - GROUP BY actor_id - ORDER BY length(appearances) DESC, actor_id - LIMIT 100 - OFFSET 0 - ''' -# --- diff --git a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_stickiness.ambr b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_stickiness.ambr deleted file mode 100644 index 1bc6e08ea80..00000000000 --- a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_stickiness.ambr +++ /dev/null @@ -1,683 +0,0 @@ -# serializer version: 1 -# name: TestClickhouseStickiness.test_aggregate_by_groups - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countDistinct(aggregation_target), - num_intervals - FROM - (SELECT e."$group_0" AS aggregation_target, - countDistinct(toStartOfWeek(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'), 0)) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'watched movie' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') - AND event = 'watched movie' - AND (NOT has([''], "$group_0")) - GROUP BY aggregation_target) - WHERE num_intervals <= 8 - GROUP BY num_intervals - ORDER BY num_intervals - ''' -# --- -# name: TestClickhouseStickiness.test_aggregate_by_groups.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT DISTINCT aggregation_target AS actor_id - FROM - (SELECT e."$group_0" AS aggregation_target, - countDistinct(toStartOfWeek(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'), 0)) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'watched movie' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') - AND event = 'watched movie' - AND (NOT has([''], "$group_0") - AND NOT has([''], "$group_0")) - GROUP BY aggregation_target) - WHERE num_intervals = 1 - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseStickiness.test_aggregate_by_groups.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT DISTINCT aggregation_target AS actor_id - FROM - (SELECT e."$group_0" AS aggregation_target, - countDistinct(toStartOfWeek(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'), 0)) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'watched movie' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') - AND event = 'watched movie' - AND (NOT has([''], "$group_0") - AND NOT has([''], "$group_0")) - GROUP BY aggregation_target) - WHERE num_intervals = 2 - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseStickiness.test_aggregate_by_groups.3 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT DISTINCT aggregation_target AS actor_id - FROM - (SELECT e."$group_0" AS aggregation_target, - countDistinct(toStartOfWeek(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'), 0)) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'watched movie' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') - AND event = 'watched movie' - AND (NOT has([''], "$group_0") - AND NOT has([''], "$group_0")) - GROUP BY aggregation_target) - WHERE num_intervals = 3 - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseStickiness.test_compare - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countDistinct(aggregation_target), - num_intervals - FROM - (SELECT pdi.person_id AS aggregation_target, - countDistinct(toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'))) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'watched movie' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND event = 'watched movie' - GROUP BY aggregation_target) - WHERE num_intervals <= 9 - GROUP BY num_intervals - ORDER BY num_intervals - ''' -# --- -# name: TestClickhouseStickiness.test_compare.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countDistinct(aggregation_target), - num_intervals - FROM - (SELECT pdi.person_id AS aggregation_target, - countDistinct(toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'))) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'watched movie' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2019-12-24 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2019-12-31 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2019-12-24 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2019-12-31 23:59:59', 'UTC') - AND event = 'watched movie' - GROUP BY aggregation_target) - WHERE num_intervals <= 9 - GROUP BY num_intervals - ORDER BY num_intervals - ''' -# --- -# name: TestClickhouseStickiness.test_filter_by_group_properties - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countDistinct(aggregation_target), - num_intervals - FROM - (SELECT pdi.person_id AS aggregation_target, - countDistinct(toStartOfWeek(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'), 0)) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'watched movie' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') - AND event = 'watched movie' - AND (has(['technology'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - GROUP BY aggregation_target) - WHERE num_intervals <= 8 - GROUP BY num_intervals - ORDER BY num_intervals - ''' -# --- -# name: TestClickhouseStickiness.test_filter_by_group_properties.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT DISTINCT aggregation_target AS actor_id - FROM - (SELECT pdi.person_id AS aggregation_target, - countDistinct(toStartOfWeek(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'), 0)) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'watched movie' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') - AND event = 'watched movie' - AND (has(['technology'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - GROUP BY aggregation_target) - WHERE num_intervals = 1 - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseStickiness.test_filter_by_group_properties.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT DISTINCT aggregation_target AS actor_id - FROM - (SELECT pdi.person_id AS aggregation_target, - countDistinct(toStartOfWeek(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'), 0)) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'watched movie' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') - AND event = 'watched movie' - AND (has(['technology'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - GROUP BY aggregation_target) - WHERE num_intervals = 2 - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseStickiness.test_filter_by_group_properties.3 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT DISTINCT aggregation_target AS actor_id - FROM - (SELECT pdi.person_id AS aggregation_target, - countDistinct(toStartOfWeek(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'), 0)) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'watched movie' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfWeek(toDateTime('2020-01-01 00:00:00', 'UTC'), 0), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-02-15 23:59:59', 'UTC') - AND event = 'watched movie' - AND (has(['technology'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - GROUP BY aggregation_target) - WHERE num_intervals = 3 - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseStickiness.test_stickiness_all_time - ''' - /* user_id:0 request:_snapshot_ */ - SELECT timestamp - from events - WHERE team_id = 2 - AND timestamp > '2015-01-01' - order by timestamp - limit 1 - ''' -# --- -# name: TestClickhouseStickiness.test_stickiness_all_time.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countDistinct(aggregation_target), - num_intervals - FROM - (SELECT pdi.person_id AS aggregation_target, - countDistinct(toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'))) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'watched movie' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND event = 'watched movie' - GROUP BY aggregation_target) - WHERE num_intervals <= 9 - GROUP BY num_intervals - ORDER BY num_intervals - ''' -# --- -# name: TestClickhouseStickiness.test_stickiness_all_time_with_sampling - ''' - /* user_id:0 request:_snapshot_ */ - SELECT timestamp - from events - WHERE team_id = 2 - AND timestamp > '2015-01-01' - order by timestamp - limit 1 - ''' -# --- -# name: TestClickhouseStickiness.test_stickiness_all_time_with_sampling.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countDistinct(aggregation_target), - num_intervals - FROM - (SELECT pdi.person_id AS aggregation_target, - countDistinct(toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'))) as num_intervals - FROM events e SAMPLE 1.0 - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'watched movie' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND event = 'watched movie' - GROUP BY aggregation_target) - WHERE num_intervals <= 9 - GROUP BY num_intervals - ORDER BY num_intervals - ''' -# --- -# name: TestClickhouseStickiness.test_stickiness_hours - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countDistinct(aggregation_target), - num_intervals - FROM - (SELECT pdi.person_id AS aggregation_target, - countDistinct(toStartOfHour(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'))) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = 'watched movie' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfHour(toDateTime('2020-01-01 12:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-01 20:00:00', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfHour(toDateTime('2020-01-01 12:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-01 20:00:00', 'UTC') - AND event = 'watched movie' - GROUP BY aggregation_target) - WHERE num_intervals <= 10 - GROUP BY num_intervals - ORDER BY num_intervals - ''' -# --- -# name: TestClickhouseStickiness.test_stickiness_people_endpoint - ''' - /* user_id:0 request:_snapshot_ */ - SELECT DISTINCT aggregation_target AS actor_id - FROM - (SELECT pdi.person_id AS aggregation_target, - countDistinct(toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'))) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND ((event = 'watched movie')) - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND ((event = 'watched movie')) - GROUP BY aggregation_target) - WHERE num_intervals = 1 - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseStickiness.test_stickiness_people_paginated - ''' - /* user_id:0 request:_snapshot_ */ - SELECT DISTINCT aggregation_target AS actor_id - FROM - (SELECT pdi.person_id AS aggregation_target, - countDistinct(toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'))) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND ((event = 'watched movie')) - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND ((event = 'watched movie')) - GROUP BY aggregation_target) - WHERE num_intervals = 1 - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: TestClickhouseStickiness.test_stickiness_people_paginated.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT DISTINCT aggregation_target AS actor_id - FROM - (SELECT pdi.person_id AS aggregation_target, - countDistinct(toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'))) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND ((event = 'watched movie')) - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND ((event = 'watched movie')) - GROUP BY aggregation_target) - WHERE num_intervals = 1 - LIMIT 100 - OFFSET 100 - ''' -# --- -# name: TestClickhouseStickiness.test_stickiness_with_person_on_events_v2 - ''' - - SELECT DISTINCT person_id - FROM events - WHERE team_id = 2 - AND distinct_id = 'person2' - ''' -# --- -# name: TestClickhouseStickiness.test_stickiness_with_person_on_events_v2.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countDistinct(aggregation_target), - num_intervals - FROM - (SELECT if(notEmpty(overrides.distinct_id), overrides.person_id, e.person_id) AS aggregation_target, - countDistinct(toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'))) as num_intervals - FROM events e - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0)) AS overrides ON e.distinct_id = overrides.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-08 23:59:59', 'UTC') - AND event = 'watched movie' - AND notEmpty(e.person_id) - GROUP BY aggregation_target) - WHERE num_intervals <= 9 - GROUP BY num_intervals - ORDER BY num_intervals - ''' -# --- -# name: TestClickhouseStickiness.test_timezones - ''' - - SELECT countDistinct(aggregation_target), - num_intervals - FROM - (SELECT pdi.person_id AS aggregation_target, - countDistinct(toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'))) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2021-05-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-15 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2021-05-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-15 23:59:59', 'UTC') - AND event = '$pageview' - GROUP BY aggregation_target) - WHERE num_intervals <= 16 - GROUP BY num_intervals - ORDER BY num_intervals - ''' -# --- -# name: TestClickhouseStickiness.test_timezones.1 - ''' - - SELECT countDistinct(aggregation_target), - num_intervals - FROM - (SELECT pdi.person_id AS aggregation_target, - countDistinct(toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'US/Pacific'))) as num_intervals - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'US/Pacific') >= toDateTime(toStartOfDay(toDateTime('2021-05-01 00:00:00', 'US/Pacific')), 'US/Pacific') - AND toTimeZone(timestamp, 'US/Pacific') <= toDateTime('2021-05-15 23:59:59', 'US/Pacific') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND toTimeZone(timestamp, 'US/Pacific') >= toDateTime(toStartOfDay(toDateTime('2021-05-01 00:00:00', 'US/Pacific')), 'US/Pacific') - AND toTimeZone(timestamp, 'US/Pacific') <= toDateTime('2021-05-15 23:59:59', 'US/Pacific') - AND event = '$pageview' - GROUP BY aggregation_target) - WHERE num_intervals <= 16 - GROUP BY num_intervals - ORDER BY num_intervals - ''' -# --- diff --git a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_trends.ambr b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_trends.ambr deleted file mode 100644 index a0a3a01a3a6..00000000000 --- a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_trends.ambr +++ /dev/null @@ -1,996 +0,0 @@ -# serializer version: 1 -# name: ClickhouseTestTrends.test_insight_trends_aggregate - ''' - /* user_id:0 request:_snapshot_ */ - SELECT count(*) AS total - FROM events e - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_aggregate.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT person_id AS actor_id, - count() AS actor_value - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') ) - GROUP BY actor_id - ORDER BY actor_value DESC, - actor_id DESC - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_basic - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total - FROM - (SELECT SUM(total) AS count, - day_start - FROM - (SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2012-01-15 23:59:59', 'UTC')) - toIntervalDay(number) AS day_start - FROM numbers(dateDiff('day', toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), toDateTime('2012-01-15 23:59:59', 'UTC'))) - UNION ALL SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')) - UNION ALL SELECT count(DISTINCT pdi.person_id) AS total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) AS date - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') - GROUP BY date) - GROUP BY day_start - ORDER BY day_start) - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_basic.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT person_id AS actor_id, - count() AS actor_value - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-14 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-14 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC') ) - GROUP BY actor_id - ORDER BY actor_value DESC, - actor_id DESC - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_clean_arg - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total - FROM - (SELECT SUM(total) AS count, - day_start - FROM - (SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2012-01-15 23:59:59', 'UTC')) - toIntervalDay(number) AS day_start - FROM numbers(dateDiff('day', toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), toDateTime('2012-01-15 23:59:59', 'UTC'))) - UNION ALL SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')) - UNION ALL SELECT count(*) AS total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) AS date - FROM events e - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') - AND (has(['val'], replaceRegexpAll(JSONExtractRaw(e.properties, 'key'), '^"|"$', ''))) - GROUP BY date) - GROUP BY day_start - ORDER BY day_start) - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_clean_arg.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT person_id AS actor_id, - count() AS actor_value - FROM - (SELECT e.timestamp as timestamp, - e."properties" as "properties", - pdi.person_id as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-14 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-14 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC') - AND (has(['val'], replaceRegexpAll(JSONExtractRaw(e.properties, 'key'), '^"|"$', ''))) ) - GROUP BY actor_id - ORDER BY actor_value DESC, - actor_id DESC - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_cumulative - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total - FROM - (SELECT SUM(total) AS count, - day_start - FROM - (SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2012-01-15 23:59:59', 'UTC')) - toIntervalDay(number) AS day_start - FROM numbers(dateDiff('day', toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), toDateTime('2012-01-15 23:59:59', 'UTC'))) - UNION ALL SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')) - UNION ALL SELECT count(*) AS total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) AS date - FROM events e - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') - GROUP BY date) - GROUP BY day_start - ORDER BY day_start) - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_cumulative.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT person_id AS actor_id, - count() AS actor_value - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC') ) - GROUP BY actor_id - ORDER BY actor_value DESC, - actor_id DESC - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_cumulative.10 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, 'key'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_cumulative.11 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total, - breakdown_value - FROM - (SELECT SUM(total) as count, - day_start, - breakdown_value - FROM - (SELECT * - FROM - (SELECT toUInt16(0) AS total, - ticks.day_start as day_start, - breakdown_value - FROM - (SELECT toStartOfDay(toDateTime('2012-01-15 23:59:59', 'UTC')) - toIntervalDay(number) as day_start - FROM numbers(15) - UNION ALL SELECT toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')) as day_start) as ticks - CROSS JOIN - (SELECT breakdown_value - FROM - (SELECT ['val', 'notval'] as breakdown_value) ARRAY - JOIN breakdown_value) as sec - ORDER BY breakdown_value, - day_start - UNION ALL SELECT count(DISTINCT person_id) as total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) as day_start, - breakdown_value - FROM - (SELECT person_id, - min(timestamp) as timestamp, - breakdown_value - FROM - (SELECT pdi.person_id as person_id, timestamp, transform(ifNull(nullIf(replaceRegexpAll(JSONExtractRaw(properties, 'key'), '^"|"$', ''), ''), '$$_posthog_breakdown_null_$$'), (['val', 'notval']), (['val', 'notval']), '$$_posthog_breakdown_other_$$') as breakdown_value - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as pdi ON events.distinct_id = pdi.distinct_id - WHERE e.team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') ) - GROUP BY person_id, - breakdown_value) AS pdi - GROUP BY day_start, - breakdown_value)) - GROUP BY day_start, - breakdown_value - ORDER BY breakdown_value, - day_start) - GROUP BY breakdown_value - ORDER BY breakdown_value - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_cumulative.12 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT person_id AS actor_id, - count() AS actor_value - FROM - (SELECT e.timestamp as timestamp, - e."properties" as "properties", - pdi.person_id as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC') - AND (has(['val'], replaceRegexpAll(JSONExtractRaw(e.properties, 'key'), '^"|"$', ''))) ) - GROUP BY actor_id - ORDER BY actor_value DESC, - actor_id DESC - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_cumulative.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total - FROM - (SELECT SUM(total) AS count, - day_start - FROM - (SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2012-01-15 23:59:59', 'UTC')) - toIntervalDay(number) AS day_start - FROM numbers(dateDiff('day', toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), toDateTime('2012-01-15 23:59:59', 'UTC'))) - UNION ALL SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')) - UNION ALL SELECT COUNT(DISTINCT actor_id) AS total, - toStartOfDay(toTimeZone(toDateTime(first_seen_timestamp, 'UTC'), 'UTC')) AS date - FROM - (SELECT pdi.person_id AS actor_id, - min(timestamp) AS first_seen_timestamp - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') - GROUP BY actor_id) - GROUP BY date) - GROUP BY day_start - ORDER BY day_start) - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_cumulative.3 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT person_id AS actor_id, - count() AS actor_value - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC') ) - GROUP BY actor_id - ORDER BY actor_value DESC, - actor_id DESC - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_cumulative.4 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, 'key'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_cumulative.5 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total, - breakdown_value - FROM - (SELECT SUM(total) as count, - day_start, - breakdown_value - FROM - (SELECT * - FROM - (SELECT toUInt16(0) AS total, - ticks.day_start as day_start, - breakdown_value - FROM - (SELECT toStartOfDay(toDateTime('2012-01-15 23:59:59', 'UTC')) - toIntervalDay(number) as day_start - FROM numbers(15) - UNION ALL SELECT toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')) as day_start) as ticks - CROSS JOIN - (SELECT breakdown_value - FROM - (SELECT ['val', 'notval'] as breakdown_value) ARRAY - JOIN breakdown_value) as sec - ORDER BY breakdown_value, - day_start - UNION ALL SELECT count(*) as total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) as day_start, - transform(ifNull(nullIf(replaceRegexpAll(JSONExtractRaw(properties, 'key'), '^"|"$', ''), ''), '$$_posthog_breakdown_null_$$'), (['val', 'notval']), (['val', 'notval']), '$$_posthog_breakdown_other_$$') as breakdown_value - FROM events e - WHERE e.team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') - GROUP BY day_start, - breakdown_value)) - GROUP BY day_start, - breakdown_value - ORDER BY breakdown_value, - day_start) - GROUP BY breakdown_value - ORDER BY breakdown_value - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_cumulative.6 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT person_id AS actor_id, - count() AS actor_value - FROM - (SELECT e.timestamp as timestamp, - e."properties" as "properties", - pdi.person_id as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC') - AND (has(['val'], replaceRegexpAll(JSONExtractRaw(e.properties, 'key'), '^"|"$', ''))) ) - GROUP BY actor_id - ORDER BY actor_value DESC, - actor_id DESC - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_cumulative.7 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, 'key'), '^"|"$', '') AS value, - count(DISTINCT pdi.person_id) as count - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND ((has(['some_val'], replaceRegexpAll(JSONExtractRaw(properties, 'key'), '^"|"$', ''))) - AND (has(['some_val'], replaceRegexpAll(JSONExtractRaw(properties, 'key'), '^"|"$', '')))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND ((has(['some_val'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'key'), '^"|"$', ''))) - AND (has(['some_val'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'key'), '^"|"$', '')))) SETTINGS optimize_aggregation_in_order = 1) person ON pdi.person_id = person.id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') - GROUP BY value - ORDER BY count DESC, value DESC - LIMIT 26 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_cumulative.8 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total, - breakdown_value - FROM - (SELECT SUM(total) as count, - day_start, - breakdown_value - FROM - (SELECT * - FROM - (SELECT toUInt16(0) AS total, - ticks.day_start as day_start, - breakdown_value - FROM - (SELECT toStartOfDay(toDateTime('2012-01-15 23:59:59', 'UTC')) - toIntervalDay(number) as day_start - FROM numbers(15) - UNION ALL SELECT toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')) as day_start) as ticks - CROSS JOIN - (SELECT breakdown_value - FROM - (SELECT ['val', 'notval'] as breakdown_value) ARRAY - JOIN breakdown_value) as sec - ORDER BY breakdown_value, - day_start - UNION ALL SELECT counts AS total, - timestamp AS day_start, - breakdown_value - FROM - (SELECT d.timestamp, - COUNT(DISTINCT person_id) counts, - breakdown_value - FROM - (SELECT toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) AS timestamp - FROM events e - WHERE team_id = 2 - AND toDateTime(timestamp, 'UTC') >= toDateTime('2011-12-25 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') - GROUP BY timestamp) d - CROSS JOIN - (SELECT toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) AS timestamp, - pdi.person_id AS person_id, - transform(ifNull(nullIf(replaceRegexpAll(JSONExtractRaw(properties, 'key'), '^"|"$', ''), ''), '$$_posthog_breakdown_null_$$'), (['val', 'notval']), (['val', 'notval']), '$$_posthog_breakdown_other_$$') AS breakdown_value - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as pdi ON events.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND ((has(['some_val'], replaceRegexpAll(JSONExtractRaw(properties, 'key'), '^"|"$', ''))) - AND (has(['some_val'], replaceRegexpAll(JSONExtractRaw(properties, 'key'), '^"|"$', '')))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND ((has(['some_val'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'key'), '^"|"$', ''))) - AND (has(['some_val'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'key'), '^"|"$', '')))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE e.team_id = 2 - AND event = '$pageview' - AND toDateTime(timestamp, 'UTC') >= toDateTime('2011-12-25 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') - GROUP BY timestamp, person_id, - breakdown_value) e - WHERE e.timestamp <= d.timestamp - AND e.timestamp > d.timestamp - INTERVAL 6 DAY - GROUP BY d.timestamp, - breakdown_value - ORDER BY d.timestamp) - WHERE 11111 = 11111 - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') )) - GROUP BY day_start, - breakdown_value - ORDER BY breakdown_value, - day_start) - GROUP BY breakdown_value - ORDER BY breakdown_value - ''' -# --- -# name: ClickhouseTestTrends.test_insight_trends_cumulative.9 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT person_id AS actor_id, - count() AS actor_value - FROM - (SELECT e.timestamp as timestamp, - e."properties" as "properties", - pdi.person_id as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toDateTime(timestamp, 'UTC') >= toDateTime('2011-12-25 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (((has(['some_val'], replaceRegexpAll(JSONExtractRaw(properties, 'key'), '^"|"$', '')))) - AND (has(['some_val'], replaceRegexpAll(JSONExtractRaw(properties, 'key'), '^"|"$', '')))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (((has(['some_val'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'key'), '^"|"$', '')))) - AND (has(['some_val'], replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'key'), '^"|"$', '')))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - AND event = '$pageview' - AND toDateTime(timestamp, 'UTC') >= toDateTime('2011-12-25 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC') - AND (((has(['val'], replaceRegexpAll(JSONExtractRaw(e.properties, 'key'), '^"|"$', ''))))) ) - GROUP BY actor_id - ORDER BY actor_value DESC, - actor_id DESC - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrendsCaching.test_insight_trends_merging - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total - FROM - (SELECT SUM(total) AS count, - day_start - FROM - (SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2012-01-15 23:59:59', 'UTC')) - toIntervalDay(number) AS day_start - FROM numbers(dateDiff('day', toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), toDateTime('2012-01-15 23:59:59', 'UTC'))) - UNION ALL SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')) - UNION ALL SELECT count(DISTINCT pdi.person_id) AS total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) AS date - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') - GROUP BY date) - GROUP BY day_start - ORDER BY day_start) - ''' -# --- -# name: ClickhouseTestTrendsCaching.test_insight_trends_merging.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total - FROM - (SELECT SUM(total) AS count, - day_start - FROM - (SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2012-01-15 23:59:59', 'UTC')) - toIntervalDay(number) AS day_start - FROM numbers(dateDiff('day', toStartOfDay(toDateTime('2012-01-14 00:00:00', 'UTC')), toDateTime('2012-01-15 23:59:59', 'UTC'))) - UNION ALL SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2012-01-14 00:00:00', 'UTC')) - UNION ALL SELECT count(DISTINCT pdi.person_id) AS total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) AS date - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-14 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2012-01-14 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-15 23:59:59', 'UTC') - GROUP BY date) - GROUP BY day_start - ORDER BY day_start) - ''' -# --- -# name: ClickhouseTestTrendsCaching.test_insight_trends_merging_skipped_interval - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total - FROM - (SELECT SUM(total) AS count, - day_start - FROM - (SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2012-01-14 23:59:59', 'UTC')) - toIntervalDay(number) AS day_start - FROM numbers(dateDiff('day', toStartOfDay(toDateTime('2011-12-31 00:00:00', 'UTC')), toDateTime('2012-01-14 23:59:59', 'UTC'))) - UNION ALL SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2011-12-31 00:00:00', 'UTC')) - UNION ALL SELECT count(DISTINCT pdi.person_id) AS total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) AS date - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2011-12-31 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2011-12-31 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-14 23:59:59', 'UTC') - GROUP BY date) - GROUP BY day_start - ORDER BY day_start) - ''' -# --- -# name: ClickhouseTestTrendsCaching.test_insight_trends_merging_skipped_interval.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total - FROM - (SELECT SUM(total) AS count, - day_start - FROM - (SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2012-01-16 23:59:59', 'UTC')) - toIntervalDay(number) AS day_start - FROM numbers(dateDiff('day', toStartOfDay(toDateTime('2012-01-02 00:00:00', 'UTC')), toDateTime('2012-01-16 23:59:59', 'UTC'))) - UNION ALL SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2012-01-02 00:00:00', 'UTC')) - UNION ALL SELECT count(DISTINCT pdi.person_id) AS total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) AS date - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-02 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-16 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2012-01-02 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2012-01-16 23:59:59', 'UTC') - GROUP BY date) - GROUP BY day_start - ORDER BY day_start) - ''' -# --- -# name: ClickhouseTestTrendsGroups.test_aggregating_by_group - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total - FROM - (SELECT SUM(total) AS count, - day_start - FROM - (SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2020-01-12 23:59:59', 'UTC')) - toIntervalDay(number) AS day_start - FROM numbers(dateDiff('day', toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), toDateTime('2020-01-12 23:59:59', 'UTC'))) - UNION ALL SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')) - UNION ALL SELECT count(DISTINCT "$group_0") AS total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) AS date - FROM events e - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-12 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND "$group_0" != '' - GROUP BY date) - GROUP BY day_start - ORDER BY day_start) - ''' -# --- -# name: ClickhouseTestTrendsGroups.test_aggregating_by_group.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT $group_0 AS actor_id, - count() AS actor_value - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as "$group_0" - FROM events e - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-02 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-02 23:59:59', 'UTC') - AND (NOT has([''], "$group_0") - AND NOT has([''], "$group_0")) - AND "$group_0" != '' ) - GROUP BY actor_id - ORDER BY actor_value DESC, - actor_id DESC - LIMIT 100 - OFFSET 0 - ''' -# --- -# name: ClickhouseTestTrendsGroups.test_aggregating_by_session - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(day_start) as date, - groupArray(count) AS total - FROM - (SELECT SUM(total) AS count, - day_start - FROM - (SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2020-01-12 23:59:59', 'UTC')) - toIntervalDay(number) AS day_start - FROM numbers(dateDiff('day', toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), toDateTime('2020-01-12 23:59:59', 'UTC'))) - UNION ALL SELECT toUInt16(0) AS total, - toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')) - UNION ALL SELECT count(DISTINCT e."$session_id") AS total, - toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC')) AS date - FROM events e - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime(toStartOfDay(toDateTime('2020-01-01 00:00:00', 'UTC')), 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-12 23:59:59', 'UTC') - GROUP BY date) - GROUP BY day_start - ORDER BY day_start) - ''' -# --- -# name: ClickhouseTestTrendsGroups.test_aggregating_by_session.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT person_id AS actor_id, - count() AS actor_value - FROM - (SELECT e.timestamp as timestamp, - e."$session_id" as "$session_id", - pdi.person_id as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-02 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-02 23:59:59', 'UTC')) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event = '$pageview' - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-02 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-02 23:59:59', 'UTC') ) - GROUP BY actor_id - ORDER BY actor_value DESC, - actor_id DESC - LIMIT 100 - OFFSET 0 - ''' -# --- diff --git a/ee/clickhouse/views/test/funnel/__init__.py b/ee/clickhouse/views/test/funnel/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/clickhouse/views/test/funnel/__snapshots__/test_clickhouse_funnel.ambr b/ee/clickhouse/views/test/funnel/__snapshots__/test_clickhouse_funnel.ambr deleted file mode 100644 index 6368ab4dfb6..00000000000 --- a/ee/clickhouse/views/test/funnel/__snapshots__/test_clickhouse_funnel.ambr +++ /dev/null @@ -1,487 +0,0 @@ -# serializer version: 1 -# name: ClickhouseTestFunnelGroups.test_funnel_aggregation_with_groups - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - ''' -# --- -# name: ClickhouseTestFunnelGroups.test_funnel_aggregation_with_groups.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND ((NOT has([''], "$group_0")) - AND (NOT has([''], "$group_0"))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- -# name: ClickhouseTestFunnelGroups.test_funnel_group_aggregation_with_groups_entity_filtering - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up' - AND (has(['org:5'], "$group_0")), 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - ''' -# --- -# name: ClickhouseTestFunnelGroups.test_funnel_group_aggregation_with_groups_entity_filtering.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up' - AND (has(['org:5'], "$group_0")), 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND ((NOT has([''], "$group_0")) - AND (NOT has([''], "$group_0"))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- -# name: ClickhouseTestFunnelGroups.test_funnel_with_groups_entity_filtering - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'user signed up' - AND (has(['org:5'], "$group_0")), 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - ''' -# --- -# name: ClickhouseTestFunnelGroups.test_funnel_with_groups_entity_filtering.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'user signed up' - AND (has(['org:5'], "$group_0")), 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- -# name: ClickhouseTestFunnelGroups.test_funnel_with_groups_global_filtering - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['finance'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - ''' -# --- -# name: ClickhouseTestFunnelGroups.test_funnel_with_groups_global_filtering.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - pdi.person_id as aggregation_target, - pdi.person_id as person_id, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (has(['finance'], replaceRegexpAll(JSONExtractRaw(group_properties_0, 'industry'), '^"|"$', ''))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- diff --git a/ee/clickhouse/views/test/funnel/__snapshots__/test_clickhouse_funnel_person.ambr b/ee/clickhouse/views/test/funnel/__snapshots__/test_clickhouse_funnel_person.ambr deleted file mode 100644 index c707e81abbe..00000000000 --- a/ee/clickhouse/views/test/funnel/__snapshots__/test_clickhouse_funnel_person.ambr +++ /dev/null @@ -1,117 +0,0 @@ -# serializer version: 1 -# name: TestFunnelPerson.test_funnel_actors_with_groups_search - ''' - /* user_id:0 request:_snapshot_ */ - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - avg(step_2_conversion_time) step_2_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner, - median(step_2_conversion_time) step_2_median_conversion_time_inner - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time, - step_2_conversion_time - FROM - (SELECT *, - if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY - AND latest_1 <= latest_2 - AND latest_2 <= latest_0 + INTERVAL 14 DAY, 3, if(latest_0 <= latest_1 - AND latest_1 <= latest_0 + INTERVAL 14 DAY, 2, 1)) AS steps , - if(isNotNull(latest_1) - AND latest_1 <= latest_0 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_0), toDateTime(latest_1)), NULL) step_1_conversion_time, - if(isNotNull(latest_2) - AND latest_2 <= latest_1 + INTERVAL 14 DAY, dateDiff('second', toDateTime(latest_1), toDateTime(latest_2)), NULL) step_2_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - latest_1, - step_2, - if(latest_2 < latest_1, NULL, latest_2) as latest_2 - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1, - step_2, - min(latest_2) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_2 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - pdi.person_id as person_id, - person.person_props as person_props, - if(event = 'step one', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'step two', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1, - if(event = 'step three', 1, 0) as step_2, - if(step_2 = 1, timestamp, null) as latest_2 - FROM events e - INNER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-10 23:59:59', 'UTC') ) - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - INNER JOIN - (SELECT id, - argMax(properties, version) as person_props - FROM person - WHERE team_id = 2 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - LEFT JOIN - (SELECT group_key, - argMax(group_properties, _timestamp) AS group_properties_0 - FROM groups - WHERE team_id = 2 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 2 - AND event IN ['step one', 'step three', 'step two'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2021-05-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2021-05-10 23:59:59', 'UTC') - AND ((replaceRegexpAll(JSONExtractRaw(person_props, 'email'), '^"|"$', '') ILIKE '%g0%' - OR replaceRegexpAll(JSONExtractRaw(person_props, 'name'), '^"|"$', '') ILIKE '%g0%' - OR replaceRegexpAll(JSONExtractRaw(e.properties, 'distinct_id'), '^"|"$', '') ILIKE '%g0%' - OR replaceRegexpAll(JSONExtractRaw(group_properties_0, 'name'), '^"|"$', '') ILIKE '%g0%' - OR replaceRegexpAll(JSONExtractRaw(group_properties_0, 'slug'), '^"|"$', '') ILIKE '%g0%') - AND (NOT has([''], "$group_0"))) - AND (step_0 = 1 - OR step_1 = 1 - OR step_2 = 1) )))) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2, 3] - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- diff --git a/ee/clickhouse/views/test/funnel/__snapshots__/test_clickhouse_funnel_unordered.ambr b/ee/clickhouse/views/test/funnel/__snapshots__/test_clickhouse_funnel_unordered.ambr deleted file mode 100644 index 142bee481d1..00000000000 --- a/ee/clickhouse/views/test/funnel/__snapshots__/test_clickhouse_funnel_unordered.ambr +++ /dev/null @@ -1,163 +0,0 @@ -# serializer version: 1 -# name: ClickhouseTestUnorderedFunnelGroups.test_unordered_funnel_with_groups - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countIf(steps = 1) step_1, - countIf(steps = 2) step_2, - avg(step_1_average_conversion_time_inner) step_1_average_conversion_time, - median(step_1_median_conversion_time_inner) step_1_median_conversion_time - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time - FROM - (SELECT *, - arraySort([latest_0,latest_1]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 14 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 14 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 - UNION ALL SELECT *, - arraySort([latest_0,latest_1]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 14 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 14 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'paid', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'user signed up', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND (NOT has([''], "$group_0")) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - ''' -# --- -# name: ClickhouseTestUnorderedFunnelGroups.test_unordered_funnel_with_groups.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT aggregation_target AS actor_id - FROM - (SELECT aggregation_target, - steps, - avg(step_1_conversion_time) step_1_average_conversion_time_inner, - median(step_1_conversion_time) step_1_median_conversion_time_inner - FROM - (SELECT aggregation_target, - steps, - max(steps) over (PARTITION BY aggregation_target) as max_steps, - step_1_conversion_time - FROM - (SELECT *, - arraySort([latest_0,latest_1]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 14 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 14 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'user signed up', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'paid', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND ((NOT has([''], "$group_0")) - AND (NOT has([''], "$group_0"))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 - UNION ALL SELECT *, - arraySort([latest_0,latest_1]) as event_times, - arraySum([if(latest_0 < latest_1 AND latest_1 <= latest_0 + INTERVAL 14 DAY, 1, 0), 1]) AS steps , - arraySort([latest_0,latest_1]) as conversion_times, - if(isNotNull(conversion_times[2]) - AND conversion_times[2] <= conversion_times[1] + INTERVAL 14 DAY, dateDiff('second', conversion_times[1], conversion_times[2]), NULL) step_1_conversion_time - FROM - (SELECT aggregation_target, timestamp, step_0, - latest_0, - step_1, - min(latest_1) over (PARTITION by aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) latest_1 - FROM - (SELECT e.timestamp as timestamp, - e."$group_0" as aggregation_target, - if(event = 'paid', 1, 0) as step_0, - if(step_0 = 1, timestamp, null) as latest_0, - if(event = 'user signed up', 1, 0) as step_1, - if(step_1 = 1, timestamp, null) as latest_1 - FROM events e - WHERE team_id = 2 - AND event IN ['paid', 'user signed up'] - AND toTimeZone(timestamp, 'UTC') >= toDateTime('2020-01-01 00:00:00', 'UTC') - AND toTimeZone(timestamp, 'UTC') <= toDateTime('2020-01-14 23:59:59', 'UTC') - AND ((NOT has([''], "$group_0")) - AND (NOT has([''], "$group_0"))) - AND (step_0 = 1 - OR step_1 = 1) )) - WHERE step_0 = 1 )) - GROUP BY aggregation_target, - steps - HAVING steps = max_steps) - WHERE steps IN [1, 2] - ORDER BY aggregation_target - LIMIT 100 - OFFSET 0 SETTINGS max_ast_elements=1000000, - max_expanded_ast_elements=1000000 - ''' -# --- diff --git a/ee/clickhouse/views/test/funnel/test_clickhouse_funnel.py b/ee/clickhouse/views/test/funnel/test_clickhouse_funnel.py deleted file mode 100644 index 32e26b0be01..00000000000 --- a/ee/clickhouse/views/test/funnel/test_clickhouse_funnel.py +++ /dev/null @@ -1,289 +0,0 @@ -import json -from datetime import datetime - -from ee.api.test.base import LicensedTestMixin -from ee.clickhouse.views.test.funnel.util import ( - EventPattern, - FunnelRequest, - get_funnel_actors_ok, - get_funnel_ok, -) -from posthog.constants import INSIGHT_FUNNELS -from posthog.models.group.util import create_group -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - snapshot_clickhouse_queries, -) -from posthog.test.test_journeys import journeys_for - - -class ClickhouseTestFunnelGroups(ClickhouseTestMixin, LicensedTestMixin, APIBaseTest): - maxDiff = None - CLASS_DATA_LEVEL_SETUP = False - - def _create_groups(self): - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - GroupTypeMapping.objects.create(team=self.team, group_type="company", group_type_index=1) - - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:5", - properties={"industry": "finance"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:6", - properties={"industry": "technology"}, - ) - - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:1", - properties={}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:2", - properties={}, - ) - - @snapshot_clickhouse_queries - def test_funnel_aggregation_with_groups(self): - self._create_groups() - - events_by_person = { - "user_1": [ - { - "event": "user signed up", - "timestamp": datetime(2020, 1, 2, 14), - "properties": {"$group_0": "org:5"}, - }, - { - "event": "user signed up", # same person, different group, so should count as different step 1 in funnel - "timestamp": datetime(2020, 1, 10, 14), - "properties": {"$group_0": "org:6"}, - }, - ], - "user_2": [ - { # different person, same group, so should count as step two in funnel - "event": "paid", - "timestamp": datetime(2020, 1, 3, 14), - "properties": {"$group_0": "org:5"}, - } - ], - } - journeys_for(events_by_person, self.team) - - params = FunnelRequest( - events=json.dumps( - [ - EventPattern(id="user signed up", type="events", order=0), - EventPattern(id="paid", type="events", order=1), - ] - ), - date_from="2020-01-01", - date_to="2020-01-14", - aggregation_group_type_index=0, - insight=INSIGHT_FUNNELS, - ) - - result = get_funnel_ok(self.client, self.team.pk, params) - - assert result["user signed up"]["count"] == 2 - assert result["paid"]["count"] == 1 - assert result["paid"]["average_conversion_time"] == 86400 - - actors = get_funnel_actors_ok(self.client, result["user signed up"]["converted_people_url"]) - actor_ids = [str(val["id"]) for val in actors] - assert actor_ids == ["org:5", "org:6"] - - @snapshot_clickhouse_queries - def test_funnel_group_aggregation_with_groups_entity_filtering(self): - self._create_groups() - - events_by_person = { - "user_1": [ - { - "event": "user signed up", - "timestamp": datetime(2020, 1, 2, 14), - "properties": {"$group_0": "org:5"}, - } - ], - "user_2": [ - { # different person, same group, so should count as step two in funnel - "event": "paid", - "timestamp": datetime(2020, 1, 3, 14), - "properties": {"$group_0": "org:5"}, - } - ], - "user_3": [ - { # different person, different group, so should be discarded from step 1 in funnel - "event": "user signed up", - "timestamp": datetime(2020, 1, 10, 14), - "properties": {"$group_0": "org:6"}, - } - ], - } - journeys_for(events_by_person, self.team) - - params = FunnelRequest( - events=json.dumps( - [ - EventPattern( - id="user signed up", - type="events", - order=0, - properties={"$group_0": "org:5"}, - ), - EventPattern(id="paid", type="events", order=1), - ] - ), - date_from="2020-01-01", - date_to="2020-01-14", - aggregation_group_type_index=0, - insight=INSIGHT_FUNNELS, - ) - - result = get_funnel_ok(self.client, self.team.pk, params) - - assert result["user signed up"]["count"] == 1 - assert result["paid"]["count"] == 1 - assert result["paid"]["average_conversion_time"] == 86400 - - actors = get_funnel_actors_ok(self.client, result["user signed up"]["converted_people_url"]) - actor_ids = [str(val["id"]) for val in actors] - assert actor_ids == ["org:5"] - - @snapshot_clickhouse_queries - def test_funnel_with_groups_entity_filtering(self): - self._create_groups() - - events_by_person = { - "user_1": [ - { - "event": "user signed up", - "timestamp": datetime(2020, 1, 2, 14), - "properties": {"$group_0": "org:5"}, - }, - { - "event": "paid", - "timestamp": datetime(2020, 1, 3, 14), - "properties": { - "$group_0": "org:6" - }, # different group, but doesn't matter since not aggregating by groups - }, - { - "event": "user signed up", # event belongs to different group, so shouldn't enter funnel - "timestamp": datetime(2020, 1, 2, 14), - "properties": {"$group_0": "org:6"}, - }, - { - "event": "paid", - "timestamp": datetime(2020, 1, 3, 14), - "properties": {"$group_0": "org:6"}, # event belongs to different group, so shouldn't enter funnel - }, - ] - } - created_people = journeys_for(events_by_person, self.team) - - params = FunnelRequest( - events=json.dumps( - [ - EventPattern( - id="user signed up", - type="events", - order=0, - properties={"$group_0": "org:5"}, - ), - EventPattern(id="paid", type="events", order=1), - ] - ), - date_from="2020-01-01", - date_to="2020-01-14", - insight=INSIGHT_FUNNELS, - ) - - result = get_funnel_ok(self.client, self.team.pk, params) - - assert result["user signed up"]["count"] == 1 - assert result["paid"]["count"] == 1 - assert result["paid"]["average_conversion_time"] == 86400 - - actors = get_funnel_actors_ok(self.client, result["user signed up"]["converted_people_url"]) - actor_ids = [str(val["id"]) for val in actors] - - assert actor_ids == sorted([str(created_people["user_1"].uuid)]) - - @snapshot_clickhouse_queries - def test_funnel_with_groups_global_filtering(self): - self._create_groups() - - events_by_person = { - "user_1": [ - { - "event": "user signed up", - "timestamp": datetime(2020, 1, 2, 14), - "properties": {"$group_0": "org:5"}, - }, - { - "event": "paid", - "timestamp": datetime(2020, 1, 3, 14), - "properties": { - "$group_0": "org:6" - }, # second event belongs to different group, so shouldn't complete funnel - }, - ], - "user_2": [ - { - "event": "user signed up", # event belongs to different group, so shouldn't enter funnel - "timestamp": datetime(2020, 1, 2, 14), - "properties": {"$group_0": "org:6"}, - }, - { - "event": "paid", - "timestamp": datetime(2020, 1, 3, 14), - "properties": {"$group_0": "org:5"}, # same group, but different person, so not in funnel - }, - ], - } - created_people = journeys_for(events_by_person, self.team) - - params = FunnelRequest( - events=json.dumps( - [ - EventPattern(id="user signed up", type="events", order=0), - EventPattern(id="paid", type="events", order=1), - ] - ), - date_from="2020-01-01", - date_to="2020-01-14", - insight=INSIGHT_FUNNELS, - properties=json.dumps( - [ - { - "key": "industry", - "value": "finance", - "type": "group", - "group_type_index": 0, - } - ] - ), - ) - - result = get_funnel_ok(self.client, self.team.pk, params) - - assert result["user signed up"]["count"] == 1 - assert result["paid"]["count"] == 0 - - actors = get_funnel_actors_ok(self.client, result["user signed up"]["converted_people_url"]) - actor_ids = [str(val["id"]) for val in actors] - - assert actor_ids == sorted([str(created_people["user_1"].uuid)]) - - # TODO: move all tests diff --git a/ee/clickhouse/views/test/funnel/test_clickhouse_funnel_correlation.py b/ee/clickhouse/views/test/funnel/test_clickhouse_funnel_correlation.py deleted file mode 100644 index f5ff3722008..00000000000 --- a/ee/clickhouse/views/test/funnel/test_clickhouse_funnel_correlation.py +++ /dev/null @@ -1,699 +0,0 @@ -import json -from datetime import datetime -from unittest.mock import ANY - -import pytest -from django.core.cache import cache -from freezegun import freeze_time - -from ee.clickhouse.views.test.funnel.util import ( - EventPattern, - FunnelCorrelationRequest, - get_funnel_correlation, - get_funnel_correlation_ok, - get_people_for_correlation_ok, -) -from posthog.constants import FunnelCorrelationType -from posthog.models.element import Element -from posthog.models.team import Team -from posthog.test.base import BaseTest, _create_event, _create_person -from posthog.test.test_journeys import journeys_for - - -@pytest.mark.clickhouse_only -class FunnelCorrelationTest(BaseTest): - """ - Tests for /api/projects/:project_id/funnel/correlation/ - """ - - CLASS_DATA_LEVEL_SETUP = False - - def test_requires_authn(self): - response = get_funnel_correlation( - client=self.client, - team_id=self.team.pk, - request=FunnelCorrelationRequest(date_to="2020-04-04", events=json.dumps([])), - ) - assert response.status_code == 403 - assert response.json() == self.unauthenticated_response() - - def test_event_correlation_endpoint_picks_up_events_for_odds_ratios(self): - with freeze_time("2020-01-01"): - self.client.force_login(self.user) - - # Add in two people: - # - # Person 1 - a single signup event - # Person 2 - a signup event and a view insights event - # - # Both of them have a "watched video" event - # - # We then create Person 3, one successful, the other - # not. Both have not watched the video. - # - # So our contingency table for "watched video" should be - # - # | | success | failure | total | - # | ---------------- | -------- | -------- | -------- | - # | watched | 1 | 1 | 2 | - # | did not watched | 1 | 0 | 1 | - # | total | 2 | 1 | 3 | - # - # For Calculating Odds Ratio, we add a prior count of 1 to everything - # - # So our odds ratio should be - # (success + prior / failure + prior) * (failure_total - failure + prior / success_total - success + prior) - # = ( 1 + 1 / 1 + 1) * ( 1 - 1 + 1 / 2 - 1 + 1) - # = 1 / 2 - - events = { - "Person 1": [ - #  Failure / watched - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - {"event": "watched video", "timestamp": datetime(2020, 1, 2)}, - ], - "Person 2": [ - #  Success / watched - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - {"event": "watched video", "timestamp": datetime(2020, 1, 2)}, - {"event": "view insights", "timestamp": datetime(2020, 1, 3)}, - ], - "Person 3": [ - # Success / did not watched - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - {"event": "view insights", "timestamp": datetime(2020, 1, 3)}, - ], - } - - journeys_for(events_by_person=events, team=self.team) - - odds = get_funnel_correlation_ok( - client=self.client, - team_id=self.team.pk, - request=FunnelCorrelationRequest( - events=json.dumps([EventPattern(id="signup"), EventPattern(id="view insights")]), - date_to="2020-04-04", - ), - ) - - assert odds == { - "is_cached": False, - "last_refresh": "2020-01-01T00:00:00Z", - "result": { - "events": [ - { - "event": { - "event": "watched video", - "elements": [], - "properties": {}, - }, - "failure_count": 1, - "success_count": 1, - "success_people_url": ANY, - "failure_people_url": ANY, - "odds_ratio": 1 / 2, - "correlation_type": "failure", - } - ], - "skewed": False, - }, - } - - def test_event_correlation_is_partitioned_by_team(self): - """ - Ensure there's no crosstalk between teams - - We check this by: - - 1. loading events into team 1 - 2. checking correlation for team 1 - 3. loading events into team 2 - 4. checking correlation for team 1 again, they should be the same - - """ - with freeze_time("2020-01-01"): - self.client.force_login(self.user) - - events = { - "Person 1": [ - {"event": "watched video", "timestamp": datetime(2019, 1, 2)}, - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - ], - "Person 2": [ - {"event": "watched video", "timestamp": datetime(2019, 1, 2)}, - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - {"event": "view insights", "timestamp": datetime(2020, 1, 3)}, - ], - } - - journeys_for(events_by_person=events, team=self.team) - - odds_before = get_funnel_correlation_ok( - client=self.client, - team_id=self.team.pk, - request=FunnelCorrelationRequest( - events=json.dumps([EventPattern(id="signup"), EventPattern(id="view insights")]), - date_to="2020-04-04", - ), - ) - - other_team = create_team(organization=self.organization) - journeys_for(events_by_person=events, team=other_team) - - # We need to make sure we clear the cache so we get the same results again - cache.clear() - - odds_after = get_funnel_correlation_ok( - client=self.client, - team_id=self.team.pk, - request=FunnelCorrelationRequest( - events=json.dumps([EventPattern(id="signup"), EventPattern(id="view insights")]), - date_to="2020-04-04", - ), - ) - - assert odds_before == odds_after - - def test_event_correlation_endpoint_does_not_include_historical_events(self): - with freeze_time("2020-01-01"): - self.client.force_login(self.user) - - # Add in two people: - # - # Person 1 - a single signup event - # Person 2 - a signup event and a view insights event - # - # Both of them have a "watched video" event but they are before the - # signup event - - events = { - "Person 1": [ - {"event": "watched video", "timestamp": datetime(2019, 1, 2)}, - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - ], - "Person 2": [ - {"event": "watched video", "timestamp": datetime(2019, 1, 2)}, - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - {"event": "view insights", "timestamp": datetime(2020, 1, 3)}, - ], - } - - journeys_for(events_by_person=events, team=self.team) - - # We need to make sure we clear the cache other tests that have run - # done interfere with this test - cache.clear() - - odds = get_funnel_correlation_ok( - client=self.client, - team_id=self.team.pk, - request=FunnelCorrelationRequest( - events=json.dumps([EventPattern(id="signup"), EventPattern(id="view insights")]), - date_to="2020-04-04", - ), - ) - - assert odds == { - "is_cached": False, - "last_refresh": "2020-01-01T00:00:00Z", - "result": {"events": [], "skewed": False}, - } - - def test_event_correlation_endpoint_does_not_include_funnel_steps(self): - with freeze_time("2020-01-01"): - self.client.force_login(self.user) - - # Add Person1 with only the funnel steps involved - - events = { - "Person 1": [ - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - {"event": "some waypoint", "timestamp": datetime(2020, 1, 2)}, - {"event": "", "timestamp": datetime(2020, 1, 3)}, - ], - # We need atleast 1 success and failure to return a result - "Person 2": [ - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - {"event": "some waypoint", "timestamp": datetime(2020, 1, 2)}, - {"event": "view insights", "timestamp": datetime(2020, 1, 3)}, - ], - } - # '' is a weird event name to have, but if it exists, our duty to report it - - journeys_for(events_by_person=events, team=self.team) - - # We need to make sure we clear the cache other tests that have run - # done interfere with this test - cache.clear() - - odds = get_funnel_correlation_ok( - client=self.client, - team_id=self.team.pk, - request=FunnelCorrelationRequest( - events=json.dumps( - [ - EventPattern(id="signup"), - EventPattern(id="some waypoint"), - EventPattern(id="view insights"), - ] - ), - date_to="2020-04-04", - ), - ) - - assert odds == { - "is_cached": False, - "last_refresh": "2020-01-01T00:00:00Z", - "result": { - "events": [ - { - "correlation_type": "failure", - "event": {"event": "", "elements": [], "properties": {}}, - "failure_count": 1, - "odds_ratio": 1 / 4, - "success_count": 0, - "success_people_url": ANY, - "failure_people_url": ANY, - } - ], - "skewed": False, - }, - } - - def test_events_correlation_endpoint_provides_people_drill_down_urls(self): - """ - Here we are setting up three users, and looking to retrieve one - correlation for watched video, with a url we can use to retrieve people - that successfully completed the funnel AND watched the video, and - another for people that did not complete the funnel but also watched the - video. - """ - - with freeze_time("2020-01-01"): - self.client.force_login(self.user) - - events = { - "Person 1": [ - # Failure / watched - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - {"event": "watched video", "timestamp": datetime(2020, 1, 2)}, - ], - "Person 2": [ - # Success / watched - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - {"event": "watched video", "timestamp": datetime(2020, 1, 2)}, - {"event": "view insights", "timestamp": datetime(2020, 1, 3)}, - ], - "Person 3": [ - # Success / did not watched. We don't expect to retrieve - # this one as part of the - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - {"event": "view insights", "timestamp": datetime(2020, 1, 3)}, - ], - } - - journeys_for(events_by_person=events, team=self.team) - - odds = get_funnel_correlation_ok( - client=self.client, - team_id=self.team.pk, - request=FunnelCorrelationRequest( - events=json.dumps([EventPattern(id="signup"), EventPattern(id="view insights")]), - date_to="2020-04-04", - ), - ) - - assert odds["result"]["events"][0]["event"]["event"] == "watched video" - watched_video_correlation = odds["result"]["events"][0] - - assert get_people_for_correlation_ok(client=self.client, correlation=watched_video_correlation) == { - "success": ["Person 2"], - "failure": ["Person 1"], - } - - def test_events_with_properties_correlation_endpoint_provides_people_drill_down_urls(self): - with freeze_time("2020-01-01"): - self.client.force_login(self.user) - - events = { - "Person 1": [ - # Failure / watched - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - { - "event": "watched video", - "properties": {"$browser": "1"}, - "timestamp": datetime(2020, 1, 2), - }, - ], - "Person 2": [ - # Success / watched - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - { - "event": "watched video", - "properties": {"$browser": "1"}, - "timestamp": datetime(2020, 1, 2), - }, - {"event": "view insights", "timestamp": datetime(2020, 1, 3)}, - ], - "Person 3": [ - # Success / watched. We need to have three event instances - # for this test otherwise the endpoint doesn't return results - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - { - "event": "watched video", - "properties": {"$browser": "1"}, - "timestamp": datetime(2020, 1, 2), - }, - {"event": "view insights", "timestamp": datetime(2020, 1, 3)}, - ], - "Person 4": [ - # Success / didn't watch. Want to use this user to verify - # that we don't pull in unrelated users erroneously - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - {"event": "view insights", "timestamp": datetime(2020, 1, 3)}, - ], - } - - journeys_for(events_by_person=events, team=self.team) - - odds = get_funnel_correlation_ok( - client=self.client, - team_id=self.team.pk, - request=FunnelCorrelationRequest( - funnel_correlation_type=FunnelCorrelationType.EVENT_WITH_PROPERTIES, - funnel_correlation_event_names=json.dumps(["watched video"]), - events=json.dumps([EventPattern(id="signup"), EventPattern(id="view insights")]), - date_to="2020-04-04", - ), - ) - - assert odds["result"]["events"][0]["event"]["event"] == "watched video::$browser::1" - watched_video_correlation = odds["result"]["events"][0] - - assert get_people_for_correlation_ok(client=self.client, correlation=watched_video_correlation) == { - "success": ["Person 2", "Person 3"], - "failure": ["Person 1"], - } - - def test_correlation_endpoint_with_properties(self): - self.client.force_login(self.user) - - for i in range(10): - _create_person( - distinct_ids=[f"user_{i}"], - team_id=self.team.pk, - properties={"$browser": "Positive"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - ) - - for i in range(10, 20): - _create_person( - distinct_ids=[f"user_{i}"], - team_id=self.team.pk, - properties={"$browser": "Negative"}, - ) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - if i % 2 == 0: - _create_event( - team=self.team, - event="negatively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - ) - - # We need to make sure we clear the cache other tests that have run - # done interfere with this test - cache.clear() - - api_response = get_funnel_correlation_ok( - client=self.client, - team_id=self.team.pk, - request=FunnelCorrelationRequest( - events=json.dumps([EventPattern(id="user signed up"), EventPattern(id="paid")]), - date_to="2020-01-14", - date_from="2020-01-01", - funnel_correlation_type=FunnelCorrelationType.PROPERTIES, - funnel_correlation_names=json.dumps(["$browser"]), - ), - ) - - self.assertFalse(api_response["result"]["skewed"]) - - result = api_response["result"]["events"] - - odds_ratios = [item.pop("odds_ratio") for item in result] - expected_odds_ratios = [121, 1 / 121] - - for odds, expected_odds in zip(odds_ratios, expected_odds_ratios): - self.assertAlmostEqual(odds, expected_odds) - - self.assertEqual( - result, - [ - { - "event": { - "event": "$browser::Positive", - "elements": [], - "properties": {}, - }, - "success_count": 10, - "failure_count": 0, - "success_people_url": ANY, - "failure_people_url": ANY, - # "odds_ratio": 121.0, - "correlation_type": "success", - }, - { - "event": { - "event": "$browser::Negative", - "elements": [], - "properties": {}, - }, - "success_count": 0, - "failure_count": 10, - "success_people_url": ANY, - "failure_people_url": ANY, - # "odds_ratio": 1 / 121, - "correlation_type": "failure", - }, - ], - ) - - def test_properties_correlation_endpoint_provides_people_drill_down_urls(self): - """ - Here we are setting up three users, two with a specified property but - differing values, and one with this property absent. We expect to be - able to use the correlation people drill down urls to retrieve the - associated people for each. - """ - - with freeze_time("2020-01-01"): - self.client.force_login(self.user) - - _create_person( - distinct_ids=["Person 1"], - team_id=self.team.pk, - properties={"$browser": "1"}, - ) - _create_person( - distinct_ids=["Person 2"], - team_id=self.team.pk, - properties={"$browser": "1"}, - ) - _create_person( - distinct_ids=["Person 3"], - team_id=self.team.pk, - properties={}, - ) - - events = { - "Person 1": [ - # Failure / $browser::1 - {"event": "signup", "timestamp": datetime(2020, 1, 1)} - ], - "Person 2": [ - # Success / $browser::1 - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - {"event": "view insights", "timestamp": datetime(2020, 1, 3)}, - ], - "Person 3": [ - # Success / $browser not set - {"event": "signup", "timestamp": datetime(2020, 1, 1)}, - {"event": "view insights", "timestamp": datetime(2020, 1, 3)}, - ], - } - - journeys_for(events_by_person=events, team=self.team, create_people=False) - - odds = get_funnel_correlation_ok( - client=self.client, - team_id=self.team.pk, - request=FunnelCorrelationRequest( - events=json.dumps([EventPattern(id="signup"), EventPattern(id="view insights")]), - date_to="2020-04-04", - funnel_correlation_type=FunnelCorrelationType.PROPERTIES, - funnel_correlation_names=json.dumps(["$browser"]), - ), - ) - - (browser_correlation,) = ( - correlation - for correlation in odds["result"]["events"] - if correlation["event"]["event"] == "$browser::1" - ) - - (notset_correlation,) = ( - correlation for correlation in odds["result"]["events"] if correlation["event"]["event"] == "$browser::" - ) - - assert get_people_for_correlation_ok(client=self.client, correlation=browser_correlation) == { - "success": ["Person 2"], - "failure": ["Person 1"], - } - - assert get_people_for_correlation_ok(client=self.client, correlation=notset_correlation) == { - "success": ["Person 3"], - "failure": [], - } - - def test_correlation_endpoint_request_with_no_steps_doesnt_fail(self): - """ - This just checks that we get an empty result, this mimics what happens - with other insight endpoints. It's questionable that perhaps this whould - be a 400 instead. - """ - self.client.force_login(self.user) - - with freeze_time("2020-01-01"): - response = get_funnel_correlation_ok( - client=self.client, - team_id=self.team.pk, - request=FunnelCorrelationRequest( - events=json.dumps([]), - date_to="2020-01-14", - date_from="2020-01-01", - funnel_correlation_type=FunnelCorrelationType.PROPERTIES, - funnel_correlation_names=json.dumps(["$browser"]), - ), - ) - - assert response == { - "is_cached": False, - "last_refresh": "2020-01-01T00:00:00Z", - "result": {"events": [], "skewed": False}, - } - - def test_funnel_correlation_with_event_properties_autocapture(self): - self.client.force_login(self.user) - - # Need a minimum of 3 hits to get a correlation result - for i in range(3): - _create_person(distinct_ids=[f"user_{i}"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - _create_event( - team=self.team, - event="$autocapture", - distinct_id=f"user_{i}", - elements=[Element(nth_of_type=1, nth_child=0, tag_name="a", href="/movie")], - timestamp="2020-01-03T14:00:00Z", - properties={"signup_source": "email", "$event_type": "click"}, - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - ) - - # Atleast one person that fails, to ensure we get results - _create_person(distinct_ids=[f"user_fail"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_fail", - timestamp="2020-01-02T14:00:00Z", - ) - - with freeze_time("2020-01-01"): - response = get_funnel_correlation_ok( - client=self.client, - team_id=self.team.pk, - request=FunnelCorrelationRequest( - events=json.dumps([EventPattern(id="user signed up"), EventPattern(id="paid")]), - date_to="2020-01-14", - date_from="2020-01-01", - funnel_correlation_type=FunnelCorrelationType.EVENT_WITH_PROPERTIES, - funnel_correlation_event_names=json.dumps(["$autocapture"]), - ), - ) - - assert response == { - "result": { - "events": [ - { - "success_count": 3, - "failure_count": 0, - "success_people_url": ANY, - "failure_people_url": ANY, - "odds_ratio": 8.0, - "correlation_type": "success", - "event": { - "event": '$autocapture::elements_chain::click__~~__a:href="/movie"nth-child="0"nth-of-type="1"', - "properties": {"$event_type": "click"}, - "elements": [ - { - "event": None, - "text": None, - "tag_name": "a", - "attr_class": None, - "href": "/movie", - "attr_id": None, - "nth_child": 0, - "nth_of_type": 1, - "attributes": {}, - "order": 0, - } - ], - }, - } - ], - "skewed": False, - }, - "last_refresh": "2020-01-01T00:00:00Z", - "is_cached": False, - } - - assert get_people_for_correlation_ok(client=self.client, correlation=response["result"]["events"][0]) == { - "success": ["user_0", "user_1", "user_2"], - "failure": [], - } - - -@pytest.fixture(autouse=True) -def clear_django_cache(): - cache.clear() - - -def create_team(organization): - return Team.objects.create(name="Test Team", organization=organization) diff --git a/ee/clickhouse/views/test/funnel/test_clickhouse_funnel_person.py b/ee/clickhouse/views/test/funnel/test_clickhouse_funnel_person.py deleted file mode 100644 index a230df59189..00000000000 --- a/ee/clickhouse/views/test/funnel/test_clickhouse_funnel_person.py +++ /dev/null @@ -1,415 +0,0 @@ -import json -from unittest.mock import patch - -from django.core.cache import cache -from rest_framework import status - -from posthog.constants import INSIGHT_FUNNELS -from posthog.models.group.util import create_group -from posthog.models.instance_setting import get_instance_setting -from posthog.models.person import Person -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - _create_event, - _create_person, - snapshot_clickhouse_queries, -) - - -class TestFunnelPerson(ClickhouseTestMixin, APIBaseTest): - def _create_sample_data(self, num, delete=False): - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="g0", - properties={"slug": "g0", "name": "g0"}, - ) - - for i in range(num): - if delete: - person = Person.objects.create(distinct_ids=[f"user_{i}"], team=self.team) - else: - _create_person(distinct_ids=[f"user_{i}"], team=self.team) - _create_event( - event="step one", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:00:00", - properties={"$browser": "Chrome", "$group_0": "g0"}, - ) - _create_event( - event="step two", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-03 00:00:00", - properties={"$browser": "Chrome", "$group_0": "g0"}, - ) - _create_event( - event="step three", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-05 00:00:00", - properties={"$browser": "Chrome", "$group_0": "g0"}, - ) - if delete: - person.delete() - - def test_basic_format(self): - self._create_sample_data(5) - request_data = { - "insight": INSIGHT_FUNNELS, - "interval": "day", - "actions": json.dumps([]), - "events": json.dumps( - [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ] - ), - "properties": json.dumps([]), - "funnel_window_days": 14, - "funnel_step": 1, - "filter_test_accounts": "false", - "new_entity": json.dumps([]), - "date_from": "2021-05-01", - "date_to": "2021-05-10", - } - - response = self.client.get("/api/person/funnel/", data=request_data) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - first_person = j["results"][0]["people"][0] - self.assertEqual(5, len(j["results"][0]["people"])) - self.assertTrue("id" in first_person and "name" in first_person and "distinct_ids" in first_person) - self.assertEqual(5, j["results"][0]["count"]) - - @snapshot_clickhouse_queries - def test_funnel_actors_with_groups_search(self): - self._create_sample_data(5) - - request_data = { - "aggregation_group_type_index": 0, - "search": "g0", - "breakdown_attribution_type": "first_touch", - "insight": INSIGHT_FUNNELS, - "interval": "day", - "actions": json.dumps([]), - "events": json.dumps( - [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ] - ), - "properties": json.dumps([]), - "funnel_window_days": 14, - "funnel_step": 1, - "filter_test_accounts": "false", - "new_entity": json.dumps([]), - "date_from": "2021-05-01", - "date_to": "2021-05-10", - } - - response = self.client.get("/api/person/funnel/", data=request_data) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - self.assertEqual(1, len(j["results"][0]["people"])) - self.assertEqual(1, j["results"][0]["count"]) - - def test_basic_pagination(self): - cache.clear() - self._create_sample_data(110) - request_data = { - "insight": INSIGHT_FUNNELS, - "interval": "day", - "actions": json.dumps([]), - "events": json.dumps( - [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ] - ), - "properties": json.dumps([]), - "funnel_window_days": 14, - "funnel_step": 1, - "filter_test_accounts": "false", - "new_entity": json.dumps([]), - "date_from": "2021-05-01", - "date_to": "2021-05-10", - } - - response = self.client.get("/api/person/funnel/", data=request_data) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - people = j["results"][0]["people"] - next = j["next"] - self.assertEqual(100, len(people)) - self.assertNotEqual(None, next) - - response = self.client.get(next) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - people = j["results"][0]["people"] - next = j["next"] - self.assertEqual(10, len(people)) - self.assertEqual(None, j["next"]) - - def test_breakdown_basic_pagination(self): - cache.clear() - self._create_sample_data(110) - request_data = { - "insight": INSIGHT_FUNNELS, - "interval": "day", - "actions": json.dumps([]), - "events": json.dumps( - [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ] - ), - "properties": json.dumps([]), - "funnel_window_days": 14, - "funnel_step": 1, - "filter_test_accounts": "false", - "new_entity": json.dumps([]), - "date_from": "2021-05-01", - "date_to": "2021-05-10", - "breakdown_type": "event", - "breakdown": "$browser", - "funnel_step_breakdown": "Chrome", - } - - response = self.client.get("/api/person/funnel/", data=request_data) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - people = j["results"][0]["people"] - next = j["next"] - self.assertEqual(100, len(people)) - - response = self.client.get(next) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - people = j["results"][0]["people"] - next = j["next"] - self.assertEqual(10, len(people)) - self.assertEqual(None, j["next"]) - - @patch("posthog.models.person.util.delete_person") - def test_basic_pagination_with_deleted(self, delete_person_patch): - if not get_instance_setting("PERSON_ON_EVENTS_ENABLED"): - return - - cache.clear() - self._create_sample_data(20, delete=True) - request_data = { - "insight": INSIGHT_FUNNELS, - "interval": "day", - "actions": json.dumps([]), - "events": json.dumps( - [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ] - ), - "properties": json.dumps([]), - "funnel_window_days": 14, - "funnel_step": 1, - "filter_test_accounts": "false", - "new_entity": json.dumps([]), - "date_from": "2021-05-01", - "date_to": "2021-05-10", - "limit": 15, - } - - response = self.client.get("/api/person/funnel/", data=request_data) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - people = j["results"][0]["people"] - next = j["next"] - missing_persons = j["missing_persons"] - self.assertEqual(0, len(people)) - self.assertEqual(15, missing_persons) - self.assertIsNotNone(next) - - response = self.client.get(next) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - people = j["results"][0]["people"] - next = j["next"] - missing_persons = j["missing_persons"] - self.assertEqual(0, len(people)) - self.assertEqual(5, missing_persons) - self.assertIsNone(next) - - def test_breakdowns(self): - request_data = { - "insight": INSIGHT_FUNNELS, - "interval": "day", - "actions": json.dumps([]), - "properties": json.dumps([]), - "funnel_step": 1, - "filter_test_accounts": "false", - "new_entity": json.dumps([]), - "events": json.dumps( - [ - {"id": "sign up", "order": 0}, - {"id": "play movie", "order": 1}, - {"id": "buy", "order": 2}, - ] - ), - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-08", - "funnel_window_days": 7, - "breakdown": "$browser", - "funnel_step_breakdown": "Chrome", - } - - # event - _create_person(distinct_ids=["person1"], team_id=self.team.pk) - _create_event( - team=self.team, - event="sign up", - distinct_id="person1", - properties={"key": "val", "$browser": "Chrome"}, - timestamp="2020-01-01T12:00:00Z", - ) - _create_event( - team=self.team, - event="play movie", - distinct_id="person1", - properties={"key": "val", "$browser": "Chrome"}, - timestamp="2020-01-01T13:00:00Z", - ) - _create_event( - team=self.team, - event="buy", - distinct_id="person1", - properties={"key": "val", "$browser": "Chrome"}, - timestamp="2020-01-01T15:00:00Z", - ) - - _create_person(distinct_ids=["person2"], team_id=self.team.pk) - _create_event( - team=self.team, - event="sign up", - distinct_id="person2", - properties={"key": "val", "$browser": "Safari"}, - timestamp="2020-01-02T14:00:00Z", - ) - _create_event( - team=self.team, - event="play movie", - distinct_id="person2", - properties={"key": "val", "$browser": "Safari"}, - timestamp="2020-01-02T16:00:00Z", - ) - - _create_person(distinct_ids=["person3"], team_id=self.team.pk) - _create_event( - team=self.team, - event="sign up", - distinct_id="person3", - properties={"key": "val", "$browser": "Safari"}, - timestamp="2020-01-02T14:00:00Z", - ) - - response = self.client.get("/api/person/funnel/", data=request_data) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - - people = j["results"][0]["people"] - self.assertEqual(1, len(people)) - self.assertEqual(None, j["next"]) - - response = self.client.get( - "/api/person/funnel/", - data={**request_data, "funnel_step_breakdown": "Safari"}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - - people = j["results"][0]["people"] - self.assertEqual(2, len(people)) - self.assertEqual(None, j["next"]) - - -class TestFunnelCorrelationActors(ClickhouseTestMixin, APIBaseTest): - """ - Tests for /api/projects/:project_id/persons/funnel/correlation/ - """ - - def test_pagination(self): - cache.clear() - - for i in range(10): - _create_person(distinct_ids=[f"user_{i}"], team_id=self.team.pk) - _create_event( - team=self.team, - event="user signed up", - distinct_id=f"user_{i}", - timestamp="2020-01-02T14:00:00Z", - ) - _create_event( - team=self.team, - event="positively_related", - distinct_id=f"user_{i}", - timestamp="2020-01-03T14:00:00Z", - ) - _create_event( - team=self.team, - event="paid", - distinct_id=f"user_{i}", - timestamp="2020-01-04T14:00:00Z", - ) - - request_data = { - "events": json.dumps( - [ - {"id": "user signed up", "type": "events", "order": 0}, - {"id": "paid", "type": "events", "order": 1}, - ] - ), - "insight": INSIGHT_FUNNELS, - "date_from": "2020-01-01", - "date_to": "2020-01-14", - "funnel_correlation_type": "events", - "funnel_correlation_person_converted": "true", - "funnel_correlation_person_limit": 4, - "funnel_correlation_person_entity": json.dumps({"id": "positively_related", "type": "events"}), - } - - response = self.client.get( - f"/api/projects/{self.team.pk}/persons/funnel/correlation", - data=request_data, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - - first_person = j["results"][0]["people"][0] - self.assertEqual(4, len(j["results"][0]["people"])) - self.assertTrue("id" in first_person and "name" in first_person and "distinct_ids" in first_person) - self.assertEqual(4, j["results"][0]["count"]) - - next = j["next"] - response = self.client.get(next) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - - people = j["results"][0]["people"] - next = j["next"] - self.assertEqual(4, len(people)) - self.assertNotEqual(None, next) - - response = self.client.get(next) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - people = j["results"][0]["people"] - next = j["next"] - self.assertEqual(2, len(people)) - self.assertEqual(None, j["next"]) diff --git a/ee/clickhouse/views/test/funnel/test_clickhouse_funnel_trends_person.py b/ee/clickhouse/views/test/funnel/test_clickhouse_funnel_trends_person.py deleted file mode 100644 index 3459e4bf13c..00000000000 --- a/ee/clickhouse/views/test/funnel/test_clickhouse_funnel_trends_person.py +++ /dev/null @@ -1,279 +0,0 @@ -import json - -from rest_framework import status - -from posthog.constants import INSIGHT_FUNNELS, FunnelOrderType, FunnelVizType -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - _create_event, - _create_person, -) - - -class TestFunnelTrendsPerson(ClickhouseTestMixin, APIBaseTest): - def test_basic_format(self): - user_a = _create_person(distinct_ids=["user a"], team=self.team) - - _create_event( - event="step one", - distinct_id="user a", - team=self.team, - timestamp="2021-06-07 19:00:00", - ) - - common_request_data = { - "insight": INSIGHT_FUNNELS, - "funnel_viz_type": FunnelVizType.TRENDS, - "interval": "day", - "date_from": "2021-06-07", - "date_to": "2021-06-13 23:59:59", - "funnel_window_days": 7, - "events": json.dumps( - [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ] - ), - "properties": json.dumps([]), - "funnel_window_days": 7, - "new_entity": json.dumps([]), - } - - # 1 user who dropped off starting 2021-06-07 - response_1 = self.client.get( - "/api/person/funnel/", - data={ - **common_request_data, - "entrance_period_start": "2021-06-07", - "drop_off": True, - }, - ) - response_1_data = response_1.json() - - self.assertEqual(response_1.status_code, status.HTTP_200_OK) - self.assertEqual( - [person["id"] for person in response_1_data["results"][0]["people"]], - [str(user_a.uuid)], - ) - - # No users converted 2021-06-07 - response_2 = self.client.get( - "/api/person/funnel/", - data={ - **common_request_data, - "entrance_period_start": "2021-06-07 00:00", - "drop_off": False, - }, - ) - response_2_data = response_2.json() - - self.assertEqual(response_2.status_code, status.HTTP_200_OK) - self.assertEqual([person["id"] for person in response_2_data["results"][0]["people"]], []) - - # No users dropped off starting 2021-06-08 - response_3 = self.client.get( - "/api/person/funnel/", - data={ - **common_request_data, - "entrance_period_start": "2021-06-08", - "drop_off": True, - }, - ) - response_3_data = response_3.json() - - self.assertEqual(response_3.status_code, status.HTTP_200_OK) - self.assertEqual([person["id"] for person in response_3_data["results"][0]["people"]], []) - - def test_strict_order(self): - user_a = _create_person(distinct_ids=["user a"], team=self.team) - user_b = _create_person(distinct_ids=["user b"], team=self.team) - - _create_event( - event="step one", - distinct_id="user a", - team=self.team, - timestamp="2021-06-07 19:00:00", - ) - _create_event( - event="step two", - distinct_id="user a", - team=self.team, - timestamp="2021-06-07 19:00:01", - ) - _create_event( - event="step one", - distinct_id="user a", - team=self.team, - timestamp="2021-06-07 19:00:02", - ) - _create_event( - event="step three", - distinct_id="user a", - team=self.team, - timestamp="2021-06-07 19:00:03", - ) - - _create_event( - event="step one", - distinct_id="user b", - team=self.team, - timestamp="2021-06-07 19:00:00", - ) - _create_event( - event="step two", - distinct_id="user b", - team=self.team, - timestamp="2021-06-07 19:00:01", - ) - _create_event( - event="step three", - distinct_id="user b", - team=self.team, - timestamp="2021-06-07 19:00:03", - ) - - common_request_data = { - "insight": INSIGHT_FUNNELS, - "funnel_viz_type": FunnelVizType.TRENDS, - "interval": "day", - "date_from": "2021-06-07", - "date_to": "2021-06-13 23:59:59", - "funnel_window_days": 7, - "funnel_order_type": FunnelOrderType.STRICT, - "events": json.dumps( - [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ] - ), - "properties": json.dumps([]), - "funnel_window_days": 7, - "new_entity": json.dumps([]), - } - - # 1 user who dropped off - response_1 = self.client.get( - "/api/person/funnel/", - data={ - **common_request_data, - "entrance_period_start": "2021-06-07", - "drop_off": True, - }, - ) - response_1_data = response_1.json() - - self.assertEqual(response_1.status_code, status.HTTP_200_OK) - self.assertEqual( - [person["id"] for person in response_1_data["results"][0]["people"]], - [str(user_a.uuid)], - ) - - # 1 user who successfully converted - response_1 = self.client.get( - "/api/person/funnel/", - data={ - **common_request_data, - "entrance_period_start": "2021-06-07", - "drop_off": False, - }, - ) - response_1_data = response_1.json() - - self.assertEqual(response_1.status_code, status.HTTP_200_OK) - self.assertEqual( - [person["id"] for person in response_1_data["results"][0]["people"]], - [str(user_b.uuid)], - ) - - def test_unordered(self): - user_a = _create_person(distinct_ids=["user a"], team=self.team) - user_b = _create_person(distinct_ids=["user b"], team=self.team) - - _create_event( - event="step one", - distinct_id="user a", - team=self.team, - timestamp="2021-06-07 19:00:00", - ) - _create_event( - event="step three", - distinct_id="user a", - team=self.team, - timestamp="2021-06-07 19:00:03", - ) - - _create_event( - event="step one", - distinct_id="user b", - team=self.team, - timestamp="2021-06-07 19:00:00", - ) - _create_event( - event="step three", - distinct_id="user b", - team=self.team, - timestamp="2021-06-07 19:00:01", - ) - _create_event( - event="step two", - distinct_id="user b", - team=self.team, - timestamp="2021-06-07 19:00:02", - ) - - common_request_data = { - "insight": INSIGHT_FUNNELS, - "funnel_viz_type": FunnelVizType.TRENDS, - "interval": "day", - "date_from": "2021-06-07", - "date_to": "2021-06-13 23:59:59", - "funnel_window_days": 7, - "funnel_order_type": FunnelOrderType.UNORDERED, - "events": json.dumps( - [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ] - ), - "properties": json.dumps([]), - "funnel_window_days": 7, - "new_entity": json.dumps([]), - } - - # 1 user who dropped off - response_1 = self.client.get( - "/api/person/funnel/", - data={ - **common_request_data, - "entrance_period_start": "2021-06-07", - "drop_off": True, - }, - ) - response_1_data = response_1.json() - - self.assertEqual(response_1.status_code, status.HTTP_200_OK) - self.assertEqual( - [person["id"] for person in response_1_data["results"][0]["people"]], - [str(user_a.uuid)], - ) - - # 1 user who successfully converted - response_1 = self.client.get( - "/api/person/funnel/", - data={ - **common_request_data, - "entrance_period_start": "2021-06-07", - "drop_off": False, - }, - ) - response_1_data = response_1.json() - - self.assertEqual(response_1.status_code, status.HTTP_200_OK) - self.assertEqual( - [person["id"] for person in response_1_data["results"][0]["people"]], - [str(user_b.uuid)], - ) diff --git a/ee/clickhouse/views/test/funnel/test_clickhouse_funnel_unordered.py b/ee/clickhouse/views/test/funnel/test_clickhouse_funnel_unordered.py deleted file mode 100644 index db5113b826b..00000000000 --- a/ee/clickhouse/views/test/funnel/test_clickhouse_funnel_unordered.py +++ /dev/null @@ -1,101 +0,0 @@ -import json -from datetime import datetime - -from ee.api.test.base import LicensedTestMixin -from ee.clickhouse.views.test.funnel.util import ( - EventPattern, - FunnelRequest, - get_funnel_actors_ok, - get_funnel_ok, -) -from posthog.constants import INSIGHT_FUNNELS -from posthog.models.group.util import create_group -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - snapshot_clickhouse_queries, -) -from posthog.test.test_journeys import journeys_for - - -class ClickhouseTestUnorderedFunnelGroups(ClickhouseTestMixin, LicensedTestMixin, APIBaseTest): - maxDiff = None - CLASS_DATA_LEVEL_SETUP = False - - @snapshot_clickhouse_queries - def test_unordered_funnel_with_groups(self): - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - GroupTypeMapping.objects.create(team=self.team, group_type="company", group_type_index=1) - - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:5", - properties={"industry": "finance"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:6", - properties={"industry": "technology"}, - ) - - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:1", - properties={}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:2", - properties={}, - ) - - events_by_person = { - "user_1": [ - { - "event": "user signed up", - "timestamp": datetime(2020, 1, 3, 14), - "properties": {"$group_0": "org:5"}, - }, - { # same person, different group, so should count as different step 1 in funnel - "event": "user signed up", - "timestamp": datetime(2020, 1, 10, 14), - "properties": {"$group_0": "org:6"}, - }, - ], - "user_2": [ - { # different person, same group, so should count as step two in funnel - "event": "paid", - "timestamp": datetime(2020, 1, 2, 14), - "properties": {"$group_0": "org:5"}, - } - ], - } - journeys_for(events_by_person, self.team) - - params = FunnelRequest( - events=json.dumps( - [ - EventPattern(id="user signed up", type="events", order=0), - EventPattern(id="paid", type="events", order=1), - ] - ), - date_from="2020-01-01", - date_to="2020-01-14", - aggregation_group_type_index=0, - funnel_order_type="unordered", - insight=INSIGHT_FUNNELS, - ) - - result = get_funnel_ok(self.client, self.team.pk, params) - - assert result["Completed 1 step"]["count"] == 2 - assert result["Completed 2 steps"]["count"] == 1 - assert result["Completed 2 steps"]["average_conversion_time"] == 86400 - - actors = get_funnel_actors_ok(self.client, result["Completed 1 step"]["converted_people_url"]) - assert len(actors) == 2 diff --git a/ee/clickhouse/views/test/funnel/util.py b/ee/clickhouse/views/test/funnel/util.py deleted file mode 100644 index 45984ee41ba..00000000000 --- a/ee/clickhouse/views/test/funnel/util.py +++ /dev/null @@ -1,103 +0,0 @@ -import dataclasses -from typing import Any, Literal, Optional, TypedDict, Union - -from django.test.client import Client - -from ee.clickhouse.queries.funnels.funnel_correlation import EventOddsRatioSerialized -from posthog.constants import FunnelCorrelationType -from posthog.models.property import GroupTypeIndex - - -class EventPattern(TypedDict, total=False): - id: str - type: Union[Literal["events"], Literal["actions"]] - order: int - properties: dict[str, Any] - - -@dataclasses.dataclass -class FunnelCorrelationRequest: - # Needs to be json encoded list of `EventPattern`s - events: str - date_to: str - funnel_step: Optional[int] = None - date_from: Optional[str] = None - funnel_correlation_type: Optional[FunnelCorrelationType] = None - # Needs to be json encoded list of `str`s - funnel_correlation_names: Optional[str] = None - funnel_correlation_event_names: Optional[str] = None - - -@dataclasses.dataclass -class FunnelRequest: - events: str - date_from: str - insight: str - aggregation_group_type_index: Optional[GroupTypeIndex] = None - date_to: Optional[str] = None - properties: Optional[str] = None - funnel_order_type: Optional[str] = None - - -def get_funnel(client: Client, team_id: int, request: FunnelRequest): - return client.post( - f"/api/projects/{team_id}/insights/funnel", - data={key: value for key, value in dataclasses.asdict(request).items() if value is not None}, - ) - - -def get_funnel_ok(client: Client, team_id: int, request: FunnelRequest) -> dict[str, Any]: - response = get_funnel(client=client, team_id=team_id, request=request) - - assert response.status_code == 200, response.content - res = response.json() - final = {} - - for step in res["result"]: - final[step["name"]] = step - - return final - - -def get_funnel_actors_ok(client: Client, url: str): - response = client.get(url) - - assert response.status_code == 200, response.content - return response.json()["results"][0]["people"] - - -def get_funnel_correlation(client: Client, team_id: int, request: FunnelCorrelationRequest): - return client.get( - f"/api/projects/{team_id}/insights/funnel/correlation", - data={key: value for key, value in dataclasses.asdict(request).items() if value is not None}, - ) - - -def get_funnel_correlation_ok(client: Client, team_id: int, request: FunnelCorrelationRequest) -> dict[str, Any]: - response = get_funnel_correlation(client=client, team_id=team_id, request=request) - - assert response.status_code == 200, response.content - return response.json() - - -def get_people_for_correlation_ok(client: Client, correlation: EventOddsRatioSerialized) -> dict[str, Any]: - """ - Helper for getting people for a correlation. Note we keep checking to just - inclusion of name, to make the stable to changes in other people props. - """ - success_people_url = correlation["success_people_url"] - failure_people_url = correlation["failure_people_url"] - - if not success_people_url or not failure_people_url: - return {} - - success_people_response = client.get(success_people_url) - assert success_people_response.status_code == 200, success_people_response.content - - failure_people_response = client.get(failure_people_url) - assert failure_people_response.status_code == 200, failure_people_response.content - - return { - "success": sorted([person["name"] for person in success_people_response.json()["results"][0]["people"]]), - "failure": sorted([person["name"] for person in failure_people_response.json()["results"][0]["people"]]), - } diff --git a/ee/clickhouse/views/test/test_clickhouse_experiment_secondary_results.py b/ee/clickhouse/views/test/test_clickhouse_experiment_secondary_results.py deleted file mode 100644 index 63f82db4a72..00000000000 --- a/ee/clickhouse/views/test/test_clickhouse_experiment_secondary_results.py +++ /dev/null @@ -1,1310 +0,0 @@ -from typing import Any -from flaky import flaky - - -from ee.api.test.base import APILicensedTest -from posthog.models.signals import mute_selected_signals -from posthog.test.base import ClickhouseTestMixin, snapshot_clickhouse_queries -from posthog.test.test_journeys import journeys_for - -DEFAULT_JOURNEYS_FOR_PAYLOAD: dict[str, list[dict[str, Any]]] = { - # For a trend pageview metric - "person1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - } - ], - "person2": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person3": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - } - ], - # doesn't have feature set - "person_out_of_control": [{"event": "$pageview", "timestamp": "2020-01-03"}], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - } - ], - # wrong feature set somehow - "person_out_of_feature_control": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "ablahebf"}, - } - ], - # for a funnel conversion metric - "person1_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageleave_funnel", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - }, - ], - "person2_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave_funnel", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person3_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave_funnel", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # doesn't have feature set - "person_out_of_control_funnel": [ - {"event": "$pageview_funnel", "timestamp": "2020-01-03"}, - {"event": "$pageleave_funnel", "timestamp": "2020-01-05"}, - ], - "person_out_of_end_date_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave_funnel", - "timestamp": "2020-08-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # non-converters with FF - "person4_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test"}, - } - ], - "person5_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - } - ], -} - -DEFAULT_EXPERIMENT_CREATION_PAYLOAD = { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": "a-b-test", - "parameters": {}, - "secondary_metrics": [ - { - "name": "trends whatever", - "filters": { - "insight": "trends", - "events": [{"order": 0, "id": "$pageview"}], - "properties": [ - { - "key": "$geoip_country_name", - "type": "person", - "value": ["france"], - "operator": "exact", - } - # properties superceded by FF breakdown - ], - }, - }, - { - "name": "funnels whatever", - "filters": { - "insight": "funnels", - "events": [ - {"order": 0, "id": "$pageview_funnel"}, - {"order": 1, "id": "$pageleave_funnel"}, - ], - "properties": [ - { - "key": "$geoip_country_name", - "type": "person", - "value": ["france"], - "operator": "exact", - } - # properties superceded by FF breakdown - ], - }, - }, - ], - # target metric insignificant since we're testing secondaries right now - "filters": {"insight": "trends", "events": [{"order": 0, "id": "whatever"}]}, -} - - -@flaky(max_runs=10, min_passes=1) -class ClickhouseTestExperimentSecondaryResults(ClickhouseTestMixin, APILicensedTest): - @snapshot_clickhouse_queries - def test_basic_secondary_metric_results(self): - journeys_for( - DEFAULT_JOURNEYS_FOR_PAYLOAD, - self.team, - ) - - # :KLUDGE: Avoid calling sync_insight_caching_state which messes with snapshots - with mute_selected_signals(): - # generates the FF which should result in the above events^ - creation_response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - DEFAULT_EXPERIMENT_CREATION_PAYLOAD, - ) - - id = creation_response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/secondary_results?id=0") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - - self.assertEqual(len(response_data["result"].items()), 2) - - self.assertEqual(response_data["result"]["control"], 3) - self.assertEqual(response_data["result"]["test"], 1) - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/secondary_results?id=1") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - - self.assertEqual(len(response_data["result"].items()), 2) - - self.assertAlmostEqual(response_data["result"]["control"], 1) - self.assertEqual(response_data["result"]["test"], round(1 / 3, 3)) - - def test_basic_secondary_metric_results_cached(self): - journeys_for( - DEFAULT_JOURNEYS_FOR_PAYLOAD, - self.team, - ) - - # generates the FF which should result in the above events^ - creation_response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - DEFAULT_EXPERIMENT_CREATION_PAYLOAD, - ) - - id = creation_response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/secondary_results?id=0") - self.assertEqual(200, response.status_code) - - response_data = response.json() - self.assertEqual(response_data.pop("is_cached"), False) - - response_data = response_data["result"] - self.assertEqual(len(response_data["result"].items()), 2) - - self.assertEqual(response_data["result"]["control"], 3) - self.assertEqual(response_data["result"]["test"], 1) - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/secondary_results?id=1") - self.assertEqual(200, response.status_code) - - response_data = response.json() - result_data = response_data["result"] - - self.assertEqual(len(result_data["result"].items()), 2) - - self.assertAlmostEqual(result_data["result"]["control"], 1) - self.assertEqual(result_data["result"]["test"], round(1 / 3, 3)) - - response2 = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/secondary_results?id=1") - response2_data = response2.json() - - self.assertEqual(response2_data.pop("is_cached"), True) - self.assertEqual(response2_data["result"], response_data["result"]) - - def test_secondary_metric_results_for_multiple_variants(self): - journeys_for( - { - # trend metric first - "person1_2_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_2"}, - } - ], - "person1_1_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - } - ], - "person2_1_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - } - ], - "person2_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - } - ], - "person3_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - } - ], - "person4_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - } - ], - # doesn't have feature set - "person_out_of_control": [{"event": "$pageview_trend", "timestamp": "2020-01-03"}], - "person_out_of_end_date": [ - { - "event": "$pageview_trend", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - } - ], - # funnel metric second - "person1_2": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_2"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test_2"}, - }, - ], - "person1_1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test_1"}, - }, - ], - "person2_1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test_1"}, - }, - ], - "person1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - }, - ], - "person2": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person3": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # doesn't have feature set - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2020-01-03"}, - {"event": "$pageleave", "timestamp": "2020-01-05"}, - ], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-08-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # non-converters with FF - "person4": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test"}, - } - ], - "person5": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - } - ], - "person6_1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - } - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - creation_response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": { - "feature_flag_variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 25, - }, - { - "key": "test_1", - "name": "Test Variant 1", - "rollout_percentage": 25, - }, - { - "key": "test_2", - "name": "Test Variant 2", - "rollout_percentage": 25, - }, - { - "key": "test", - "name": "Test Variant 3", - "rollout_percentage": 25, - }, - ] - }, - "secondary_metrics": [ - { - "name": "secondary metric", - "filters": { - "insight": "trends", - "events": [{"order": 0, "id": "$pageview_trend"}], - }, - }, - { - "name": "funnel metric", - "filters": { - "insight": "funnels", - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - }, - }, - ], - # target metric insignificant since we're testing secondaries right now - "filters": { - "insight": "trends", - "events": [{"order": 0, "id": "whatever"}], - }, - }, - ) - - id = creation_response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/secondary_results?id=0") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - - # trend missing 'test' variant, so it's not in the results - self.assertEqual(len(response_data["result"].items()), 3) - - self.assertEqual(response_data["result"]["control"], 3) - self.assertEqual(response_data["result"]["test_1"], 2) - self.assertEqual(response_data["result"]["test_2"], 1) - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/secondary_results?id=1") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - - # funnel not missing 'test' variant, so it's in the results - self.assertEqual(len(response_data["result"].items()), 4) - - self.assertAlmostEqual(response_data["result"]["control"], 1) - self.assertAlmostEqual(response_data["result"]["test"], round(1 / 3, 3)) - self.assertAlmostEqual(response_data["result"]["test_1"], round(2 / 3, 3)) - self.assertAlmostEqual(response_data["result"]["test_2"], 1) - - def test_secondary_metric_results_for_multiple_variants_with_trend_count_per_actor(self): - journeys_for( - { - # trend metric first - "person1_2_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_2"}, - } - ], - "person1_1_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - } - ], - "person2_1_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - } - ], - "person2_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - } - ], - "person3_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - } - ], - "person4_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - } - ], - # doesn't have feature set - "person_out_of_control": [{"event": "$pageview_trend", "timestamp": "2020-01-03"}], - "person_out_of_end_date": [ - { - "event": "$pageview_trend", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - } - ], - # avg count per user metric second - "person1_2": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_2"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_2"}, - }, - ], - "person1_1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - }, - ], - "person2_1": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test_1"}, - }, - ], - "person1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - ], - "person2": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person3": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # doesn't have feature set - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2020-01-03"}, - {"event": "$pageleave", "timestamp": "2020-01-05"}, - ], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-08-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # non-converters with FF - "person4": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test"}, - } - ], - "person5": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - } - ], - "person6_1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - } - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - creation_response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": { - "feature_flag_variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 25, - }, - { - "key": "test_1", - "name": "Test Variant 1", - "rollout_percentage": 25, - }, - { - "key": "test_2", - "name": "Test Variant 2", - "rollout_percentage": 25, - }, - { - "key": "test", - "name": "Test Variant 3", - "rollout_percentage": 25, - }, - ] - }, - "secondary_metrics": [ - { - "name": "secondary metric", - "filters": { - "insight": "trends", - "events": [{"order": 0, "id": "$pageview_trend"}], - }, - }, - { - "name": "funnel metric", - "filters": { - "insight": "trends", - "events": [ - { - "order": 0, - "id": "$pageview", - "math": "avg_count_per_actor", - } - ], - }, - }, - ], - # target metric insignificant since we're testing secondaries right now - "filters": { - "insight": "trends", - "events": [{"order": 0, "id": "whatever"}], - }, - }, - ) - - id = creation_response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/secondary_results?id=0") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - - # trend missing 'test' variant, so it's not in the results - self.assertEqual(len(response_data["result"].items()), 3) - - self.assertEqual(response_data["result"]["control"], 3) - self.assertEqual(response_data["result"]["test_1"], 2) - self.assertEqual(response_data["result"]["test_2"], 1) - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/secondary_results?id=1") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - - # funnel not missing 'test' variant, so it's in the results - self.assertEqual(len(response_data["result"].items()), 4) - - self.assertAlmostEqual(response_data["result"]["control"], round(3.5 / 6, 3), 3) - self.assertAlmostEqual(response_data["result"]["test"], 0.5) - self.assertAlmostEqual(response_data["result"]["test_1"], 0.5) - self.assertAlmostEqual(response_data["result"]["test_2"], round(1 / 3, 3), 3) - - def test_secondary_metric_results_for_multiple_variants_with_trend_count_per_property_value(self): - journeys_for( - { - # trend metric first - "person1_2_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_2"}, - } - ], - "person1_1_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - } - ], - "person2_1_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - } - ], - "person2_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - } - ], - "person3_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - } - ], - "person4_trend": [ - { - "event": "$pageview_trend", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - } - ], - # doesn't have feature set - "person_out_of_control": [{"event": "$pageview_trend", "timestamp": "2020-01-03"}], - "person_out_of_end_date": [ - { - "event": "$pageview_trend", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - } - ], - # avg per mathable property second - "person1_2": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_2", "mathable": 1}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_2", "mathable": 2}, - }, - ], - "person1_1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1", "mathable": 1}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1", "mathable": 2}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1", "mathable": 3}, - }, - ], - "person2_1": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test_1", "mathable": 10}, - }, - ], - "person1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "mathable": 200}, - }, - ], - "person2": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person3": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # doesn't have feature set - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2020-01-03"}, - {"event": "$pageleave", "timestamp": "2020-01-05"}, - ], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-08-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - creation_response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": { - "feature_flag_variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 25, - }, - { - "key": "test_1", - "name": "Test Variant 1", - "rollout_percentage": 25, - }, - { - "key": "test_2", - "name": "Test Variant 2", - "rollout_percentage": 25, - }, - { - "key": "test", - "name": "Test Variant 3", - "rollout_percentage": 25, - }, - ] - }, - "secondary_metrics": [ - { - "name": "secondary metric", - "filters": { - "insight": "trends", - "events": [{"order": 0, "id": "$pageview_trend"}], - }, - }, - { - "name": "funnel metric", - "filters": { - "insight": "trends", - "events": [ - { - "order": 0, - "id": "$pageview", - "math": "avg", - "math_property": "mathable", - } - ], - }, - }, - ], - # target metric insignificant since we're testing secondaries right now - "filters": { - "insight": "trends", - "events": [{"order": 0, "id": "whatever"}], - }, - }, - ) - - id = creation_response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/secondary_results?id=0") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - - # trend missing 'test' variant, so it's not in the results - self.assertEqual(len(response_data["result"].items()), 3) - - self.assertEqual(response_data["result"]["control"], 3) - self.assertEqual(response_data["result"]["test_1"], 2) - self.assertEqual(response_data["result"]["test_2"], 1) - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/secondary_results?id=1") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - - self.assertEqual(len(response_data["result"].items()), 4) - - self.assertAlmostEqual(response_data["result"]["control"], 0, 3) - self.assertAlmostEqual(response_data["result"]["test"], 33.3333, 3) - self.assertAlmostEqual(response_data["result"]["test_1"], 2, 3) - self.assertAlmostEqual(response_data["result"]["test_2"], 0.25, 3) - - def test_metrics_without_full_flag_information_are_valid(self): - journeys_for( - { - # doesn't have feature set - "person_out_of_control": [{"event": "$pageview_funnel", "timestamp": "2020-01-03"}], - "person_out_of_end_date": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - } - ], - # has invalid feature set - "person_out_of_all_controls": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "XYZABC"}, - } - ], - # for a funnel conversion metric - "person1_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-01-02", - # "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageleave_funnel", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - }, - ], - "person2_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave_funnel", - "timestamp": "2020-01-05", - # "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person3_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave_funnel", - "timestamp": "2020-01-05", - # "properties": {"$feature/a-b-test": "control"}, - }, - ], - # doesn't have feature set - "person_out_of_control_funnel": [ - {"event": "$pageview_funnel", "timestamp": "2020-01-03"}, - {"event": "$pageleave_funnel", "timestamp": "2020-01-05"}, - ], - "person_out_of_end_date_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave_funnel", - "timestamp": "2020-08-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # non-converters with FF - "person4_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test"}, - } - ], - "person5_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - } - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - creation_response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": {}, - "secondary_metrics": [ - { - "name": "funnels whatever", - "filters": { - "insight": "funnels", - "events": [ - {"order": 0, "id": "$pageview_funnel"}, - {"order": 1, "id": "$pageleave_funnel"}, - ], - "properties": [ - { - "key": "$geoip_country_name", - "type": "person", - "value": ["france"], - "operator": "exact", - } - # properties superceded by FF breakdown - ], - }, - }, - ], - # target metric insignificant since we're testing secondaries right now - "filters": { - "insight": "trends", - "events": [{"order": 0, "id": "whatever"}], - }, - }, - ) - - id = creation_response.json()["id"] - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/secondary_results?id=0") - self.assertEqual(200, response.status_code) - - response_data = response.json() - result_data = response_data["result"] - - self.assertEqual(len(result_data["result"].items()), 2) - self.assertAlmostEqual(result_data["result"]["control"], 1) - self.assertEqual(result_data["result"]["test"], round(1 / 3, 3)) - - assert set(response_data["result"].keys()) == { - "result", - "insight", - "filters", - "probability", - "significant", - "significance_code", - "expected_loss", - "credible_intervals", - "variants", - } - - assert response_data["result"]["variants"] == [ - { - "failure_count": 0, - "key": "control", - "success_count": 2, - }, - { - "failure_count": 2, - "key": "test", - "success_count": 1, - }, - ] - - assert response_data["result"]["significant"] is False - assert response_data["result"]["significance_code"] == "not_enough_exposure" - - def test_no_metric_validation_errors_for_secondary_metrics(self): - journeys_for( - { - # for trend metric, no test - "person2": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # doesn't have feature set - "person_out_of_control": [{"event": "$pageview_funnel", "timestamp": "2020-01-03"}], - "person_out_of_end_date": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - } - ], - # has invalid feature set - "person_out_of_all_controls": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "XYZABC"}, - } - ], - # for a funnel conversion metric - no control variant - "person1_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-01-02", - # "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageleave_funnel", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - }, - ], - # doesn't have feature set - "person_out_of_control_funnel": [ - {"event": "$pageview_funnel", "timestamp": "2020-01-03"}, - {"event": "$pageleave_funnel", "timestamp": "2020-01-05"}, - ], - "person_out_of_end_date_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave_funnel", - "timestamp": "2020-08-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # non-converters with FF - "person4_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test"}, - } - ], - "person5_funnel": [ - { - "event": "$pageview_funnel", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - } - ], - }, - self.team, - ) - - creation_response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - DEFAULT_EXPERIMENT_CREATION_PAYLOAD, - ) - - id = creation_response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/secondary_results?id=0") - self.assertEqual(200, response.status_code) - - response_data = response.json() - result_data = response_data["result"] - - assert set(response_data["result"].keys()) == { - "result", - "insight", - "filters", - } - - self.assertEqual(result_data["result"]["control"], 2) - assert "test" not in result_data["result"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/secondary_results?id=1") - self.assertEqual(200, response.status_code) - - response_data = response.json() - result_data = response_data["result"] - - self.assertEqual(len(response_data["result"].items()), 3) - - assert set(response_data["result"].keys()) == { - "result", - "insight", - "filters", - } - - assert "control" not in result_data["result"] - - self.assertEqual(result_data["result"]["test"], round(1 / 3, 3)) diff --git a/ee/clickhouse/views/test/test_clickhouse_experiments.py b/ee/clickhouse/views/test/test_clickhouse_experiments.py deleted file mode 100644 index 84b85fbb5d4..00000000000 --- a/ee/clickhouse/views/test/test_clickhouse_experiments.py +++ /dev/null @@ -1,4281 +0,0 @@ -from datetime import datetime, timedelta, UTC -from django.core.cache import cache -from flaky import flaky -from rest_framework import status - -from ee.api.test.base import APILicensedTest -from dateutil import parser -from posthog.constants import ExperimentSignificanceCode -from posthog.models.action.action import Action -from posthog.models.cohort.cohort import Cohort -from posthog.models.experiment import Experiment -from posthog.models.feature_flag import FeatureFlag, get_feature_flags_for_team_in_cache -from posthog.test.base import ( - ClickhouseTestMixin, - _create_event, - _create_person, - flush_persons_and_events, - snapshot_clickhouse_insert_cohortpeople_queries, - snapshot_clickhouse_queries, - FuzzyInt, -) -from posthog.test.test_journeys import journeys_for - - -class TestExperimentCRUD(APILicensedTest): - # List experiments - def test_can_list_experiments(self): - response = self.client.get(f"/api/projects/{self.team.id}/experiments/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_getting_experiments_is_not_nplus1(self) -> None: - self.client.post( - f"/api/projects/{self.team.id}/experiments/", - data={ - "name": "Test Experiment", - "feature_flag_key": f"flag_0", - "filters": {"events": [{"order": 0, "id": "$pageview"}]}, - "start_date": "2021-12-01T10:23", - "parameters": None, - }, - format="json", - ).json() - - self.client.post( - f"/api/projects/{self.team.id}/experiments/", - data={ - "name": "Test Experiment", - "feature_flag_key": f"exp_flag_000", - "filters": {"events": [{"order": 0, "id": "$pageview"}]}, - "start_date": "2021-12-01T10:23", - "end_date": "2021-12-01T10:23", - "archived": True, - "parameters": None, - }, - format="json", - ).json() - - with self.assertNumQueries(FuzzyInt(8, 9)): - response = self.client.get(f"/api/projects/{self.team.id}/experiments") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - for i in range(1, 5): - self.client.post( - f"/api/projects/{self.team.id}/experiments/", - data={ - "name": "Test Experiment", - "feature_flag_key": f"flag_{i}", - "filters": {"events": [{"order": 0, "id": "$pageview"}]}, - "start_date": "2021-12-01T10:23", - "parameters": None, - }, - format="json", - ).json() - - with self.assertNumQueries(FuzzyInt(8, 9)): - response = self.client.get(f"/api/projects/{self.team.id}/experiments") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_creating_updating_basic_experiment(self): - ff_key = "a-b-tests" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2021-12-01T10:23", - "end_date": None, - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["name"], "Test Experiment") - self.assertEqual(response.json()["feature_flag_key"], ff_key) - - created_ff = FeatureFlag.objects.get(key=ff_key) - - self.assertEqual(created_ff.key, ff_key) - self.assertEqual(created_ff.filters["multivariate"]["variants"][0]["key"], "control") - self.assertEqual(created_ff.filters["multivariate"]["variants"][1]["key"], "test") - self.assertEqual(created_ff.filters["groups"][0]["properties"], []) - - id = response.json()["id"] - end_date = "2021-12-10T00:00" - - # Now update - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - {"description": "Bazinga", "end_date": end_date}, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - experiment = Experiment.objects.get(pk=id) - self.assertEqual(experiment.description, "Bazinga") - self.assertEqual(experiment.end_date.strftime("%Y-%m-%dT%H:%M"), end_date) - - def test_adding_behavioral_cohort_filter_to_experiment_fails(self): - cohort = Cohort.objects.create( - team=self.team, - filters={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "event_type": "events", - "time_value": 2, - "time_interval": "week", - "value": "performed_event_first_time", - "type": "behavioral", - }, - ], - } - }, - name="cohort_behavioral", - ) - ff_key = "a-b-tests" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2021-12-01T10:23", - "end_date": None, - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - id = response.json()["id"] - - # Now update - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - {"filters": {"properties": [{"key": "id", "value": cohort.pk, "type": "cohort"}]}}, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["type"], "validation_error") - self.assertEqual( - response.json()["detail"], - "Experiments do not support global filter properties", - ) - - def test_invalid_create(self): - # Draft experiment - ff_key = "a-b-tests" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": None, # invalid - "description": "", - "start_date": None, - "end_date": None, - "feature_flag_key": ff_key, - "parameters": {}, - "filters": {}, # also invalid - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "This field may not be null.") - - ff_key = "a-b-tests" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "None", - "description": "", - "start_date": None, - "end_date": None, - "feature_flag_key": ff_key, - "parameters": {}, - "filters": {}, # still invalid - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Filters are required to create an Experiment") - - def test_invalid_update(self): - # Draft experiment - ff_key = "a-b-tests" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": None, - "end_date": None, - "feature_flag_key": ff_key, - "parameters": {}, - "filters": {"events": []}, - }, - ) - - id = response.json()["id"] - - # Now update - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - { - "description": "Bazinga", - "filters": {}, - "feature_flag_key": "new_key", - }, # invalid - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json()["detail"], - "Can't update keys: get_feature_flag_key on Experiment", - ) - - def test_cant_reuse_existing_feature_flag(self): - ff_key = "a-b-test" - FeatureFlag.objects.create( - team=self.team, - rollout_percentage=50, - name="Beta feature", - key=ff_key, - created_by=self.user, - ) - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2021-12-01T10:23", - "end_date": None, - "feature_flag_key": ff_key, - "parameters": None, - "filters": {"events": []}, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "There is already a feature flag with this key.") - - def test_draft_experiment_doesnt_have_FF_active(self): - # Draft experiment - ff_key = "a-b-tests" - self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": None, - "end_date": None, - "feature_flag_key": ff_key, - "parameters": {}, - "filters": {"events": []}, - }, - ) - - created_ff = FeatureFlag.objects.get(key=ff_key) - self.assertEqual(created_ff.key, ff_key) - self.assertFalse(created_ff.active) - - def test_draft_experiment_doesnt_have_FF_active_even_after_updates(self): - # Draft experiment - ff_key = "a-b-tests" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": None, - "end_date": None, - "feature_flag_key": ff_key, - "parameters": {}, - "filters": {"events": []}, - }, - ) - - id = response.json()["id"] - - created_ff = FeatureFlag.objects.get(key=ff_key) - self.assertEqual(created_ff.key, ff_key) - self.assertFalse(created_ff.active) - - # Now update - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - {"description": "Bazinga", "filters": {}}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - created_ff = FeatureFlag.objects.get(key=ff_key) - self.assertEqual(created_ff.key, ff_key) - self.assertFalse(created_ff.active) # didn't change to enabled while still draft - - # Now launch experiment - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - {"start_date": "2021-12-01T10:23"}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - created_ff = FeatureFlag.objects.get(key=ff_key) - self.assertEqual(created_ff.key, ff_key) - self.assertTrue(created_ff.active) - - def test_launching_draft_experiment_activates_FF(self): - # Draft experiment - ff_key = "a-b-tests" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": None, - "end_date": None, - "feature_flag_key": ff_key, - "parameters": {}, - "filters": {"events": []}, - }, - ) - - id = response.json()["id"] - created_ff = FeatureFlag.objects.get(key=ff_key) - self.assertEqual(created_ff.key, ff_key) - self.assertFalse(created_ff.active) - - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - {"description": "Bazinga", "start_date": "2021-12-01T10:23"}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - updated_ff = FeatureFlag.objects.get(key=ff_key) - self.assertTrue(updated_ff.active) - - def test_create_multivariate_experiment_can_update_variants_in_draft(self): - ff_key = "a-b-test" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "end_date": None, - "feature_flag_key": ff_key, - "parameters": { - "feature_flag_variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 33, - }, - { - "key": "test_1", - "name": "Test Variant", - "rollout_percentage": 33, - }, - { - "key": "test_2", - "name": "Test Variant", - "rollout_percentage": 34, - }, - ] - }, - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["name"], "Test Experiment") - self.assertEqual(response.json()["feature_flag_key"], ff_key) - - created_ff = FeatureFlag.objects.get(key=ff_key) - - self.assertEqual(created_ff.key, ff_key) - self.assertEqual(created_ff.active, False) - self.assertEqual(created_ff.filters["multivariate"]["variants"][0]["key"], "control") - self.assertEqual(created_ff.filters["multivariate"]["variants"][1]["key"], "test_1") - self.assertEqual(created_ff.filters["multivariate"]["variants"][2]["key"], "test_2") - self.assertEqual(created_ff.filters["groups"][0]["properties"], []) - - id = response.json()["id"] - - experiment = Experiment.objects.get(id=response.json()["id"]) - self.assertTrue(experiment.is_draft) - # Now try updating FF - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - { - "description": "Bazinga", - "parameters": { - "feature_flag_variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 33, - }, - { - "key": "test_1", - "name": "Test Variant", - "rollout_percentage": 33, - }, - { - "key": "test_2", - "name": "Test Variant", - "rollout_percentage": 24, - }, - { - "key": "test_3", - "name": "Test Variant", - "rollout_percentage": 10, - }, - ] - }, - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - created_ff = FeatureFlag.objects.get(key=ff_key) - - self.assertEqual(created_ff.key, ff_key) - self.assertEqual(created_ff.active, False) - self.assertEqual(created_ff.filters["multivariate"]["variants"][0]["key"], "control") - self.assertEqual(created_ff.filters["multivariate"]["variants"][3]["key"], "test_3") - - def test_create_multivariate_experiment(self): - ff_key = "a-b-test" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2021-12-01T10:23", - "end_date": None, - "feature_flag_key": ff_key, - "parameters": { - "feature_flag_variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 33, - }, - { - "key": "test_1", - "name": "Test Variant", - "rollout_percentage": 33, - }, - { - "key": "test_2", - "name": "Test Variant", - "rollout_percentage": 34, - }, - ] - }, - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["name"], "Test Experiment") - self.assertEqual(response.json()["feature_flag_key"], ff_key) - - created_ff = FeatureFlag.objects.get(key=ff_key) - - self.assertEqual(created_ff.key, ff_key) - self.assertEqual(created_ff.active, True) - self.assertEqual(created_ff.filters["multivariate"]["variants"][0]["key"], "control") - self.assertEqual(created_ff.filters["multivariate"]["variants"][1]["key"], "test_1") - self.assertEqual(created_ff.filters["multivariate"]["variants"][2]["key"], "test_2") - self.assertEqual(created_ff.filters["groups"][0]["properties"], []) - - id = response.json()["id"] - - experiment = Experiment.objects.get(id=response.json()["id"]) - self.assertFalse(experiment.is_draft) - # Now try updating FF - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - { - "description": "Bazinga", - "parameters": {"feature_flag_variants": [{"key": "control", "name": "X", "rollout_percentage": 33}]}, - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json()["detail"], - "Can't update feature_flag_variants on Experiment", - ) - - # Allow changing FF rollout %s - created_ff = FeatureFlag.objects.get(key=ff_key) - created_ff.filters = { - **created_ff.filters, - "multivariate": { - "variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 35, - }, - {"key": "test_1", "name": "Test Variant", "rollout_percentage": 33}, - {"key": "test_2", "name": "Test Variant", "rollout_percentage": 32}, - ] - }, - } - created_ff.save() - - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - { - "description": "Bazinga 222", - "parameters": { - "feature_flag_variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 33, - }, - { - "key": "test_1", - "name": "Test Variant", - "rollout_percentage": 33, - }, - { - "key": "test_2", - "name": "Test Variant", - "rollout_percentage": 34, - }, - ] - }, - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["parameters"]["feature_flag_variants"][0]["key"], "control") - self.assertEqual(response.json()["description"], "Bazinga 222") - created_ff = FeatureFlag.objects.get(key=ff_key) - - self.assertEqual(created_ff.key, ff_key) - self.assertEqual(created_ff.active, True) - self.assertEqual(created_ff.filters["multivariate"]["variants"][0]["key"], "control") - self.assertEqual(created_ff.filters["multivariate"]["variants"][0]["rollout_percentage"], 35) - self.assertEqual(created_ff.filters["multivariate"]["variants"][1]["key"], "test_1") - self.assertEqual(created_ff.filters["multivariate"]["variants"][1]["rollout_percentage"], 33) - self.assertEqual(created_ff.filters["multivariate"]["variants"][2]["key"], "test_2") - self.assertEqual(created_ff.filters["multivariate"]["variants"][2]["rollout_percentage"], 32) - - # Now try changing FF keys - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - { - "description": "Bazinga", - "parameters": { - "feature_flag_variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 33, - }, - { - "key": "test", - "name": "Test Variant", - "rollout_percentage": 33, - }, - { - "key": "test2", - "name": "Test Variant", - "rollout_percentage": 34, - }, - ] - }, - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json()["detail"], - "Can't update feature_flag_variants on Experiment", - ) - - # Now try updating other parameter keys - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - {"description": "Bazinga", "parameters": {"recommended_sample_size": 1500}}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["parameters"]["recommended_sample_size"], 1500) - - def test_creating_invalid_multivariate_experiment_no_control(self): - ff_key = "a-b-test" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2021-12-01T10:23", - "end_date": None, - "feature_flag_key": ff_key, - "parameters": { - "feature_flag_variants": [ - # no control - { - "key": "test_0", - "name": "Control Group", - "rollout_percentage": 33, - }, - { - "key": "test_1", - "name": "Test Variant", - "rollout_percentage": 33, - }, - { - "key": "test_2", - "name": "Test Variant", - "rollout_percentage": 33, - }, - ] - }, - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json()["detail"], - "Feature flag variants must contain a control variant", - ) - - def test_deleting_experiment_soft_deletes_feature_flag(self): - ff_key = "a-b-tests" - data = { - "name": "Test Experiment", - "description": "", - "start_date": "2021-12-01T10:23", - "end_date": None, - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - } - response = self.client.post(f"/api/projects/{self.team.id}/experiments/", data) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["name"], "Test Experiment") - self.assertEqual(response.json()["feature_flag_key"], ff_key) - - created_ff = FeatureFlag.objects.get(key=ff_key) - - id = response.json()["id"] - - # Now delete the experiment - response = self.client.delete(f"/api/projects/{self.team.id}/experiments/{id}") - - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - - with self.assertRaises(Experiment.DoesNotExist): - Experiment.objects.get(pk=id) - - # soft deleted - self.assertEqual(FeatureFlag.objects.get(pk=created_ff.id).deleted, True) - - # can recreate new experiment with same FF key - response = self.client.post(f"/api/projects/{self.team.id}/experiments/", data) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - def test_soft_deleting_feature_flag_does_not_delete_experiment(self): - ff_key = "a-b-tests" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2021-12-01T10:23", - "end_date": None, - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["name"], "Test Experiment") - self.assertEqual(response.json()["feature_flag_key"], ff_key) - - created_ff = FeatureFlag.objects.get(key=ff_key) - - id = response.json()["id"] - - # Now delete the feature flag - response = self.client.patch( - f"/api/projects/{self.team.id}/feature_flags/{created_ff.pk}/", - {"deleted": True}, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - feature_flag_response = self.client.get(f"/api/projects/{self.team.id}/feature_flags/{created_ff.pk}/") - self.assertEqual(feature_flag_response.json().get("deleted"), True) - - self.assertIsNotNone(Experiment.objects.get(pk=id)) - - def test_cant_add_global_properties_to_new_experiment(self): - ff_key = "a-b-tests" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": None, - "end_date": None, - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [ - { - "key": "industry", - "type": "group", - "value": ["technology"], - "operator": "exact", - "group_type_index": 1, - } - ], - "aggregation_group_type_index": 1, - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json()["detail"], - "Experiments do not support global filter properties", - ) - - def test_creating_updating_experiment_with_group_aggregation(self): - ff_key = "a-b-tests" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": None, - "end_date": None, - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - "aggregation_group_type_index": 1, - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["name"], "Test Experiment") - self.assertEqual(response.json()["feature_flag_key"], ff_key) - - created_ff = FeatureFlag.objects.get(key=ff_key) - - self.assertEqual(created_ff.key, ff_key) - self.assertEqual(created_ff.filters["multivariate"]["variants"][0]["key"], "control") - self.assertEqual(created_ff.filters["multivariate"]["variants"][1]["key"], "test") - self.assertEqual(created_ff.filters["groups"][0]["properties"], []) - self.assertTrue(created_ff.filters["aggregation_group_type_index"] is None) - - id = response.json()["id"] - - # Now update group type index on filter - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - { - "description": "Bazinga", - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - "aggregation_group_type_index": 0, - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - experiment = Experiment.objects.get(pk=id) - self.assertEqual(experiment.description, "Bazinga") - - created_ff = FeatureFlag.objects.get(key=ff_key) - self.assertEqual(created_ff.key, ff_key) - self.assertFalse(created_ff.active) - self.assertEqual(created_ff.filters["multivariate"]["variants"][0]["key"], "control") - self.assertEqual(created_ff.filters["multivariate"]["variants"][1]["key"], "test") - self.assertEqual(created_ff.filters["groups"][0]["properties"], []) - self.assertTrue(created_ff.filters["aggregation_group_type_index"] is None) - - # Now remove group type index - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - { - "description": "Bazinga", - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - # "aggregation_group_type_index": None, # removed key - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - experiment = Experiment.objects.get(pk=id) - self.assertEqual(experiment.description, "Bazinga") - - created_ff = FeatureFlag.objects.get(key=ff_key) - self.assertEqual(created_ff.key, ff_key) - self.assertFalse(created_ff.active) - self.assertEqual(created_ff.filters["multivariate"]["variants"][0]["key"], "control") - self.assertEqual(created_ff.filters["multivariate"]["variants"][1]["key"], "test") - self.assertEqual(created_ff.filters["groups"][0]["properties"], []) - self.assertTrue(created_ff.filters["aggregation_group_type_index"] is None) - - def test_creating_experiment_with_group_aggregation_parameter(self): - ff_key = "a-b-tests" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": None, - "end_date": None, - "feature_flag_key": ff_key, - "parameters": { - "aggregation_group_type_index": 0, - }, - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["name"], "Test Experiment") - self.assertEqual(response.json()["feature_flag_key"], ff_key) - - created_ff = FeatureFlag.objects.get(key=ff_key) - - self.assertEqual(created_ff.key, ff_key) - self.assertEqual(created_ff.filters["multivariate"]["variants"][0]["key"], "control") - self.assertEqual(created_ff.filters["multivariate"]["variants"][1]["key"], "test") - self.assertEqual(created_ff.filters["groups"][0]["properties"], []) - self.assertEqual(created_ff.filters["aggregation_group_type_index"], 0) - - id = response.json()["id"] - - # Now update group type index on filter - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - { - "description": "Bazinga", - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - "aggregation_group_type_index": 1, - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - experiment = Experiment.objects.get(pk=id) - self.assertEqual(experiment.description, "Bazinga") - - created_ff = FeatureFlag.objects.get(key=ff_key) - self.assertEqual(created_ff.key, ff_key) - self.assertFalse(created_ff.active) - self.assertEqual(created_ff.filters["multivariate"]["variants"][0]["key"], "control") - self.assertEqual(created_ff.filters["multivariate"]["variants"][1]["key"], "test") - self.assertEqual(created_ff.filters["groups"][0]["properties"], []) - self.assertEqual(created_ff.filters["aggregation_group_type_index"], 0) - - def test_used_in_experiment_is_populated_correctly_for_feature_flag_list(self) -> None: - ff_key = "a-b-test" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2021-12-01T10:23", - "end_date": None, - "feature_flag_key": ff_key, - "parameters": { - "feature_flag_variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 33, - }, - { - "key": "test_1", - "name": "Test Variant", - "rollout_percentage": 33, - }, - { - "key": "test_2", - "name": "Test Variant", - "rollout_percentage": 34, - }, - ] - }, - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["name"], "Test Experiment") - self.assertEqual(response.json()["feature_flag_key"], ff_key) - - created_experiment = response.json()["id"] - - # add another random feature flag - self.client.post( - f"/api/projects/{self.team.id}/feature_flags/", - data={ - "name": f"flag", - "key": f"flag_0", - "filters": {"groups": [{"rollout_percentage": 5}]}, - }, - format="json", - ).json() - - # TODO: Make sure permission bool doesn't cause n + 1 - with self.assertNumQueries(12): - response = self.client.get(f"/api/projects/{self.team.id}/feature_flags") - self.assertEqual(response.status_code, status.HTTP_200_OK) - result = response.json() - - self.assertEqual(result["count"], 2) - - self.assertCountEqual( - [(res["key"], res["experiment_set"]) for res in result["results"]], - [("flag_0", []), (ff_key, [created_experiment])], - ) - - def test_create_experiment_updates_feature_flag_cache(self): - cache.clear() - - initial_cached_flags = get_feature_flags_for_team_in_cache(self.team.pk) - self.assertIsNone(initial_cached_flags) - - ff_key = "a-b-test" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": None, - "end_date": None, - "feature_flag_key": ff_key, - "parameters": { - "feature_flag_variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 33, - }, - { - "key": "test_1", - "name": "Test Variant", - "rollout_percentage": 33, - }, - { - "key": "test_2", - "name": "Test Variant", - "rollout_percentage": 34, - }, - ] - }, - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["name"], "Test Experiment") - self.assertEqual(response.json()["feature_flag_key"], ff_key) - - # save was called, but no flags saved because experiment is in draft mode, so flag is not active - cached_flags = get_feature_flags_for_team_in_cache(self.team.pk) - assert cached_flags is not None - self.assertEqual(0, len(cached_flags)) - - id = response.json()["id"] - - # launch experiment - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - { - "start_date": "2021-12-01T10:23", - }, - ) - - cached_flags = get_feature_flags_for_team_in_cache(self.team.pk) - assert cached_flags is not None - self.assertEqual(1, len(cached_flags)) - self.assertEqual(cached_flags[0].key, ff_key) - self.assertEqual( - cached_flags[0].filters, - { - "groups": [ - { - "properties": [], - "rollout_percentage": 100, - } - ], - "multivariate": { - "variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 33, - }, - { - "key": "test_1", - "name": "Test Variant", - "rollout_percentage": 33, - }, - { - "key": "test_2", - "name": "Test Variant", - "rollout_percentage": 34, - }, - ] - }, - "aggregation_group_type_index": None, - }, - ) - - # Now try updating FF - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - { - "description": "Bazinga", - "parameters": {"feature_flag_variants": [{"key": "control", "name": "X", "rollout_percentage": 33}]}, - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json()["detail"], - "Can't update feature_flag_variants on Experiment", - ) - - # ensure cache doesn't change either - cached_flags = get_feature_flags_for_team_in_cache(self.team.pk) - assert cached_flags is not None - self.assertEqual(1, len(cached_flags)) - self.assertEqual(cached_flags[0].key, ff_key) - self.assertEqual( - cached_flags[0].filters, - { - "groups": [ - { - "properties": [], - "rollout_percentage": 100, - } - ], - "multivariate": { - "variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 33, - }, - { - "key": "test_1", - "name": "Test Variant", - "rollout_percentage": 33, - }, - { - "key": "test_2", - "name": "Test Variant", - "rollout_percentage": 34, - }, - ] - }, - "aggregation_group_type_index": None, - }, - ) - - # Now try changing FF rollout %s - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}", - { - "description": "Bazinga", - "parameters": { - "feature_flag_variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 34, - }, - { - "key": "test_1", - "name": "Test Variant", - "rollout_percentage": 33, - }, - { - "key": "test_2", - "name": "Test Variant", - "rollout_percentage": 32, - }, - ] - }, - }, - ) - # changing variants isn't really supported by experiments anymore, need to do it directly - # on the FF - self.assertEqual(response.status_code, status.HTTP_200_OK) - - # ensure cache doesn't change either - cached_flags = get_feature_flags_for_team_in_cache(self.team.pk) - assert cached_flags is not None - self.assertEqual(1, len(cached_flags)) - self.assertEqual(cached_flags[0].key, ff_key) - self.assertEqual( - cached_flags[0].filters, - { - "groups": [ - { - "properties": [], - "rollout_percentage": 100, - } - ], - "multivariate": { - "variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 33, - }, - { - "key": "test_1", - "name": "Test Variant", - "rollout_percentage": 33, - }, - { - "key": "test_2", - "name": "Test Variant", - "rollout_percentage": 34, - }, - ] - }, - "aggregation_group_type_index": None, - }, - ) - - -class TestExperimentAuxiliaryEndpoints(ClickhouseTestMixin, APILicensedTest): - def _generate_experiment(self, start_date="2024-01-01T10:23", extra_parameters=None): - ff_key = "a-b-test" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": start_date, - "end_date": None, - "feature_flag_key": ff_key, - "parameters": { - "feature_flag_variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 33, - }, - { - "key": "test_1", - "name": "Test Variant", - "rollout_percentage": 33, - }, - { - "key": "test_2", - "name": "Test Variant", - "rollout_percentage": 34, - }, - ], - **(extra_parameters or {}), - }, - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["name"], "Test Experiment") - self.assertEqual(response.json()["feature_flag_key"], ff_key) - return response - - def test_create_exposure_cohort_for_experiment(self): - response = self._generate_experiment("2024-01-01T10:23") - - created_experiment = response.json()["id"] - - journeys_for( - { - "person1": [ - { - "event": "$feature_flag_called", - "timestamp": "2024-01-02", - "properties": {"$feature_flag": "a-b-test", "$feature_flag_response": "control"}, - }, - { - "event": "$feature_flag_called", - "timestamp": "2024-01-03", - "properties": {"$feature_flag": "a-b-test", "$feature_flag_response": "control"}, - }, - ], - "person2": [ - { - "event": "$feature_flag_called", - "timestamp": "2024-01-02", - "properties": {"$feature_flag": "a-b-test", "$feature_flag_response": "test_1"}, - }, - ], - "personX": [ - { - "event": "$feature_flag_called", - "timestamp": "2024-01-02", - "properties": {"$feature_flag": "a-b-test2", "$feature_flag_response": "test_1"}, - }, - ], - # out of time range - "person3": [ - { - "event": "$feature_flag_called", - "timestamp": "2023-01-02", - "properties": {"$feature_flag": "a-b-test", "$feature_flag_response": "control"}, - }, - ], - # wrong event - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2024-01-03"}, - {"event": "$pageleave", "timestamp": "2024-01-05"}, - ], - # doesn't have feature value set - "person_out_of_end_date": [ - { - "event": "$feature_flag_called", - "timestamp": "2024-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - }, - self.team, - ) - flush_persons_and_events() - - # now call to make cohort - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/{created_experiment}/create_exposure_cohort_for_experiment/", - {}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - cohort = response.json()["cohort"] - self.assertEqual(cohort["name"], 'Users exposed to experiment "Test Experiment"') - self.assertEqual(cohort["experiment_set"], [created_experiment]) - - cohort_id = cohort["id"] - - while cohort["is_calculating"]: - response = self.client.get(f"/api/projects/{self.team.id}/cohorts/{cohort_id}") - cohort = response.json() - - response = self.client.get(f"/api/projects/{self.team.id}/cohorts/{cohort_id}/persons/?cohort={cohort_id}") - self.assertEqual(response.status_code, 200, response.content) - self.assertEqual(["person1", "person2"], sorted([res["name"] for res in response.json()["results"]])) - - def test_create_exposure_cohort_for_experiment_with_custom_event_exposure(self): - self.maxDiff = None - - cohort_extra = Cohort.objects.create( - team=self.team, - filters={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "value": "http://example.com", - "type": "person", - }, - ], - } - }, - name="cohort_X", - ) - response = self._generate_experiment( - "2024-01-01T10:23", - { - "custom_exposure_filter": { - "events": [ - { - "id": "custom_exposure_event", - "order": 0, - "entity_type": "events", - "properties": [ - {"key": "bonk", "value": "bonk"}, - {"key": "id", "value": cohort_extra.id, "type": "cohort"}, - {"key": "properties.$current_url in ('x', 'y')", "type": "hogql"}, - {"key": "bonk-person", "value": "bonk", "type": "person"}, - ], - } - ], - "filter_test_accounts": False, - } - }, - ) - - created_experiment = response.json()["id"] - - journeys_for( - { - "person1": [ - { - "event": "custom_exposure_event", - "timestamp": "2024-01-02", - "properties": {"$current_url": "x", "bonk": "bonk"}, - }, - ], - "person2": [ - { - "event": "custom_exposure_event", - "timestamp": "2024-01-02", - "properties": {"$current_url": "y", "bonk": "bonk"}, - }, - ], - "person2-no-bonk": [ - { - "event": "custom_exposure_event", - "timestamp": "2024-01-02", - "properties": {"$current_url": "y"}, - }, - ], - "person2-not-in-prop": [ - { - "event": "custom_exposure_event", - "timestamp": "2024-01-02", - "properties": {"$current_url": "yxxxx"}, - }, - ], - "personX": [ - { - "event": "$feature_flag_called", - "timestamp": "2024-01-02", - "properties": {"$feature_flag": "a-b-test2", "$feature_flag_response": "test_1"}, - }, - ], - # out of time range - "person3": [ - { - "event": "custom_exposure_event", - "timestamp": "2023-01-02", - "properties": {"$current_url": "y"}, - }, - ], - # wrong event - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2024-01-03"}, - {"event": "$pageleave", "timestamp": "2024-01-05"}, - ], - }, - self.team, - ) - flush_persons_and_events() - - # now call to make cohort - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/{created_experiment}/create_exposure_cohort_for_experiment/", - {}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - cohort = response.json()["cohort"] - self.assertEqual(cohort["name"], 'Users exposed to experiment "Test Experiment"') - self.assertEqual(cohort["experiment_set"], [created_experiment]) - self.assertEqual( - cohort["filters"], - { - "properties": { - "type": "OR", - "values": [ - { - "type": "OR", - "values": [ - { - "event_filters": [ - {"key": "bonk", "type": "event", "value": "bonk"}, - {"key": "properties.$current_url in ('x', 'y')", "type": "hogql"}, - ], - "event_type": "events", - "explicit_datetime": "2024-01-01T10:23:00+00:00", - "key": "custom_exposure_event", - "negation": False, - "type": "behavioral", - "value": "performed_event", - } - ], - } - ], - } - }, - ) - - cohort_id = cohort["id"] - - while cohort["is_calculating"]: - response = self.client.get(f"/api/projects/{self.team.id}/cohorts/{cohort_id}") - cohort = response.json() - - response = self.client.get(f"/api/projects/{self.team.id}/cohorts/{cohort_id}/persons/?cohort={cohort_id}") - self.assertEqual(response.status_code, 200, response.content) - self.assertEqual(["person1", "person2"], sorted([res["name"] for res in response.json()["results"]])) - - @snapshot_clickhouse_insert_cohortpeople_queries - def test_create_exposure_cohort_for_experiment_with_custom_action_filters_exposure(self): - cohort_extra = Cohort.objects.create( - team=self.team, - filters={ - "properties": { - "type": "AND", - "values": [ - { - "key": "$pageview", - "value": "http://example.com", - "type": "person", - }, - ], - } - }, - name="cohort_X", - ) - cohort_extra.calculate_people_ch(pending_version=1) - - action1 = Action.objects.create( - team=self.team, - name="action1", - steps_json=[ - { - "event": "insight viewed", - "properties": [ - { - "key": "insight", - "type": "event", - "value": ["RETENTION"], - "operator": "exact", - }, - { - "key": "id", - "value": cohort_extra.id, - "type": "cohort", - }, - ], - }, - { - "event": "insight viewed", - "properties": [ - { - "key": "filters_count", - "type": "event", - "value": "1", - "operator": "gt", - } - ], - }, - { - "event": "$autocapture", - "url": "/123", - "url_matching": "regex", - }, - ], - ) - response = self._generate_experiment( - datetime.now() - timedelta(days=5), - { - "custom_exposure_filter": { - "actions": [ - { - "id": str(action1.id), # should support string ids - "order": 0, - "entity_type": "actions", - "properties": [ - {"key": "bonk", "value": "bonk"}, - {"key": "id", "value": cohort_extra.id, "type": "cohort"}, - {"key": "properties.$current_url in ('x', 'y')", "type": "hogql"}, - {"key": "bonk-person", "value": "bonk", "type": "person"}, - ], - } - ], - "filter_test_accounts": False, - } - }, - ) - - created_experiment = response.json()["id"] - - journeys_for( - { - "person1": [ - { - "event": "insight viewed", - "timestamp": datetime.now() - timedelta(days=2), - "properties": {"$current_url": "x", "bonk": "bonk", "filters_count": 2}, - }, - ], - "person2": [ - { - "event": "insight viewed", - "timestamp": datetime.now() - timedelta(days=2), - "properties": { - "$current_url": "y", - "bonk": "bonk", - "insight": "RETENTION", - }, # missing pageview person property - }, - ], - "person2-no-bonk": [ - { - "event": "insight viewed", - "timestamp": datetime.now() - timedelta(days=2), - "properties": {"$current_url": "y", "filters_count": 3}, - }, - ], - "person2-not-in-prop": [ - { - "event": "$autocapture", - "timestamp": datetime.now() - timedelta(days=2), - "properties": { - "$current_url": "https://posthog.com/feedback/1234" - }, # can't match because clashing current_url filters - }, - ], - }, - self.team, - ) - _create_person( - distinct_ids=["1"], - team_id=self.team.pk, - properties={"$pageview": "http://example.com"}, - ) - _create_event( - event="insight viewed", - team=self.team, - distinct_id="1", - properties={"insight": "RETENTION", "$current_url": "x", "bonk": "bonk"}, - timestamp=datetime.now() - timedelta(days=2), - ) - _create_person( - distinct_ids=["2"], - team_id=self.team.pk, - properties={"$pageview": "http://example.com"}, - ) - _create_event( - event="insight viewed", - team=self.team, - distinct_id="2", - properties={"insight": "RETENTION", "$current_url": "x"}, - timestamp=datetime.now() - timedelta(days=2), - ) - flush_persons_and_events() - - # now call to make cohort - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/{created_experiment}/create_exposure_cohort_for_experiment/", - {}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - cohort = response.json()["cohort"] - self.assertEqual(cohort["name"], 'Users exposed to experiment "Test Experiment"') - self.assertEqual(cohort["experiment_set"], [created_experiment]) - - self.maxDiff = None - target_filter = cohort["filters"]["properties"]["values"][0]["values"][0] - self.assertEqual( - target_filter["event_filters"], - [ - {"key": "bonk", "type": "event", "value": "bonk"}, - {"key": "properties.$current_url in ('x', 'y')", "type": "hogql"}, - ], - cohort["filters"], - ) - self.assertEqual( - target_filter["event_type"], - "actions", - ) - self.assertEqual( - target_filter["key"], - action1.id, - ) - self.assertEqual( - target_filter["type"], - "behavioral", - ) - self.assertEqual( - target_filter["value"], - "performed_event", - ) - explicit_datetime = parser.isoparse(target_filter["explicit_datetime"]) - - self.assertTrue( - explicit_datetime <= datetime.now(UTC) - timedelta(days=5) - and explicit_datetime >= datetime.now(UTC) - timedelta(days=5, hours=1) - ) - - cohort_id = cohort["id"] - - while cohort["is_calculating"]: - response = self.client.get(f"/api/projects/{self.team.id}/cohorts/{cohort_id}") - cohort = response.json() - - response = self.client.get(f"/api/projects/{self.team.id}/cohorts/{cohort_id}/persons/?cohort={cohort_id}") - self.assertEqual(response.status_code, 200, response.content) - self.assertEqual(["1", "person1"], sorted([res["name"] for res in response.json()["results"]])) - - def test_create_exposure_cohort_for_experiment_with_invalid_action_filters_exposure(self): - response = self._generate_experiment( - "2024-01-01T10:23", - { - "custom_exposure_filter": { - "actions": [ - { - "id": "oogabooga", - "order": 0, - "entity_type": "actions", - "properties": [ - {"key": "bonk", "value": "bonk"}, - {"key": "properties.$current_url in ('x', 'y')", "type": "hogql"}, - {"key": "bonk-person", "value": "bonk", "type": "person"}, - ], - } - ], - "filter_test_accounts": False, - } - }, - ) - - created_experiment = response.json()["id"] - - # now call to make cohort - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/{created_experiment}/create_exposure_cohort_for_experiment/", - {}, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Invalid action ID") - - def test_create_exposure_cohort_for_experiment_with_draft_experiment(self): - response = self._generate_experiment(None) - - created_experiment = response.json()["id"] - - # now call to make cohort - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/{created_experiment}/create_exposure_cohort_for_experiment/", - {}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Experiment does not have a start date") - - def test_create_exposure_cohort_for_experiment_with_existing_cohort(self): - response = self._generate_experiment() - - created_experiment = response.json()["id"] - - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/{created_experiment}/create_exposure_cohort_for_experiment/", - {}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - # now call to make cohort again - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/{created_experiment}/create_exposure_cohort_for_experiment/", - {}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Experiment already has an exposure cohort") - - -@flaky(max_runs=10, min_passes=1) -class ClickhouseTestFunnelExperimentResults(ClickhouseTestMixin, APILicensedTest): - @snapshot_clickhouse_queries - def test_experiment_flow_with_event_results(self): - journeys_for( - { - "person1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - }, - ], - "person2": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person3": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # doesn't have feature set - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2020-01-03"}, - {"event": "$pageleave", "timestamp": "2020-01-05"}, - ], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-08-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # non-converters with FF - "person4": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test"}, - } - ], - "person5": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - } - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "insight": "funnels", - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - }, - ) - - id = response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - result = sorted(response_data["insight"], key=lambda x: x[0]["breakdown_value"][0]) - - self.assertEqual(result[0][0]["name"], "$pageview") - self.assertEqual(result[0][0]["count"], 2) - self.assertEqual("control", result[0][0]["breakdown_value"][0]) - - self.assertEqual(result[0][1]["name"], "$pageleave") - self.assertEqual(result[0][1]["count"], 2) - self.assertEqual("control", result[0][1]["breakdown_value"][0]) - - self.assertEqual(result[1][0]["name"], "$pageview") - self.assertEqual(result[1][0]["count"], 3) - self.assertEqual("test", result[1][0]["breakdown_value"][0]) - - self.assertEqual(result[1][1]["name"], "$pageleave") - self.assertEqual(result[1][1]["count"], 1) - self.assertEqual("test", result[1][1]["breakdown_value"][0]) - - # Variant with test: Beta(2, 3) and control: Beta(3, 1) distribution - # The variant has very low probability of being better. - self.assertAlmostEqual(response_data["probability"]["test"], 0.114, places=2) - self.assertEqual( - response_data["significance_code"], - ExperimentSignificanceCode.NOT_ENOUGH_EXPOSURE, - ) - self.assertAlmostEqual(response_data["expected_loss"], 1, places=2) - - @snapshot_clickhouse_queries - def test_experiment_flow_with_event_results_with_hogql_aggregation(self): - journeys_for( - { - "person1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": { - "$feature/a-b-test": "test", - "$account_id": "person1", - }, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": { - "$feature/a-b-test": "test", - "$account_id": "person1", - }, - }, - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": { - "$feature/a-b-test": "control", - "$account_id": "person2", - }, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-05", - "properties": { - "$feature/a-b-test": "control", - "$account_id": "person2", - }, - }, - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": { - "$feature/a-b-test": "control", - "$account_id": "person3", - }, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-05", - "properties": { - "$feature/a-b-test": "control", - "$account_id": "person3", - }, - }, - # doesn't have feature set - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$account_id": "person_out_of_control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-05", - "properties": {"$account_id": "person_out_of_control"}, - }, - # non converter - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": { - "$feature/a-b-test": "test", - "$account_id": "person4", - }, - }, - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": { - "$feature/a-b-test": "test", - "$account_id": "person5", - }, - }, - # doesn't have any properties - {"event": "$pageview", "timestamp": "2020-01-03"}, - {"event": "$pageleave", "timestamp": "2020-01-05"}, - ], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-08-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "insight": "funnels", - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - "funnel_aggregate_by_hogql": "properties.$account_id", - }, - }, - ) - - id = response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - result = sorted(response_data["insight"], key=lambda x: x[0]["breakdown_value"][0]) - - self.assertEqual(result[0][0]["name"], "$pageview") - self.assertEqual(result[0][0]["count"], 2) - self.assertEqual("control", result[0][0]["breakdown_value"][0]) - - self.assertEqual(result[0][1]["name"], "$pageleave") - self.assertEqual(result[0][1]["count"], 2) - self.assertEqual("control", result[0][1]["breakdown_value"][0]) - - self.assertEqual(result[1][0]["name"], "$pageview") - self.assertEqual(result[1][0]["count"], 3) - self.assertEqual("test", result[1][0]["breakdown_value"][0]) - - self.assertEqual(result[1][1]["name"], "$pageleave") - self.assertEqual(result[1][1]["count"], 1) - self.assertEqual("test", result[1][1]["breakdown_value"][0]) - - # Variant with test: Beta(2, 3) and control: Beta(3, 1) distribution - # The variant has very low probability of being better. - self.assertAlmostEqual(response_data["probability"]["test"], 0.114, places=2) - self.assertEqual( - response_data["significance_code"], - ExperimentSignificanceCode.NOT_ENOUGH_EXPOSURE, - ) - self.assertAlmostEqual(response_data["expected_loss"], 1, places=2) - - def test_experiment_with_test_account_filters(self): - self.team.test_account_filters = [ - { - "key": "exclude", - "type": "event", - "value": "yes", - "operator": "is_not_set", - } - ] - self.team.save() - - journeys_for( - { - "person1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "exclude": "yes"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test", "exclude": "yes"}, - }, - ], - "person2": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person3": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person3_exclude": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control", "exclude": "yes"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control", "exclude": "yes"}, - }, - ], - # doesn't have feature set - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2020-01-03"}, - {"event": "$pageleave", "timestamp": "2020-01-05"}, - ], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-08-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # non-converters with FF - "person4": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test"}, - } - ], - "person5": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - } - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "filter_test_accounts": True, - "insight": "funnels", - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - }, - ) - - id = response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - result = sorted(response_data["insight"], key=lambda x: x[0]["breakdown_value"][0]) - - self.assertEqual(result[0][0]["name"], "$pageview") - self.assertEqual(result[0][0]["count"], 2) - self.assertEqual("control", result[0][0]["breakdown_value"][0]) - - self.assertEqual(result[0][1]["name"], "$pageleave") - self.assertEqual(result[0][1]["count"], 2) - self.assertEqual("control", result[0][1]["breakdown_value"][0]) - - self.assertEqual(result[1][0]["name"], "$pageview") - self.assertEqual(result[1][0]["count"], 3) - self.assertEqual("test", result[1][0]["breakdown_value"][0]) - - self.assertEqual(result[1][1]["name"], "$pageleave") - self.assertEqual(result[1][1]["count"], 1) - self.assertEqual("test", result[1][1]["breakdown_value"][0]) - - # Variant with test: Beta(2, 3) and control: Beta(3, 1) distribution - # The variant has very low probability of being better. - self.assertAlmostEqual(response_data["probability"]["test"], 0.114, places=2) - self.assertEqual( - response_data["significance_code"], - ExperimentSignificanceCode.NOT_ENOUGH_EXPOSURE, - ) - self.assertAlmostEqual(response_data["expected_loss"], 1, places=2) - - def test_experiment_flow_with_event_results_cached(self): - journeys_for( - { - "person1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - }, - ], - "person2": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person3": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # doesn't have feature set - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2020-01-03"}, - {"event": "$pageleave", "timestamp": "2020-01-05"}, - ], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-08-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # non-converters with FF - "person4": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test"}, - } - ], - "person5": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - } - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - - experiment_payload = { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "insight": "funnels", - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - } - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - experiment_payload, - ) - - id = response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - self.assertEqual(200, response.status_code) - - response_json = response.json() - response_data = response_json["result"] - result = sorted(response_data["insight"], key=lambda x: x[0]["breakdown_value"][0]) - - self.assertEqual(response_json.pop("is_cached"), False) - - self.assertEqual(result[0][0]["name"], "$pageview") - self.assertEqual(result[0][0]["count"], 2) - self.assertEqual("control", result[0][0]["breakdown_value"][0]) - - self.assertEqual(result[0][1]["name"], "$pageleave") - self.assertEqual(result[0][1]["count"], 2) - self.assertEqual("control", result[0][1]["breakdown_value"][0]) - - self.assertEqual(result[1][0]["name"], "$pageview") - self.assertEqual(result[1][0]["count"], 3) - self.assertEqual("test", result[1][0]["breakdown_value"][0]) - - self.assertEqual(result[1][1]["name"], "$pageleave") - self.assertEqual(result[1][1]["count"], 1) - self.assertEqual("test", result[1][1]["breakdown_value"][0]) - - # Variant with test: Beta(2, 3) and control: Beta(3, 1) distribution - # The variant has very low probability of being better. - self.assertAlmostEqual(response_data["probability"]["test"], 0.114, places=2) - self.assertEqual( - response_data["significance_code"], - ExperimentSignificanceCode.NOT_ENOUGH_EXPOSURE, - ) - self.assertAlmostEqual(response_data["expected_loss"], 1, places=2) - - response2 = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - - response2_json = response2.json() - - self.assertEqual(response2_json.pop("is_cached"), True) - self.assertEqual(response2_json["result"], response_data) - - @snapshot_clickhouse_queries - def test_experiment_flow_with_event_results_and_events_out_of_time_range_timezones(self): - journeys_for( - { - "person1": [ - { - "event": "$pageview", - "timestamp": "2020-01-01T13:40:00", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04T13:00:00", - "properties": {"$feature/a-b-test": "test"}, - }, - ], - "person2": [ - { - "event": "$pageview", - "timestamp": "2020-01-03T13:00:00", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-05 13:00:00", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person3": [ - { - "event": "$pageview", - "timestamp": "2020-01-04T13:00:00", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-05T13:00:00", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # non-converters with FF - "person4": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test"}, - } - ], - "person5": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - } - ], - # converted on the same day as end date, but offset by a few minutes. - # experiment ended at 10 AM, UTC+1, so this person should not be included. - "person6": [ - { - "event": "$pageview", - "timestamp": "2020-01-06T09:10:00", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-06T09:25:00", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - }, - self.team, - ) - - self.team.timezone = "Europe/Amsterdam" # GMT+1 - self.team.save() - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "insight": "funnels", - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - }, - ) - - id = response.json()["id"] - - self.client.patch( - f"/api/projects/{self.team.id}/experiments/{id}/", - { - "start_date": "2020-01-01T13:20:21.710000Z", # date is after first event, BUT timezone is GMT+1, so should be included - "end_date": "2020-01-06 09:00", - }, - ) - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - result = sorted(response_data["insight"], key=lambda x: x[0]["breakdown_value"][0]) - - self.assertEqual(result[0][0]["name"], "$pageview") - self.assertEqual(result[0][0]["count"], 2) - self.assertEqual("control", result[0][0]["breakdown_value"][0]) - - self.assertEqual(result[0][1]["name"], "$pageleave") - self.assertEqual(result[0][1]["count"], 2) - self.assertEqual("control", result[0][1]["breakdown_value"][0]) - - self.assertEqual(result[1][0]["name"], "$pageview") - self.assertEqual(result[1][0]["count"], 3) - self.assertEqual("test", result[1][0]["breakdown_value"][0]) - - self.assertEqual(result[1][1]["name"], "$pageleave") - self.assertEqual(result[1][1]["count"], 1) - self.assertEqual("test", result[1][1]["breakdown_value"][0]) - - # Variant with test: Beta(2, 3) and control: Beta(3, 1) distribution - # The variant has very low probability of being better. - self.assertAlmostEqual(response_data["probability"]["test"], 0.114, places=2) - self.assertEqual( - response_data["significance_code"], - ExperimentSignificanceCode.NOT_ENOUGH_EXPOSURE, - ) - self.assertAlmostEqual(response_data["expected_loss"], 1, places=2) - - @snapshot_clickhouse_queries - def test_experiment_flow_with_event_results_for_three_test_variants(self): - journeys_for( - { - "person1_2": [ - # one event having the property is sufficient, since first touch breakdown is the default - {"event": "$pageview", "timestamp": "2020-01-02", "properties": {}}, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test_2"}, - }, - ], - "person1_1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": {}, - }, - ], - "person2_1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test_1"}, - }, - ], - "person1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": {}, - }, - ], - "person2": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person3": [ - {"event": "$pageview", "timestamp": "2020-01-04", "properties": {}}, - { - "event": "$pageleave", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # doesn't have feature set - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2020-01-03"}, - {"event": "$pageleave", "timestamp": "2020-01-05"}, - ], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-08-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - # non-converters with FF - "person4": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test"}, - } - ], - "person5": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "test"}, - } - ], - "person6_1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - } - ], - # converters with unknown flag variant set - "person_unknown_1": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "unknown_1"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "unknown_1"}, - }, - ], - "person_unknown_2": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "unknown_2"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "unknown_2"}, - }, - ], - "person_unknown_3": [ - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "unknown_3"}, - }, - { - "event": "$pageleave", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "unknown_3"}, - }, - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": { - "feature_flag_variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 25, - }, - { - "key": "test_1", - "name": "Test Variant 1", - "rollout_percentage": 25, - }, - { - "key": "test_2", - "name": "Test Variant 2", - "rollout_percentage": 25, - }, - { - "key": "test", - "name": "Test Variant 3", - "rollout_percentage": 25, - }, - ] - }, - "filters": { - "insight": "funnels", - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - }, - ) - - id = response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - result = sorted(response_data["insight"], key=lambda x: x[0]["breakdown_value"][0]) - - self.assertEqual(result[0][0]["name"], "$pageview") - self.assertEqual(result[0][0]["count"], 2) - self.assertEqual("control", result[0][0]["breakdown_value"][0]) - - self.assertEqual(result[0][1]["name"], "$pageleave") - self.assertEqual(result[0][1]["count"], 2) - self.assertEqual("control", result[0][1]["breakdown_value"][0]) - - self.assertEqual(result[1][0]["name"], "$pageview") - self.assertEqual(result[1][0]["count"], 3) - self.assertEqual("test", result[1][0]["breakdown_value"][0]) - - self.assertEqual(result[1][1]["name"], "$pageleave") - self.assertEqual(result[1][1]["count"], 1) - self.assertEqual("test", result[1][1]["breakdown_value"][0]) - - self.assertAlmostEqual(response_data["probability"]["test"], 0.031, places=1) - self.assertAlmostEqual(response_data["probability"]["test_1"], 0.158, places=1) - self.assertAlmostEqual(response_data["probability"]["test_2"], 0.324, places=1) - self.assertAlmostEqual(response_data["probability"]["control"], 0.486, places=1) - self.assertEqual( - response_data["significance_code"], - ExperimentSignificanceCode.NOT_ENOUGH_EXPOSURE, - ) - self.assertAlmostEqual(response_data["expected_loss"], 1, places=2) - - -@flaky(max_runs=10, min_passes=1) -class ClickhouseTestTrendExperimentResults(ClickhouseTestMixin, APILicensedTest): - @snapshot_clickhouse_queries - def test_experiment_flow_with_event_results(self): - self.team.test_account_filters = [ - { - "key": "exclude", - "type": "event", - "value": "yes", - "operator": "is_not_set", - } - ] - self.team.save() - - journeys_for( - { - "person1": [ - # 5 counts, single person - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "exclude": "yes"}, - }, - # exposure measured via $feature_flag_called events - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "test", - }, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-03", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "test", - }, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "test", - "exclude": "yes", - }, - }, - ], - "person2": [ - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - "exclude": "yes", - }, - }, - # 1 exposure, but more absolute counts - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control", "exclude": "yes"}, - }, - ], - "person3": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-03", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - ], - # doesn't have feature set - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2020-01-03"}, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "random", - }, - }, - ], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-08-03", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - creation_response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "insight": "TRENDS", - "events": [{"order": 0, "id": "$pageview"}], - "filter_test_accounts": True, - }, - }, - ) - - id = creation_response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - result = sorted(response_data["insight"], key=lambda x: x["breakdown_value"]) - - self.assertEqual(result[0]["count"], 4) - self.assertEqual("control", result[0]["breakdown_value"]) - - self.assertEqual(result[1]["count"], 5) - self.assertEqual("test", result[1]["breakdown_value"]) - - # Variant with test: Gamma(5, 0.5) and control: Gamma(5, 1) distribution - # The variant has high probability of being better. (effectively Gamma(10,1)) - self.assertAlmostEqual(response_data["probability"]["test"], 0.923, places=2) - self.assertFalse(response_data["significant"]) - - def test_experiment_flow_with_event_results_with_custom_exposure(self): - self.team.test_account_filters = [ - { - "key": "exclude", - "type": "event", - "value": "yes", - "operator": "is_not_set", - } - ] - self.team.save() - - journeys_for( - { - "person1": [ - # 5 counts, single person - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "exclude": "yes"}, - }, - # exposure measured via $feature_flag_called events - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "test", - }, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-03", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "test", - }, - }, - { - "event": "custom_exposure_event", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test", "bonk": "bonk"}, - }, - { - "event": "custom_exposure_event", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test", "bonk": "bonk", "exclude": "yes"}, - }, - { - "event": "custom_exposure_event", - "timestamp": "2020-01-03", - "properties": { - "$feature/a-b-test": "control", - "bonk": "no-bonk", - }, - }, - ], - "person2": [ - { - "event": "custom_exposure_event", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control", "bonk": "bonk"}, - }, - { - "event": "custom_exposure_event", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control", "bonk": "bonk", "exclude": "yes"}, - }, - # 1 exposure, but more absolute counts - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person3": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "custom_exposure_event", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control", "bonk": "bonk"}, - }, - { - "event": "custom_exposure_event", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test", "bonk": "no-bonk"}, - }, - ], - # doesn't have feature set - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2020-01-03"}, - { - "event": "custom_exposure_event", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "random", "bonk": "bonk"}, - }, - { - "event": "custom_exposure_event", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "test", "bonk": "no-bonk"}, - }, - ], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-08-03", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - { - "event": "custom_exposure_event", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "test", "bonk": "bonk"}, - }, - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - creation_response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": { - "custom_exposure_filter": { - "events": [ - { - "id": "custom_exposure_event", - "order": 0, - "properties": [{"key": "bonk", "value": "bonk"}], - } - ], - "filter_test_accounts": True, - } - }, - "filters": { - "insight": "TRENDS", - "events": [{"order": 0, "id": "$pageview"}], - "filter_test_accounts": True, - }, - }, - ) - - id = creation_response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - result = sorted(response_data["insight"], key=lambda x: x["breakdown_value"]) - - self.assertEqual(result[0]["count"], 4) - self.assertEqual("control", result[0]["breakdown_value"]) - - self.assertEqual(result[1]["count"], 5) - self.assertEqual("test", result[1]["breakdown_value"]) - - # Variant with test: Gamma(5, 0.5) and control: Gamma(5, 1) distribution - # The variant has high probability of being better. (effectively Gamma(10,1)) - self.assertAlmostEqual(response_data["probability"]["test"], 0.923, places=2) - self.assertFalse(response_data["significant"]) - - @snapshot_clickhouse_queries - def test_experiment_flow_with_event_results_with_hogql_filter(self): - journeys_for( - { - "person1": [ - # 5 counts, single person - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "hogql": "true"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "hogql": "true"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "hogql": "true"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "hogql": "true"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "hogql": "true"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - # exposure measured via $feature_flag_called events - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "test", - }, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-03", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "test", - }, - }, - ], - "person2": [ - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - # 1 exposure, but more absolute counts - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control", "hogql": "true"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control", "hogql": "true"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control", "hogql": "true"}, - }, - ], - "person3": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control", "hogql": "true"}, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-03", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - ], - # doesn't have feature set - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2020-01-03"}, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "random", - }, - }, - ], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-08-03", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - creation_response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "insight": "TRENDS", - "events": [ - { - "order": 0, - "id": "$pageview", - "properties": [ - { - "key": "properties.hogql ilike 'true'", - "type": "hogql", - "value": None, - } - ], - } - ], - }, - }, - ) - - id = creation_response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - result = sorted(response_data["insight"], key=lambda x: x["breakdown_value"]) - - self.assertEqual(result[0]["count"], 4) - self.assertEqual("control", result[0]["breakdown_value"]) - - self.assertEqual(result[1]["count"], 5) - self.assertEqual("test", result[1]["breakdown_value"]) - - # Variant with test: Gamma(5, 0.5) and control: Gamma(5, 1) distribution - # The variant has high probability of being better. (effectively Gamma(10,1)) - self.assertAlmostEqual(response_data["probability"]["test"], 0.923, places=2) - self.assertFalse(response_data["significant"]) - - @snapshot_clickhouse_queries - def test_experiment_flow_with_event_results_out_of_timerange_timezone(self): - journeys_for( - { - "person1": [ - # 5 counts, single person - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - # exposure measured via $feature_flag_called events - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "test", - }, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-03", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "test", - }, - }, - ], - "person2": [ - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - # 1 exposure, but more absolute counts - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person3": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-03", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - ], - # doesn't have feature set - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2020-01-03"}, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "random", - }, - }, - ], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-08-03", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - ], - # slightly out of time range - "person_t1": [ - { - "event": "$pageview", - "timestamp": "2020-01-01 09:00:00", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-01 08:00:00", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-01 07:00:00", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-01 06:00:00", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-01 06:00:00", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "test", - }, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-01 08:00:00", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "test", - }, - }, - ], - "person_t2": [ - { - "event": "$pageview", - "timestamp": "2020-01-06 15:02:00", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-06 15:02:00", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-06 16:00:00", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - ], - }, - self.team, - ) - - self.team.timezone = "US/Pacific" # GMT -8 - self.team.save() - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - creation_response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T10:10", # 2 PM in GMT-8 is 10 PM in GMT - "end_date": "2020-01-06T15:00", - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "insight": "TRENDS", - "events": [{"order": 0, "id": "$pageview"}], - }, - }, - ) - - id = creation_response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - result = sorted(response_data["insight"], key=lambda x: x["breakdown_value"]) - - self.assertEqual(result[0]["count"], 4) - self.assertEqual("control", result[0]["breakdown_value"]) - - self.assertEqual(result[1]["count"], 5) - self.assertEqual("test", result[1]["breakdown_value"]) - - # Variant with test: Gamma(5, 0.5) and control: Gamma(5, 1) distribution - # The variant has high probability of being better. (effectively Gamma(10,1)) - self.assertAlmostEqual(response_data["probability"]["test"], 0.923, places=2) - self.assertFalse(response_data["significant"]) - - @snapshot_clickhouse_queries - def test_experiment_flow_with_event_results_for_three_test_variants(self): - journeys_for( - { - "person1_2": [ - { - "event": "$pageview1", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_2"}, - } - ], - "person1_1": [ - { - "event": "$pageview1", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - } - ], - "person2_1": [ - { - "event": "$pageview1", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - } - ], - # "person1": [ - # {"event": "$pageview1", "timestamp": "2020-01-02", "properties": {"$feature/a-b-test": "test"},}, - # ], - "person2": [ - { - "event": "$pageview1", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - } - ], - "person3": [ - { - "event": "$pageview1", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - } - ], - "person4": [ - { - "event": "$pageview1", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - } - ], - # doesn't have feature set - "person_out_of_control": [{"event": "$pageview1", "timestamp": "2020-01-03"}], - "person_out_of_end_date": [ - { - "event": "$pageview1", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - } - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": { - "feature_flag_variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 25, - }, - { - "key": "test_1", - "name": "Test Variant 1", - "rollout_percentage": 25, - }, - { - "key": "test_2", - "name": "Test Variant 2", - "rollout_percentage": 25, - }, - { - "key": "test", - "name": "Test Variant 3", - "rollout_percentage": 25, - }, - ] - }, - "filters": { - "insight": "trends", - "events": [{"order": 0, "id": "$pageview1"}], - "properties": [], - }, - }, - ) - - id = response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - result = sorted(response_data["insight"], key=lambda x: x["breakdown_value"]) - - self.assertEqual(result[0]["count"], 3) - self.assertEqual("control", result[0]["breakdown_value"]) - - self.assertEqual(result[1]["count"], 2) - self.assertEqual("test_1", result[1]["breakdown_value"]) - - self.assertEqual(result[2]["count"], 1) - self.assertEqual("test_2", result[2]["breakdown_value"]) - - # test missing from results, since no events - self.assertAlmostEqual(response_data["probability"]["test_1"], 0.299, places=2) - self.assertAlmostEqual(response_data["probability"]["test_2"], 0.119, places=2) - self.assertAlmostEqual(response_data["probability"]["control"], 0.583, places=2) - - def test_experiment_flow_with_event_results_for_two_test_variants_with_varying_exposures(self): - journeys_for( - { - "person1_2": [ - # for count data - { - "event": "$pageview1", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_2"}, - }, - { - "event": "$pageview1", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_2"}, - }, - # for exposure counting (counted as 1 only) - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "test_2", - }, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "test_2", - }, - }, - ], - "person1_1": [ - { - "event": "$pageview1", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "test_1", - }, - }, - ], - "person2_1": [ - { - "event": "$pageview1", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - }, - { - "event": "$pageview1", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test_1"}, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "test_1", - }, - }, - ], - "person2": [ - { - "event": "$pageview1", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview1", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - # 0 exposure shouldn't ideally happen, but it's possible - ], - "person3": [ - { - "event": "$pageview1", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - ], - "person4": [ - { - "event": "$pageview1", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-01-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - ], - # doesn't have feature set - "person_out_of_control": [{"event": "$pageview1", "timestamp": "2020-01-03"}], - "person_out_of_end_date": [ - { - "event": "$pageview1", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$feature_flag_called", - "timestamp": "2020-08-02", - "properties": { - "$feature_flag": "a-b-test", - "$feature_flag_response": "control", - }, - }, - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": { - "feature_flag_variants": [ - { - "key": "control", - "name": "Control Group", - "rollout_percentage": 33, - }, - { - "key": "test_1", - "name": "Test Variant 1", - "rollout_percentage": 33, - }, - { - "key": "test_2", - "name": "Test Variant 2", - "rollout_percentage": 34, - }, - ] - }, - "filters": { - "insight": "trends", - "events": [{"order": 0, "id": "$pageview1"}], - }, - }, - ) - - id = response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - result = sorted(response_data["insight"], key=lambda x: x["breakdown_value"]) - - self.assertEqual(result[0]["count"], 4) - self.assertEqual("control", result[0]["breakdown_value"]) - - self.assertEqual(result[1]["count"], 3) - self.assertEqual("test_1", result[1]["breakdown_value"]) - - self.assertEqual(result[2]["count"], 2) - self.assertEqual("test_2", result[2]["breakdown_value"]) - - # control: Gamma(4, 1) - # test1: Gamma(3, 1) - # test2: Gamma(2, 0.5) - self.assertAlmostEqual(response_data["probability"]["test_1"], 0.177, places=2) - self.assertAlmostEqual(response_data["probability"]["test_2"], 0.488, places=2) - self.assertAlmostEqual(response_data["probability"]["control"], 0.334, places=2) - - def test_experiment_flow_with_avg_count_per_user_event_results(self): - journeys_for( - { - "person1": [ - # 5 counts, single person - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test"}, - }, - ], - "person2": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person3": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - "person4": [ - { - "event": "$pageview", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "test"}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "test"}, - }, - ], - # doesn't have feature set - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2020-01-03"}, - ], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - creation_response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "insight": "TRENDS", - "events": [ - { - "order": 0, - "id": "$pageview", - "math": "avg_count_per_actor", - "name": "$pageview", - } - ], - "properties": [], - }, - }, - ) - - id = creation_response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - result = sorted(response_data["insight"], key=lambda x: x["breakdown_value"]) - - self.assertEqual(result[0]["data"], [0.0, 0.0, 1.0, 1.0, 1.0, 0.0]) - self.assertEqual("control", result[0]["breakdown_value"]) - - self.assertEqual(result[1]["data"], [0.0, 5.0, 0.0, 0.0, 2.0, 0.0]) - self.assertEqual("test", result[1]["breakdown_value"]) - - # Variant with test: Gamma(7, 1) and control: Gamma(4, 1) distribution - # The variant has high probability of being better. (effectively Gamma(10,1)) - self.assertAlmostEqual(response_data["probability"]["test"], 0.805, places=2) - self.assertFalse(response_data["significant"]) - - def test_experiment_flow_with_avg_count_per_property_value_results(self): - journeys_for( - { - "person1": [ - # 5 counts, single person - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "mathable": 1}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "mathable": 1}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "mathable": 3}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "mathable": 3}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "mathable": 100}, - }, - ], - "person2": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control", "mathable": 1}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control", "mathable": 1}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control", "mathable": 1}, - }, - ], - "person3": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control", "mathable": 2}, - }, - ], - "person4": [ - { - "event": "$pageview", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "test", "mathable": 1}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "test", "mathable": 1.5}, - }, - ], - # doesn't have feature set - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2020-01-03"}, - ], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - creation_response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "insight": "TRENDS", - "events": [ - { - "order": 0, - "id": "$pageview", - "math": "max", - "math_property": "mathable", - } - ], - "properties": [], - }, - }, - ) - - id = creation_response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - result = sorted(response_data["insight"], key=lambda x: x["breakdown_value"]) - - self.assertEqual(result[0]["data"], [0.0, 0.0, 1.0, 2.0, 1.0, 0.0]) - self.assertEqual("control", result[0]["breakdown_value"]) - - self.assertEqual(result[1]["data"], [0.0, 100.0, 0.0, 0.0, 1.5, 0.0]) - self.assertEqual("test", result[1]["breakdown_value"]) - - # Variant with test: Gamma(7, 1) and control: Gamma(4, 1) distribution - # The variant has high probability of being better. (effectively Gamma(10,1)) - self.assertAlmostEqual(response_data["probability"]["test"], 0.805, places=2) - self.assertFalse(response_data["significant"]) - - def test_experiment_flow_with_sum_count_per_property_value_results(self): - journeys_for( - { - "person1": [ - # 5 counts, single person - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "mathable": 1}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "mathable": 1}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "mathable": 3}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "mathable": 3}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-02", - "properties": {"$feature/a-b-test": "test", "mathable": 10}, - }, - ], - "person2": [ - { - "event": "$pageview", - "timestamp": "2020-01-03", - "properties": {"$feature/a-b-test": "control", "mathable": 1}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control", "mathable": 1}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "control", "mathable": 1}, - }, - ], - "person3": [ - { - "event": "$pageview", - "timestamp": "2020-01-04", - "properties": {"$feature/a-b-test": "control", "mathable": 2}, - }, - ], - "person4": [ - { - "event": "$pageview", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "test", "mathable": 1}, - }, - { - "event": "$pageview", - "timestamp": "2020-01-05", - "properties": {"$feature/a-b-test": "test", "mathable": 1.5}, - }, - ], - # doesn't have feature set - "person_out_of_control": [ - {"event": "$pageview", "timestamp": "2020-01-03"}, - ], - "person_out_of_end_date": [ - { - "event": "$pageview", - "timestamp": "2020-08-03", - "properties": {"$feature/a-b-test": "control"}, - }, - ], - }, - self.team, - ) - - ff_key = "a-b-test" - # generates the FF which should result in the above events^ - creation_response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2020-01-01T00:00", - "end_date": "2020-01-06T00:00", - "feature_flag_key": ff_key, - "parameters": { - "custom_exposure_filter": { - "events": [ - { - "id": "$pageview", # exposure is total pageviews - "order": 0, - } - ], - } - }, - "filters": { - "insight": "TRENDS", - "events": [ - { - "order": 0, - "id": "$pageview", - "math": "sum", - "math_property": "mathable", - } - ], - "properties": [], - }, - }, - ) - - id = creation_response.json()["id"] - - response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") - self.assertEqual(200, response.status_code) - - response_data = response.json()["result"] - result = sorted(response_data["insight"], key=lambda x: x["breakdown_value"]) - - self.assertEqual(result[0]["data"], [0.0, 0.0, 1.0, 4.0, 5.0, 5.0]) - self.assertEqual("control", result[0]["breakdown_value"]) - - self.assertEqual(result[1]["data"], [0.0, 18.0, 18.0, 18.0, 20.5, 20.5]) - self.assertEqual("test", result[1]["breakdown_value"]) - - # Variant with test: Gamma(7, 1) and control: Gamma(4, 1) distribution - # The variant has high probability of being better. (effectively Gamma(10,1)) - self.assertAlmostEqual(response_data["probability"]["test"], 0.9513, places=2) - self.assertFalse(response_data["significant"]) diff --git a/ee/clickhouse/views/test/test_clickhouse_groups.py b/ee/clickhouse/views/test/test_clickhouse_groups.py deleted file mode 100644 index 10e064095c4..00000000000 --- a/ee/clickhouse/views/test/test_clickhouse_groups.py +++ /dev/null @@ -1,567 +0,0 @@ -from unittest import mock -from uuid import UUID - -from freezegun.api import freeze_time - -from posthog.models import GroupTypeMapping, Person -from posthog.models.group.util import create_group -from posthog.models.organization import Organization -from posthog.models.sharing_configuration import SharingConfiguration -from posthog.models.team.team import Team -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - _create_event, - snapshot_clickhouse_queries, -) - - -class ClickhouseTestGroupsApi(ClickhouseTestMixin, APIBaseTest): - maxDiff = None - - @freeze_time("2021-05-02") - def test_groups_list(self): - with freeze_time("2021-05-01"): - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:5", - properties={"industry": "finance", "name": "Mr. Krabs"}, - ) - with freeze_time("2021-05-02"): - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:6", - properties={"industry": "technology"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:1", - properties={"name": "Plankton"}, - ) - - response_data = self.client.get(f"/api/projects/{self.team.id}/groups?group_type_index=0").json() - self.assertEqual( - response_data, - { - "next": None, - "previous": None, - "results": [ - { - "created_at": "2021-05-02T00:00:00Z", - "group_key": "org:6", - "group_properties": {"industry": "technology"}, - "group_type_index": 0, - }, - { - "created_at": "2021-05-01T00:00:00Z", - "group_key": "org:5", - "group_properties": { - "industry": "finance", - "name": "Mr. Krabs", - }, - "group_type_index": 0, - }, - ], - }, - ) - response_data = self.client.get(f"/api/projects/{self.team.id}/groups?group_type_index=0&search=Krabs").json() - self.assertEqual( - response_data, - { - "next": None, - "previous": None, - "results": [ - { - "created_at": "2021-05-01T00:00:00Z", - "group_key": "org:5", - "group_properties": { - "industry": "finance", - "name": "Mr. Krabs", - }, - "group_type_index": 0, - }, - ], - }, - ) - - response_data = self.client.get(f"/api/projects/{self.team.id}/groups?group_type_index=0&search=org:5").json() - self.assertEqual( - response_data, - { - "next": None, - "previous": None, - "results": [ - { - "created_at": "2021-05-01T00:00:00Z", - "group_key": "org:5", - "group_properties": { - "industry": "finance", - "name": "Mr. Krabs", - }, - "group_type_index": 0, - }, - ], - }, - ) - - @freeze_time("2021-05-02") - def test_groups_list_no_group_type(self): - response_data = self.client.get(f"/api/projects/{self.team.id}/groups/").json() - self.assertEqual( - response_data, - { - "type": "validation_error", - "attr": "group_type_index", - "code": "invalid_input", - "detail": mock.ANY, - }, - ) - - @freeze_time("2021-05-02") - def test_retrieve_group(self): - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="key", - properties={"industry": "finance", "name": "Mr. Krabs"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="foo//bar", - properties={}, - ) - - fail_response = self.client.get(f"/api/projects/{self.team.id}/groups/find?group_type_index=1&group_key=key") - self.assertEqual(fail_response.status_code, 404) - - ok_response_data = self.client.get(f"/api/projects/{self.team.id}/groups/find?group_type_index=0&group_key=key") - self.assertEqual(ok_response_data.status_code, 200) - self.assertEqual( - ok_response_data.json(), - { - "created_at": "2021-05-02T00:00:00Z", - "group_key": "key", - "group_properties": {"industry": "finance", "name": "Mr. Krabs"}, - "group_type_index": 0, - }, - ) - ok_response_data = self.client.get( - f"/api/projects/{self.team.id}/groups/find?group_type_index=1&group_key=foo//bar" - ) - self.assertEqual(ok_response_data.status_code, 200) - self.assertEqual( - ok_response_data.json(), - { - "created_at": "2021-05-02T00:00:00Z", - "group_key": "foo//bar", - "group_properties": {}, - "group_type_index": 1, - }, - ) - - @freeze_time("2021-05-10") - @snapshot_clickhouse_queries - def test_related_groups(self): - self._create_related_groups_data() - - response_data = self.client.get( - f"/api/projects/{self.team.id}/groups/related?id=0::0&group_type_index=0" - ).json() - self.assertEqual( - response_data, - [ - { - "created_at": "2021-05-10T00:00:00Z", - "distinct_ids": ["1", "2"], - "id": "01795392-cc00-0003-7dc7-67a694604d72", - "uuid": "01795392-cc00-0003-7dc7-67a694604d72", - "is_identified": False, - "name": "1", - "properties": {}, - "type": "person", - "matched_recordings": [], - "value_at_data_point": None, - }, - { - "created_at": "2021-05-10T00:00:00Z", - "group_key": "1::2", - "group_type_index": 1, - "id": "1::2", - "properties": {}, - "type": "group", - "matched_recordings": [], - "value_at_data_point": None, - }, - { - "created_at": "2021-05-10T00:00:00Z", - "group_key": "1::3", - "group_type_index": 1, - "id": "1::3", - "properties": {}, - "type": "group", - "matched_recordings": [], - "value_at_data_point": None, - }, - ], - ) - - @freeze_time("2021-05-10") - @snapshot_clickhouse_queries - def test_related_groups_person(self): - uuid = self._create_related_groups_data() - - response_data = self.client.get(f"/api/projects/{self.team.id}/groups/related?id={uuid}").json() - self.assertEqual( - response_data, - [ - { - "created_at": "2021-05-10T00:00:00Z", - "group_key": "0::0", - "group_type_index": 0, - "id": "0::0", - "properties": {}, - "type": "group", - "matched_recordings": [], - "value_at_data_point": None, - }, - { - "created_at": "2021-05-10T00:00:00Z", - "group_key": "0::1", - "group_type_index": 0, - "id": "0::1", - "properties": {}, - "type": "group", - "matched_recordings": [], - "value_at_data_point": None, - }, - { - "created_at": "2021-05-10T00:00:00Z", - "group_key": "1::2", - "group_type_index": 1, - "id": "1::2", - "properties": {}, - "type": "group", - "matched_recordings": [], - "value_at_data_point": None, - }, - { - "created_at": "2021-05-10T00:00:00Z", - "group_key": "1::3", - "group_type_index": 1, - "id": "1::3", - "properties": {}, - "type": "group", - "matched_recordings": [], - "value_at_data_point": None, - }, - ], - ) - - def test_property_definitions(self): - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:5", - properties={"industry": "finance", "name": "Mr. Krabs"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:6", - properties={"industry": "technology"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:1", - properties={"name": "Plankton"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:2", - properties={}, - ) - - response_data = self.client.get(f"/api/projects/{self.team.id}/groups/property_definitions").json() - self.assertEqual( - response_data, - { - "0": [{"name": "industry", "count": 2}, {"name": "name", "count": 1}], - "1": [{"name": "name", "count": 1}], - }, - ) - - def test_property_values(self): - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:5", - properties={"industry": "finance"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:6", - properties={"industry": "technology"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="org:1", - properties={"industry": "finance"}, - ) - response_data = self.client.get( - f"/api/projects/{self.team.id}/groups/property_values/?key=industry&group_type_index=0" - ).json() - self.assertEqual(len(response_data), 2) - self.assertEqual(response_data, [{"name": "finance"}, {"name": "technology"}]) - - def test_empty_property_values(self): - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:5", - properties={"industry": "finance"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:6", - properties={"industry": "technology"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="org:1", - properties={"industry": "finance"}, - ) - response_data = self.client.get( - f"/api/projects/{self.team.id}/groups/property_values/?key=name&group_type_index=0" - ).json() - self.assertEqual(len(response_data), 0) - self.assertEqual(response_data, []) - - def test_update_groups_metadata(self): - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - GroupTypeMapping.objects.create(team=self.team, group_type="playlist", group_type_index=1) - GroupTypeMapping.objects.create(team=self.team, group_type="another", group_type_index=2) - - response_data = self.client.patch( - f"/api/projects/{self.team.id}/groups_types/update_metadata", - [ - {"group_type_index": 0, "name_singular": "organization!"}, - { - "group_type_index": 1, - "group_type": "rename attempt", - "name_plural": "playlists", - }, - ], - ).json() - - self.assertEqual( - response_data, - [ - { - "group_type_index": 0, - "group_type": "organization", - "name_singular": "organization!", - "name_plural": None, - }, - { - "group_type_index": 1, - "group_type": "playlist", - "name_singular": None, - "name_plural": "playlists", - }, - { - "group_type_index": 2, - "group_type": "another", - "name_singular": None, - "name_plural": None, - }, - ], - ) - - def test_list_group_types(self): - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - GroupTypeMapping.objects.create(team=self.team, group_type="playlist", group_type_index=1) - GroupTypeMapping.objects.create(team=self.team, group_type="another", group_type_index=2) - - response_data = self.client.get(f"/api/projects/{self.team.id}/groups_types").json() - - self.assertEqual( - response_data, - [ - { - "group_type_index": 0, - "group_type": "organization", - "name_singular": None, - "name_plural": None, - }, - { - "group_type_index": 1, - "group_type": "playlist", - "name_singular": None, - "name_plural": None, - }, - { - "group_type_index": 2, - "group_type": "another", - "name_singular": None, - "name_plural": None, - }, - ], - ) - - def test_cannot_list_group_types_of_another_org(self): - other_org = Organization.objects.create(name="other org") - other_team = Team.objects.create(organization=other_org, name="other project") - - GroupTypeMapping.objects.create(team=other_team, group_type="organization", group_type_index=0) - GroupTypeMapping.objects.create(team=other_team, group_type="playlist", group_type_index=1) - GroupTypeMapping.objects.create(team=other_team, group_type="another", group_type_index=2) - - response = self.client.get(f"/api/projects/{other_team.id}/groups_types") # No access to this project - - self.assertEqual(response.status_code, 403, response.json()) - self.assertEqual( - response.json(), - self.permission_denied_response("You don't have access to the project."), - ) - - def test_cannot_list_group_types_of_another_org_with_sharing_token(self): - sharing_configuration = SharingConfiguration.objects.create(team=self.team, enabled=True) - - other_org = Organization.objects.create(name="other org") - other_team = Team.objects.create(organization=other_org, name="other project") - - GroupTypeMapping.objects.create(team=other_team, group_type="organization", group_type_index=0) - GroupTypeMapping.objects.create(team=other_team, group_type="playlist", group_type_index=1) - GroupTypeMapping.objects.create(team=other_team, group_type="another", group_type_index=2) - - response = self.client.get( - f"/api/projects/{other_team.id}/groups_types/?sharing_access_token={sharing_configuration.access_token}" - ) - - self.assertEqual(response.status_code, 403, response.json()) - self.assertEqual( - response.json(), - self.permission_denied_response("You do not have permission to perform this action."), - ) - - def test_can_list_group_types_of_another_org_with_sharing_access_token(self): - other_org = Organization.objects.create(name="other org") - other_team = Team.objects.create(organization=other_org, name="other project") - sharing_configuration = SharingConfiguration.objects.create(team=other_team, enabled=True) - - GroupTypeMapping.objects.create(team=other_team, group_type="organization", group_type_index=0) - GroupTypeMapping.objects.create(team=other_team, group_type="playlist", group_type_index=1) - GroupTypeMapping.objects.create(team=other_team, group_type="another", group_type_index=2) - - disabled_response = self.client.get( - f"/api/projects/{other_team.id}/groups_types/?sharing_access_token={sharing_configuration.access_token}" - ).json() - - self.assertEqual( - disabled_response, - [ - { - "group_type_index": 0, - "group_type": "organization", - "name_singular": None, - "name_plural": None, - }, - { - "group_type_index": 1, - "group_type": "playlist", - "name_singular": None, - "name_plural": None, - }, - { - "group_type_index": 2, - "group_type": "another", - "name_singular": None, - "name_plural": None, - }, - ], - ) - - # Disable the config now - sharing_configuration.enabled = False - sharing_configuration.save() - - disabled_response = self.client.get( - f"/api/projects/{other_team.id}/groups_types?sharing_access_token={sharing_configuration.access_token}" - ) - - self.assertEqual(disabled_response.status_code, 403, disabled_response.json()) - self.assertEqual( - disabled_response.json(), - self.unauthenticated_response("Sharing access token is invalid.", "authentication_failed"), - ) - - def _create_related_groups_data(self): - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - GroupTypeMapping.objects.create(team=self.team, group_type="playlist", group_type_index=1) - - uuid = UUID("01795392-cc00-0003-7dc7-67a694604d72") - - Person.objects.create(uuid=uuid, team_id=self.team.pk, distinct_ids=["1", "2"]) - Person.objects.create(team_id=self.team.pk, distinct_ids=["3"]) - Person.objects.create(team_id=self.team.pk, distinct_ids=["4"]) - - create_group(self.team.pk, 0, "0::0") - create_group(self.team.pk, 0, "0::1") - create_group(self.team.pk, 1, "1::2") - create_group(self.team.pk, 1, "1::3") - create_group(self.team.pk, 1, "1::4") - create_group(self.team.pk, 1, "1::5") - - _create_event( - event="$pageview", - team=self.team, - distinct_id="1", - timestamp="2021-05-05 00:00:00", - properties={"$group_0": "0::0", "$group_1": "1::2"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="1", - timestamp="2021-05-05 00:00:00", - properties={"$group_0": "0::0", "$group_1": "1::3"}, - ) - - _create_event( - event="$pageview", - team=self.team, - distinct_id="1", - timestamp="2021-05-05 00:00:00", - properties={"$group_0": "0::1", "$group_1": "1::3"}, - ) - - # Event too old, not counted - _create_event( - event="$pageview", - team=self.team, - distinct_id="1", - timestamp="2000-05-05 00:00:00", - properties={"$group_0": "0::0", "$group_1": "1::4"}, - ) - - # No such group exists in groups table - _create_event( - event="$pageview", - team=self.team, - distinct_id="1", - timestamp="2000-05-05 00:00:00", - properties={"$group_0": "0::0", "$group_1": "no such group"}, - ) - - return uuid diff --git a/ee/clickhouse/views/test/test_clickhouse_path_person.py b/ee/clickhouse/views/test/test_clickhouse_path_person.py deleted file mode 100644 index 48fc8a2475c..00000000000 --- a/ee/clickhouse/views/test/test_clickhouse_path_person.py +++ /dev/null @@ -1,274 +0,0 @@ -import json -import urllib.parse -from unittest.mock import patch - -from django.core.cache import cache -from rest_framework import status - -from posthog.constants import FUNNEL_PATH_AFTER_STEP, INSIGHT_FUNNELS, INSIGHT_PATHS -from posthog.models.cohort import Cohort -from posthog.models.instance_setting import get_instance_setting -from posthog.models.person import Person -from posthog.tasks.calculate_cohort import insert_cohort_from_insight_filter -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - _create_event, - _create_person, -) - - -class TestPathPerson(ClickhouseTestMixin, APIBaseTest): - def _create_sample_data(self, num, delete=False): - for i in range(num): - if delete: - person = Person.objects.create(distinct_ids=[f"user_{i}"], team=self.team) - else: - person = None - _create_person(distinct_ids=[f"user_{i}"], team=self.team) - _create_event( - event="step one", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:00:00", - properties={"$browser": "Chrome"}, - person_id=person.uuid if person else None, - ) - if i % 2 == 0: - _create_event( - event="step two", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:10:00", - properties={"$browser": "Chrome"}, - person_id=person.uuid if person else None, - ) - _create_event( - event="step three", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:20:00", - properties={"$browser": "Chrome"}, - person_id=person.uuid if person else None, - ) - if delete: - person.delete() - - def test_basic_format(self): - self._create_sample_data(5) - request_data = { - "insight": INSIGHT_PATHS, - "filter_test_accounts": "false", - "date_from": "2021-05-01", - "date_to": "2021-05-10", - } - - response = self.client.get("/api/person/path/", data=request_data) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - first_person = j["results"][0]["people"][0] - self.assertEqual(5, len(j["results"][0]["people"])) - self.assertTrue("id" in first_person and "name" in first_person and "distinct_ids" in first_person) - self.assertEqual(5, j["results"][0]["count"]) - - @patch("posthog.tasks.calculate_cohort.insert_cohort_from_insight_filter.delay") - def test_create_paths_cohort(self, _insert_cohort_from_insight_filter): - self._create_sample_data(5) - - params = { - "insight": INSIGHT_PATHS, - "filter_test_accounts": "false", - "date_from": "2021-05-01", - "date_to": "2021-05-10", - } - - response = self.client.post( - f"/api/projects/{self.team.id}/cohorts/?{urllib.parse.urlencode(params)}", - {"name": "test", "is_static": True}, - ).json() - - cohort_id = response["id"] - - _insert_cohort_from_insight_filter.assert_called_once_with( - cohort_id, - { - "insight": "PATHS", - "filter_test_accounts": "false", - "date_from": "2021-05-01", - "date_to": "2021-05-10", - }, - ) - - insert_cohort_from_insight_filter(cohort_id, params) - - cohort = Cohort.objects.get(pk=cohort_id) - people = Person.objects.filter(cohort__id=cohort.pk) - self.assertEqual(cohort.errors_calculating, 0) - self.assertEqual(people.count(), 5) - self.assertEqual(cohort.count, 5) - - def test_basic_format_with_path_start_key_constraints(self): - self._create_sample_data(5) - request_data = { - "insight": INSIGHT_PATHS, - "filter_test_accounts": "false", - "date_from": "2021-05-01", - "date_to": "2021-05-10", - "path_start_key": "2_step two", - } - - response = self.client.get("/api/person/path/", data=request_data) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - first_person = j["results"][0]["people"][0] - self.assertEqual(3, len(j["results"][0]["people"])) - self.assertTrue("id" in first_person and "name" in first_person and "distinct_ids" in first_person) - self.assertEqual(3, j["results"][0]["count"]) - - def test_basic_format_with_start_point_constraints(self): - self._create_sample_data(7) - request_data = { - "insight": INSIGHT_PATHS, - "filter_test_accounts": "false", - "date_from": "2021-05-01", - "date_to": "2021-05-10", - "path_start_key": "1_step two", - "start_point": "step two", - } - - response = self.client.get("/api/person/path/", data=request_data) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - first_person = j["results"][0]["people"][0] - self.assertEqual(4, len(j["results"][0]["people"])) - self.assertTrue("id" in first_person and "name" in first_person and "distinct_ids" in first_person) - self.assertEqual(4, j["results"][0]["count"]) - - def test_basic_pagination(self): - self._create_sample_data(20) - request_data = { - "insight": INSIGHT_PATHS, - "filter_test_accounts": "false", - "date_from": "2021-05-01", - "date_to": "2021-05-10", - "limit": 15, - } - - response = self.client.get("/api/person/path/", data=request_data) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - people = j["results"][0]["people"] - next = j["next"] - - self.assertEqual(15, len(people)) - self.assertNotEqual(None, next) - - response = self.client.get(next) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - people = j["results"][0]["people"] - next = j["next"] - self.assertEqual(5, len(people)) - self.assertEqual(None, j["next"]) - - @patch("posthog.models.person.util.delete_person") - def test_basic_pagination_with_deleted(self, delete_person_patch): - if not get_instance_setting("PERSON_ON_EVENTS_ENABLED"): - return - - cache.clear() - self._create_sample_data(20, delete=True) - request_data = { - "insight": INSIGHT_PATHS, - "filter_test_accounts": "false", - "date_from": "2021-05-01", - "date_to": "2021-05-10", - "limit": 15, - } - - response = self.client.get(f"/api/person/path/", data=request_data) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - people = j["results"][0]["people"] - next = j["next"] - missing_persons = j["missing_persons"] - self.assertEqual(0, len(people)) - self.assertEqual(15, missing_persons) - self.assertIsNotNone(next) - - response = self.client.get(next) - self.assertEqual(response.status_code, status.HTTP_200_OK) - j = response.json() - people = j["results"][0]["people"] - next = j["next"] - missing_persons = j["missing_persons"] - self.assertEqual(0, len(people)) - self.assertEqual(5, missing_persons) - self.assertIsNone(next) - - def test_basic_format_with_funnel_path_post(self): - self._create_sample_data(7) - request_data = { - "insight": INSIGHT_PATHS, - "funnel_paths": FUNNEL_PATH_AFTER_STEP, - "filter_test_accounts": "false", - "date_from": "2021-05-01", - "date_to": "2021-05-07", - "path_start_key": "1_step two", - "path_end_key": "2_step three", - } - - funnel_filter = { - "insight": INSIGHT_FUNNELS, - "interval": "day", - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "funnel_window_interval": 7, - "funnel_window_interval_unit": "day", - "funnel_step": 2, - "events": [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ], - } - - post_response = self.client.post("/api/person/path/", data={**request_data, "funnel_filter": funnel_filter}) - self.assertEqual(post_response.status_code, status.HTTP_200_OK) - post_j = post_response.json() - self.assertEqual(4, len(post_j["results"][0]["people"])) - - def test_basic_format_with_funnel_path_get(self): - self._create_sample_data(7) - request_data = { - "insight": INSIGHT_PATHS, - "funnel_paths": FUNNEL_PATH_AFTER_STEP, - "filter_test_accounts": "false", - "date_from": "2021-05-01", - "date_to": "2021-05-07", - "path_start_key": "1_step two", - "path_end_key": "2_step three", - } - - funnel_filter = { - "insight": INSIGHT_FUNNELS, - "interval": "day", - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "funnel_window_interval": 7, - "funnel_window_interval_unit": "day", - "funnel_step": 2, - "events": [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ], - } - - get_response = self.client.get( - "/api/person/path/", - data={**request_data, "funnel_filter": json.dumps(funnel_filter)}, - ) - self.assertEqual(get_response.status_code, status.HTTP_200_OK) - get_j = get_response.json() - self.assertEqual(4, len(get_j["results"][0]["people"])) diff --git a/ee/clickhouse/views/test/test_clickhouse_paths.py b/ee/clickhouse/views/test/test_clickhouse_paths.py deleted file mode 100644 index 2a9e7f47cf4..00000000000 --- a/ee/clickhouse/views/test/test_clickhouse_paths.py +++ /dev/null @@ -1,299 +0,0 @@ -import json - -from rest_framework import status - -from posthog.constants import FUNNEL_PATH_AFTER_STEP, INSIGHT_FUNNELS, INSIGHT_PATHS -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - _create_event, - _create_person, -) - - -class TestClickhousePaths(ClickhouseTestMixin, APIBaseTest): - def _create_sample_data(self, num, delete=False): - for i in range(num): - person = _create_person(distinct_ids=[f"user_{i}"], team=self.team) - _create_event( - event="step one", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:00:00", - properties={"$browser": "Chrome"}, - ) - if i % 2 == 0: - _create_event( - event="step two", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:10:00", - properties={"$browser": "Chrome"}, - ) - _create_event( - event="step three", - distinct_id=f"user_{i}", - team=self.team, - timestamp="2021-05-01 00:20:00", - properties={"$browser": "Chrome"}, - ) - if delete: - person.delete() - - def test_insight_paths_basic(self): - _create_person(team=self.team, distinct_ids=["person_1"]) - _create_event( - properties={"$current_url": "/"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - ) - _create_event( - properties={"$current_url": "/about"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - ) - - response = self.client.get(f"/api/projects/{self.team.id}/insights/path").json() - self.assertEqual(len(response["result"]), 1) - - def test_insight_paths_basic_exclusions(self): - _create_person(team=self.team, distinct_ids=["person_1"]) - _create_event(distinct_id="person_1", event="first event", team=self.team) - _create_event(distinct_id="person_1", event="second event", team=self.team) - _create_event(distinct_id="person_1", event="third event", team=self.team) - - response = self.client.get( - f"/api/projects/{self.team.id}/insights/path", - data={"exclude_events": '["second event"]'}, - ).json() - self.assertEqual(len(response["result"]), 1) - - def test_backwards_compatible_path_types(self): - _create_person(team=self.team, distinct_ids=["person_1"]) - _create_event( - properties={"$current_url": "/"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - ) - _create_event( - properties={"$current_url": "/about"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - ) - _create_event( - properties={"$current_url": "/something else"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - ) - _create_event( - properties={"$screen_name": "/screen1"}, - distinct_id="person_1", - event="$screen", - team=self.team, - ) - _create_event(distinct_id="person_1", event="custom1", team=self.team) - _create_event(distinct_id="person_1", event="custom2", team=self.team) - response = self.client.get( - f"/api/projects/{self.team.id}/insights/path", - data={"path_type": "$pageview", "insight": "PATHS"}, - ).json() - self.assertEqual(len(response["result"]), 2) - - response = self.client.get( - f"/api/projects/{self.team.id}/insights/path", - data={"path_type": "custom_event", "insight": "PATHS"}, - ).json() - self.assertEqual(len(response["result"]), 1) - response = self.client.get( - f"/api/projects/{self.team.id}/insights/path", - data={"path_type": "$screen", "insight": "PATHS"}, - ).json() - self.assertEqual(len(response["result"]), 0) - - def test_backwards_compatible_start_point(self): - _create_person(team=self.team, distinct_ids=["person_1"]) - _create_event( - properties={"$current_url": "/"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - ) - _create_event( - properties={"$current_url": "/about"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - ) - _create_event( - properties={"$current_url": "/something else"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - ) - _create_event( - properties={"$screen_name": "/screen1"}, - distinct_id="person_1", - event="$screen", - team=self.team, - ) - _create_event( - properties={"$screen_name": "/screen2"}, - distinct_id="person_1", - event="$screen", - team=self.team, - ) - _create_event(distinct_id="person_1", event="custom1", team=self.team) - _create_event(distinct_id="person_1", event="custom2", team=self.team) - response = self.client.get( - f"/api/projects/{self.team.id}/insights/path", - data={ - "path_type": "$pageview", - "insight": "PATHS", - "start_point": "/about", - }, - ).json() - self.assertEqual(len(response["result"]), 1) - - response = self.client.get( - f"/api/projects/{self.team.id}/insights/path", - data={ - "path_type": "custom_event", - "insight": "PATHS", - "start_point": "custom2", - }, - ).json() - self.assertEqual(len(response["result"]), 0) - response = self.client.get( - f"/api/projects/{self.team.id}/insights/path", - data={ - "path_type": "$screen", - "insight": "PATHS", - "start_point": "/screen1", - }, - ).json() - self.assertEqual(len(response["result"]), 1) - - def test_path_groupings(self): - _create_person(team=self.team, distinct_ids=["person_1"]) - _create_event( - properties={"$current_url": "/about_1"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - ) - _create_event( - properties={"$current_url": "/about_2"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - ) - _create_event( - properties={"$current_url": "/something else"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - ) - _create_event( - properties={"$current_url": "/about3"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - ) - _create_event( - properties={"$current_url": "/about4"}, - distinct_id="person_1", - event="$pageview", - team=self.team, - ) - - _create_person(team=self.team, distinct_ids=["person_2"]) - _create_event( - properties={"$current_url": "/about_1"}, - distinct_id="person_2", - event="$pageview", - team=self.team, - ) - _create_event( - properties={"$current_url": "/about_2"}, - distinct_id="person_2", - event="$pageview", - team=self.team, - ) - _create_event( - properties={"$current_url": "/something else"}, - distinct_id="person_2", - event="$pageview", - team=self.team, - ) - _create_event( - properties={"$current_url": "/about3"}, - distinct_id="person_2", - event="$pageview", - team=self.team, - ) - _create_event( - properties={"$current_url": "/about4"}, - distinct_id="person_2", - event="$pageview", - team=self.team, - ) - - response = self.client.get( - f"/api/projects/{self.team.id}/insights/path", - data={"insight": "PATHS", "path_groupings": json.dumps(["/about*"])}, - ).json() - self.assertEqual(len(response["result"]), 2) - - response = self.client.get( - f"/api/projects/{self.team.id}/insights/path", - data={"insight": "PATHS", "path_groupings": json.dumps(["/about_*"])}, - ).json() - self.assertEqual(len(response["result"]), 3) - - def test_funnel_path_post(self): - self._create_sample_data(7) - request_data = { - "insight": INSIGHT_PATHS, - "funnel_paths": FUNNEL_PATH_AFTER_STEP, - "filter_test_accounts": "false", - "date_from": "2021-05-01", - "date_to": "2021-05-07", - } - - funnel_filter = { - "insight": INSIGHT_FUNNELS, - "interval": "day", - "date_from": "2021-05-01 00:00:00", - "date_to": "2021-05-07 00:00:00", - "funnel_window_interval": 7, - "funnel_window_interval_unit": "day", - "funnel_step": 2, - "events": [ - {"id": "step one", "order": 0}, - {"id": "step two", "order": 1}, - {"id": "step three", "order": 2}, - ], - } - - post_response = self.client.post( - f"/api/projects/{self.team.id}/insights/path/", - data={**request_data, "funnel_filter": funnel_filter}, - ) - self.assertEqual(post_response.status_code, status.HTTP_200_OK) - post_j = post_response.json() - self.assertEqual( - post_j["result"], - [ - { - "source": "1_step two", - "target": "2_step three", - "value": 4, - "average_conversion_time": 600000.0, - } - ], - ) diff --git a/ee/clickhouse/views/test/test_clickhouse_retention.py b/ee/clickhouse/views/test/test_clickhouse_retention.py deleted file mode 100644 index 5deff716a26..00000000000 --- a/ee/clickhouse/views/test/test_clickhouse_retention.py +++ /dev/null @@ -1,833 +0,0 @@ -from dataclasses import asdict, dataclass -from typing import Literal, Optional, TypedDict, Union - -from django.test.client import Client - -from ee.clickhouse.views.test.funnel.util import EventPattern -from posthog.api.test.test_organization import create_organization -from posthog.api.test.test_team import create_team -from posthog.api.test.test_user import create_user -from posthog.models.instance_setting import ( - get_instance_setting, - override_instance_config, -) -from posthog.models.person import Person as PersonModel -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - also_test_with_materialized_columns, - snapshot_clickhouse_queries, -) -from posthog.test.test_journeys import create_all_events, update_or_create_person -from posthog.utils import encode_get_request_params - - -class RetentionTests(APIBaseTest, ClickhouseTestMixin): - @snapshot_clickhouse_queries - def test_retention_test_account_filters(self): - organization = create_organization(name="test") - team = create_team(organization=organization) - user = create_user(email="test@posthog.com", password="1234", organization=organization) - - self.client.force_login(user) - - team.test_account_filters = [ - { - "key": "email", - "type": "person", - "value": "posthog.com", - "operator": "not_icontains", - } - ] - team.save() - - update_or_create_person( - distinct_ids=["person 1"], - team_id=team.pk, - properties={"email": "posthog.com"}, - ) - update_or_create_person(distinct_ids=["person 2"], team_id=team.pk) - update_or_create_person(distinct_ids=["person 3"], team_id=team.pk) - - setup_user_activity_by_day( - daily_activity={ - "2020-01-01": { - "person 1": [{"event": "target event"}], - "person 2": [{"event": "target event"}], - }, - "2020-01-02": { - "person 1": [{"event": "target event"}], - "person 3": [{"event": "target event"}], - }, - "2020-01-03": { - "person 1": [{"event": "target event"}], - "person 3": [{"event": "target event"}], - }, - }, - team=team, - ) - - retention = get_retention_ok( - client=self.client, - team_id=team.pk, - request=RetentionRequest( - target_entity={"id": "target event", "type": "events"}, - returning_entity={"id": "target event", "type": "events"}, - date_from="2020-01-01", - total_intervals=2, - date_to="2020-01-02", - period="Day", - retention_type="retention_first_time", - filter_test_accounts="true", - ), - ) - - retention_by_cohort_by_period = get_by_cohort_by_period_for_response(client=self.client, response=retention) - - assert retention_by_cohort_by_period == { - "Day 0": {"1": ["person 2"], "2": []}, - "Day 1": {"1": ["person 3"]}, - } - - @snapshot_clickhouse_queries - def test_retention_aggregation_by_distinct_id_and_retrieve_people(self): - organization = create_organization(name="test") - team = create_team(organization=organization) - user = create_user(email="test@posthog.com", password="1234", organization=organization) - - self.client.force_login(user) - - p1 = update_or_create_person(distinct_ids=["person 1", "another one"], team_id=team.pk) - p2 = update_or_create_person(distinct_ids=["person 2"], team_id=team.pk) - - setup_user_activity_by_day( - daily_activity={ - "2020-01-01": { - "person 1": [{"event": "target event"}], - "another one": [{"event": "target event"}], - }, - "2020-01-02": { - "person 1": [{"event": "target event"}], - "person 2": [{"event": "target event"}], - }, - "2020-01-03": {"another one": [{"event": "target event"}]}, - }, - team=team, - ) - - with override_instance_config("AGGREGATE_BY_DISTINCT_IDS_TEAMS", f"{team.pk}"): - retention = get_retention_ok( - client=self.client, - team_id=team.pk, - request=RetentionRequest( - target_entity={"id": "target event", "type": "events"}, - returning_entity={"id": "target event", "type": "events"}, - date_from="2020-01-01", - total_intervals=3, - date_to="2020-01-03", - period="Day", - retention_type="retention_first_time", - ), - ) - - assert retention["result"][0]["values"][0]["count"] == 2 # person 1 and another one - assert retention["result"][0]["values"][1]["count"] == 1 # person 1 - assert retention["result"][0]["values"][2]["count"] == 1 # another one - - #  person 2 - assert retention["result"][1]["values"][0]["count"] == 1 - assert retention["result"][1]["values"][1]["count"] == 0 - - people_url = retention["result"][0]["values"][0]["people_url"] - people_response = self.client.get(people_url) - assert people_response.status_code == 200 - - people = people_response.json()["result"] - # person1 and another one are the same person - assert len(people) == 1 - assert people[0]["person"]["id"] == str(p1.uuid) - assert people[0]["appearances"] == [1, 1, 1] - - people_url = retention["result"][1]["values"][0]["people_url"] - people_response = self.client.get(people_url) - assert people_response.status_code == 200 - - people = people_response.json()["result"] - assert len(people) == 1 - assert people[0]["person"]["id"] == str(p2.uuid) - assert people[0]["appearances"] == [1, 0, 0] - - def test_people_stable_pagination(self): - organization = create_organization(name="test") - team = create_team(organization=organization) - user = create_user(email="test@posthog.com", password="1234", organization=organization) - - self.client.force_login(user) - - for i in range(15): - update_or_create_person(distinct_ids=[f"person {i}"], team_id=team.pk) - - setup_user_activity_by_day( - daily_activity={ - "2020-01-01": {f"person {i}": [{"event": "target event"}] for i in range(15)}, - "2020-01-02": {f"person {i}": [{"event": "target event"}] for i in range(5)}, - "2020-01-03": {f"person {i}": [{"event": "target event"}] for i in range(10, 15)}, - "2020-01-04": {f"person {i}": [{"event": "target event"}] for i in range(5, 10)}, - "2020-01-05": {f"person {i}": [{"event": "target event"}] for i in range(6)}, - }, - team=team, - ) - - retention = get_retention_ok( - client=self.client, - team_id=team.pk, - request=RetentionRequest( - target_entity={"id": "target event", "type": "events"}, - returning_entity={"id": "target event", "type": "events"}, - date_from="2020-01-01", - total_intervals=5, - date_to="2020-01-05", - period="Day", - retention_type="retention_first_time", - limit=10, - ), - ) - - assert retention["result"][0]["values"][0]["count"] == 15 - - people_url = retention["result"][0]["values"][0]["people_url"] - people_response = self.client.get(people_url) - assert people_response.status_code == 200 - - response_json = people_response.json() - - assert response_json["missing_persons"] == 0 - people = response_json["result"] - assert len(people) == 10 - distinct_ids = [person["person"]["distinct_ids"][0] for person in people] - appearances_count = [sum(person["appearances"]) for person in people] - self.assertEqual(appearances_count, [3, 3, 3, 3, 3, 3, 2, 2, 2, 2]) - # the actor IDs are random, so we can't assert their sequence - # but we can assert that all 3 count distinct IDs should be in this list. - self.assertTrue( - distinct_id in distinct_ids - for distinct_id in [ - "person 4", - "person 3", - "person 1", - "person 2", - "person 0", - "person 5", - ] - ) - - people_url = response_json["next"] - people_response = self.client.get(people_url) - assert people_response.status_code == 200 - - response_json = people_response.json() - - people = response_json["result"] - assert response_json["missing_persons"] == 0 - assert response_json["next"] is None - assert len(people) == 5 - distinct_ids = [person["person"]["distinct_ids"][0] for person in people] - appearances_count = [sum(person["appearances"]) for person in people] - self.assertEqual(appearances_count, [2, 2, 2, 2, 2]) - - def test_deleted_people_show_up_as_missing_persons(self): - if not get_instance_setting("PERSON_ON_EVENTS_ENABLED"): - return - - organization = create_organization(name="test") - team = create_team(organization=organization) - user = create_user(email="test@posthog.com", password="1234", organization=organization) - - self.client.force_login(user) - - persons_to_delete = [] - for i in range(15): - person = PersonModel.objects.create(distinct_ids=[f"person {i}"], team=team) - persons_to_delete.append(person) - - setup_user_activity_by_day( - daily_activity={ - "2020-01-01": {f"person {i}": [{"event": "target event"}] for i in range(15)}, - "2020-01-02": {f"person {i}": [{"event": "target event"}] for i in range(5)}, - "2020-01-03": {f"person {i}": [{"event": "target event"}] for i in range(10, 15)}, - "2020-01-04": {f"person {i}": [{"event": "target event"}] for i in range(5, 10)}, - "2020-01-05": {f"person {i}": [{"event": "target event"}] for i in range(6)}, - }, - team=team, - ) - - for person in persons_to_delete: - person.delete() - - retention = get_retention_ok( - client=self.client, - team_id=team.pk, - request=RetentionRequest( - target_entity={"id": "target event", "type": "events"}, - returning_entity={"id": "target event", "type": "events"}, - date_from="2020-01-01", - total_intervals=5, - date_to="2020-01-05", - period="Day", - retention_type="retention_first_time", - limit=10, - ), - ) - - assert retention["result"][0]["values"][0]["count"] == 15 - - people_url = retention["result"][0]["values"][0]["people_url"] - people_response = self.client.get(people_url) - assert people_response.status_code == 200 - - response_json = people_response.json() - - assert response_json["missing_persons"] == 10 - people = response_json["result"] - assert len(people) == 0 - appearances_count = [sum(person["appearances"]) for person in people] - self.assertEqual(appearances_count, []) - - people_url = response_json["next"] - people_response = self.client.get(people_url) - assert people_response.status_code == 200 - - response_json = people_response.json() - - people = response_json["result"] - assert response_json["missing_persons"] == 5 - assert response_json["next"] is None - assert len(people) == 0 - - -class BreakdownTests(APIBaseTest, ClickhouseTestMixin): - def test_can_get_retention_cohort_breakdown(self): - organization = create_organization(name="test") - team = create_team(organization=organization) - user = create_user(email="test@posthog.com", password="1234", organization=organization) - - self.client.force_login(user) - - update_or_create_person(distinct_ids=["person 1"], team_id=team.pk) - update_or_create_person(distinct_ids=["person 2"], team_id=team.pk) - update_or_create_person(distinct_ids=["person 3"], team_id=team.pk) - - setup_user_activity_by_day( - daily_activity={ - "2020-01-01": { - "person 1": [{"event": "target event"}], - "person 2": [{"event": "target event"}], - }, - "2020-01-02": { - "person 1": [{"event": "target event"}], - "person 3": [{"event": "target event"}], - }, - "2020-01-03": { - "person 1": [{"event": "target event"}], - "person 3": [{"event": "target event"}], - }, - }, - team=team, - ) - - retention = get_retention_ok( - client=self.client, - team_id=team.pk, - request=RetentionRequest( - target_entity={"id": "target event", "type": "events"}, - returning_entity={"id": "target event", "type": "events"}, - date_from="2020-01-01", - total_intervals=2, - date_to="2020-01-02", - period="Day", - retention_type="retention_first_time", - ), - ) - - retention_by_cohort_by_period = get_by_cohort_by_period_for_response(client=self.client, response=retention) - - assert retention_by_cohort_by_period == { - "Day 0": {"1": ["person 1", "person 2"], "2": ["person 1"]}, - "Day 1": {"1": ["person 3"]}, - } - - def test_can_get_retention_cohort_breakdown_with_retention_type_target(self): - organization = create_organization(name="test") - team = create_team(organization=organization) - user = create_user(email="test@posthog.com", password="1234", organization=organization) - - self.client.force_login(user) - - update_or_create_person(distinct_ids=["person 1"], team_id=team.pk) - update_or_create_person(distinct_ids=["person 2"], team_id=team.pk) - update_or_create_person(distinct_ids=["person 3"], team_id=team.pk) - - setup_user_activity_by_day( - daily_activity={ - "2020-01-01": { - "person 1": [{"event": "target event"}], - "person 2": [{"event": "target event"}], - }, - "2020-01-02": { - "person 1": [{"event": "target event"}], - "person 3": [{"event": "target event"}], - }, - "2020-01-03": { - "person 1": [{"event": "target event"}], - "person 3": [{"event": "target event"}], - }, - }, - team=team, - ) - - retention = get_retention_ok( - client=self.client, - team_id=team.pk, - request=RetentionRequest( - target_entity={"id": "target event", "type": "events"}, - returning_entity={"id": "target event", "type": "events"}, - date_from="2020-01-01", - total_intervals=2, - date_to="2020-01-02", - period="Day", - retention_type="retention", - ), - ) - - retention_by_cohort_by_period = get_by_cohort_by_period_for_response(client=self.client, response=retention) - self.assertDictEqual( - retention_by_cohort_by_period, - { - "Day 0": {"1": ["person 1", "person 2"], "2": ["person 1"]}, - "Day 1": {"1": ["person 1", "person 3"]}, - }, - ) - - @also_test_with_materialized_columns(person_properties=["os"]) - def test_can_specify_breakdown_person_property(self): - """ - By default, we group users together by the first time they perform the - `target_event`. However, we should also be able to specify, e.g. the - users OS to be able to compare retention between the OSs. - """ - organization = create_organization(name="test") - team = create_team(organization=organization) - user = create_user(email="test@posthog.com", password="1234", organization=organization) - - self.client.force_login(user) - - update_or_create_person(distinct_ids=["person 1"], team_id=team.pk, properties={"os": "Chrome"}) - update_or_create_person(distinct_ids=["person 2"], team_id=team.pk, properties={"os": "Safari"}) - - setup_user_activity_by_day( - daily_activity={ - "2020-01-01": {"person 1": [{"event": "target event"}]}, - "2020-01-02": { - "person 1": [{"event": "target event"}], - "person 2": [{"event": "target event"}], - }, - # IMPORTANT: we include data past the end of the requested - # window, as we want to ensure that we pick up all retention - # periods for a user. e.g. for "person 2" we do not want to miss - # the count from 2020-01-03 e.g. the second period, otherwise we - # will skew results for users that didn't perform their target - # event right at the beginning of the requested range. - "2020-01-03": { - "person 1": [{"event": "target event"}], - "person 2": [{"event": "target event"}], - }, - }, - team=team, - ) - - retention = get_retention_ok( - client=self.client, - team_id=team.pk, - request=RetentionRequest( - target_entity={"id": "target event", "type": "events"}, - returning_entity={"id": "target event", "type": "events"}, - date_from="2020-01-01", - total_intervals=2, - date_to="2020-01-02", - period="Day", - retention_type="retention_first_time", - breakdowns=[Breakdown(type="person", property="os")], - # NOTE: we need to specify breakdown_type as well, as the - # breakdown logic currently does not support multiple differing - # types - breakdown_type="person", - ), - ) - - retention_by_cohort_by_period = get_by_cohort_by_period_for_response(client=self.client, response=retention) - - assert retention_by_cohort_by_period, { - "Chrome": {"1": ["person 1"], "2": ["person 1"]}, - "Safari": { - "1": ["person 2"], - "2": ["person 2"], - }, # IMPORTANT: the "2" value is from past the requested `date_to` - } - - @also_test_with_materialized_columns(event_properties=["os"]) - def test_can_specify_breakdown_event_property(self): - """ - By default, we group users together by the first time they perform the - `target_event`. However, we should also be able to specify, e.g. the - users OS to be able to compare retention between the OSs. - """ - organization = create_organization(name="test") - team = create_team(organization=organization) - user = create_user(email="test@posthog.com", password="1234", organization=organization) - - self.client.force_login(user) - - update_or_create_person(distinct_ids=["person 1"], team_id=team.pk) - update_or_create_person(distinct_ids=["person 2"], team_id=team.pk) - - setup_user_activity_by_day( - daily_activity={ - "2020-01-01": {"person 1": [{"event": "target event", "properties": {"os": "Chrome"}}]}, - "2020-01-02": { - "person 1": [{"event": "target event"}], - "person 2": [{"event": "target event", "properties": {"os": "Safari"}}], - }, - # IMPORTANT: we include data past the end of the requested - # window, as we want to ensure that we pick up all retention - # periods for a user. e.g. for "person 2" we do not want to miss - # the count from 2020-01-03 e.g. the second period, otherwise we - # will skew results for users that didn't perform their target - # event right at the beginning of the requested range. - "2020-01-03": { - "person 1": [{"event": "target event"}], - "person 2": [{"event": "target event"}], - }, - }, - team=team, - ) - - retention = get_retention_ok( - client=self.client, - team_id=team.pk, - request=RetentionRequest( - target_entity={"id": "target event", "type": "events"}, - returning_entity={"id": "target event", "type": "events"}, - date_from="2020-01-01", - total_intervals=2, - date_to="2020-01-02", - period="Day", - retention_type="retention_first_time", - breakdowns=[Breakdown(type="event", property="os")], - # NOTE: we need to specify breakdown_type as well, as the - # breakdown logic currently does not support multiple differing - # types - breakdown_type="event", - ), - ) - - retention_by_cohort_by_period = get_by_cohort_by_period_for_response(client=self.client, response=retention) - - assert retention_by_cohort_by_period == { - "Chrome": {"1": ["person 1"], "2": ["person 1"]}, - "Safari": { - "1": ["person 2"], - "2": ["person 2"], - }, # IMPORTANT: the "2" value is from past the requested `date_to` - } - - @also_test_with_materialized_columns(event_properties=["os"]) - def test_can_specify_breakdown_event_property_and_retrieve_people(self): - """ - This test is slightly different from the - get_by_cohort_by_period_for_response based tests in that here we are - checking a cohort/period specific people url that does not include the - "appearances" detail. - - This is used, e.g. for the frontend retentions trend graph - """ - organization = create_organization(name="test") - team = create_team(organization=organization) - user = create_user(email="test@posthog.com", password="1234", organization=organization) - - self.client.force_login(user) - - update_or_create_person(distinct_ids=["person 1"], team_id=team.pk) - update_or_create_person(distinct_ids=["person 2"], team_id=team.pk) - - setup_user_activity_by_day( - daily_activity={ - "2020-01-01": { - "person 1": [{"event": "target event", "properties": {"os": "Chrome"}}], - "person 2": [{"event": "target event", "properties": {"os": "Safari"}}], - }, - "2020-01-02": { - "person 1": [{"event": "target event"}], - "person 2": [{"event": "target event"}], - }, - }, - team=team, - ) - - retention = get_retention_ok( - client=self.client, - team_id=team.pk, - request=RetentionRequest( - target_entity={"id": "target event", "type": "events"}, - returning_entity={"id": "target event", "type": "events"}, - date_from="2020-01-01", - total_intervals=2, - date_to="2020-01-02", - period="Day", - retention_type="retention_first_time", - breakdowns=[Breakdown(type="event", property="os")], - # NOTE: we need to specify breakdown_type as well, as the - # breakdown logic currently does not support multiple differing - # types - breakdown_type="event", - ), - ) - - chrome_cohort = next(cohort for cohort in retention["result"] if cohort["label"] == "Chrome") - people_url = chrome_cohort["values"][0]["people_url"] - people_response = self.client.get(people_url) - assert people_response.status_code == 200 - - people = people_response.json()["result"] - assert [distinct_id for person in people for distinct_id in person["person"]["distinct_ids"]] == ["person 1"] - - -class IntervalTests(APIBaseTest, ClickhouseTestMixin): - def test_can_get_retention_week_interval(self): - organization = create_organization(name="test") - team = create_team(organization=organization) - user = create_user(email="test@posthog.com", password="1234", organization=organization) - - self.client.force_login(user) - - update_or_create_person(distinct_ids=["person 1"], team_id=team.pk) - update_or_create_person(distinct_ids=["person 2"], team_id=team.pk) - - setup_user_activity_by_day( - daily_activity={ - "2020-01-01": {"person 1": [{"event": "target event"}]}, - "2020-01-08": {"person 2": [{"event": "target event"}]}, - }, - team=team, - ) - - retention = get_retention_ok( - client=self.client, - team_id=team.pk, - request=RetentionRequest( - target_entity={"id": "target event", "type": "events"}, - returning_entity={"id": "target event", "type": "events"}, - date_from="2020-01-01", - total_intervals=2, - date_to="2020-01-08", - period="Week", - retention_type="retention_first_time", - ), - ) - - retention_by_cohort_by_period = get_by_cohort_by_period_for_response(client=self.client, response=retention) - - assert retention_by_cohort_by_period == { - "Week 0": {"1": ["person 1"], "2": []}, - "Week 1": {"1": ["person 2"]}, - } - - -class RegressionTests(APIBaseTest, ClickhouseTestMixin): - def test_can_get_actors_and_use_percent_char_filter(self): - """ - References https://github.com/PostHog/posthog/issues/7747 - - Essentially we were performing a double string substitution, which - causes issues if, in that case, we use a string substitution that - includes a '%' character, and then run substitution again. - - This was the case for instance when you wanted to filter out test users - e.g. by postgres LIKE matching '%posthog.com%' - """ - organization = create_organization(name="test") - team = create_team(organization=organization) - user = create_user(email="test@posthog.com", password="1234", organization=organization) - - self.client.force_login(user) - - response = get_retention_people( - client=self.client, - team_id=team.pk, - request=RetentionRequest( - target_entity={"id": "target event", "type": "events"}, - returning_entity={"id": "target event", "type": "events"}, - date_from="2020-01-01", - total_intervals=2, - date_to="2020-01-08", - period="Week", - retention_type="retention_first_time", - properties=[ - { - "key": "email", - "value": "posthog.com", - "operator": "not_icontains", - "type": "person", - } - ], - ), - ) - - assert response.status_code == 200 - - -def setup_user_activity_by_day(daily_activity, team): - create_all_events( - [ - {"distinct_id": person_id, "team": team, "timestamp": timestamp, **event} - for timestamp, people in daily_activity.items() - for person_id, events in people.items() - for event in events - ] - ) - - -@dataclass(frozen=True) -class Breakdown: - type: str - property: str - - -class PropertyFilter(TypedDict): - key: str - value: str - operator: Literal["not_icontains"] # NOTE: not exhaustive - type: Literal["person"] # NOTE: not exhaustive - - -@dataclass(frozen=True) -class RetentionRequest: - date_from: str # From what I can tell, this doesn't do anything, rather `total_intervals` is used - total_intervals: int - date_to: str - target_entity: EventPattern - returning_entity: EventPattern - period: Union[Literal["Hour"], Literal["Day"], Literal["Week"], Literal["Month"]] - retention_type: Literal["retention_first_time", "retention"] # probably not an exhaustive list - - breakdowns: Optional[list[Breakdown]] = None - breakdown_type: Optional[Literal["person", "event"]] = None - - properties: Optional[list[PropertyFilter]] = None - filter_test_accounts: Optional[str] = None - - limit: Optional[int] = None - - -class Value(TypedDict): - count: int - people_url: str - - -class Cohort(TypedDict): - values: list[Value] - date: str - label: str - - -class RetentionResponse(TypedDict): - result: list[Cohort] - - -class Person(TypedDict): - distinct_ids: list[str] - - -class RetentionTableAppearance(TypedDict): - person: Person - appearances: list[int] - - -class RetentionTablePeopleResponse(TypedDict): - result: list[RetentionTableAppearance] - - -def get_retention_ok(client: Client, team_id: int, request: RetentionRequest) -> RetentionResponse: - response = get_retention(client=client, team_id=team_id, request=request) - assert response.status_code == 200, response.content - return response.json() - - -def get_retention(client: Client, team_id: int, request: RetentionRequest): - return client.get( - f"/api/projects/{team_id}/insights/retention/", - # NOTE: for get requests we need to JSON encode non-scalars - data=encode_get_request_params(asdict(request)), - ) - - -def get_retention_people(client: Client, team_id: int, request: RetentionRequest): - return client.get( - f"/api/person/retention/", - # NOTE: for get requests we need to JSON encode non-scalars - data=encode_get_request_params(asdict(request)), - ) - - -def get_retention_table_people_from_url_ok(client: Client, people_url: str): - response = client.get(people_url) - assert response.status_code == 200 - return response.json() - - -def get_by_cohort_by_period_for_response(client: Client, response: RetentionResponse): - """ - Helper that, given a retention response, will fetch all corresponding distinct ids - and return in the format: - - ``` - { - "": { - "1": ["person 1", ...] - "2": [...] - ... - } - ... - } - ``` - """ - - def create_cohort_period(people, period, value): - people_in_period = [ - distinct_id - for person in people - for distinct_id in person["person"]["distinct_ids"] - if person["appearances"][period] - ] - - # Check the count is the same as the people size. We don't handle any - # pagination so this could be wrong for large counts - assert value["count"] == len(people_in_period) - - return sorted(people_in_period) - - def create_cohort_response(cohort): - people = get_retention_table_people_from_url_ok(client=client, people_url=cohort["people_url"])["result"] - - return { - f"{period + 1}": create_cohort_period(people, period, value) - for period, value in enumerate(cohort["values"]) - } - - return {cohort["label"]: create_cohort_response(cohort) for cohort in response["result"]} - - -def get_by_cohort_by_period_from_response(response: RetentionResponse): - return { - cohort["label"]: {f"{period + 1}": value["count"] for period, value in enumerate(cohort["values"])} - for cohort in response["result"] - } diff --git a/ee/clickhouse/views/test/test_clickhouse_stickiness.py b/ee/clickhouse/views/test/test_clickhouse_stickiness.py deleted file mode 100644 index 5a3081c904a..00000000000 --- a/ee/clickhouse/views/test/test_clickhouse_stickiness.py +++ /dev/null @@ -1,230 +0,0 @@ -from datetime import datetime, timedelta - -from django.test.client import Client -from freezegun.api import freeze_time - -from ee.clickhouse.queries.stickiness import ClickhouseStickiness -from posthog.api.test.test_stickiness import ( - get_stickiness_time_series_ok, - stickiness_test_factory, -) -from posthog.models.action import Action -from posthog.models.filters.stickiness_filter import StickinessFilter -from posthog.models.group.util import create_group -from posthog.queries.util import get_earliest_timestamp -from posthog.test.base import ( - ClickhouseTestMixin, - _create_event, - _create_person, - snapshot_clickhouse_queries, -) -from posthog.test.test_journeys import journeys_for - - -def _create_action(**kwargs): - team = kwargs.pop("team") - name = kwargs.pop("name") - event_name = kwargs.pop("event_name") - action = Action.objects.create(team=team, name=name, steps_json=[{"event": event_name}]) - return action - - -def get_people_from_url_ok(client: Client, url: str): - response = client.get("/" + url) - assert response.status_code == 200, response.content - return response.json()["results"][0]["people"] - - -class TestClickhouseStickiness( - ClickhouseTestMixin, - stickiness_test_factory( - ClickhouseStickiness, - _create_event, - _create_person, - _create_action, - get_earliest_timestamp, - ), -): # type: ignore - @snapshot_clickhouse_queries - def test_filter_by_group_properties(self): - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key=f"org:1", - properties={"industry": "technology"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key=f"org:2", - properties={"industry": "agriculture"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key=f"org:3", - properties={"industry": "technology"}, - ) - create_group(team_id=self.team.pk, group_type_index=0, group_key=f"org:4", properties={}) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key=f"company:1", - properties={"industry": "technology"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key=f"instance:1", - properties={}, - ) - - p1, p2, p3, p4 = self._create_multiple_people( - period=timedelta(weeks=1), - event_properties=lambda i: { - "$group_0": f"org:{i}", - "$group_1": "instance:1", - }, - ) - - with freeze_time("2020-02-15T13:01:01Z"): - data = get_stickiness_time_series_ok( - client=self.client, - team=self.team, - request={ - "shown_as": "Stickiness", - "date_from": "2020-01-01", - "date_to": "2020-02-15", - "events": [{"id": "watched movie"}], - "properties": [ - { - "key": "industry", - "value": "technology", - "type": "group", - "group_type_index": 0, - } - ], - "interval": "week", - }, - ) - - assert data["watched movie"][1].value == 1 - assert data["watched movie"][2].value == 0 - assert data["watched movie"][3].value == 1 - - with freeze_time("2020-02-15T13:01:01Z"): - week1_actors = get_people_from_url_ok(self.client, data["watched movie"][1].person_url) - week2_actors = get_people_from_url_ok(self.client, data["watched movie"][2].person_url) - week3_actors = get_people_from_url_ok(self.client, data["watched movie"][3].person_url) - - assert sorted([p["id"] for p in week1_actors]) == sorted([str(p1.uuid)]) - assert sorted([p["id"] for p in week2_actors]) == sorted([]) - assert sorted([p["id"] for p in week3_actors]) == sorted([str(p3.uuid)]) - - @snapshot_clickhouse_queries - def test_aggregate_by_groups(self): - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key=f"org:0", - properties={"industry": "technology"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key=f"org:1", - properties={"industry": "agriculture"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key=f"org:2", - properties={"industry": "technology"}, - ) - self._create_multiple_people( - period=timedelta(weeks=1), - event_properties=lambda i: {"$group_0": f"org:{i // 2}"}, - ) - - with freeze_time("2020-02-15T13:01:01Z"): - data = get_stickiness_time_series_ok( - client=self.client, - team=self.team, - request={ - "shown_as": "Stickiness", - "date_from": "2020-01-01", - "date_to": "2020-02-15", - "events": [ - { - "id": "watched movie", - "math": "unique_group", - "math_group_type_index": 0, - } - ], - "interval": "week", - }, - ) - - assert data["watched movie"][1].value == 2 - assert data["watched movie"][2].value == 0 - assert data["watched movie"][3].value == 1 - - with freeze_time("2020-02-15T13:01:01Z"): - week1_actors = get_people_from_url_ok(self.client, data["watched movie"][1].person_url) - week2_actors = get_people_from_url_ok(self.client, data["watched movie"][2].person_url) - week3_actors = get_people_from_url_ok(self.client, data["watched movie"][3].person_url) - - assert sorted([p["id"] for p in week1_actors]) == sorted(["org:0", "org:2"]) - assert sorted([p["id"] for p in week2_actors]) == sorted([]) - assert sorted([p["id"] for p in week3_actors]) == sorted(["org:1"]) - - @snapshot_clickhouse_queries - def test_timezones(self): - journeys_for( - { - "person1": [ - { - "event": "$pageview", - "timestamp": datetime(2021, 5, 2, 1), - }, # this time will fall on 5/1 in US Pacific - {"event": "$pageview", "timestamp": datetime(2021, 5, 2, 9)}, - {"event": "$pageview", "timestamp": datetime(2021, 5, 4, 3)}, - ] - }, - self.team, - ) - - data = ClickhouseStickiness().run( - filter=StickinessFilter( - data={ - "shown_as": "Stickiness", - "date_from": "2021-05-01", - "date_to": "2021-05-15", - "events": [{"id": "$pageview"}], - }, - team=self.team, - ), - team=self.team, - ) - - self.assertEqual(data[0]["days"], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) - self.assertEqual(data[0]["data"], [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) - - self.team.timezone = "US/Pacific" - self.team.save() - - data_pacific = ClickhouseStickiness().run( - filter=StickinessFilter( - data={ - "shown_as": "Stickiness", - "date_from": "2021-05-01", - "date_to": "2021-05-15", - "events": [{"id": "$pageview"}], - }, - team=self.team, - ), - team=self.team, - ) - - self.assertEqual(data_pacific[0]["days"], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) - self.assertEqual(data_pacific[0]["data"], [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) diff --git a/ee/clickhouse/views/test/test_clickhouse_trends.py b/ee/clickhouse/views/test/test_clickhouse_trends.py deleted file mode 100644 index d9b2d5b964a..00000000000 --- a/ee/clickhouse/views/test/test_clickhouse_trends.py +++ /dev/null @@ -1,1633 +0,0 @@ -import json -from dataclasses import dataclass, field -from datetime import datetime -from typing import Any, Optional, Union -from unittest.case import skip -from unittest.mock import ANY - -import pytest -from django.core.cache import cache -from django.test import Client -from freezegun import freeze_time - -from ee.api.test.base import LicensedTestMixin -from posthog.api.test.test_cohort import create_cohort_ok -from posthog.api.test.test_event_definition import ( - create_organization, - create_team, - create_user, -) -from posthog.models.group.util import create_group -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.models.instance_setting import set_instance_setting -from posthog.models.team import Team -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - _create_person, - also_test_with_materialized_columns, - snapshot_clickhouse_queries, -) -from posthog.test.test_journeys import journeys_for, update_or_create_person - - -@pytest.mark.django_db -@pytest.mark.ee -def test_includes_only_intervals_within_range(client: Client): - """ - This is the case highlighted by https://github.com/PostHog/posthog/issues/2675 - - Here the issue is that we request, for instance, 14 days as the - date_from, display at weekly intervals but previously we - were displaying 4 ticks on the date axis. If we were exactly on the - beginning of the week for two weeks then we'd want 2 ticks. - Otherwise we would have 3 ticks as the range would be intersecting - with three weeks. We should never need to display 4 ticks. - """ - organization = create_organization(name="test org") - team = create_team(organization=organization) - user = create_user("user", "pass", organization) - - client.force_login(user) - cache.clear() - - #  I'm creating a cohort here so that I can use as a breakdown, just because - #  this is what was used demonstrated in - #  https://github.com/PostHog/posthog/issues/2675 but it might not be the - #  simplest way to reproduce - - # "2021-09-19" is a sunday, i.e. beginning of week - with freeze_time("2021-09-20T16:00:00"): - #  First identify as a member of the cohort - distinct_id = "abc" - update_or_create_person( - distinct_ids=[distinct_id], - team_id=team.id, - properties={"cohort_identifier": 1}, - ) - cohort = create_cohort_ok( - client=client, - team_id=team.id, - name="test cohort", - groups=[{"properties": [{"key": "cohort_identifier", "value": 1, "type": "person"}]}], - ) - - journeys_for( - events_by_person={ - distinct_id: [ - {"event": "$pageview", "timestamp": "2021-09-04"}, - {"event": "$pageview", "timestamp": "2021-09-05"}, - {"event": "$pageview", "timestamp": "2021-09-12"}, - {"event": "$pageview", "timestamp": "2021-09-19"}, - ] - }, - team=team, - ) - - trends = get_trends_ok( - client, - team=team, - request=TrendsRequestBreakdown( - date_from="-14days", - date_to="2021-09-21", - interval="week", - insight="TRENDS", - breakdown=json.dumps([cohort["id"]]), - breakdown_type="cohort", - display="ActionsLineGraph", - events=[ - { - "id": "$pageview", - "math": "dau", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - } - ], - ), - ) - assert trends == trends | { - "is_cached": False, - "last_refresh": "2021-09-20T16:00:00Z", - "next": None, - "timezone": "UTC", - "result": [ - { - "action": ANY, - "breakdown_value": cohort["id"], - "label": "test cohort", - "count": 3.0, - "data": [1.0, 1.0, 1.0], - # Prior to the fix this would also include '29-Aug-2021' - "labels": ["5-Sep-2021", "12-Sep-2021", "19-Sep-2021"], - "days": ["2021-09-05", "2021-09-12", "2021-09-19"], - "persons_urls": ANY, - "filter": ANY, - } - ], - } - - -@pytest.mark.django_db -@pytest.mark.ee -def test_can_specify_number_of_smoothing_intervals(client: Client): - """ - The Smoothing feature should allow specifying a number of intervals over - which we will provide smoothing of the aggregated trend data. - """ - organization = create_organization(name="test org") - team = create_team(organization=organization) - user = create_user("user", "pass", organization) - - client.force_login(user) - - with freeze_time("2021-09-20T16:00:00"): - journeys_for( - events_by_person={ - "abc": [ - {"event": "$pageview", "timestamp": "2021-09-01"}, - {"event": "$pageview", "timestamp": "2021-09-01"}, - {"event": "$pageview", "timestamp": "2021-09-02"}, - {"event": "$pageview", "timestamp": "2021-09-03"}, - {"event": "$pageview", "timestamp": "2021-09-03"}, - {"event": "$pageview", "timestamp": "2021-09-03"}, - ] - }, - team=team, - ) - - interval_3_trend = get_trends_ok( - client, - team=team, - request=TrendsRequest( - date_from="2021-09-01", - date_to="2021-09-03", - interval="day", - insight="TRENDS", - display="ActionsLineGraph", - smoothing_intervals=3, - events=[ - { - "id": "$pageview", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - } - ], - ), - ) - - assert interval_3_trend == interval_3_trend | { - "is_cached": False, - "last_refresh": "2021-09-20T16:00:00Z", - "next": None, - "timezone": "UTC", - "result": [ - { - "action": ANY, - "label": "$pageview", - "count": 5, - "data": [2.0, 1, 2.0], - "labels": ["1-Sep-2021", "2-Sep-2021", "3-Sep-2021"], - "days": ["2021-09-01", "2021-09-02", "2021-09-03"], - "persons_urls": ANY, - "filter": ANY, - } - ], - } - - interval_2_trend = get_trends_ok( - client, - team=team, - request=TrendsRequest( - date_from="2021-09-01", - date_to="2021-09-03", - interval="day", - insight="TRENDS", - display="ActionsLineGraph", - smoothing_intervals=2, - events=[ - { - "id": "$pageview", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - } - ], - ), - ) - - assert interval_2_trend == interval_2_trend | { - "is_cached": False, - "last_refresh": "2021-09-20T16:00:00Z", - "next": None, - "timezone": "UTC", - "result": [ - { - "action": ANY, - "label": "$pageview", - "count": 5, - "data": [2.0, 1, 2.0], - "labels": ["1-Sep-2021", "2-Sep-2021", "3-Sep-2021"], - "days": ["2021-09-01", "2021-09-02", "2021-09-03"], - "persons_urls": ANY, - "filter": ANY, - } - ], - } - - interval_1_trend = get_trends_ok( - client, - team=team, - request=TrendsRequest( - date_from="2021-09-01", - date_to="2021-09-03", - interval="day", - insight="TRENDS", - display="ActionsLineGraph", - smoothing_intervals=1, - events=[ - { - "id": "$pageview", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - } - ], - ), - ) - - assert interval_1_trend == interval_1_trend | { - "is_cached": False, - "last_refresh": "2021-09-20T16:00:00Z", - "next": None, - "timezone": "UTC", - "result": [ - { - "action": { - "id": "$pageview", - "type": "events", - "order": 0, - "name": "$pageview", - "custom_name": None, - "math": None, - "math_hogql": None, - "math_property": None, - "math_group_type_index": ANY, - "properties": {}, - "distinct_id_field": None, - "id_field": None, - "timestamp_field": None, - "table_name": None, - }, - "label": "$pageview", - "count": 6.0, - "data": [2.0, 1.0, 3.0], - "labels": ["1-Sep-2021", "2-Sep-2021", "3-Sep-2021"], - "days": ["2021-09-01", "2021-09-02", "2021-09-03"], - "persons_urls": ANY, - "filter": ANY, - } - ], - } - - -@pytest.mark.django_db -@pytest.mark.ee -def test_smoothing_intervals_copes_with_null_values(client: Client): - """ - The Smoothing feature should allow specifying a number of intervals over - which we will provide smoothing of the aggregated trend data. - """ - organization = create_organization(name="test org") - team = create_team(organization=organization) - user = create_user("user", "pass", organization) - - client.force_login(user) - cache.clear() - - with freeze_time("2021-09-20T16:00:00"): - journeys_for( - events_by_person={ - "abc": [ - {"event": "$pageview", "timestamp": "2021-09-01"}, - {"event": "$pageview", "timestamp": "2021-09-01"}, - {"event": "$pageview", "timestamp": "2021-09-01"}, - # No events on 2 Sept - {"event": "$pageview", "timestamp": "2021-09-03"}, - {"event": "$pageview", "timestamp": "2021-09-03"}, - {"event": "$pageview", "timestamp": "2021-09-03"}, - ] - }, - team=team, - ) - - interval_3_trend = get_trends_ok( - client, - team=team, - request=TrendsRequest( - date_from="2021-09-01", - date_to="2021-09-03", - interval="day", - insight="TRENDS", - display="ActionsLineGraph", - smoothing_intervals=3, - events=[ - { - "id": "$pageview", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - } - ], - ), - ) - - assert interval_3_trend == interval_3_trend | { - "is_cached": False, - "last_refresh": "2021-09-20T16:00:00Z", - "next": None, - "timezone": "UTC", - "result": [ - { - "action": ANY, - "label": "$pageview", - "count": 6.0, - "data": [3.0, 1.0, 2.0], - "labels": ["1-Sep-2021", "2-Sep-2021", "3-Sep-2021"], - "days": ["2021-09-01", "2021-09-02", "2021-09-03"], - "persons_urls": ANY, - "filter": ANY, - } - ], - } - - interval_1_trend = get_trends_ok( - client, - team=team, - request=TrendsRequest( - date_from="2021-09-01", - date_to="2021-09-03", - interval="day", - insight="TRENDS", - display="ActionsLineGraph", - smoothing_intervals=1, - events=[ - { - "id": "$pageview", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - } - ], - ), - ) - - assert interval_1_trend == interval_1_trend | { - "is_cached": False, - "last_refresh": "2021-09-20T16:00:00Z", - "next": None, - "timezone": "UTC", - "result": [ - { - "action": ANY, - "label": "$pageview", - "count": 6.0, - "data": [3.0, 0.0, 3.0], - "labels": ["1-Sep-2021", "2-Sep-2021", "3-Sep-2021"], - "days": ["2021-09-01", "2021-09-02", "2021-09-03"], - "persons_urls": ANY, - "filter": ANY, - } - ], - } - - -@dataclass -class TrendsRequest: - date_from: Optional[str] = None - date_to: Optional[str] = None - interval: Optional[str] = None - insight: Optional[str] = None - display: Optional[str] = None - compare: Optional[bool] = None - events: list[dict[str, Any]] = field(default_factory=list) - properties: list[dict[str, Any]] = field(default_factory=list) - smoothing_intervals: Optional[int] = 1 - refresh: Optional[bool] = False - - -@dataclass -class TrendsRequestBreakdown(TrendsRequest): - breakdown: Optional[Union[list[int], str]] = None - breakdown_type: Optional[str] = None - - -def get_trends(client, request: Union[TrendsRequestBreakdown, TrendsRequest], team: Team): - data: dict[str, Any] = { - "date_from": request.date_from, - "date_to": request.date_to, - "interval": request.interval, - "insight": request.insight, - "display": request.display, - "compare": request.compare, - "events": json.dumps(request.events), - "properties": json.dumps(request.properties), - "smoothing_intervals": request.smoothing_intervals, - "refresh": request.refresh, - } - - if isinstance(request, TrendsRequestBreakdown): - data["breakdown"] = request.breakdown - data["breakdown_type"] = request.breakdown_type - - filtered_data = {k: v for k, v in data.items() if v is not None} - - return client.get(f"/api/projects/{team.id}/insights/trend/", data=filtered_data) - - -def get_trends_ok(client: Client, request: TrendsRequest, team: Team): - response = get_trends(client=client, request=request, team=team) - assert response.status_code == 200, response.content - return response.json() - - -@dataclass -class NormalizedTrendResult: - value: float - label: str - person_url: str - breakdown_value: Optional[Union[str, int]] - - -def get_trends_time_series_ok( - client: Client, request: TrendsRequest, team: Team, with_order: bool = False -) -> dict[str, dict[str, NormalizedTrendResult]]: - data = get_trends_ok(client=client, request=request, team=team) - res = {} - for item in data["result"]: - collect_dates = {} - for idx, date in enumerate(item["days"]): - collect_dates[date] = NormalizedTrendResult( - value=item["data"][idx], - label=item["labels"][idx], - person_url=item["persons_urls"][idx]["url"], - breakdown_value=item.get("breakdown_value", None), - ) - suffix = " - {}".format(item["compare_label"]) if item.get("compare_label") else "" - if with_order: - suffix += " - {}".format(item["action"]["order"]) if item["action"].get("order") is not None else "" - res["{}{}".format(item["label"], suffix)] = collect_dates - - return res - - -def get_trends_aggregate_ok(client: Client, request: TrendsRequest, team: Team) -> dict[str, NormalizedTrendResult]: - data = get_trends_ok(client=client, request=request, team=team) - res = {} - for item in data["result"]: - res[item["label"]] = NormalizedTrendResult( - value=item["aggregated_value"], - label=item["action"]["name"], - person_url=item["persons"]["url"], - breakdown_value=item.get("breakdown_value", None), - ) - - return res - - -def get_trends_people_ok(client: Client, url: str): - response = client.get("/" + url) - assert response.status_code == 200, response.content - return response.json()["results"][0]["people"] - - -def get_people_from_url_ok(client: Client, url: str): - response = client.get("/" + url) - assert response.status_code == 200, response.content - return response.json()["results"][0]["people"] - - -class ClickhouseTestTrends(ClickhouseTestMixin, LicensedTestMixin, APIBaseTest): - maxDiff = None - CLASS_DATA_LEVEL_SETUP = False - - @snapshot_clickhouse_queries - def test_insight_trends_basic(self): - events_by_person = { - "1": [{"event": "$pageview", "timestamp": datetime(2012, 1, 14, 3)}], - "2": [{"event": "$pageview", "timestamp": datetime(2012, 1, 14, 3)}], - } - created_people = journeys_for(events_by_person, self.team) - - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequest( - date_from="-14d", - display="ActionsLineGraph", - events=[ - { - "id": "$pageview", - "math": "dau", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - } - ], - ) - data = get_trends_time_series_ok(self.client, request, self.team) - - assert data["$pageview"]["2012-01-13"].value == 0 - assert data["$pageview"]["2012-01-14"].value == 2 - assert data["$pageview"]["2012-01-14"].label == "14-Jan-2012" - assert data["$pageview"]["2012-01-15"].value == 0 - - with freeze_time("2012-01-15T04:01:34.000Z"): - people = get_people_from_url_ok(self.client, data["$pageview"]["2012-01-14"].person_url) - - assert sorted([p["id"] for p in people]) == sorted( - [str(created_people["1"].uuid), str(created_people["2"].uuid)] - ) - - def test_insight_trends_entity_overlap(self): - events_by_person = { - "1": [ - { - "event": "$pageview", - "timestamp": datetime(2012, 1, 14, 3), - "properties": {"key": "val"}, - } - ], - "2": [{"event": "$pageview", "timestamp": datetime(2012, 1, 14, 3)}], - "3": [{"event": "$pageview", "timestamp": datetime(2012, 1, 14, 3)}], - } - created_people = journeys_for(events_by_person, self.team) - - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequest( - date_from="-14d", - display="ActionsLineGraph", - events=[ - { - "id": "$pageview", - "math": "dau", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - }, - { - "id": "$pageview", - "math": "dau", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 1, - "properties": [{"key": "key", "value": "val"}], - "math_property": None, - }, - ], - ) - data = get_trends_time_series_ok(self.client, request, self.team, with_order=True) - - assert data["$pageview - 0"]["2012-01-13"].value == 0 - assert data["$pageview - 0"]["2012-01-14"].value == 3 - assert data["$pageview - 1"]["2012-01-14"].value == 1 - assert data["$pageview - 0"]["2012-01-14"].label == "14-Jan-2012" - assert data["$pageview - 0"]["2012-01-15"].value == 0 - - with freeze_time("2012-01-15T04:01:34.000Z"): - people = get_people_from_url_ok(self.client, data["$pageview - 1"]["2012-01-14"].person_url) - - assert sorted([p["id"] for p in people]) == sorted([str(created_people["1"].uuid)]) - - with freeze_time("2012-01-15T04:01:34.000Z"): - people = get_people_from_url_ok(self.client, data["$pageview - 0"]["2012-01-14"].person_url) - - assert sorted([p["id"] for p in people]) == sorted( - [ - str(created_people["1"].uuid), - str(created_people["2"].uuid), - str(created_people["3"].uuid), - ] - ) - - @snapshot_clickhouse_queries - def test_insight_trends_clean_arg(self): - events_by_actor = { - "1": [ - { - "event": "$pageview", - "timestamp": datetime(2012, 1, 14, 3), - "properties": {"key": "val"}, - } - ], - "2": [{"event": "$pageview", "timestamp": datetime(2012, 1, 14, 3)}], - } - created_actors = journeys_for(events_by_actor, self.team) - - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequest( - date_from="-14d", - display="ActionsLineGraph", - events=[ - { - "id": "$pageview", - "math": None, # this argument will now be removed from the request instead of becoming a string - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [{"key": "key", "value": "val"}], - "math_property": None, - } - ], - ) - data = get_trends_time_series_ok(self.client, request, self.team) - - actors = get_people_from_url_ok(self.client, data["$pageview"]["2012-01-14"].person_url) - - # this would return 2 people prior to #8103 fix - # 'None' values have to be purged before formatting into the actor url - assert sorted([p["id"] for p in actors]) == sorted([str(created_actors["1"].uuid)]) - - @snapshot_clickhouse_queries - def test_insight_trends_aggregate(self): - events_by_person = { - "1": [{"event": "$pageview", "timestamp": datetime(2012, 1, 13, 3)}], - "2": [{"event": "$pageview", "timestamp": datetime(2012, 1, 14, 3)}], - } - created_people = journeys_for(events_by_person, self.team) - - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequest( - date_from="-14d", - display="ActionsPie", - events=[ - { - "id": "$pageview", - "math": None, - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - } - ], - ) - data = get_trends_aggregate_ok(self.client, request, self.team) - - assert data["$pageview"].value == 2 - assert data["$pageview"].label == "$pageview" - - with freeze_time("2012-01-15T04:01:34.000Z"): - people = get_people_from_url_ok(self.client, data["$pageview"].person_url) - - assert sorted([p["id"] for p in people]) == sorted( - [str(created_people["1"].uuid), str(created_people["2"].uuid)] - ) - - @snapshot_clickhouse_queries - def test_insight_trends_cumulative(self): - _create_person(team_id=self.team.pk, distinct_ids=["p1"], properties={"key": "some_val"}) - _create_person(team_id=self.team.pk, distinct_ids=["p2"], properties={"key": "some_val"}) - _create_person(team_id=self.team.pk, distinct_ids=["p3"], properties={"key": "some_val"}) - - events_by_person = { - "p1": [ - { - "event": "$pageview", - "timestamp": datetime(2012, 1, 13, 3), - "properties": {"key": "val"}, - }, - { - "event": "$pageview", - "timestamp": datetime(2012, 1, 14, 3), - "properties": {"key": "val"}, - }, - ], - "p2": [ - { - "event": "$pageview", - "timestamp": datetime(2012, 1, 13, 3), - "properties": {"key": "notval"}, - } - ], - "p3": [ - { - "event": "$pageview", - "timestamp": datetime(2012, 1, 14, 3), - "properties": {"key": "val"}, - } - ], - } - created_people = journeys_for(events_by_person, self.team, create_people=False) - - # Total Volume - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequest( - date_from="-14d", - display="ActionsLineGraphCumulative", - events=[ - { - "id": "$pageview", - "math": None, - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - } - ], - ) - data_response = get_trends_time_series_ok(self.client, request, self.team) - person_response = get_people_from_url_ok(self.client, data_response["$pageview"]["2012-01-14"].person_url) - - assert data_response["$pageview"]["2012-01-13"].value == 2 - assert data_response["$pageview"]["2012-01-14"].value == 4 - assert data_response["$pageview"]["2012-01-15"].value == 4 - assert data_response["$pageview"]["2012-01-14"].label == "14-Jan-2012" - - assert sorted([p["id"] for p in person_response]) == sorted( - [ - str(created_people["p1"].uuid), - str(created_people["p2"].uuid), - str(created_people["p3"].uuid), - ] - ) - - # DAU - - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequest( - date_from="-14d", - display="ActionsLineGraphCumulative", - events=[ - { - "id": "$pageview", - "math": "dau", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - } - ], - ) - data_response = get_trends_time_series_ok(self.client, request, self.team) - person_response = get_people_from_url_ok(self.client, data_response["$pageview"]["2012-01-14"].person_url) - - assert data_response["$pageview"]["2012-01-13"].value == 2 - assert data_response["$pageview"]["2012-01-14"].value == 3 - assert data_response["$pageview"]["2012-01-15"].value == 3 - assert data_response["$pageview"]["2012-01-14"].label == "14-Jan-2012" - - assert sorted([p["id"] for p in person_response]) == sorted( - [ - str(created_people["p1"].uuid), - str(created_people["p2"].uuid), - str(created_people["p3"].uuid), - ] - ) - - # breakdown - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequestBreakdown( - date_from="-14d", - display="ActionsLineGraphCumulative", - breakdown="key", - breakdown_type="event", - events=[ - { - "id": "$pageview", - "math": None, - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - } - ], - ) - data_response = get_trends_time_series_ok(self.client, request, self.team) - person_response = get_people_from_url_ok(self.client, data_response["val"]["2012-01-14"].person_url) - - assert data_response["val"]["2012-01-13"].value == 1 - assert data_response["val"]["2012-01-13"].breakdown_value == "val" - assert data_response["val"]["2012-01-14"].value == 3 - assert data_response["val"]["2012-01-14"].label == "14-Jan-2012" - - assert sorted([p["id"] for p in person_response]) == sorted( - [str(created_people["p1"].uuid), str(created_people["p3"].uuid)] - ) - - # breakdown wau - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequestBreakdown( - date_from="-14d", - display="ActionsLineGraphCumulative", - breakdown="key", - breakdown_type="event", - events=[ - { - "id": "$pageview", - "math": "weekly_active", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [{"type": "person", "key": "key", "value": "some_val"}], - "math_property": None, - } - ], - properties=[{"type": "person", "key": "key", "value": "some_val"}], - ) - data_response = get_trends_time_series_ok(self.client, request, self.team) - people = get_people_from_url_ok(self.client, data_response["val"]["2012-01-14"].person_url) - - assert data_response["val"]["2012-01-13"].value == 1 - assert data_response["val"]["2012-01-13"].breakdown_value == "val" - assert data_response["val"]["2012-01-14"].value == 3 - assert data_response["val"]["2012-01-14"].label == "14-Jan-2012" - - assert sorted([p["id"] for p in people]) == sorted( - [str(created_people["p1"].uuid), str(created_people["p3"].uuid)] - ) - - # breakdown dau - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequestBreakdown( - date_from="-14d", - display="ActionsLineGraphCumulative", - breakdown="key", - breakdown_type="event", - events=[ - { - "id": "$pageview", - "math": "dau", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - } - ], - ) - data_response = get_trends_time_series_ok(self.client, request, self.team) - people = get_people_from_url_ok(self.client, data_response["val"]["2012-01-14"].person_url) - - assert data_response["val"]["2012-01-13"].value == 1 - assert data_response["val"]["2012-01-13"].breakdown_value == "val" - assert data_response["val"]["2012-01-14"].value == 2 - assert data_response["val"]["2012-01-14"].label == "14-Jan-2012" - - assert sorted([p["id"] for p in people]) == sorted( - [str(created_people["p1"].uuid), str(created_people["p3"].uuid)] - ) - - @also_test_with_materialized_columns(["key"]) - def test_breakdown_with_filter(self): - events_by_person = { - "person1": [ - { - "event": "sign up", - "timestamp": datetime(2012, 1, 13, 3), - "properties": {"key": "val"}, - } - ], - "person2": [ - { - "event": "sign up", - "timestamp": datetime(2012, 1, 13, 3), - "properties": {"key": "oh"}, - } - ], - } - created_people = journeys_for(events_by_person, self.team) - - with freeze_time("2012-01-15T04:01:34.000Z"): - params = TrendsRequestBreakdown( - date_from="-14d", - breakdown="key", - events=[{"id": "sign up", "name": "sign up", "type": "events", "order": 0}], - properties=[{"key": "key", "value": "oh", "operator": "not_icontains"}], - ) - data_response = get_trends_time_series_ok(self.client, params, self.team) - person_response = get_people_from_url_ok(self.client, data_response["val"]["2012-01-13"].person_url) - - assert data_response["val"]["2012-01-13"].value == 1 - assert data_response["val"]["2012-01-13"].breakdown_value == "val" - - assert sorted([p["id"] for p in person_response]) == sorted([str(created_people["person1"].uuid)]) - - with freeze_time("2012-01-15T04:01:34.000Z"): - params = TrendsRequestBreakdown( - date_from="-14d", - breakdown="key", - display="ActionsPie", - events=[{"id": "sign up", "name": "sign up", "type": "events", "order": 0}], - ) - aggregate_response = get_trends_aggregate_ok(self.client, params, self.team) - aggregate_person_response = get_people_from_url_ok(self.client, aggregate_response["val"].person_url) - - assert aggregate_response["val"].value == 1 - assert sorted([p["id"] for p in aggregate_person_response]) == sorted([str(created_people["person1"].uuid)]) - - def test_insight_trends_compare(self): - events_by_person = { - "p1": [ - { - "event": "$pageview", - "timestamp": datetime(2012, 1, 5, 3), - "properties": {"key": "val"}, - }, - { - "event": "$pageview", - "timestamp": datetime(2012, 1, 14, 3), - "properties": {"key": "val"}, - }, - ], - "p2": [ - { - "event": "$pageview", - "timestamp": datetime(2012, 1, 5, 3), - "properties": {"key": "notval"}, - }, - { - "event": "$pageview", - "timestamp": datetime(2012, 1, 14, 3), - "properties": {"key": "notval"}, - }, - ], - } - created_people = journeys_for(events_by_person, self.team) - - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequest( - date_from="-7d", - compare=True, - events=[ - { - "id": "$pageview", - "name": "$pageview", - "type": "events", - "order": 0, - } - ], - ) - data_response = get_trends_time_series_ok(self.client, request, self.team) - - assert data_response["$pageview - current"]["2012-01-13"].value == 0 - assert data_response["$pageview - current"]["2012-01-14"].value == 2 - - assert data_response["$pageview - previous"]["2012-01-04"].value == 0 - assert data_response["$pageview - previous"]["2012-01-05"].value == 2 - - with freeze_time("2012-01-15T04:01:34.000Z"): - curr_people = get_people_from_url_ok( - self.client, - data_response["$pageview - current"]["2012-01-14"].person_url, - ) - prev_people = get_people_from_url_ok( - self.client, - data_response["$pageview - previous"]["2012-01-05"].person_url, - ) - - assert sorted([p["id"] for p in curr_people]) == sorted( - [str(created_people["p1"].uuid), str(created_people["p2"].uuid)] - ) - assert sorted([p["id"] for p in prev_people]) == sorted( - [str(created_people["p1"].uuid), str(created_people["p2"].uuid)] - ) - - -class ClickhouseTestTrendsGroups(ClickhouseTestMixin, LicensedTestMixin, APIBaseTest): - maxDiff = None - CLASS_DATA_LEVEL_SETUP = False - - def _create_groups(self): - GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) - GroupTypeMapping.objects.create(team=self.team, group_type="company", group_type_index=1) - - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:5", - properties={"industry": "finance"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:6", - properties={"industry": "technology"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=0, - group_key="org:7", - properties={"industry": "finance"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="company:10", - properties={"industry": "finance"}, - ) - - @snapshot_clickhouse_queries - def test_aggregating_by_group(self): - self._create_groups() - - events_by_person = { - "person1": [ - { - "event": "$pageview", - "timestamp": datetime(2020, 1, 2, 12), - "properties": {"$group_0": "org:5"}, - }, - { - "event": "$pageview", - "timestamp": datetime(2020, 1, 2, 12), - "properties": {"$group_0": "org:6"}, - }, - { - "event": "$pageview", - "timestamp": datetime(2020, 1, 2, 12), - "properties": {"$group_0": "org:6", "$group_1": "company:10"}, - }, - ] - } - journeys_for(events_by_person, self.team) - - request = TrendsRequest( - date_from="2020-01-01 00:00:00", - date_to="2020-01-12 00:00:00", - events=[ - { - "id": "$pageview", - "type": "events", - "order": 0, - "math": "unique_group", - "math_group_type_index": 0, - } - ], - ) - data_response = get_trends_time_series_ok(self.client, request, self.team) - - assert data_response["$pageview"]["2020-01-01"].value == 0 - assert data_response["$pageview"]["2020-01-02"].value == 2 - - curr_people = get_people_from_url_ok(self.client, data_response["$pageview"]["2020-01-02"].person_url) - - assert sorted([p["group_key"] for p in curr_people]) == sorted(["org:5", "org:6"]) - - @snapshot_clickhouse_queries - def test_aggregating_by_session(self): - events_by_person = { - "person1": [ - { - "event": "$pageview", - "timestamp": datetime(2020, 1, 1, 12), - "properties": {"$session_id": "1"}, - }, - { - "event": "$pageview", - "timestamp": datetime(2020, 1, 1, 12), - "properties": {"$session_id": "1"}, - }, - { - "event": "$pageview", - "timestamp": datetime(2020, 1, 2, 12), - "properties": {"$session_id": "2"}, - }, - ], - "person2": [ - { - "event": "$pageview", - "timestamp": datetime(2020, 1, 2, 12), - "properties": {"$session_id": "3"}, - } - ], - } - journeys_for(events_by_person, self.team) - - request = TrendsRequest( - date_from="2020-01-01 00:00:00", - date_to="2020-01-12 00:00:00", - events=[ - { - "id": "$pageview", - "type": "events", - "order": 0, - "math": "unique_session", - } - ], - ) - data_response = get_trends_time_series_ok(self.client, request, self.team) - - assert data_response["$pageview"]["2020-01-01"].value == 1 - assert data_response["$pageview"]["2020-01-02"].value == 2 - - curr_people = get_people_from_url_ok(self.client, data_response["$pageview"]["2020-01-02"].person_url) - - assert sorted([p["distinct_ids"][0] for p in curr_people]) == sorted(["person1", "person2"]) - - -class ClickhouseTestTrendsCaching(ClickhouseTestMixin, LicensedTestMixin, APIBaseTest): - maxDiff = None - CLASS_DATA_LEVEL_SETUP = False - - @snapshot_clickhouse_queries - def test_insight_trends_merging(self): - set_instance_setting("STRICT_CACHING_TEAMS", "all") - - events_by_person = { - "1": [{"event": "$pageview", "timestamp": datetime(2012, 1, 13, 3)}], - "2": [{"event": "$pageview", "timestamp": datetime(2012, 1, 13, 3)}], - } - journeys_for(events_by_person, self.team) - - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequest( - date_from="-14d", - display="ActionsLineGraph", - events=[ - { - "id": "$pageview", - "math": "dau", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - } - ], - ) - data = get_trends_time_series_ok(self.client, request, self.team) - - assert data["$pageview"]["2012-01-13"].value == 2 - assert data["$pageview"]["2012-01-14"].value == 0 - assert data["$pageview"]["2012-01-15"].value == 0 - - events_by_person = {"1": [{"event": "$pageview", "timestamp": datetime(2012, 1, 15, 3)}]} - journeys_for(events_by_person, self.team) - - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequest( - date_from="-14d", - display="ActionsLineGraph", - events=[ - { - "id": "$pageview", - "math": "dau", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - } - ], - refresh=True, - ) - data = get_trends_time_series_ok(self.client, request, self.team) - - assert data["$pageview"]["2012-01-13"].value == 2 - assert data["$pageview"]["2012-01-14"].value == 0 - assert data["$pageview"]["2012-01-15"].value == 1 - - def test_insight_trends_merging_multiple(self): - set_instance_setting("STRICT_CACHING_TEAMS", "all") - - events_by_person = { - "1": [ - {"event": "$pageview", "timestamp": datetime(2012, 1, 13, 3)}, - {"event": "$action", "timestamp": datetime(2012, 1, 13, 3)}, - ], - "2": [ - {"event": "$pageview", "timestamp": datetime(2012, 1, 13, 3)}, - {"event": "$action", "timestamp": datetime(2012, 1, 13, 3)}, - ], - } - journeys_for(events_by_person, self.team) - - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequest( - date_from="-14d", - display="ActionsLineGraph", - events=[ - { - "id": "$pageview", - "math": "dau", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - }, - { - "id": "$action", - "math": "dau", - "name": "$action", - "custom_name": None, - "type": "events", - "order": 1, - "properties": [], - "math_property": None, - }, - ], - ) - data = get_trends_time_series_ok(self.client, request, self.team) - - assert data["$pageview"]["2012-01-13"].value == 2 - assert data["$pageview"]["2012-01-14"].value == 0 - assert data["$pageview"]["2012-01-15"].value == 0 - - assert data["$action"]["2012-01-13"].value == 2 - assert data["$action"]["2012-01-14"].value == 0 - assert data["$action"]["2012-01-15"].value == 0 - - events_by_person = { - "1": [ - {"event": "$pageview", "timestamp": datetime(2012, 1, 15, 3)}, - {"event": "$action", "timestamp": datetime(2012, 1, 15, 3)}, - ], - "3": [ # thhis won't be counted - {"event": "$pageview", "timestamp": datetime(2012, 1, 13, 3)}, - {"event": "$action", "timestamp": datetime(2012, 1, 13, 3)}, - ], - } - journeys_for(events_by_person, self.team) - - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequest( - date_from="-14d", - display="ActionsLineGraph", - events=[ - { - "id": "$pageview", - "math": "dau", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - }, - { - "id": "$action", - "math": "dau", - "name": "$action", - "custom_name": None, - "type": "events", - "order": 1, - "properties": [], - "math_property": None, - }, - ], - refresh=True, - ) - data = get_trends_time_series_ok(self.client, request, self.team) - - assert data["$pageview"]["2012-01-13"].value == 2 - assert data["$pageview"]["2012-01-14"].value == 0 - assert data["$pageview"]["2012-01-15"].value == 1 - - assert data["$action"]["2012-01-13"].value == 2 - assert data["$action"]["2012-01-14"].value == 0 - assert data["$action"]["2012-01-15"].value == 1 - - @skip("Don't handle breakdowns right now") - def test_insight_trends_merging_breakdown(self): - set_instance_setting("STRICT_CACHING_TEAMS", "all") - - events_by_person = { - "1": [ - { - "event": "$action", - "timestamp": datetime(2012, 1, 13, 3), - "properties": {"key": "1"}, - }, - { - "event": "$action", - "timestamp": datetime(2012, 1, 13, 3), - "properties": {"key": "2"}, - }, - ], - "2": [ - { - "event": "$action", - "timestamp": datetime(2012, 1, 13, 3), - "properties": {"key": "1"}, - } - ], - } - journeys_for(events_by_person, self.team) - - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequestBreakdown( - date_from="-14d", - display="ActionsLineGraph", - events=[ - { - "id": "$action", - "math": "dau", - "name": "$action", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - } - ], - breakdown="key", - ) - data = get_trends_time_series_ok(self.client, request, self.team) - - assert data["$action - 1"]["2012-01-13"].value == 2 - assert data["$action - 1"]["2012-01-14"].value == 0 - assert data["$action - 1"]["2012-01-15"].value == 0 - - assert data["$action - 2"]["2012-01-13"].value == 1 - assert data["$action - 2"]["2012-01-14"].value == 0 - assert data["$action - 2"]["2012-01-15"].value == 0 - - events_by_person = { - "1": [ - { - "event": "$action", - "timestamp": datetime(2012, 1, 15, 3), - "properties": {"key": "2"}, - } - ], - "2": [ - { - "event": "$action", - "timestamp": datetime(2012, 1, 13, 3), - "properties": {"key": "2"}, - } - ], # this won't be counted - } - journeys_for(events_by_person, self.team) - - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequestBreakdown( - date_from="-14d", - display="ActionsLineGraph", - events=[ - { - "id": "$action", - "math": "dau", - "name": "$action", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - } - ], - breakdown="key", - refresh=True, - ) - data = get_trends_time_series_ok(self.client, request, self.team) - - assert data["$action - 1"]["2012-01-13"].value == 2 - assert data["$action - 1"]["2012-01-14"].value == 0 - assert data["$action - 1"]["2012-01-15"].value == 0 - - assert data["$action - 2"]["2012-01-13"].value == 1 - assert data["$action - 2"]["2012-01-14"].value == 0 - assert data["$action - 2"]["2012-01-15"].value == 1 - - @skip("Don't handle breakdowns right now") - def test_insight_trends_merging_breakdown_multiple(self): - set_instance_setting("STRICT_CACHING_TEAMS", "all") - - events_by_person = { - "1": [ - { - "event": "$pageview", - "timestamp": datetime(2012, 1, 13, 3), - "properties": {"key": "1"}, - }, - { - "event": "$action", - "timestamp": datetime(2012, 1, 13, 3), - "properties": {"key": "1"}, - }, - { - "event": "$action", - "timestamp": datetime(2012, 1, 13, 3), - "properties": {"key": "2"}, - }, - ], - "2": [ - { - "event": "$pageview", - "timestamp": datetime(2012, 1, 13, 3), - "properties": {"key": "1"}, - }, - { - "event": "$action", - "timestamp": datetime(2012, 1, 13, 3), - "properties": {"key": "1"}, - }, - ], - } - journeys_for(events_by_person, self.team) - - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequestBreakdown( - date_from="-14d", - display="ActionsLineGraph", - events=[ - { - "id": "$pageview", - "math": "dau", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - }, - { - "id": "$action", - "math": "dau", - "name": "$action", - "custom_name": None, - "type": "events", - "order": 1, - "properties": [], - "math_property": None, - }, - ], - breakdown="key", - ) - data = get_trends_time_series_ok(self.client, request, self.team) - - assert data["$pageview - 1"]["2012-01-13"].value == 2 - assert data["$pageview - 1"]["2012-01-14"].value == 0 - assert data["$pageview - 1"]["2012-01-15"].value == 0 - - assert data["$action - 1"]["2012-01-13"].value == 2 - assert data["$action - 1"]["2012-01-14"].value == 0 - assert data["$action - 1"]["2012-01-15"].value == 0 - - assert data["$action - 2"]["2012-01-13"].value == 1 - assert data["$action - 2"]["2012-01-14"].value == 0 - assert data["$action - 2"]["2012-01-15"].value == 0 - - events_by_person = { - "1": [ - { - "event": "$pageview", - "timestamp": datetime(2012, 1, 15, 3), - "properties": {"key": "1"}, - }, - { - "event": "$action", - "timestamp": datetime(2012, 1, 15, 3), - "properties": {"key": "2"}, - }, - ], - "2": [ - { - "event": "$action", - "timestamp": datetime(2012, 1, 13, 3), - "properties": {"key": "2"}, - } # this won't be counted - ], - } - journeys_for(events_by_person, self.team) - - with freeze_time("2012-01-15T04:01:34.000Z"): - request = TrendsRequestBreakdown( - date_from="-14d", - display="ActionsLineGraph", - events=[ - { - "id": "$pageview", - "math": "dau", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - }, - { - "id": "$action", - "math": "dau", - "name": "$action", - "custom_name": None, - "type": "events", - "order": 1, - "properties": [], - "math_property": None, - }, - ], - breakdown="key", - refresh=True, - ) - data = get_trends_time_series_ok(self.client, request, self.team) - - assert data["$pageview - 1"]["2012-01-13"].value == 2 - assert data["$pageview - 1"]["2012-01-14"].value == 0 - assert data["$pageview - 1"]["2012-01-15"].value == 1 - - assert data["$action - 1"]["2012-01-13"].value == 2 - assert data["$action - 1"]["2012-01-14"].value == 0 - assert data["$action - 1"]["2012-01-15"].value == 0 - - assert data["$action - 2"]["2012-01-13"].value == 1 - assert data["$action - 2"]["2012-01-14"].value == 0 - assert data["$action - 2"]["2012-01-15"].value == 1 - - # When the latest time interval in the cached result doesn't match the current interval, do not use caching pattern - @snapshot_clickhouse_queries - def test_insight_trends_merging_skipped_interval(self): - set_instance_setting("STRICT_CACHING_TEAMS", "all") - - events_by_person = { - "1": [{"event": "$pageview", "timestamp": datetime(2012, 1, 13, 3)}], - "2": [{"event": "$pageview", "timestamp": datetime(2012, 1, 13, 3)}], - } - journeys_for(events_by_person, self.team) - - with freeze_time("2012-01-14T04:01:34.000Z"): - request = TrendsRequest( - date_from="-14d", - display="ActionsLineGraph", - events=[ - { - "id": "$pageview", - "math": "dau", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - } - ], - ) - data = get_trends_time_series_ok(self.client, request, self.team) - - assert data["$pageview"]["2012-01-13"].value == 2 - assert data["$pageview"]["2012-01-14"].value == 0 - - events_by_person = { - "1": [{"event": "$pageview", "timestamp": datetime(2012, 1, 15, 3)}], - "2": [{"event": "$pageview", "timestamp": datetime(2012, 1, 16, 3)}], - } - journeys_for(events_by_person, self.team) - - with freeze_time("2012-01-16T04:01:34.000Z"): - request = TrendsRequest( - date_from="-14d", - display="ActionsLineGraph", - events=[ - { - "id": "$pageview", - "math": "dau", - "name": "$pageview", - "custom_name": None, - "type": "events", - "order": 0, - "properties": [], - "math_property": None, - } - ], - refresh=True, - ) - data = get_trends_time_series_ok(self.client, request, self.team) - - assert data["$pageview"]["2012-01-13"].value == 2 - assert data["$pageview"]["2012-01-14"].value == 0 - assert data["$pageview"]["2012-01-15"].value == 1 - assert data["$pageview"]["2012-01-16"].value == 1 diff --git a/ee/conftest.py b/ee/conftest.py deleted file mode 100644 index 0de792f0d3d..00000000000 --- a/ee/conftest.py +++ /dev/null @@ -1,2 +0,0 @@ -# flake8: noqa -from posthog.conftest import * diff --git a/ee/frontend/exports.ts b/ee/frontend/exports.ts deleted file mode 100644 index d973463019b..00000000000 --- a/ee/frontend/exports.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { PostHogEE } from '@posthog/ee/types' - -import { transformEventToWeb, transformToWeb } from './mobile-replay' - -export default async (): Promise => - Promise.resolve({ - enabled: true, - mobileReplay: { - transformEventToWeb, - transformToWeb, - }, - }) diff --git a/ee/frontend/mobile-replay/__mocks__/increment-with-child-duplication.json b/ee/frontend/mobile-replay/__mocks__/increment-with-child-duplication.json deleted file mode 100644 index 7ffc2e5f38e..00000000000 --- a/ee/frontend/mobile-replay/__mocks__/increment-with-child-duplication.json +++ /dev/null @@ -1,217 +0,0 @@ -{ - "data": { - "adds": [ - { - "parentId": 183891344, - "wireframe": { - "childWireframes": [ - { - "childWireframes": [ - { - "disabled": false, - "height": 19, - "id": 52129787, - "style": { - "color": "#000000", - "fontFamily": "sans-serif", - "fontSize": 14, - "horizontalAlign": "left", - "paddingBottom": 0, - "paddingLeft": 0, - "paddingRight": 0, - "paddingTop": 0, - "verticalAlign": "top" - }, - "text": "PostHog/posthog-ios", - "type": "text", - "width": 368, - "x": 66, - "y": 556 - }, - { - "disabled": false, - "height": 19, - "id": 99571736, - "style": { - "color": "#000000", - "fontFamily": "sans-serif", - "fontSize": 14, - "horizontalAlign": "left", - "paddingBottom": 0, - "paddingLeft": 0, - "paddingRight": 0, - "paddingTop": 0, - "verticalAlign": "top" - }, - "text": "PostHog iOS integration", - "type": "text", - "width": 150, - "x": 10, - "y": 584 - }, - { - "disabled": false, - "height": 32, - "id": 240124529, - "style": { - "color": "#000000", - "fontFamily": "sans-serif", - "fontSize": 14, - "horizontalAlign": "center", - "paddingBottom": 6, - "paddingLeft": 32, - "paddingRight": 0, - "paddingTop": 6, - "verticalAlign": "center" - }, - "text": "20", - "type": "text", - "width": 48, - "x": 10, - "y": 548 - } - ], - "disabled": false, - "height": 62, - "id": 209272202, - "style": {}, - "width": 406, - "x": 2, - "y": 540 - } - ], - "disabled": false, - "height": 70, - "id": 142908405, - "style": { - "backgroundImage": "iVBORw0KGgoAAAANSUhEUgAABDgAAAC5CAYAAADNs4/hAAAAAXNSR0IArs4c6QAAAARzQklUCAgI\nCHwIZIgAAAWeSURBVHic7dyxqh1lGIbR77cIBuzTWKUShDSChVh7ITaWFoH0Emsr78D7sBVCBFOZ\nQMo0XoAEEshvZeeZ8cR9PDywVjsfm7d+mNkzAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAADA7Vn/5mjv/fHMfDszX83MgxtdBAAAADDzbGZ+npkf1lqvzo5PA8fe++uZ+XFm\n7v73bQAAAADX8npmvllr/XR0dBg49t73Z+b3mblzwWEAAAAA1/FmZj5da7286uCDkx94POIGAAAA\ncLvuzMx3RwdngeOzy20BAAAAeG+HjeLKT1T23h/OzJ9zHkEAAAAAbtq7mflorfX6nx6e/QfHvpFJ\nAAAAANe01rqyY3g7AwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgT\nOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACA\nPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAA\nAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgA\nAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyB\nAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADI\nEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAA\ngDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMA\nAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4\nAAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8\ngQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAA\nyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAA\nAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIED\nAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgT\nOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACA\nPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAA\nAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgA\nAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIOwscL/6XFQAAAADHDhvFWeD47YJD\nAAAAAN7XYaNYRw/33p/PzC/jUxYAAADg9rydmS/WWk+vOjgMF2utJzPz+NKrAAAAAK7h0VHcmDl5\ng+Nve+8vZ+b7mflkZu5dYBgAAADAkT9m5vnMPFxr/XrbYwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAgCN/AW0xMqHnNQceAAAAAElFTkSuQmCC\n" - }, - "width": 411, - "x": 0, - "y": 536 - } - }, - { - "parentId": 142908405, - "wireframe": { - "childWireframes": [ - { - "disabled": false, - "height": 19, - "id": 52129787, - "style": { - "color": "#000000", - "fontFamily": "sans-serif", - "fontSize": 14, - "horizontalAlign": "left", - "paddingBottom": 0, - "paddingLeft": 0, - "paddingRight": 0, - "paddingTop": 0, - "verticalAlign": "top" - }, - "text": "PostHog/posthog-ios", - "type": "text", - "width": 368, - "x": 66, - "y": 556 - }, - { - "disabled": false, - "height": 19, - "id": 99571736, - "style": { - "color": "#000000", - "fontFamily": "sans-serif", - "fontSize": 14, - "horizontalAlign": "left", - "paddingBottom": 0, - "paddingLeft": 0, - "paddingRight": 0, - "paddingTop": 0, - "verticalAlign": "top" - }, - "text": "PostHog iOS integration", - "type": "text", - "width": 150, - "x": 10, - "y": 584 - }, - { - "disabled": false, - "height": 32, - "id": 240124529, - "style": { - "color": "#000000", - "fontFamily": "sans-serif", - "fontSize": 14, - "horizontalAlign": "center", - "paddingBottom": 6, - "paddingLeft": 32, - "paddingRight": 0, - "paddingTop": 6, - "verticalAlign": "center" - }, - "text": "20", - "type": "text", - "width": 48, - "x": 10, - "y": 548 - } - ], - "disabled": false, - "height": 62, - "id": 209272202, - "style": {}, - "width": 406, - "x": 2, - "y": 540 - } - }, - { - "parentId": 209272202, - "wireframe": { - "disabled": false, - "height": 19, - "id": 52129787, - "style": { - "color": "#000000", - "fontFamily": "sans-serif", - "fontSize": 14, - "horizontalAlign": "left", - "paddingBottom": 0, - "paddingLeft": 0, - "paddingRight": 0, - "paddingTop": 0, - "verticalAlign": "top" - }, - "text": "PostHog/posthog-ios", - "type": "text", - "width": 368, - "x": 66, - "y": 556 - } - }, - { - "parentId": 209272202, - "wireframe": { - "id": 52129787123, - "type": "text" - } - } - ], - "removes": [ - { - "id": 149659273, - "parentId": 47740111 - }, - { - "id": 151255663, - "parentId": 149659273 - } - ], - "source": 0 - }, - "timestamp": 1706104140861, - "type": 3 -} diff --git a/ee/frontend/mobile-replay/__snapshots__/transform.test.ts.snap b/ee/frontend/mobile-replay/__snapshots__/transform.test.ts.snap deleted file mode 100644 index 7a2e0b8820b..00000000000 --- a/ee/frontend/mobile-replay/__snapshots__/transform.test.ts.snap +++ /dev/null @@ -1,9005 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`replay/transform transform can convert images 1`] = ` -[ - { - "data": { - "height": 600, - "href": "", - "width": 300, - }, - "timestamp": 1, - "type": 4, - }, - { - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 102, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 101, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12345, - "style": "color: #ffffff;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;overflow:hidden;white-space:normal;", - }, - "childNodes": [ - { - "id": 100, - "textContent": "Ⱏ遲䩞㡛쓯잘ጫ䵤㥦鷁끞鈅毅┌빯湌Თ", - "type": 3, - }, - ], - "id": 12345, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 12345, - "height": 30, - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=", - "style": "width: 100px;height: 30px;position: fixed;left: 25px;top: 42px;", - "width": 100, - }, - "childNodes": [], - "id": 12345, - "tagName": "img", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, - }, -] -`; - -exports[`replay/transform transform can convert invalid text wireframe 1`] = ` -[ - { - "data": { - "height": 600, - "href": "", - "width": 300, - }, - "timestamp": 1, - "type": 4, - }, - { - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 102, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 101, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12345, - "style": "border-width: 4px;border-radius: 10px;border-color: #ee3ee4;border-style: solid;color: #ee3ee4;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;overflow:hidden;white-space:normal;", - }, - "childNodes": [], - "id": 12345, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, - }, -] -`; - -exports[`replay/transform transform can convert navigation bar 1`] = ` -[ - { - "data": { - "height": 600, - "href": "", - "width": 300, - }, - "timestamp": 1, - "type": 4, - }, - { - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 107, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 106, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12345, - "style": "border-width: 4px;border-radius: 10px;border-color: #ee3ee4;border-style: solid;color: #ee3ee4;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;display:flex;flex-direction:row;align-items:center;justify-content:space-around;color:black;", - }, - "childNodes": [ - { - "attributes": {}, - "childNodes": [ - { - "id": 101, - "textContent": "◀", - "type": 3, - }, - ], - "id": 100, - "tagName": "div", - "type": 2, - }, - { - "attributes": {}, - "childNodes": [ - { - "id": 103, - "textContent": "⚪", - "type": 3, - }, - ], - "id": 102, - "tagName": "div", - "type": 2, - }, - { - "attributes": {}, - "childNodes": [ - { - "id": 105, - "textContent": "⬜️", - "type": 3, - }, - ], - "id": 104, - "tagName": "div", - "type": 2, - }, - ], - "id": 12345, - "tagName": "div", - "type": 2, - }, - ], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, - }, -] -`; - -exports[`replay/transform transform can convert rect with text 1`] = ` -[ - { - "data": { - "height": 600, - "href": "", - "width": 300, - }, - "timestamp": 1, - "type": 4, - }, - { - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 102, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 101, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12345, - "style": "border-width: 4px;border-radius: 10px;border-color: #ee3ee4;border-style: solid;color: #ee3ee4;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;", - }, - "childNodes": [], - "id": 12345, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 12345, - "style": "width: 100px;height: 30px;position: fixed;left: 13px;top: 17px;overflow:hidden;white-space:normal;", - }, - "childNodes": [ - { - "id": 100, - "textContent": "i am in the box", - "type": 3, - }, - ], - "id": 12345, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, - }, -] -`; - -exports[`replay/transform transform can convert status bar 1`] = ` -[ - { - "data": { - "height": 600, - "href": "", - "width": 300, - }, - "timestamp": 1, - "type": 4, - }, - { - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 104, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 103, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12, - "style": "color: black;width: 100px;height: 0px;position: fixed;left: 13px;top: 17px;display:flex;flex-direction:row;align-items:center;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 102, - "style": "width: 5px;", - }, - "childNodes": [], - "id": 102, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 100, - }, - "childNodes": [ - { - "id": 101, - "textContent": "12:00 AM", - "type": 3, - }, - ], - "id": 100, - "tagName": "div", - "type": 2, - }, - ], - "id": 12, - "tagName": "div", - "type": 2, - }, - ], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, - }, -] -`; - -exports[`replay/transform transform can ignore unknown wireframe types 1`] = ` -[ - { - "data": { - "height": 600, - "href": "", - "width": 300, - }, - "timestamp": 1, - "type": 4, - }, - { - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, - }, -] -`; - -exports[`replay/transform transform can process screenshot mutation 1`] = ` -[ - { - "data": { - "height": 600, - "href": "", - "width": 300, - }, - "timestamp": 1, - "type": 4, - }, - { - "data": { - "adds": [ - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 151700670, - "height": 914, - "src": "data:image/png;base64,mutated-image-content", - "style": "background-color: #F3EFF7;width: 411px;height: 914px;position: fixed;left: 0px;top: 0px;", - "width": 411, - }, - "childNodes": [], - "id": 151700670, - "tagName": "img", - "type": 2, - }, - "parentId": 5, - }, - ], - "attributes": [], - "removes": [ - { - "id": 151700670, - "parentId": 5, - }, - ], - "source": 0, - "texts": [], - }, - "seen": 3551987272322930, - "timestamp": 1714397336836, - "type": 3, - "windowId": "5173a13e-abac-4def-b227-2f81dc2808b6", - }, -] -`; - -exports[`replay/transform transform can process top level screenshot 1`] = ` -[ - { - "data": { - "height": 600, - "href": "", - "width": 300, - }, - "timestamp": 1, - "type": 4, - }, - { - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 151700670, - "height": 914, - "src": "data:image/png;base64,image-content", - "style": "background-color: #F3EFF7;width: 411px;height: 914px;position: fixed;left: 0px;top: 0px;", - "width": 411, - }, - "childNodes": [], - "id": 151700670, - "tagName": "img", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1714397321578, - "type": 2, - }, -] -`; - -exports[`replay/transform transform can process unknown types without error 1`] = ` -[ - { - "data": { - "height": 600, - "href": "", - "width": 300, - }, - "timestamp": 1, - "type": 4, - }, - { - "data": { - "height": 600, - "href": "included when present", - "width": 300, - }, - "timestamp": 1, - "type": 4, - }, - { - "type": 9999, - }, - { - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 102, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 101, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12345, - "style": "background-color: #f3f4ef;background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNOCAwSDE2TDAgMTZWOEw4IDBaIiBmaWxsPSIjMkQyRDJEIi8+CjxwYXRoIGQ9Ik0xNiA4VjE2SDhMMTYgOFoiIGZpbGw9IiMyRDJEMkQiLz4KPC9zdmc+Cg==");background-size: auto;background-repeat: unset;color: #35373e;width: 100px;height: 30px;position: fixed;left: 25px;top: 42px;align-items: center;justify-content: center;display: flex;", - }, - "childNodes": [ - { - "id": 100, - "textContent": "image", - "type": 3, - }, - ], - "id": 12345, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, - }, -] -`; - -exports[`replay/transform transform can set background image to base64 png 1`] = ` -[ - { - "data": { - "height": 600, - "href": "", - "width": 300, - }, - "timestamp": 1, - "type": 4, - }, - { - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12345, - "style": "background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=');background-size: contain;background-repeat: no-repeat;height: 30px;position: fixed;left: 0px;top: 0px;overflow:hidden;white-space:nowrap;", - }, - "childNodes": [], - "id": 12345, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 12346, - "style": "background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=');background-size: contain;background-repeat: no-repeat;height: 30px;position: fixed;left: 0px;top: 0px;overflow:hidden;white-space:nowrap;", - }, - "childNodes": [], - "id": 12346, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 12346, - "style": "background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=');background-size: cover;background-repeat: no-repeat;height: 30px;position: fixed;left: 0px;top: 0px;overflow:hidden;white-space:nowrap;", - }, - "childNodes": [], - "id": 12346, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 12346, - "style": "height: 30px;position: fixed;left: 0px;top: 0px;overflow:hidden;white-space:nowrap;", - }, - "childNodes": [], - "id": 12346, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, - }, -] -`; - -exports[`replay/transform transform can short-circuit non-mobile full snapshot 1`] = ` -[ - { - "data": { - "height": 600, - "href": "https://my-awesome.site", - "width": 300, - }, - "timestamp": 1, - "type": 4, - }, - { - "data": { - "node": { - "the": "payload", - }, - }, - "timestamp": 1, - "type": 2, - }, -] -`; - -exports[`replay/transform transform child wireframes are processed 1`] = ` -[ - { - "data": { - "height": 600, - "href": "", - "width": 300, - }, - "timestamp": 1, - "type": 4, - }, - { - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 104, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 103, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 123456789, - "style": "position: fixed;left: 0px;top: 0px;overflow:hidden;white-space:nowrap;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 98765, - "style": "position: fixed;left: 0px;top: 0px;overflow:hidden;white-space:nowrap;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12345, - "style": "background-color: #000000;border-width: 4px;border-radius: 10px;border-color: #000ddd;border-style: solid;color: #ffffff;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;overflow:hidden;white-space:normal;", - }, - "childNodes": [ - { - "id": 100, - "textContent": "first nested", - "type": 3, - }, - ], - "id": 12345, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 12345, - "style": "background-color: #000000;border-width: 4px;border-radius: 10px;border-color: #000ddd;border-style: solid;color: #ffffff;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;overflow:hidden;white-space:normal;", - }, - "childNodes": [ - { - "id": 101, - "textContent": "second nested", - "type": 3, - }, - ], - "id": 12345, - "tagName": "div", - "type": 2, - }, - ], - "id": 98765, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 12345, - "style": "background-color: #000000;border-width: 4px;border-radius: 10px;border-color: #000ddd;border-style: solid;color: #ffffff;width: 100px;height: 30px;position: fixed;left: 11px;top: 12px;overflow:hidden;white-space:normal;", - }, - "childNodes": [ - { - "id": 102, - "textContent": "third (different level) nested", - "type": 3, - }, - ], - "id": 12345, - "tagName": "div", - "type": 2, - }, - ], - "id": 123456789, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, - }, -] -`; - -exports[`replay/transform transform incremental mutations de-duplicate the tree 1`] = ` -{ - "data": { - "adds": [ - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 142908405, - "style": "background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABDgAAAC5CAYAAADNs4/hAAAAAXNSR0IArs4c6QAAAARzQklUCAgICHwIZIgAAAWeSURBVHic7dyxqh1lGIbR77cIBuzTWKUShDSChVh7ITaWFoH0Emsr78D7sBVCBFOZQMo0XoAEEshvZeeZ8cR9PDywVjsfm7d+mNkzAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA7Vn/5mjv/fHMfDszX83MgxtdBAAAADDzbGZ+npkf1lqvzo5PA8fe++uZ+XFm7v73bQAAAADX8npmvllr/XR0dBg49t73Z+b3mblzwWEAAAAA1/FmZj5da7286uCDkx94POIGAAAAcLvuzMx3RwdngeOzy20BAAAAeG+HjeLKT1T23h/OzJ9zHkEAAAAAbtq7mflorfX6nx6e/QfHvpFJAAAAANe01rqyY3g7AwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIOwscL/6XFQAAAADHDhvFWeD47YJDAAAAAN7XYaNYRw/33p/PzC/jUxYAAADg9rydmS/WWk+vOjgMF2utJzPz+NKrAAAAAK7h0VHcmDl5g+Nve+8vZ+b7mflkZu5dYBgAAADAkT9m5vnMPFxr/XrbYwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgCN/AW0xMqHnNQceAAAAAElFTkSuQmCC');background-size: contain;background-repeat: no-repeat;width: 411px;height: 70px;position: fixed;left: 0px;top: 536px;overflow:hidden;white-space:nowrap;", - }, - "childNodes": [], - "id": 142908405, - "tagName": "div", - "type": 2, - }, - "parentId": 183891344, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 209272202, - "style": "width: 406px;height: 62px;position: fixed;left: 2px;top: 540px;overflow:hidden;white-space:nowrap;", - }, - "childNodes": [], - "id": 209272202, - "tagName": "div", - "type": 2, - }, - "parentId": 142908405, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 99571736, - "style": "color: #000000;width: 150px;height: 19px;position: fixed;left: 10px;top: 584px;align-items: flex-start;justify-content: flex-start;display: flex;padding-left: 0px;padding-right: 0px;padding-top: 0px;padding-bottom: 0px;font-size: 14px;font-family: sans-serif;overflow:hidden;white-space:normal;", - }, - "childNodes": [], - "id": 99571736, - "tagName": "div", - "type": 2, - }, - "parentId": 209272202, - }, - { - "nextId": null, - "node": { - "id": 109, - "textContent": "PostHog iOS integration", - "type": 3, - }, - "parentId": 99571736, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 240124529, - "style": "color: #000000;width: 48px;height: 32px;position: fixed;left: 10px;top: 548px;align-items: center;justify-content: center;display: flex;padding-left: 32px;padding-right: 0px;padding-top: 6px;padding-bottom: 6px;font-size: 14px;font-family: sans-serif;overflow:hidden;white-space:normal;", - }, - "childNodes": [], - "id": 240124529, - "tagName": "div", - "type": 2, - }, - "parentId": 209272202, - }, - { - "nextId": null, - "node": { - "id": 110, - "textContent": "20", - "type": 3, - }, - "parentId": 240124529, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 52129787, - "style": "color: #000000;width: 368px;height: 19px;position: fixed;left: 66px;top: 556px;align-items: flex-start;justify-content: flex-start;display: flex;padding-left: 0px;padding-right: 0px;padding-top: 0px;padding-bottom: 0px;font-size: 14px;font-family: sans-serif;overflow:hidden;white-space:normal;", - }, - "childNodes": [], - "id": 52129787, - "tagName": "div", - "type": 2, - }, - "parentId": 209272202, - }, - { - "nextId": null, - "node": { - "id": 111, - "textContent": "PostHog/posthog-ios", - "type": 3, - }, - "parentId": 52129787, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 52129787123, - "style": "position: fixed;left: 0px;top: 0px;overflow:hidden;white-space:normal;", - }, - "childNodes": [], - "id": 52129787123, - "tagName": "div", - "type": 2, - }, - "parentId": 209272202, - }, - ], - "attributes": [], - "removes": [ - { - "id": 149659273, - "parentId": 47740111, - }, - { - "id": 151255663, - "parentId": 149659273, - }, - ], - "source": 0, - "texts": [], - }, - "default": { - "data": { - "adds": [ - { - "parentId": 183891344, - "wireframe": { - "childWireframes": [ - { - "childWireframes": [ - { - "disabled": false, - "height": 19, - "id": 52129787, - "style": { - "color": "#000000", - "fontFamily": "sans-serif", - "fontSize": 14, - "horizontalAlign": "left", - "paddingBottom": 0, - "paddingLeft": 0, - "paddingRight": 0, - "paddingTop": 0, - "verticalAlign": "top", - }, - "text": "PostHog/posthog-ios", - "type": "text", - "width": 368, - "x": 66, - "y": 556, - }, - { - "disabled": false, - "height": 19, - "id": 99571736, - "style": { - "color": "#000000", - "fontFamily": "sans-serif", - "fontSize": 14, - "horizontalAlign": "left", - "paddingBottom": 0, - "paddingLeft": 0, - "paddingRight": 0, - "paddingTop": 0, - "verticalAlign": "top", - }, - "text": "PostHog iOS integration", - "type": "text", - "width": 150, - "x": 10, - "y": 584, - }, - { - "disabled": false, - "height": 32, - "id": 240124529, - "style": { - "color": "#000000", - "fontFamily": "sans-serif", - "fontSize": 14, - "horizontalAlign": "center", - "paddingBottom": 6, - "paddingLeft": 32, - "paddingRight": 0, - "paddingTop": 6, - "verticalAlign": "center", - }, - "text": "20", - "type": "text", - "width": 48, - "x": 10, - "y": 548, - }, - ], - "disabled": false, - "height": 62, - "id": 209272202, - "style": {}, - "width": 406, - "x": 2, - "y": 540, - }, - ], - "disabled": false, - "height": 70, - "id": 142908405, - "style": { - "backgroundImage": "iVBORw0KGgoAAAANSUhEUgAABDgAAAC5CAYAAADNs4/hAAAAAXNSR0IArs4c6QAAAARzQklUCAgI -CHwIZIgAAAWeSURBVHic7dyxqh1lGIbR77cIBuzTWKUShDSChVh7ITaWFoH0Emsr78D7sBVCBFOZ -QMo0XoAEEshvZeeZ8cR9PDywVjsfm7d+mNkzAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAADA7Vn/5mjv/fHMfDszX83MgxtdBAAAADDzbGZ+npkf1lqvzo5PA8fe++uZ+XFm -7v73bQAAAADX8npmvllr/XR0dBg49t73Z+b3mblzwWEAAAAA1/FmZj5da7286uCDkx94POIGAAAA -cLvuzMx3RwdngeOzy20BAAAAeG+HjeLKT1T23h/OzJ9zHkEAAAAAbtq7mflorfX6nx6e/QfHvpFJ -AAAAANe01rqyY3g7AwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgT -OAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACA -PIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAA -AMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgA -AACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyB -AwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADI -EzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAA -gDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMA -AADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4 -AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8 -gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAA -yBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAA -AIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIED -AAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgT -OAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACA -PIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAA -AMgTOAAAAIA8gQMAAADIEzgAAACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIEzgA -AACAPIEDAAAAyBM4AAAAgDyBAwAAAMgTOAAAAIA8gQMAAADIOwscL/6XFQAAAADHDhvFWeD47YJD -AAAAAN7XYaNYRw/33p/PzC/jUxYAAADg9rydmS/WWk+vOjgMF2utJzPz+NKrAAAAAK7h0VHcmDl5 -g+Nve+8vZ+b7mflkZu5dYBgAAADAkT9m5vnMPFxr/XrbYwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAgCN/AW0xMqHnNQceAAAAAElFTkSuQmCC -", - }, - "width": 411, - "x": 0, - "y": 536, - }, - }, - { - "parentId": 142908405, - "wireframe": { - "childWireframes": [ - { - "disabled": false, - "height": 19, - "id": 52129787, - "style": { - "color": "#000000", - "fontFamily": "sans-serif", - "fontSize": 14, - "horizontalAlign": "left", - "paddingBottom": 0, - "paddingLeft": 0, - "paddingRight": 0, - "paddingTop": 0, - "verticalAlign": "top", - }, - "text": "PostHog/posthog-ios", - "type": "text", - "width": 368, - "x": 66, - "y": 556, - }, - { - "disabled": false, - "height": 19, - "id": 99571736, - "style": { - "color": "#000000", - "fontFamily": "sans-serif", - "fontSize": 14, - "horizontalAlign": "left", - "paddingBottom": 0, - "paddingLeft": 0, - "paddingRight": 0, - "paddingTop": 0, - "verticalAlign": "top", - }, - "text": "PostHog iOS integration", - "type": "text", - "width": 150, - "x": 10, - "y": 584, - }, - { - "disabled": false, - "height": 32, - "id": 240124529, - "style": { - "color": "#000000", - "fontFamily": "sans-serif", - "fontSize": 14, - "horizontalAlign": "center", - "paddingBottom": 6, - "paddingLeft": 32, - "paddingRight": 0, - "paddingTop": 6, - "verticalAlign": "center", - }, - "text": "20", - "type": "text", - "width": 48, - "x": 10, - "y": 548, - }, - ], - "disabled": false, - "height": 62, - "id": 209272202, - "style": {}, - "width": 406, - "x": 2, - "y": 540, - }, - }, - { - "parentId": 209272202, - "wireframe": { - "disabled": false, - "height": 19, - "id": 52129787, - "style": { - "color": "#000000", - "fontFamily": "sans-serif", - "fontSize": 14, - "horizontalAlign": "left", - "paddingBottom": 0, - "paddingLeft": 0, - "paddingRight": 0, - "paddingTop": 0, - "verticalAlign": "top", - }, - "text": "PostHog/posthog-ios", - "type": "text", - "width": 368, - "x": 66, - "y": 556, - }, - }, - { - "parentId": 209272202, - "wireframe": { - "id": 52129787123, - "type": "text", - }, - }, - ], - "removes": [ - { - "id": 151255663, - "parentId": 149659273, - }, - { - "id": 149659273, - "parentId": 47740111, - }, - ], - "source": 0, - }, - "timestamp": 1706104140861, - "type": 3, - }, - "timestamp": 1706104140861, - "type": 3, -} -`; - -exports[`replay/transform transform inputs buttons with nested elements 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 102, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 101, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12359, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "button", - }, - "childNodes": [], - "id": 12359, - "tagName": "button", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 12361, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "button", - }, - "childNodes": [ - { - "id": 100, - "textContent": "click me", - "type": 3, - }, - ], - "id": 12361, - "tagName": "button", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs closed keyboard custom event 1`] = ` -{ - "data": { - "adds": [], - "attributes": [], - "removes": [ - { - "id": 10, - "parentId": 9, - }, - ], - "source": 0, - "texts": [], - }, - "timestamp": 1, - "type": 3, -} -`; - -exports[`replay/transform transform inputs input - $inputType - hello 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - button - click me 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 102, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 101, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12358, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "button", - }, - "childNodes": [ - { - "id": 100, - "textContent": "click me", - "type": 3, - }, - ], - "id": 12358, - "tagName": "button", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - checkbox - $value 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 103, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 102, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 101, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [ - { - "attributes": { - "checked": true, - "data-rrweb-id": 12357, - "style": null, - "type": "checkbox", - }, - "childNodes": [], - "id": 12357, - "tagName": "input", - "type": 2, - }, - { - "id": 100, - "textContent": "first", - "type": 3, - }, - ], - "id": 101, - "tagName": "label", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - checkbox - $value 2`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 103, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 102, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 101, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12357, - "style": null, - "type": "checkbox", - }, - "childNodes": [], - "id": 12357, - "tagName": "input", - "type": 2, - }, - { - "id": 100, - "textContent": "second", - "type": 3, - }, - ], - "id": 101, - "tagName": "label", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - checkbox - $value 3`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 103, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 102, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 101, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [ - { - "attributes": { - "checked": true, - "data-rrweb-id": 12357, - "disabled": true, - "style": null, - "type": "checkbox", - }, - "childNodes": [], - "id": 12357, - "tagName": "input", - "type": 2, - }, - { - "id": 100, - "textContent": "third", - "type": 3, - }, - ], - "id": 101, - "tagName": "label", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - checkbox - $value 4`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "checked": true, - "data-rrweb-id": 12357, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "checkbox", - }, - "childNodes": [], - "id": 12357, - "tagName": "input", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - email - $value 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12349, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "email", - "value": "", - }, - "childNodes": [], - "id": 12349, - "tagName": "input", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - number - $value 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12350, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "number", - "value": "", - }, - "childNodes": [], - "id": 12350, - "tagName": "input", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - password - $value 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12348, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "password", - "value": "", - }, - "childNodes": [], - "id": 12348, - "tagName": "input", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - progress - $value 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 104, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 103, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12365, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 102, - "style": "background-color: #f3f4ef;width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;border: 4px solid #35373e;border-radius: 50%;border-top: 4px solid #fff;animation: spin 2s linear infinite;", - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": "@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 102, - "tagName": "div", - "type": 2, - }, - ], - "id": 12365, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - progress - $value 2`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12365, - "max": null, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": null, - "value": null, - }, - "childNodes": [], - "id": 12365, - "tagName": "progress", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - progress - 0.75 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12365, - "max": null, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": null, - "value": 0.75, - }, - "childNodes": [], - "id": 12365, - "tagName": "progress", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - progress - 0.75 2`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12365, - "max": 2.5, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": null, - "value": 0.75, - }, - "childNodes": [], - "id": 12365, - "tagName": "progress", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - search - $value 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12351, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "search", - "value": "", - }, - "childNodes": [], - "id": 12351, - "tagName": "input", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - select - hello 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 105, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 104, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12365, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "select", - "value": "hello", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 100, - "selected": true, - }, - "childNodes": [ - { - "id": 101, - "textContent": "hello", - "type": 3, - }, - ], - "id": 100, - "tagName": "option", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 102, - }, - "childNodes": [ - { - "id": 103, - "textContent": "world", - "type": 3, - }, - ], - "id": 102, - "tagName": "option", - "type": 2, - }, - ], - "id": 12365, - "tagName": "select", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - tel - $value 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12352, - "disabled": true, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "tel", - "value": "", - }, - "childNodes": [], - "id": 12352, - "tagName": "input", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - text - $value 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12347, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "text", - "value": "", - }, - "childNodes": [], - "id": 12347, - "tagName": "input", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - text - hello 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12346, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "text", - "value": "hello", - }, - "childNodes": [], - "id": 12346, - "tagName": "input", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - textArea - $value 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12364, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "textArea", - "value": "", - }, - "childNodes": [], - "id": 12364, - "tagName": "input", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - textArea - hello 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12363, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "textArea", - "value": "hello", - }, - "childNodes": [], - "id": 12363, - "tagName": "input", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - toggle - $value 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 106, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 105, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 104, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [ - { - "id": 103, - "textContent": "first", - "type": 3, - }, - { - "attributes": { - "data-rrweb-id": 12357, - "style": "height:100%;flex:1;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 100, - "style": "position:relative;width:100%;height:100%;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 101, - "data-toggle-part": "slider", - "style": "position:absolute;top:33%;left:5%;display:inline-block;width:75%;height:33%;opacity: 0.2;border-radius:7.5%;background-color:#1d4aff;", - }, - "childNodes": [], - "id": 101, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 102, - "data-toggle-part": "handle", - "style": "position:absolute;top:1.5%;right:5%;display:flex;align-items:center;justify-content:center;width:40%;height:75%;cursor:inherit;border-radius:50%;background-color:#1d4aff;border:2px solid #1d4aff;", - }, - "childNodes": [], - "id": 102, - "tagName": "div", - "type": 2, - }, - ], - "id": 100, - "tagName": "div", - "type": 2, - }, - ], - "id": 12357, - "tagName": "div", - "type": 2, - }, - ], - "id": 104, - "tagName": "label", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - toggle - $value 2`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 106, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 105, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 104, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [ - { - "id": 103, - "textContent": "second", - "type": 3, - }, - { - "attributes": { - "data-rrweb-id": 12357, - "style": "height:100%;flex:1;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 100, - "style": "position:relative;width:100%;height:100%;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 101, - "data-toggle-part": "slider", - "style": "position:absolute;top:33%;left:5%;display:inline-block;width:75%;height:33%;opacity: 0.2;border-radius:7.5%;background-color:#f3f4ef;", - }, - "childNodes": [], - "id": 101, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 102, - "data-toggle-part": "handle", - "style": "position:absolute;top:1.5%;left:5%;display:flex;align-items:center;justify-content:center;width:40%;height:75%;cursor:inherit;border-radius:50%;background-color:#f3f4ef;border:2px solid #f3f4ef;", - }, - "childNodes": [], - "id": 102, - "tagName": "div", - "type": 2, - }, - ], - "id": 100, - "tagName": "div", - "type": 2, - }, - ], - "id": 12357, - "tagName": "div", - "type": 2, - }, - ], - "id": 104, - "tagName": "label", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - toggle - $value 3`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 106, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 105, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 104, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [ - { - "id": 103, - "textContent": "third", - "type": 3, - }, - { - "attributes": { - "data-rrweb-id": 12357, - "style": "height:100%;flex:1;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 100, - "style": "position:relative;width:100%;height:100%;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 101, - "data-toggle-part": "slider", - "style": "position:absolute;top:33%;left:5%;display:inline-block;width:75%;height:33%;opacity: 0.2;border-radius:7.5%;background-color:#1d4aff;", - }, - "childNodes": [], - "id": 101, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 102, - "data-toggle-part": "handle", - "style": "position:absolute;top:1.5%;right:5%;display:flex;align-items:center;justify-content:center;width:40%;height:75%;cursor:inherit;border-radius:50%;background-color:#1d4aff;border:2px solid #1d4aff;", - }, - "childNodes": [], - "id": 102, - "tagName": "div", - "type": 2, - }, - ], - "id": 100, - "tagName": "div", - "type": 2, - }, - ], - "id": 12357, - "tagName": "div", - "type": 2, - }, - ], - "id": 104, - "tagName": "label", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - toggle - $value 4`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 104, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 103, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12357, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 100, - "style": "position:relative;width:100%;height:100%;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 101, - "data-toggle-part": "slider", - "style": "position:absolute;top:33%;left:5%;display:inline-block;width:75%;height:33%;opacity: 0.2;border-radius:7.5%;background-color:#1d4aff;", - }, - "childNodes": [], - "id": 101, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 102, - "data-toggle-part": "handle", - "style": "position:absolute;top:1.5%;right:5%;display:flex;align-items:center;justify-content:center;width:40%;height:75%;cursor:inherit;border-radius:50%;background-color:#1d4aff;border:2px solid #1d4aff;", - }, - "childNodes": [], - "id": 102, - "tagName": "div", - "type": 2, - }, - ], - "id": 100, - "tagName": "div", - "type": 2, - }, - ], - "id": 12357, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input - url - https://example.io 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12352, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "url", - "value": "https://example.io", - }, - "childNodes": [], - "id": 12352, - "tagName": "input", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs input gets 0 padding by default but can be overridden 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12359, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - "type": "text", - "value": "", - }, - "childNodes": [], - "id": 12359, - "tagName": "input", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 12361, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;padding-left: 16px;padding-right: 16px;", - "type": "text", - "value": "", - }, - "childNodes": [], - "id": 12361, - "tagName": "input", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs isolated add mutation 1`] = ` -{ - "data": { - "adds": [ - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 12365, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [], - "id": 12365, - "tagName": "div", - "type": 2, - }, - "parentId": 54321, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 201, - "style": "position: relative;display: flex;flex-direction: row;padding: 2px 4px;", - }, - "childNodes": [], - "id": 201, - "tagName": "div", - "type": 2, - }, - "parentId": 12365, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 153, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 153, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 201, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 154, - }, - "childNodes": [], - "id": 154, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 153, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 155, - }, - "childNodes": [], - "id": 155, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 153, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 157, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 157, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 201, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 158, - }, - "childNodes": [], - "id": 158, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 157, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 159, - }, - "childNodes": [], - "id": 159, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 157, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 161, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 161, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 201, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 162, - }, - "childNodes": [], - "id": 162, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 161, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 163, - }, - "childNodes": [], - "id": 163, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 161, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 165, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 165, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 201, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 166, - }, - "childNodes": [], - "id": 166, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 165, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 167, - }, - "childNodes": [], - "id": 167, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 165, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 169, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 169, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 201, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 170, - }, - "childNodes": [], - "id": 170, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 169, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 171, - }, - "childNodes": [], - "id": 171, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 169, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 173, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 173, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 201, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 174, - }, - "childNodes": [], - "id": 174, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 173, - }, - { - "nextId": null, - "node": { - "id": 176, - "textContent": "filled star", - "type": 3, - }, - "parentId": 174, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 175, - }, - "childNodes": [], - "id": 175, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 173, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 177, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 177, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 201, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 178, - }, - "childNodes": [], - "id": 178, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 177, - }, - { - "nextId": null, - "node": { - "id": 180, - "textContent": "half-filled star", - "type": 3, - }, - "parentId": 178, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,15.4V6.1L13.71,10.13L18.09,10.5L14.77,13.39L15.76,17.67M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 179, - }, - "childNodes": [], - "id": 179, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 177, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 181, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 181, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 201, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 182, - }, - "childNodes": [], - "id": 182, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 181, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 183, - }, - "childNodes": [], - "id": 183, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 181, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 185, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 185, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 201, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 186, - }, - "childNodes": [], - "id": 186, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 185, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 187, - }, - "childNodes": [], - "id": 187, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 185, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 189, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 189, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 201, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 190, - }, - "childNodes": [], - "id": 190, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 189, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 191, - }, - "childNodes": [], - "id": 191, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 189, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 193, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 193, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 201, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 194, - }, - "childNodes": [], - "id": 194, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 193, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 195, - }, - "childNodes": [], - "id": 195, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 193, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 197, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 197, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 201, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 198, - }, - "childNodes": [], - "id": 198, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 197, - }, - { - "nextId": null, - "node": { - "id": 200, - "textContent": "empty star", - "type": 3, - }, - "parentId": 198, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 199, - }, - "childNodes": [], - "id": 199, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 197, - }, - ], - "attributes": [], - "removes": [], - "source": 0, - "texts": [], - }, - "timestamp": 1, - "type": 3, -} -`; - -exports[`replay/transform transform inputs isolated remove mutation 1`] = ` -{ - "data": { - "removes": [ - { - "id": 12345, - "parentId": 54321, - }, - ], - "source": 0, - }, - "timestamp": 1, - "type": 3, -} -`; - -exports[`replay/transform transform inputs isolated update mutation 1`] = ` -{ - "data": { - "adds": [ - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 12365, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [], - "id": 12365, - "tagName": "div", - "type": 2, - }, - "parentId": 54321, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 250, - "style": "position: relative;display: flex;flex-direction: row;padding: 2px 4px;", - }, - "childNodes": [], - "id": 250, - "tagName": "div", - "type": 2, - }, - "parentId": 12365, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 202, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 202, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 250, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 203, - }, - "childNodes": [], - "id": 203, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 202, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 204, - }, - "childNodes": [], - "id": 204, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 202, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 206, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 206, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 250, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 207, - }, - "childNodes": [], - "id": 207, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 206, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 208, - }, - "childNodes": [], - "id": 208, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 206, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 210, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 210, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 250, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 211, - }, - "childNodes": [], - "id": 211, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 210, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 212, - }, - "childNodes": [], - "id": 212, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 210, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 214, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 214, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 250, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 215, - }, - "childNodes": [], - "id": 215, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 214, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 216, - }, - "childNodes": [], - "id": 216, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 214, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 218, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 218, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 250, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 219, - }, - "childNodes": [], - "id": 219, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 218, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 220, - }, - "childNodes": [], - "id": 220, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 218, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 222, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 222, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 250, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 223, - }, - "childNodes": [], - "id": 223, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 222, - }, - { - "nextId": null, - "node": { - "id": 225, - "textContent": "filled star", - "type": 3, - }, - "parentId": 223, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 224, - }, - "childNodes": [], - "id": 224, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 222, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 226, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 226, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 250, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 227, - }, - "childNodes": [], - "id": 227, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 226, - }, - { - "nextId": null, - "node": { - "id": 229, - "textContent": "half-filled star", - "type": 3, - }, - "parentId": 227, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,15.4V6.1L13.71,10.13L18.09,10.5L14.77,13.39L15.76,17.67M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 228, - }, - "childNodes": [], - "id": 228, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 226, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 230, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 230, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 250, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 231, - }, - "childNodes": [], - "id": 231, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 230, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 232, - }, - "childNodes": [], - "id": 232, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 230, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 234, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 234, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 250, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 235, - }, - "childNodes": [], - "id": 235, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 234, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 236, - }, - "childNodes": [], - "id": 236, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 234, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 238, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 238, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 250, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 239, - }, - "childNodes": [], - "id": 239, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 238, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 240, - }, - "childNodes": [], - "id": 240, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 238, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 242, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 242, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 250, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 243, - }, - "childNodes": [], - "id": 243, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 242, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 244, - }, - "childNodes": [], - "id": 244, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 242, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 246, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [], - "id": 246, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - "parentId": 250, - }, - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 247, - }, - "childNodes": [], - "id": 247, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - "parentId": 246, - }, - { - "nextId": null, - "node": { - "id": 249, - "textContent": "empty star", - "type": 3, - }, - "parentId": 247, - }, - { - "nextId": null, - "node": { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 248, - }, - "childNodes": [], - "id": 248, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - "parentId": 246, - }, - ], - "attributes": [], - "removes": [ - { - "id": 12365, - "parentId": 54321, - }, - ], - "source": 0, - "texts": [], - }, - "timestamp": 1, - "type": 3, -} -`; - -exports[`replay/transform transform inputs open keyboard custom event 1`] = ` -{ - "data": { - "adds": [ - { - "nextId": null, - "node": { - "attributes": { - "data-rrweb-id": 10, - "style": "background-color: #f3f4ef;background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNOCAwSDE2TDAgMTZWOEw4IDBaIiBmaWxsPSIjMkQyRDJEIi8+CjxwYXRoIGQ9Ik0xNiA4VjE2SDhMMTYgOFoiIGZpbGw9IiMyRDJEMkQiLz4KPC9zdmc+Cg==");background-size: auto;background-repeat: unset;color: #35373e;width: 100vw;height: 150px;bottom: 0;position: fixed;align-items: center;justify-content: center;display: flex;", - }, - "childNodes": [ - { - "id": 151, - "textContent": "keyboard", - "type": 3, - }, - ], - "id": 10, - "tagName": "div", - "type": 2, - }, - "parentId": 9, - }, - { - "nextId": null, - "node": { - "id": 152, - "textContent": "keyboard", - "type": 3, - }, - "parentId": 10, - }, - ], - "attributes": [], - "removes": [], - "source": 0, - "texts": [], - }, - "timestamp": 1, - "type": 3, -} -`; - -exports[`replay/transform transform inputs placeholder - $inputType - $value 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 102, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 101, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12365, - "style": "background-color: #f3f4ef;background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNOCAwSDE2TDAgMTZWOEw4IDBaIiBmaWxsPSIjMkQyRDJEIi8+CjxwYXRoIGQ9Ik0xNiA4VjE2SDhMMTYgOFoiIGZpbGw9IiMyRDJEMkQiLz4KPC9zdmc+Cg==");background-size: auto;background-repeat: unset;color: #35373e;width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;align-items: center;justify-content: center;display: flex;", - }, - "childNodes": [ - { - "id": 100, - "textContent": "hello", - "type": 3, - }, - ], - "id": 12365, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs progress rating 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 150, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 149, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12365, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 148, - "style": "position: relative;display: flex;flex-direction: row;padding: 2px 4px;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 100, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 101, - }, - "childNodes": [ - { - "id": 103, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 101, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 102, - }, - "childNodes": [], - "id": 102, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 100, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 104, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 105, - }, - "childNodes": [ - { - "id": 107, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 105, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 106, - }, - "childNodes": [], - "id": 106, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 104, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 108, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 109, - }, - "childNodes": [ - { - "id": 111, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 109, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 110, - }, - "childNodes": [], - "id": 110, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 108, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 112, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 113, - }, - "childNodes": [ - { - "id": 115, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 113, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 114, - }, - "childNodes": [], - "id": 114, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 112, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 116, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 117, - }, - "childNodes": [ - { - "id": 119, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 117, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 118, - }, - "childNodes": [], - "id": 118, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 116, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 120, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 121, - }, - "childNodes": [ - { - "id": 123, - "textContent": "filled star", - "type": 3, - }, - ], - "id": 121, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z", - "data-rrweb-id": 122, - }, - "childNodes": [], - "id": 122, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 120, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 124, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 125, - }, - "childNodes": [ - { - "id": 127, - "textContent": "half-filled star", - "type": 3, - }, - ], - "id": 125, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.4V6.1L13.71,10.13L18.09,10.5L14.77,13.39L15.76,17.67M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 126, - }, - "childNodes": [], - "id": 126, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 124, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 128, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 129, - }, - "childNodes": [ - { - "id": 131, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 129, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 130, - }, - "childNodes": [], - "id": 130, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 128, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 132, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 133, - }, - "childNodes": [ - { - "id": 135, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 133, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 134, - }, - "childNodes": [], - "id": 134, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 132, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 136, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 137, - }, - "childNodes": [ - { - "id": 139, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 137, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 138, - }, - "childNodes": [], - "id": 138, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 136, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 140, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 141, - }, - "childNodes": [ - { - "id": 143, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 141, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 142, - }, - "childNodes": [], - "id": 142, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 140, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 144, - "fill": "currentColor", - "style": "height: 100%;overflow-clip-margin: content-box;overflow:hidden;", - "viewBox": "0 0 24 24", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 145, - }, - "childNodes": [ - { - "id": 147, - "textContent": "empty star", - "type": 3, - }, - ], - "id": 145, - "isSVG": true, - "tagName": "title", - "type": 2, - }, - { - "attributes": { - "d": "M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z", - "data-rrweb-id": 146, - }, - "childNodes": [], - "id": 146, - "isSVG": true, - "tagName": "path", - "type": 2, - }, - ], - "id": 144, - "isSVG": true, - "tagName": "svg", - "type": 2, - }, - ], - "id": 148, - "tagName": "div", - "type": 2, - }, - ], - "id": 12365, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs radio group - $inputType - $value 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs radio_group - $inputType - $value 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 123123, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [], - "id": 123123, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs radio_group 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 101, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 100, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 54321, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [], - "id": 54321, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs web_view - $inputType - $value 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 102, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 101, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12365, - "style": "background-color: #f3f4ef;background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNOCAwSDE2TDAgMTZWOEw4IDBaIiBmaWxsPSIjMkQyRDJEIi8+CjxwYXRoIGQ9Ik0xNiA4VjE2SDhMMTYgOFoiIGZpbGw9IiMyRDJEMkQiLz4KPC9zdmc+Cg==");background-size: auto;background-repeat: unset;color: #35373e;width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;align-items: center;justify-content: center;display: flex;", - }, - "childNodes": [ - { - "id": 100, - "textContent": "web_view", - "type": 3, - }, - ], - "id": 12365, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs web_view with URL 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 102, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 101, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12365, - "style": "background-color: #f3f4ef;background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNOCAwSDE2TDAgMTZWOEw4IDBaIiBmaWxsPSIjMkQyRDJEIi8+CjxwYXRoIGQ9Ik0xNiA4VjE2SDhMMTYgOFoiIGZpbGw9IiMyRDJEMkQiLz4KPC9zdmc+Cg==");background-size: auto;background-repeat: unset;color: #35373e;width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;align-items: center;justify-content: center;display: flex;", - }, - "childNodes": [ - { - "id": 100, - "textContent": "https://example.com", - "type": 3, - }, - ], - "id": 12365, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform inputs wrapping with labels 1`] = ` -{ - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 103, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 102, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 101, - "style": "width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12359, - "style": null, - "type": "checkbox", - }, - "childNodes": [], - "id": 12359, - "tagName": "input", - "type": 2, - }, - { - "id": 100, - "textContent": "i will wrap the checkbox", - "type": 3, - }, - ], - "id": 101, - "tagName": "label", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, -} -`; - -exports[`replay/transform transform omitting x and y is equivalent to setting them to 0 1`] = ` -[ - { - "data": { - "initialOffset": { - "left": 0, - "top": 0, - }, - "node": { - "childNodes": [ - { - "id": 2, - "name": "html", - "publicId": "", - "systemId": "", - "type": 1, - }, - { - "attributes": { - "data-rrweb-id": 3, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 4, - }, - "childNodes": [ - { - "attributes": { - "type": "text/css", - }, - "childNodes": [ - { - "id": 102, - "textContent": " - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - ", - "type": 3, - }, - ], - "id": 101, - "tagName": "style", - "type": 2, - }, - ], - "id": 4, - "tagName": "head", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 5, - "style": "height: 100vh; width: 100vw;", - }, - "childNodes": [ - { - "attributes": { - "data-rrweb-id": 12345, - "style": "background-color: #f3f4ef;background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNOCAwSDE2TDAgMTZWOEw4IDBaIiBmaWxsPSIjMkQyRDJEIi8+CjxwYXRoIGQ9Ik0xNiA4VjE2SDhMMTYgOFoiIGZpbGw9IiMyRDJEMkQiLz4KPC9zdmc+Cg==");background-size: auto;background-repeat: unset;color: #35373e;width: 100px;height: 30px;position: fixed;left: 0px;top: 0px;align-items: center;justify-content: center;display: flex;", - }, - "childNodes": [ - { - "id": 100, - "textContent": "image", - "type": 3, - }, - ], - "id": 12345, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-render-reason": "a fixed placeholder to contain the keyboard in the correct stacking position", - "data-rrweb-id": 9, - }, - "childNodes": [], - "id": 9, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 7, - }, - "childNodes": [], - "id": 7, - "tagName": "div", - "type": 2, - }, - { - "attributes": { - "data-rrweb-id": 11, - }, - "childNodes": [], - "id": 11, - "tagName": "div", - "type": 2, - }, - ], - "id": 5, - "tagName": "body", - "type": 2, - }, - ], - "id": 3, - "tagName": "html", - "type": 2, - }, - ], - "id": 1, - "type": 0, - }, - }, - "timestamp": 1, - "type": 2, - }, -] -`; - -exports[`replay/transform transform respect incremental ids, replace with body otherwise 1`] = ` -[ - { - "data": { - "id": 5, - "pointerType": 2, - "source": 2, - "type": 7, - "x": 523, - "y": 683, - }, - "delay": 2160, - "timestamp": 1701355473313, - "type": 3, - "windowId": "ddc9c89d-2272-4b07-a280-c00db3a9182f", - }, - { - "data": { - "id": 145, - "pointerType": 2, - "source": 2, - "type": 7, - "x": 523, - "y": 683, - }, - "delay": 2160, - "timestamp": 1701355473313, - "type": 3, - "windowId": "ddc9c89d-2272-4b07-a280-c00db3a9182f", - }, -] -`; diff --git a/ee/frontend/mobile-replay/index.ts b/ee/frontend/mobile-replay/index.ts deleted file mode 100644 index 56a7d2ee45b..00000000000 --- a/ee/frontend/mobile-replay/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { eventWithTime } from '@rrweb/types' -import { captureException, captureMessage } from '@sentry/react' -import Ajv, { ErrorObject } from 'ajv' - -import { mobileEventWithTime } from './mobile.types' -import mobileSchema from './schema/mobile/rr-mobile-schema.json' -import webSchema from './schema/web/rr-web-schema.json' -import { makeCustomEvent, makeFullEvent, makeIncrementalEvent, makeMetaEvent } from './transformer/transformers' - -const ajv = new Ajv({ - allowUnionTypes: true, -}) // options can be passed, e.g. {allErrors: true} - -const transformers: Record eventWithTime> = { - 2: makeFullEvent, - 3: makeIncrementalEvent, - 4: makeMetaEvent, - 5: makeCustomEvent, -} - -const mobileSchemaValidator = ajv.compile(mobileSchema) - -export function validateFromMobile(data: unknown): { - isValid: boolean - errors: ErrorObject[] | null | undefined -} { - const isValid = mobileSchemaValidator(data) - return { - isValid, - errors: isValid ? null : mobileSchemaValidator.errors, - } -} - -const webSchemaValidator = ajv.compile(webSchema) - -function couldBeEventWithTime(x: unknown): x is eventWithTime | mobileEventWithTime { - return typeof x === 'object' && x !== null && 'type' in x && 'timestamp' in x -} - -export function transformEventToWeb(event: unknown, validateTransformation?: boolean): eventWithTime { - // the transformation needs to never break a recording itself - // so, we default to returning what we received - // replacing it only if there's a valid transformation - let result = event as eventWithTime - try { - if (couldBeEventWithTime(event)) { - const transformer = transformers[event.type] - if (transformer) { - const transformed = transformer(event) - if (validateTransformation) { - validateAgainstWebSchema(transformed) - } - result = transformed - } - } else { - captureMessage(`No type in event`, { extra: { event } }) - } - } catch (e) { - captureException(e, { extra: { event } }) - } - return result -} - -export function transformToWeb(mobileData: (eventWithTime | mobileEventWithTime)[]): eventWithTime[] { - return mobileData.reduce((acc, event) => { - const transformed = transformEventToWeb(event) - acc.push(transformed ? transformed : (event as eventWithTime)) - return acc - }, [] as eventWithTime[]) -} - -export function validateAgainstWebSchema(data: unknown): boolean { - const validationResult = webSchemaValidator(data) - if (!validationResult) { - // we are passing all data through this validation now and don't know how safe the schema is - captureMessage('transformation did not match schema', { - extra: { data, errors: webSchemaValidator.errors }, - }) - } - - return validationResult -} diff --git a/ee/frontend/mobile-replay/mobile.types.ts b/ee/frontend/mobile-replay/mobile.types.ts deleted file mode 100644 index 7e18622fa8f..00000000000 --- a/ee/frontend/mobile-replay/mobile.types.ts +++ /dev/null @@ -1,406 +0,0 @@ -// copied from rrweb-snapshot, not included in rrweb types -import { customEvent, EventType, IncrementalSource, removedNodeMutation } from '@rrweb/types' - -export enum NodeType { - Document = 0, - DocumentType = 1, - Element = 2, - Text = 3, - CDATA = 4, - Comment = 5, -} - -export type documentNode = { - type: NodeType.Document - childNodes: serializedNodeWithId[] - compatMode?: string -} - -export type documentTypeNode = { - type: NodeType.DocumentType - name: string - publicId: string - systemId: string -} - -export type attributes = { - [key: string]: string | number | true | null -} - -export type elementNode = { - type: NodeType.Element - tagName: string - attributes: attributes - childNodes: serializedNodeWithId[] - isSVG?: true - needBlock?: boolean - // This is a custom element or not. - isCustom?: true -} - -export type textNode = { - type: NodeType.Text - textContent: string - isStyle?: true -} - -export type cdataNode = { - type: NodeType.CDATA - textContent: '' -} - -export type commentNode = { - type: NodeType.Comment - textContent: string -} - -export type serializedNode = (documentNode | documentTypeNode | elementNode | textNode | cdataNode | commentNode) & { - rootId?: number - isShadowHost?: boolean - isShadow?: boolean -} - -export type serializedNodeWithId = serializedNode & { id: number } - -// end copied section - -export type MobileNodeType = - | 'text' - | 'image' - | 'screenshot' - | 'rectangle' - | 'placeholder' - | 'web_view' - | 'input' - | 'div' - | 'radio_group' - | 'status_bar' - | 'navigation_bar' - -export type MobileStyles = { - /** - * @description maps to CSS color. Accepts any valid CSS color value. Expects a #RGB value e.g. #000 or #000000 - */ - color?: string - /** - * @description maps to CSS background-color. Accepts any valid CSS color value. Expects a #RGB value e.g. #000 or #000000 - */ - backgroundColor?: string - /** - * @description if provided this will be used as a base64 encoded image source for the backgroundImage css property, with no other attributes it is assumed to be a PNG - */ - backgroundImage?: string - /** - * @description can be used alongside the background image property to specify how the image is rendered. Accepts a subset of the valid values for CSS background-size property. If not provided (and backgroundImage is present) defaults to 'auto' - */ - backgroundSize?: 'contain' | 'cover' | 'auto' - /** - * @description if borderWidth is present, then border style is assumed to be solid - */ - borderWidth?: string | number - /** - * @description if borderRadius is present, then border style is assumed to be solid - */ - borderRadius?: string | number - /** - * @description if borderColor is present, then border style is assumed to be solid - */ - borderColor?: string - /** - * @description vertical alignment with respect to its parent - */ - verticalAlign?: 'top' | 'bottom' | 'center' - /** - * @description horizontal alignment with respect to its parent - */ - horizontalAlign?: 'left' | 'right' | 'center' - /** - * @description maps to CSS font-size. Accepts any valid CSS font-size value. Expects a number (treated as pixels) or a string that is a number followed by px e.g. 16px - */ - fontSize?: string | number - /** - * @description maps to CSS font-family. Accepts any valid CSS font-family value. - */ - fontFamily?: string - /** - * @description maps to CSS padding-left. Expects a number (treated as pixels) or a string that is a number followed by px e.g. 16px - */ - paddingLeft?: string | number - /** - * @description maps to CSS padding-right. Expects a number (treated as pixels) or a string that is a number followed by px e.g. 16px - */ - paddingRight?: string | number - /** - * @description maps to CSS padding-top. Expects a number (treated as pixels) or a string that is a number followed by px e.g. 16px - */ - paddingTop?: string | number - /** - * @description maps to CSS padding-bottom. Expects a number (treated as pixels) or a string that is a number followed by px e.g. 16px - */ - paddingBottom?: string | number -} - -type wireframeBase = { - id: number - /** - * @description x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0 - */ - x?: number - y?: number - /* - * @description the width dimension of the element, either '100vw' i.e. viewport width. Or a value in pixels. You can omit the unit when specifying pixels. - */ - width: number | '100vw' - /* - * @description the height dimension of the element, the only accepted units is pixels. You can omit the unit. - */ - height: number - childWireframes?: wireframe[] - type: MobileNodeType - style?: MobileStyles -} - -export type wireframeInputBase = wireframeBase & { - type: 'input' - /** - * @description for several attributes we technically only care about true or absent as values. They are represented as bare attributes in HTML . When true that attribute is added to the HTML element, when absent that attribute is not added to the HTML element. When false or absent they are not added to the element. - */ - disabled: boolean -} - -export type wireframeCheckBox = wireframeInputBase & { - inputType: 'checkbox' - /** - * @description for several attributes we technically only care about true or absent as values. They are represented as bare attributes in HTML . When true that attribute is added to the HTML element, when absent that attribute is not added to the HTML element. When false or absent they are not added to the element. - */ - checked: boolean - label?: string -} - -export type wireframeToggle = wireframeInputBase & { - inputType: 'toggle' - checked: boolean - label?: string -} - -export type wireframeRadioGroup = wireframeBase & { - type: 'radio_group' -} - -export type wireframeRadio = wireframeInputBase & { - inputType: 'radio' - /** - * @description for several attributes we technically only care about true or absent as values. They are represented as bare attributes in HTML . When true that attribute is added to the HTML element, when absent that attribute is not added to the HTML element. When false or absent they are not added to the element. - */ - checked: boolean - label?: string -} - -export type wireframeInput = wireframeInputBase & { - inputType: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url' - value?: string -} - -export type wireframeSelect = wireframeInputBase & { - inputType: 'select' - value?: string - options?: string[] -} - -export type wireframeTextArea = wireframeInputBase & { - inputType: 'text_area' - value?: string -} - -export type wireframeButton = wireframeInputBase & { - inputType: 'button' - /** - * @description this is the text that is displayed on the button, if not sent then you must send childNodes with the button content - */ - value?: string -} - -export type wireframeProgress = wireframeInputBase & { - inputType: 'progress' - /** - * @description This attribute specifies how much of the task that has been completed. It must be a valid floating point number between 0 and max, or between 0 and 1 if max is omitted. If there is no value attribute, the progress bar is indeterminate; this indicates that an activity is ongoing with no indication of how long it is expected to take. When bar style is rating this is the number of filled stars. - */ - value?: number - /** - * @description The max attribute, if present, must have a value greater than 0 and be a valid floating point number. The default value is 1. When bar style is rating this is the number of stars. - */ - max?: number - style?: MobileStyles & { - bar: 'horizontal' | 'circular' | 'rating' - } -} - -// these are grouped as a type so that we can easily use them as function parameters -export type wireframeInputComponent = - | wireframeCheckBox - | wireframeRadio - | wireframeInput - | wireframeSelect - | wireframeTextArea - | wireframeButton - | wireframeProgress - | wireframeToggle - -export type wireframeText = wireframeBase & { - type: 'text' - text: string -} - -export type wireframeImage = wireframeBase & { - type: 'image' - /** - * @description this will be used as base64 encoded image source, with no other attributes it is assumed to be a PNG, if omitted a placeholder is rendered - */ - base64?: string -} - -/** - * @description a screenshot behaves exactly like an image, but it is expected to be a screenshot of the screen at the time of the event, when sent as a mutation it must always attached to the root of the playback, when sent as an initial snapshot it must be sent as the first or only snapshot so that it attaches to the body of the playback - */ -export type wireframeScreenshot = wireframeImage & { - type: 'screenshot' -} - -export type wireframeRectangle = wireframeBase & { - type: 'rectangle' -} - -export type wireframeWebView = wireframeBase & { - type: 'web_view' - url?: string -} - -export type wireframePlaceholder = wireframeBase & { - type: 'placeholder' - label?: string -} - -export type wireframeDiv = wireframeBase & { - /* - * @description this is the default type, if no type is specified then it is assumed to be a div - */ - type: 'div' -} - -/** - * @description the status bar respects styling and positioning, but it is expected to be at the top of the screen with limited styling and no child elements - */ -export type wireframeStatusBar = wireframeBase & { - type: 'status_bar' -} - -/** - * @description the navigation bar respects styling and positioning, but it is expected to be at the bottom of the screen with limited styling and no child elements - */ -export type wireframeNavigationBar = wireframeBase & { - type: 'navigation_bar' -} - -export type wireframe = - | wireframeText - | wireframeImage - | wireframeScreenshot - | wireframeRectangle - | wireframeDiv - | wireframeInputComponent - | wireframeRadioGroup - | wireframeWebView - | wireframePlaceholder - | wireframeStatusBar - | wireframeNavigationBar - -// the rrweb full snapshot event type, but it contains wireframes not html -export type fullSnapshotEvent = { - type: EventType.FullSnapshot - data: { - /** - * @description This mimics the RRWeb full snapshot event type, except instead of reporting a serialized DOM it reports a wireframe representation of the screen. - */ - wireframes: wireframe[] - initialOffset: { - top: number - left: number - } - } -} - -export type incrementalSnapshotEvent = - | { - type: EventType.IncrementalSnapshot - data: any // keeps a loose incremental type so that we can accept any rrweb incremental snapshot event type - } - | MobileIncrementalSnapshotEvent - -export type MobileNodeMutation = { - parentId: number - wireframe: wireframe -} - -export type MobileNodeMutationData = { - source: IncrementalSource.Mutation - /** - * @description An update is implemented as a remove and then an add, so the updates array contains the ID of the removed node and the wireframe for the added node - */ - updates?: MobileNodeMutation[] - adds?: MobileNodeMutation[] - /** - * @description A mobile remove is identical to a web remove - */ - removes?: removedNodeMutation[] -} - -export type MobileIncrementalSnapshotEvent = { - type: EventType.IncrementalSnapshot - /** - * @description This sits alongside the RRWeb incremental snapshot event type, mobile replay can send any of the RRWeb incremental snapshot event types, which will be passed unchanged to the player - for example to send touch events. removed node mutations are passed unchanged to the player. - */ - data: MobileNodeMutationData -} - -export type metaEvent = { - type: EventType.Meta - data: { - href?: string - width: number - height: number - } -} - -// this is a custom event _but_ rrweb only types tag as string, and we want to be more specific -export type keyboardEvent = { - type: EventType.Custom - data: { - tag: 'keyboard' - payload: - | { - open: true - styles?: MobileStyles - /** - * @description x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present then the keyboard is at the bottom of the screen - */ - x?: number - y?: number - /* - * @description the height dimension of the keyboard, the only accepted units is pixels. You can omit the unit. - */ - height: number - /* - * @description the width dimension of the keyboard, the only accepted units is pixels. You can omit the unit. If not present defaults to width of the viewport - */ - width?: number - } - | { open: false } - } -} - -export type mobileEvent = fullSnapshotEvent | metaEvent | customEvent | incrementalSnapshotEvent | keyboardEvent - -export type mobileEventWithTime = mobileEvent & { - timestamp: number - delay?: number -} diff --git a/ee/frontend/mobile-replay/schema/mobile/rr-mobile-schema.json b/ee/frontend/mobile-replay/schema/mobile/rr-mobile-schema.json deleted file mode 100644 index 55ce111b3ab..00000000000 --- a/ee/frontend/mobile-replay/schema/mobile/rr-mobile-schema.json +++ /dev/null @@ -1,1349 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "anyOf": [ - { - "additionalProperties": false, - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "initialOffset": { - "additionalProperties": false, - "properties": { - "left": { - "type": "number" - }, - "top": { - "type": "number" - } - }, - "required": ["top", "left"], - "type": "object" - }, - "wireframes": { - "description": "This mimics the RRWeb full snapshot event type, except instead of reporting a serialized DOM it reports a wireframe representation of the screen.", - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - } - }, - "required": ["wireframes", "initialOffset"], - "type": "object" - }, - "delay": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "type": { - "$ref": "#/definitions/EventType.FullSnapshot" - } - }, - "required": ["data", "timestamp", "type"], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "height": { - "type": "number" - }, - "href": { - "type": "string" - }, - "width": { - "type": "number" - } - }, - "required": ["width", "height"], - "type": "object" - }, - "delay": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "type": { - "$ref": "#/definitions/EventType.Meta" - } - }, - "required": ["data", "timestamp", "type"], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "payload": {}, - "tag": { - "type": "string" - } - }, - "required": ["tag", "payload"], - "type": "object" - }, - "delay": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "type": { - "$ref": "#/definitions/EventType.Custom" - } - }, - "required": ["data", "timestamp", "type"], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "data": {}, - "delay": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "type": { - "$ref": "#/definitions/EventType.IncrementalSnapshot" - } - }, - "required": ["data", "timestamp", "type"], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "data": { - "$ref": "#/definitions/MobileNodeMutationData", - "description": "This sits alongside the RRWeb incremental snapshot event type, mobile replay can send any of the RRWeb incremental snapshot event types, which will be passed unchanged to the player - for example to send touch events. removed node mutations are passed unchanged to the player." - }, - "delay": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "type": { - "$ref": "#/definitions/EventType.IncrementalSnapshot" - } - }, - "required": ["data", "timestamp", "type"], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "payload": { - "anyOf": [ - { - "additionalProperties": false, - "properties": { - "height": { - "type": "number" - }, - "open": { - "const": true, - "type": "boolean" - }, - "styles": { - "$ref": "#/definitions/MobileStyles" - }, - "width": { - "type": "number" - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present then the keyboard is at the bottom of the screen", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["open", "height"], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "open": { - "const": false, - "type": "boolean" - } - }, - "required": ["open"], - "type": "object" - } - ] - }, - "tag": { - "const": "keyboard", - "type": "string" - } - }, - "required": ["tag", "payload"], - "type": "object" - }, - "delay": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "type": { - "$ref": "#/definitions/EventType.Custom" - } - }, - "required": ["data", "timestamp", "type"], - "type": "object" - } - ], - "definitions": { - "EventType.Custom": { - "const": 5, - "type": "number" - }, - "EventType.FullSnapshot": { - "const": 2, - "type": "number" - }, - "EventType.IncrementalSnapshot": { - "const": 3, - "type": "number" - }, - "EventType.Meta": { - "const": 4, - "type": "number" - }, - "IncrementalSource.Mutation": { - "const": 0, - "type": "number" - }, - "MobileNodeMutation": { - "additionalProperties": false, - "properties": { - "parentId": { - "type": "number" - }, - "wireframe": { - "$ref": "#/definitions/wireframe" - } - }, - "required": ["parentId", "wireframe"], - "type": "object" - }, - "MobileNodeMutationData": { - "additionalProperties": false, - "properties": { - "adds": { - "items": { - "$ref": "#/definitions/MobileNodeMutation" - }, - "type": "array" - }, - "removes": { - "description": "A mobile remove is identical to a web remove", - "items": { - "$ref": "#/definitions/removedNodeMutation" - }, - "type": "array" - }, - "source": { - "$ref": "#/definitions/IncrementalSource.Mutation" - }, - "updates": { - "description": "An update is implemented as a remove and then an add, so the updates array contains the ID of the removed node and the wireframe for the added node", - "items": { - "$ref": "#/definitions/MobileNodeMutation" - }, - "type": "array" - } - }, - "required": ["source"], - "type": "object" - }, - "MobileNodeType": { - "enum": [ - "text", - "image", - "screenshot", - "rectangle", - "placeholder", - "web_view", - "input", - "div", - "radio_group", - "status_bar", - "navigation_bar" - ], - "type": "string" - }, - "MobileStyles": { - "additionalProperties": false, - "properties": { - "backgroundColor": { - "description": "maps to CSS background-color. Accepts any valid CSS color value. Expects a #RGB value e.g. #000 or #000000", - "type": "string" - }, - "backgroundImage": { - "description": "if provided this will be used as a base64 encoded image source for the backgroundImage css property, with no other attributes it is assumed to be a PNG", - "type": "string" - }, - "backgroundSize": { - "description": "can be used alongside the background image property to specify how the image is rendered. Accepts a subset of the valid values for CSS background-size property. If not provided (and backgroundImage is present) defaults to 'auto'", - "enum": ["contain", "cover", "auto"], - "type": "string" - }, - "borderColor": { - "description": "if borderColor is present, then border style is assumed to be solid", - "type": "string" - }, - "borderRadius": { - "description": "if borderRadius is present, then border style is assumed to be solid", - "type": ["string", "number"] - }, - "borderWidth": { - "description": "if borderWidth is present, then border style is assumed to be solid", - "type": ["string", "number"] - }, - "color": { - "description": "maps to CSS color. Accepts any valid CSS color value. Expects a #RGB value e.g. #000 or #000000", - "type": "string" - }, - "fontFamily": { - "description": "maps to CSS font-family. Accepts any valid CSS font-family value.", - "type": "string" - }, - "fontSize": { - "description": "maps to CSS font-size. Accepts any valid CSS font-size value. Expects a number (treated as pixels) or a string that is a number followed by px e.g. 16px", - "type": ["string", "number"] - }, - "horizontalAlign": { - "description": "horizontal alignment with respect to its parent", - "enum": ["left", "right", "center"], - "type": "string" - }, - "paddingBottom": { - "description": "maps to CSS padding-bottom. Expects a number (treated as pixels) or a string that is a number followed by px e.g. 16px", - "type": ["string", "number"] - }, - "paddingLeft": { - "description": "maps to CSS padding-left. Expects a number (treated as pixels) or a string that is a number followed by px e.g. 16px", - "type": ["string", "number"] - }, - "paddingRight": { - "description": "maps to CSS padding-right. Expects a number (treated as pixels) or a string that is a number followed by px e.g. 16px", - "type": ["string", "number"] - }, - "paddingTop": { - "description": "maps to CSS padding-top. Expects a number (treated as pixels) or a string that is a number followed by px e.g. 16px", - "type": ["string", "number"] - }, - "verticalAlign": { - "description": "vertical alignment with respect to its parent", - "enum": ["top", "bottom", "center"], - "type": "string" - } - }, - "type": "object" - }, - "removedNodeMutation": { - "additionalProperties": false, - "properties": { - "id": { - "type": "number" - }, - "isShadow": { - "type": "boolean" - }, - "parentId": { - "type": "number" - } - }, - "required": ["parentId", "id"], - "type": "object" - }, - "wireframe": { - "anyOf": [ - { - "$ref": "#/definitions/wireframeText" - }, - { - "$ref": "#/definitions/wireframeImage" - }, - { - "$ref": "#/definitions/wireframeScreenshot" - }, - { - "$ref": "#/definitions/wireframeRectangle" - }, - { - "$ref": "#/definitions/wireframeDiv" - }, - { - "$ref": "#/definitions/wireframeInputComponent" - }, - { - "$ref": "#/definitions/wireframeRadioGroup" - }, - { - "$ref": "#/definitions/wireframeWebView" - }, - { - "$ref": "#/definitions/wireframePlaceholder" - }, - { - "$ref": "#/definitions/wireframeStatusBar" - }, - { - "$ref": "#/definitions/wireframeNavigationBar" - } - ] - }, - "wireframeButton": { - "additionalProperties": false, - "properties": { - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "disabled": { - "description": "for several attributes we technically only care about true or absent as values. They are represented as bare attributes in HTML . When true that attribute is added to the HTML element, when absent that attribute is not added to the HTML element. When false or absent they are not added to the element.", - "type": "boolean" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "inputType": { - "const": "button", - "type": "string" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "value": { - "description": "this is the text that is displayed on the button, if not sent then you must send childNodes with the button content", - "type": "string" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["disabled", "height", "id", "inputType", "type", "width"], - "type": "object" - }, - "wireframeCheckBox": { - "additionalProperties": false, - "properties": { - "checked": { - "description": "for several attributes we technically only care about true or absent as values. They are represented as bare attributes in HTML . When true that attribute is added to the HTML element, when absent that attribute is not added to the HTML element. When false or absent they are not added to the element.", - "type": "boolean" - }, - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "disabled": { - "description": "for several attributes we technically only care about true or absent as values. They are represented as bare attributes in HTML . When true that attribute is added to the HTML element, when absent that attribute is not added to the HTML element. When false or absent they are not added to the element.", - "type": "boolean" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "inputType": { - "const": "checkbox", - "type": "string" - }, - "label": { - "type": "string" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["checked", "disabled", "height", "id", "inputType", "type", "width"], - "type": "object" - }, - "wireframeDiv": { - "additionalProperties": false, - "properties": { - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["height", "id", "type", "width"], - "type": "object" - }, - "wireframeImage": { - "additionalProperties": false, - "properties": { - "base64": { - "description": "this will be used as base64 encoded image source, with no other attributes it is assumed to be a PNG, if omitted a placeholder is rendered", - "type": "string" - }, - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["height", "id", "type", "width"], - "type": "object" - }, - "wireframeInput": { - "additionalProperties": false, - "properties": { - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "disabled": { - "description": "for several attributes we technically only care about true or absent as values. They are represented as bare attributes in HTML . When true that attribute is added to the HTML element, when absent that attribute is not added to the HTML element. When false or absent they are not added to the element.", - "type": "boolean" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "inputType": { - "enum": ["text", "password", "email", "number", "search", "tel", "url"], - "type": "string" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "value": { - "type": "string" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["disabled", "height", "id", "inputType", "type", "width"], - "type": "object" - }, - "wireframeInputComponent": { - "anyOf": [ - { - "$ref": "#/definitions/wireframeCheckBox" - }, - { - "$ref": "#/definitions/wireframeRadio" - }, - { - "$ref": "#/definitions/wireframeInput" - }, - { - "$ref": "#/definitions/wireframeSelect" - }, - { - "$ref": "#/definitions/wireframeTextArea" - }, - { - "$ref": "#/definitions/wireframeButton" - }, - { - "$ref": "#/definitions/wireframeProgress" - }, - { - "$ref": "#/definitions/wireframeToggle" - } - ] - }, - "wireframeNavigationBar": { - "additionalProperties": false, - "description": "the navigation bar respects styling and positioning, but it is expected to be at the bottom of the screen with limited styling and no child elements", - "properties": { - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["height", "id", "type", "width"], - "type": "object" - }, - "wireframePlaceholder": { - "additionalProperties": false, - "properties": { - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "label": { - "type": "string" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["height", "id", "type", "width"], - "type": "object" - }, - "wireframeProgress": { - "additionalProperties": false, - "properties": { - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "disabled": { - "description": "for several attributes we technically only care about true or absent as values. They are represented as bare attributes in HTML . When true that attribute is added to the HTML element, when absent that attribute is not added to the HTML element. When false or absent they are not added to the element.", - "type": "boolean" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "inputType": { - "const": "progress", - "type": "string" - }, - "max": { - "description": "The max attribute, if present, must have a value greater than 0 and be a valid floating point number. The default value is 1. When bar style is rating this is the number of stars.", - "type": "number" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "value": { - "description": "This attribute specifies how much of the task that has been completed. It must be a valid floating point number between 0 and max, or between 0 and 1 if max is omitted. If there is no value attribute, the progress bar is indeterminate; this indicates that an activity is ongoing with no indication of how long it is expected to take. When bar style is rating this is the number of filled stars.", - "type": "number" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["disabled", "height", "id", "inputType", "type", "width"], - "type": "object" - }, - "wireframeRadio": { - "additionalProperties": false, - "properties": { - "checked": { - "description": "for several attributes we technically only care about true or absent as values. They are represented as bare attributes in HTML . When true that attribute is added to the HTML element, when absent that attribute is not added to the HTML element. When false or absent they are not added to the element.", - "type": "boolean" - }, - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "disabled": { - "description": "for several attributes we technically only care about true or absent as values. They are represented as bare attributes in HTML . When true that attribute is added to the HTML element, when absent that attribute is not added to the HTML element. When false or absent they are not added to the element.", - "type": "boolean" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "inputType": { - "const": "radio", - "type": "string" - }, - "label": { - "type": "string" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["checked", "disabled", "height", "id", "inputType", "type", "width"], - "type": "object" - }, - "wireframeRadioGroup": { - "additionalProperties": false, - "properties": { - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["height", "id", "type", "width"], - "type": "object" - }, - "wireframeRectangle": { - "additionalProperties": false, - "properties": { - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["height", "id", "type", "width"], - "type": "object" - }, - "wireframeScreenshot": { - "additionalProperties": false, - "description": "a screenshot behaves exactly like an image, but it is expected to be a screenshot of the screen at the time of the event, when sent as a mutation it must always attached to the root of the playback, when sent as an initial snapshot it must be sent as the first or only snapshot so that it attaches to the body of the playback", - "properties": { - "base64": { - "description": "this will be used as base64 encoded image source, with no other attributes it is assumed to be a PNG, if omitted a placeholder is rendered", - "type": "string" - }, - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["height", "id", "type", "width"], - "type": "object" - }, - "wireframeSelect": { - "additionalProperties": false, - "properties": { - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "disabled": { - "description": "for several attributes we technically only care about true or absent as values. They are represented as bare attributes in HTML . When true that attribute is added to the HTML element, when absent that attribute is not added to the HTML element. When false or absent they are not added to the element.", - "type": "boolean" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "inputType": { - "const": "select", - "type": "string" - }, - "options": { - "items": { - "type": "string" - }, - "type": "array" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "value": { - "type": "string" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["disabled", "height", "id", "inputType", "type", "width"], - "type": "object" - }, - "wireframeStatusBar": { - "additionalProperties": false, - "description": "the status bar respects styling and positioning, but it is expected to be at the top of the screen with limited styling and no child elements", - "properties": { - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["height", "id", "type", "width"], - "type": "object" - }, - "wireframeText": { - "additionalProperties": false, - "properties": { - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "text": { - "type": "string" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["height", "id", "text", "type", "width"], - "type": "object" - }, - "wireframeTextArea": { - "additionalProperties": false, - "properties": { - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "disabled": { - "description": "for several attributes we technically only care about true or absent as values. They are represented as bare attributes in HTML . When true that attribute is added to the HTML element, when absent that attribute is not added to the HTML element. When false or absent they are not added to the element.", - "type": "boolean" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "inputType": { - "const": "text_area", - "type": "string" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "value": { - "type": "string" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["disabled", "height", "id", "inputType", "type", "width"], - "type": "object" - }, - "wireframeToggle": { - "additionalProperties": false, - "properties": { - "checked": { - "type": "boolean" - }, - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "disabled": { - "description": "for several attributes we technically only care about true or absent as values. They are represented as bare attributes in HTML . When true that attribute is added to the HTML element, when absent that attribute is not added to the HTML element. When false or absent they are not added to the element.", - "type": "boolean" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "inputType": { - "const": "toggle", - "type": "string" - }, - "label": { - "type": "string" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["checked", "disabled", "height", "id", "inputType", "type", "width"], - "type": "object" - }, - "wireframeWebView": { - "additionalProperties": false, - "properties": { - "childWireframes": { - "items": { - "$ref": "#/definitions/wireframe" - }, - "type": "array" - }, - "height": { - "type": "number" - }, - "id": { - "type": "number" - }, - "style": { - "$ref": "#/definitions/MobileStyles" - }, - "type": { - "$ref": "#/definitions/MobileNodeType" - }, - "url": { - "type": "string" - }, - "width": { - "anyOf": [ - { - "type": "number" - }, - { - "const": "100vw", - "type": "string" - } - ] - }, - "x": { - "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned, if they are not present this is equivalent to setting them to 0", - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["height", "id", "type", "width"], - "type": "object" - } - } -} diff --git a/ee/frontend/mobile-replay/schema/web/rr-web-schema.json b/ee/frontend/mobile-replay/schema/web/rr-web-schema.json deleted file mode 100644 index 4f6d3bb7093..00000000000 --- a/ee/frontend/mobile-replay/schema/web/rr-web-schema.json +++ /dev/null @@ -1,981 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "anyOf": [ - { - "additionalProperties": false, - "properties": { - "data": {}, - "delay": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "type": { - "$ref": "#/definitions/EventType.DomContentLoaded" - } - }, - "required": ["data", "timestamp", "type"], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "data": {}, - "delay": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "type": { - "$ref": "#/definitions/EventType.Load" - } - }, - "required": ["data", "timestamp", "type"], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "initialOffset": { - "additionalProperties": false, - "properties": { - "left": { - "type": "number" - }, - "top": { - "type": "number" - } - }, - "required": ["top", "left"], - "type": "object" - }, - "node": {} - }, - "required": ["node", "initialOffset"], - "type": "object" - }, - "delay": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "type": { - "$ref": "#/definitions/EventType.FullSnapshot" - } - }, - "required": ["data", "timestamp", "type"], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "data": { - "$ref": "#/definitions/incrementalData" - }, - "delay": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "type": { - "$ref": "#/definitions/EventType.IncrementalSnapshot" - } - }, - "required": ["data", "timestamp", "type"], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "height": { - "type": "number" - }, - "href": { - "type": "string" - }, - "width": { - "type": "number" - } - }, - "required": ["href", "width", "height"], - "type": "object" - }, - "delay": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "type": { - "$ref": "#/definitions/EventType.Meta" - } - }, - "required": ["data", "timestamp", "type"], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "payload": {}, - "tag": { - "type": "string" - } - }, - "required": ["tag", "payload"], - "type": "object" - }, - "delay": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "type": { - "$ref": "#/definitions/EventType.Custom" - } - }, - "required": ["data", "timestamp", "type"], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "payload": {}, - "plugin": { - "type": "string" - } - }, - "required": ["plugin", "payload"], - "type": "object" - }, - "delay": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "type": { - "$ref": "#/definitions/EventType.Plugin" - } - }, - "required": ["data", "timestamp", "type"], - "type": "object" - } - ], - "definitions": { - "CanvasContext": { - "enum": [0, 1, 2], - "type": "number" - }, - "EventType.Custom": { - "const": 5, - "type": "number" - }, - "EventType.DomContentLoaded": { - "const": 0, - "type": "number" - }, - "EventType.FullSnapshot": { - "const": 2, - "type": "number" - }, - "EventType.IncrementalSnapshot": { - "const": 3, - "type": "number" - }, - "EventType.Load": { - "const": 1, - "type": "number" - }, - "EventType.Meta": { - "const": 4, - "type": "number" - }, - "EventType.Plugin": { - "const": 6, - "type": "number" - }, - "FontDisplay": { - "enum": ["auto", "block", "fallback", "optional", "swap"], - "type": "string" - }, - "FontFaceDescriptors": { - "additionalProperties": false, - "properties": { - "ascentOverride": { - "type": "string" - }, - "descentOverride": { - "type": "string" - }, - "display": { - "$ref": "#/definitions/FontDisplay" - }, - "featureSettings": { - "type": "string" - }, - "lineGapOverride": { - "type": "string" - }, - "stretch": { - "type": "string" - }, - "style": { - "type": "string" - }, - "unicodeRange": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "weight": { - "type": "string" - } - }, - "type": "object" - }, - "IncrementalSource.AdoptedStyleSheet": { - "const": 15, - "type": "number" - }, - "IncrementalSource.CanvasMutation": { - "const": 9, - "type": "number" - }, - "IncrementalSource.CustomElement": { - "const": 16, - "type": "number" - }, - "IncrementalSource.Drag": { - "const": 12, - "type": "number" - }, - "IncrementalSource.Font": { - "const": 10, - "type": "number" - }, - "IncrementalSource.Input": { - "const": 5, - "type": "number" - }, - "IncrementalSource.MediaInteraction": { - "const": 7, - "type": "number" - }, - "IncrementalSource.MouseInteraction": { - "const": 2, - "type": "number" - }, - "IncrementalSource.MouseMove": { - "const": 1, - "type": "number" - }, - "IncrementalSource.Mutation": { - "const": 0, - "type": "number" - }, - "IncrementalSource.Scroll": { - "const": 3, - "type": "number" - }, - "IncrementalSource.Selection": { - "const": 14, - "type": "number" - }, - "IncrementalSource.StyleDeclaration": { - "const": 13, - "type": "number" - }, - "IncrementalSource.StyleSheetRule": { - "const": 8, - "type": "number" - }, - "IncrementalSource.TouchMove": { - "const": 6, - "type": "number" - }, - "IncrementalSource.ViewportResize": { - "const": 4, - "type": "number" - }, - "MediaInteractions": { - "enum": [0, 1, 2, 3, 4], - "type": "number" - }, - "MouseInteractions": { - "enum": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], - "type": "number" - }, - "PointerTypes": { - "enum": [0, 1, 2], - "type": "number" - }, - "SelectionRange": { - "additionalProperties": false, - "properties": { - "end": { - "type": "number" - }, - "endOffset": { - "type": "number" - }, - "start": { - "type": "number" - }, - "startOffset": { - "type": "number" - } - }, - "required": ["start", "startOffset", "end", "endOffset"], - "type": "object" - }, - "addedNodeMutation": { - "additionalProperties": false, - "properties": { - "nextId": { - "type": ["number", "null"] - }, - "node": {}, - "parentId": { - "type": "number" - }, - "previousId": { - "type": ["number", "null"] - } - }, - "required": ["parentId", "nextId", "node"], - "type": "object" - }, - "adoptedStyleSheetData": { - "additionalProperties": false, - "properties": { - "id": { - "type": "number" - }, - "source": { - "$ref": "#/definitions/IncrementalSource.AdoptedStyleSheet" - }, - "styleIds": { - "items": { - "type": "number" - }, - "type": "array" - }, - "styles": { - "items": { - "additionalProperties": false, - "properties": { - "rules": { - "items": { - "$ref": "#/definitions/styleSheetAddRule" - }, - "type": "array" - }, - "styleId": { - "type": "number" - } - }, - "required": ["styleId", "rules"], - "type": "object" - }, - "type": "array" - } - }, - "required": ["id", "source", "styleIds"], - "type": "object" - }, - "attributeMutation": { - "additionalProperties": false, - "properties": { - "attributes": { - "additionalProperties": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/styleOMValue" - }, - { - "type": "null" - } - ] - }, - "type": "object" - }, - "id": { - "type": "number" - } - }, - "required": ["id", "attributes"], - "type": "object" - }, - "canvasMutationCommand": { - "additionalProperties": false, - "properties": { - "args": { - "items": {}, - "type": "array" - }, - "property": { - "type": "string" - }, - "setter": { - "const": true, - "type": "boolean" - } - }, - "required": ["property", "args"], - "type": "object" - }, - "canvasMutationData": { - "anyOf": [ - { - "additionalProperties": false, - "properties": { - "commands": { - "items": { - "$ref": "#/definitions/canvasMutationCommand" - }, - "type": "array" - }, - "id": { - "type": "number" - }, - "source": { - "$ref": "#/definitions/IncrementalSource.CanvasMutation" - }, - "type": { - "$ref": "#/definitions/CanvasContext" - } - }, - "required": ["commands", "id", "source", "type"], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "args": { - "items": {}, - "type": "array" - }, - "id": { - "type": "number" - }, - "property": { - "type": "string" - }, - "setter": { - "const": true, - "type": "boolean" - }, - "source": { - "$ref": "#/definitions/IncrementalSource.CanvasMutation" - }, - "type": { - "$ref": "#/definitions/CanvasContext" - } - }, - "required": ["args", "id", "property", "source", "type"], - "type": "object" - } - ] - }, - "customElementData": { - "additionalProperties": false, - "properties": { - "define": { - "additionalProperties": false, - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"], - "type": "object" - }, - "source": { - "$ref": "#/definitions/IncrementalSource.CustomElement" - } - }, - "required": ["source"], - "type": "object" - }, - "fontData": { - "additionalProperties": false, - "properties": { - "buffer": { - "type": "boolean" - }, - "descriptors": { - "$ref": "#/definitions/FontFaceDescriptors" - }, - "family": { - "type": "string" - }, - "fontSource": { - "type": "string" - }, - "source": { - "$ref": "#/definitions/IncrementalSource.Font" - } - }, - "required": ["buffer", "family", "fontSource", "source"], - "type": "object" - }, - "incrementalData": { - "anyOf": [ - { - "$ref": "#/definitions/mutationData" - }, - { - "$ref": "#/definitions/mousemoveData" - }, - { - "$ref": "#/definitions/mouseInteractionData" - }, - { - "$ref": "#/definitions/scrollData" - }, - { - "$ref": "#/definitions/viewportResizeData" - }, - { - "$ref": "#/definitions/inputData" - }, - { - "$ref": "#/definitions/mediaInteractionData" - }, - { - "$ref": "#/definitions/styleSheetRuleData" - }, - { - "$ref": "#/definitions/canvasMutationData" - }, - { - "$ref": "#/definitions/fontData" - }, - { - "$ref": "#/definitions/selectionData" - }, - { - "$ref": "#/definitions/styleDeclarationData" - }, - { - "$ref": "#/definitions/adoptedStyleSheetData" - }, - { - "$ref": "#/definitions/customElementData" - } - ] - }, - "inputData": { - "additionalProperties": false, - "properties": { - "id": { - "type": "number" - }, - "isChecked": { - "type": "boolean" - }, - "source": { - "$ref": "#/definitions/IncrementalSource.Input" - }, - "text": { - "type": "string" - }, - "userTriggered": { - "type": "boolean" - } - }, - "required": ["id", "isChecked", "source", "text"], - "type": "object" - }, - "mediaInteractionData": { - "additionalProperties": false, - "properties": { - "currentTime": { - "type": "number" - }, - "id": { - "type": "number" - }, - "loop": { - "type": "boolean" - }, - "muted": { - "type": "boolean" - }, - "playbackRate": { - "type": "number" - }, - "source": { - "$ref": "#/definitions/IncrementalSource.MediaInteraction" - }, - "type": { - "$ref": "#/definitions/MediaInteractions" - }, - "volume": { - "type": "number" - } - }, - "required": ["id", "source", "type"], - "type": "object" - }, - "mouseInteractionData": { - "additionalProperties": false, - "properties": { - "id": { - "type": "number" - }, - "pointerType": { - "$ref": "#/definitions/PointerTypes" - }, - "source": { - "$ref": "#/definitions/IncrementalSource.MouseInteraction" - }, - "type": { - "$ref": "#/definitions/MouseInteractions" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["id", "source", "type"], - "type": "object" - }, - "mousePosition": { - "additionalProperties": false, - "properties": { - "id": { - "type": "number" - }, - "timeOffset": { - "type": "number" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["x", "y", "id", "timeOffset"], - "type": "object" - }, - "mousemoveData": { - "additionalProperties": false, - "properties": { - "positions": { - "items": { - "$ref": "#/definitions/mousePosition" - }, - "type": "array" - }, - "source": { - "anyOf": [ - { - "$ref": "#/definitions/IncrementalSource.MouseMove" - }, - { - "$ref": "#/definitions/IncrementalSource.TouchMove" - }, - { - "$ref": "#/definitions/IncrementalSource.Drag" - } - ] - } - }, - "required": ["source", "positions"], - "type": "object" - }, - "mutationData": { - "additionalProperties": false, - "properties": { - "adds": { - "items": { - "$ref": "#/definitions/addedNodeMutation" - }, - "type": "array" - }, - "attributes": { - "items": { - "$ref": "#/definitions/attributeMutation" - }, - "type": "array" - }, - "isAttachIframe": { - "const": true, - "type": "boolean" - }, - "removes": { - "items": { - "$ref": "#/definitions/removedNodeMutation" - }, - "type": "array" - }, - "source": { - "$ref": "#/definitions/IncrementalSource.Mutation" - }, - "texts": { - "items": { - "$ref": "#/definitions/textMutation" - }, - "type": "array" - } - }, - "required": ["adds", "attributes", "removes", "source", "texts"], - "type": "object" - }, - "removedNodeMutation": { - "additionalProperties": false, - "properties": { - "id": { - "type": "number" - }, - "isShadow": { - "type": "boolean" - }, - "parentId": { - "type": "number" - } - }, - "required": ["parentId", "id"], - "type": "object" - }, - "scrollData": { - "additionalProperties": false, - "properties": { - "id": { - "type": "number" - }, - "source": { - "$ref": "#/definitions/IncrementalSource.Scroll" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": ["id", "source", "x", "y"], - "type": "object" - }, - "selectionData": { - "additionalProperties": false, - "properties": { - "ranges": { - "items": { - "$ref": "#/definitions/SelectionRange" - }, - "type": "array" - }, - "source": { - "$ref": "#/definitions/IncrementalSource.Selection" - } - }, - "required": ["ranges", "source"], - "type": "object" - }, - "styleDeclarationData": { - "additionalProperties": false, - "properties": { - "id": { - "type": "number" - }, - "index": { - "items": { - "type": "number" - }, - "type": "array" - }, - "remove": { - "additionalProperties": false, - "properties": { - "property": { - "type": "string" - } - }, - "required": ["property"], - "type": "object" - }, - "set": { - "additionalProperties": false, - "properties": { - "priority": { - "type": "string" - }, - "property": { - "type": "string" - }, - "value": { - "type": ["string", "null"] - } - }, - "required": ["property", "value"], - "type": "object" - }, - "source": { - "$ref": "#/definitions/IncrementalSource.StyleDeclaration" - }, - "styleId": { - "type": "number" - } - }, - "required": ["index", "source"], - "type": "object" - }, - "styleOMValue": { - "additionalProperties": { - "anyOf": [ - { - "$ref": "#/definitions/styleValueWithPriority" - }, - { - "type": "string" - }, - { - "const": false, - "type": "boolean" - } - ] - }, - "type": "object" - }, - "styleSheetAddRule": { - "additionalProperties": false, - "properties": { - "index": { - "anyOf": [ - { - "type": "number" - }, - { - "items": { - "type": "number" - }, - "type": "array" - } - ] - }, - "rule": { - "type": "string" - } - }, - "required": ["rule"], - "type": "object" - }, - "styleSheetDeleteRule": { - "additionalProperties": false, - "properties": { - "index": { - "anyOf": [ - { - "type": "number" - }, - { - "items": { - "type": "number" - }, - "type": "array" - } - ] - } - }, - "required": ["index"], - "type": "object" - }, - "styleSheetRuleData": { - "additionalProperties": false, - "properties": { - "adds": { - "items": { - "$ref": "#/definitions/styleSheetAddRule" - }, - "type": "array" - }, - "id": { - "type": "number" - }, - "removes": { - "items": { - "$ref": "#/definitions/styleSheetDeleteRule" - }, - "type": "array" - }, - "replace": { - "type": "string" - }, - "replaceSync": { - "type": "string" - }, - "source": { - "$ref": "#/definitions/IncrementalSource.StyleSheetRule" - }, - "styleId": { - "type": "number" - } - }, - "required": ["source"], - "type": "object" - }, - "styleValueWithPriority": { - "items": { - "type": "string" - }, - "maxItems": 2, - "minItems": 2, - "type": "array" - }, - "textMutation": { - "additionalProperties": false, - "properties": { - "id": { - "type": "number" - }, - "value": { - "type": ["string", "null"] - } - }, - "required": ["id", "value"], - "type": "object" - }, - "viewportResizeData": { - "additionalProperties": false, - "properties": { - "height": { - "type": "number" - }, - "source": { - "$ref": "#/definitions/IncrementalSource.ViewportResize" - }, - "width": { - "type": "number" - } - }, - "required": ["height", "source", "width"], - "type": "object" - } - } -} diff --git a/ee/frontend/mobile-replay/transform.test.ts b/ee/frontend/mobile-replay/transform.test.ts deleted file mode 100644 index 77c5316d5a2..00000000000 --- a/ee/frontend/mobile-replay/transform.test.ts +++ /dev/null @@ -1,1235 +0,0 @@ -import posthogEE from '@posthog/ee/exports' -import { EventType } from '@rrweb/types' -import { ifEeDescribe } from 'lib/ee.test' - -import { PostHogEE } from '../../../frontend/@posthog/ee/types' -import * as incrementalSnapshotJson from './__mocks__/increment-with-child-duplication.json' -import { validateAgainstWebSchema, validateFromMobile } from './index' -import { wireframe } from './mobile.types' -import { stripBarsFromWireframes } from './transformer/transformers' - -const unspecifiedBase64ImageURL = - 'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=' - -const heartEyesEmojiURL = 'data:image/png;base64,' + unspecifiedBase64ImageURL - -function fakeWireframe(type: string, children?: wireframe[]): wireframe { - // this is a fake so we can force the type - return { type, childWireframes: children || [] } as Partial as wireframe -} - -describe('replay/transform', () => { - describe('validation', () => { - test('example of validating incoming _invalid_ data', () => { - const invalidData = { - foo: 'abc', - bar: 'abc', - } - - expect(validateFromMobile(invalidData).isValid).toBe(false) - }) - - test('example of validating mobile meta event', () => { - const validData = { - data: { width: 1, height: 1 }, - timestamp: 1, - type: EventType.Meta, - } - - expect(validateFromMobile(validData)).toStrictEqual({ - isValid: true, - errors: null, - }) - }) - - describe('validate web schema', () => { - test('does not block when invalid', () => { - expect(validateAgainstWebSchema({})).toBeFalsy() - }) - - test('should be valid when...', () => { - expect(validateAgainstWebSchema({ data: {}, timestamp: 12345, type: 0 })).toBeTruthy() - }) - }) - }) - - ifEeDescribe('transform', () => { - let posthogEEModule: PostHogEE - beforeEach(async () => { - posthogEEModule = await posthogEE() - }) - - test('can process top level screenshot', () => { - expect( - posthogEEModule.mobileReplay?.transformToWeb([ - { - data: { width: 300, height: 600 }, - timestamp: 1, - type: 4, - }, - { - windowId: '5173a13e-abac-4def-b227-2f81dc2808b6', - data: { - wireframes: [ - { - base64: 'image-content', - height: 914, - id: 151700670, - style: { - backgroundColor: '#F3EFF7', - }, - type: 'screenshot', - width: 411, - x: 0, - y: 0, - }, - ], - }, - timestamp: 1714397321578, - type: 2, - }, - ]) - ).toMatchSnapshot() - }) - - test('can process screenshot mutation', () => { - expect( - posthogEEModule.mobileReplay?.transformToWeb([ - { - data: { width: 300, height: 600 }, - timestamp: 1, - type: 4, - }, - { - windowId: '5173a13e-abac-4def-b227-2f81dc2808b6', - data: { - source: 0, - updates: [ - { - wireframe: { - base64: 'mutated-image-content', - height: 914, - id: 151700670, - style: { - backgroundColor: '#F3EFF7', - }, - type: 'screenshot', - width: 411, - x: 0, - y: 0, - }, - }, - ], - }, - timestamp: 1714397336836, - type: 3, - seen: 3551987272322930, - }, - ]) - ).toMatchSnapshot() - }) - - test('can process unknown types without error', () => { - expect( - posthogEEModule.mobileReplay?.transformToWeb([ - { - data: { width: 300, height: 600 }, - timestamp: 1, - type: 4, - }, - { - data: { href: 'included when present', width: 300, height: 600 }, - timestamp: 1, - type: 4, - }, - { type: 9999 }, - { - type: 2, - data: { - wireframes: [ - { - id: 12345, - x: 25, - y: 42, - width: 100, - height: 30, - type: 'image', - }, - ], - }, - timestamp: 1, - }, - ]) - ).toMatchSnapshot() - }) - - test('can ignore unknown wireframe types', () => { - const unexpectedWireframeType = posthogEEModule.mobileReplay?.transformToWeb([ - { - data: { screen: 'App Home Page', width: 300, height: 600 }, - timestamp: 1, - type: 4, - }, - { - type: 2, - data: { - wireframes: [ - { - id: 12345, - x: 11, - y: 12, - width: 100, - height: 30, - type: 'something in the SDK but not yet the transformer', - }, - ], - }, - timestamp: 1, - }, - ]) - expect(unexpectedWireframeType).toMatchSnapshot() - }) - - test('can short-circuit non-mobile full snapshot', () => { - const allWeb = posthogEEModule.mobileReplay?.transformToWeb([ - { - data: { href: 'https://my-awesome.site', width: 300, height: 600 }, - timestamp: 1, - type: 4, - }, - { - type: 2, - data: { - node: { the: 'payload' }, - }, - timestamp: 1, - }, - ]) - expect(allWeb).toMatchSnapshot() - }) - - test('can convert images', () => { - const exampleWithImage = posthogEEModule.mobileReplay?.transformToWeb([ - { - data: { - screen: 'App Home Page', - width: 300, - height: 600, - }, - timestamp: 1, - type: 4, - }, - { - type: 2, - data: { - wireframes: [ - { - id: 12345, - x: 11, - y: 12, - width: 100, - height: 30, - // clip: { - // bottom: 83, - // right: 44, - // }, - type: 'text', - text: 'Ⱏ遲䩞㡛쓯잘ጫ䵤㥦鷁끞鈅毅┌빯湌Თ', - style: { - // family: '疴ꖻ䖭㋑⁃⻋ꑧٹ㧕Ⓖ', - // size: 4220431756569966319, - color: '#ffffff', - }, - }, - { - id: 12345, - x: 25, - y: 42, - width: 100, - height: 30, - // clip: { - // bottom: 83, - // right: 44, - // }, - type: 'image', - base64: heartEyesEmojiURL, - }, - ], - }, - timestamp: 1, - }, - ]) - expect(exampleWithImage).toMatchSnapshot() - }) - - test('can convert rect with text', () => { - const exampleWithRectAndText = posthogEEModule.mobileReplay?.transformToWeb([ - { - data: { - width: 300, - height: 600, - }, - timestamp: 1, - type: 4, - }, - { - type: 2, - data: { - wireframes: [ - { - id: 12345, - x: 11, - y: 12, - width: 100, - height: 30, - type: 'rectangle', - style: { - color: '#ee3ee4', - borderColor: '#ee3ee4', - borderWidth: '4', - borderRadius: '10px', - }, - }, - { - id: 12345, - x: 13, - y: 17, - width: 100, - height: 30, - verticalAlign: 'top', - horizontalAlign: 'right', - type: 'text', - text: 'i am in the box', - fontSize: '12px', - fontFamily: 'sans-serif', - }, - ], - }, - timestamp: 1, - }, - ]) - expect(exampleWithRectAndText).toMatchSnapshot() - }) - - test('child wireframes are processed', () => { - const textEvent = posthogEEModule.mobileReplay?.transformToWeb([ - { - data: { screen: 'App Home Page', width: 300, height: 600 }, - timestamp: 1, - type: 4, - }, - { - type: 2, - data: { - wireframes: [ - { - id: 123456789, - childWireframes: [ - { - id: 98765, - childWireframes: [ - { - id: 12345, - x: 11, - y: 12, - width: 100, - height: 30, - type: 'text', - text: 'first nested', - style: { - color: '#ffffff', - backgroundColor: '#000000', - borderWidth: '4px', - borderColor: '#000ddd', - borderRadius: '10px', - }, - }, - { - id: 12345, - x: 11, - y: 12, - width: 100, - height: 30, - type: 'text', - text: 'second nested', - style: { - color: '#ffffff', - backgroundColor: '#000000', - borderWidth: '4px', - borderColor: '#000ddd', - borderRadius: '10px', - }, - }, - ], - }, - { - id: 12345, - x: 11, - y: 12, - width: 100, - height: 30, - // clip: { - // bottom: 83, - // right: 44, - // }, - type: 'text', - text: 'third (different level) nested', - style: { - // family: '疴ꖻ䖭㋑⁃⻋ꑧٹ㧕Ⓖ', - // size: 4220431756569966319, - color: '#ffffff', - backgroundColor: '#000000', - borderWidth: '4px', - borderColor: '#000ddd', - borderRadius: '10', // you can omit the pixels - }, - }, - ], - }, - ], - }, - timestamp: 1, - }, - ]) - expect(textEvent).toMatchSnapshot() - }) - - test('respect incremental ids, replace with body otherwise', () => { - const textEvent = posthogEEModule.mobileReplay?.transformToWeb([ - { - windowId: 'ddc9c89d-2272-4b07-a280-c00db3a9182f', - data: { - id: 0, // must be an element id - replace with body - pointerType: 2, - source: 2, - type: 7, - x: 523, - y: 683, - }, - timestamp: 1701355473313, - type: 3, - delay: 2160, - }, - { - windowId: 'ddc9c89d-2272-4b07-a280-c00db3a9182f', - data: { - id: 145, // element provided - respected without validation - pointerType: 2, - source: 2, - type: 7, - x: 523, - y: 683, - }, - timestamp: 1701355473313, - type: 3, - delay: 2160, - }, - ]) - expect(textEvent).toMatchSnapshot() - }) - - test('incremental mutations de-duplicate the tree', () => { - const conversion = posthogEEModule.mobileReplay?.transformEventToWeb(incrementalSnapshotJson) - expect(conversion).toMatchSnapshot() - }) - - test('omitting x and y is equivalent to setting them to 0', () => { - expect( - posthogEEModule.mobileReplay?.transformToWeb([ - { - type: 2, - data: { - wireframes: [ - { - id: 12345, - width: 100, - height: 30, - type: 'image', - }, - ], - }, - timestamp: 1, - }, - ]) - ).toMatchSnapshot() - }) - - test('can convert status bar', () => { - const converted = posthogEEModule.mobileReplay?.transformToWeb([ - { - data: { - width: 300, - height: 600, - }, - timestamp: 1, - type: 4, - }, - { - type: 2, - data: { - wireframes: [ - { - id: 12345, - type: 'status_bar', - // _we'll process the x and y, but they should always be 0 - x: 0, - y: 0, - // we'll process the width - // width: 100, - height: 30, - style: { - // we can't expect to receive all of these values, - // but we'll handle them, because that's easier than not doing - color: '#ee3ee4', - borderColor: '#ee3ee4', - borderWidth: '4', - borderRadius: '10px', - backgroundColor: '#000000', - }, - }, - { - id: 12345, - type: 'status_bar', - x: 13, - y: 17, - width: 100, - // zero height is respected - height: 0, - // as with styling we don't expect to receive these values, - // but we'll respect them if they are present - horizontalAlign: 'right', - verticalAlign: 'top', - fontSize: '12px', - fontFamily: 'sans-serif', - }, - ], - }, - timestamp: 1, - }, - ]) - expect(converted).toMatchSnapshot() - }) - - test('can convert navigation bar', () => { - const converted = posthogEEModule.mobileReplay?.transformToWeb([ - { - data: { - width: 300, - height: 600, - }, - timestamp: 1, - type: 4, - }, - { - type: 2, - data: { - wireframes: [ - { - id: 12345, - type: 'navigation_bar', - // we respect x and y but expect this to be at the bottom of the screen - x: 11, - y: 12, - // we respect width but expect it to be fullscreen - width: 100, - height: 30, - // as with status bar, we don't expect to receive all of these values, - // but we'll respect them if they are present - style: { - color: '#ee3ee4', - borderColor: '#ee3ee4', - borderWidth: '4', - borderRadius: '10px', - }, - }, - ], - }, - timestamp: 1, - }, - ]) - expect(converted).toMatchSnapshot() - }) - - test('can convert invalid text wireframe', () => { - const converted = posthogEEModule.mobileReplay?.transformToWeb([ - { - data: { - width: 300, - height: 600, - }, - timestamp: 1, - type: 4, - }, - { - type: 2, - data: { - wireframes: [ - { - id: 12345, - type: 'text', - x: 11, - y: 12, - width: 100, - height: 30, - style: { - color: '#ee3ee4', - borderColor: '#ee3ee4', - borderWidth: '4', - borderRadius: '10px', - }, - // text property is missing - }, - ], - }, - timestamp: 1, - }, - ]) - expect(converted).toMatchSnapshot() - }) - - test('can set background image to base64 png', () => { - const converted = posthogEEModule.mobileReplay?.transformToWeb([ - { - data: { - width: 300, - height: 600, - }, - timestamp: 1, - type: 4, - }, - { - type: 2, - data: { - wireframes: [ - { - id: 12345, - type: 'div', - x: 0, - y: 0, - height: 30, - style: { backgroundImage: heartEyesEmojiURL }, - }, - { - id: 12346, - type: 'div', - x: 0, - y: 0, - height: 30, - style: { backgroundImage: unspecifiedBase64ImageURL }, - }, - { - id: 12346, - type: 'div', - x: 0, - y: 0, - height: 30, - style: { backgroundImage: unspecifiedBase64ImageURL, backgroundSize: 'cover' }, - }, - { - id: 12346, - type: 'div', - x: 0, - y: 0, - height: 30, - // should be ignored - style: { backgroundImage: null }, - }, - ], - }, - timestamp: 1, - }, - ]) - expect(converted).toMatchSnapshot() - }) - - describe('inputs', () => { - test('input gets 0 padding by default but can be overridden', () => { - expect( - posthogEEModule.mobileReplay?.transformEventToWeb({ - type: 2, - data: { - wireframes: [ - { - id: 12359, - width: 100, - height: 30, - type: 'input', - inputType: 'text', - }, - { - id: 12361, - width: 100, - height: 30, - type: 'input', - inputType: 'text', - style: { - paddingLeft: '16px', - paddingRight: 16, - }, - }, - ], - }, - timestamp: 1, - }) - ).toMatchSnapshot() - }) - - test('buttons with nested elements', () => { - expect( - posthogEEModule.mobileReplay?.transformEventToWeb({ - type: 2, - data: { - wireframes: [ - { - id: 12359, - width: 100, - height: 30, - type: 'input', - inputType: 'button', - childNodes: [ - { - id: 12360, - width: 100, - height: 30, - type: 'text', - text: 'click me', - }, - ], - }, - { - id: 12361, - width: 100, - height: 30, - type: 'input', - inputType: 'button', - value: 'click me', - childNodes: [ - { - id: 12362, - width: 100, - height: 30, - type: 'text', - text: 'and have more text', - }, - ], - }, - ], - }, - timestamp: 1, - }) - ).toMatchSnapshot() - }) - test('wrapping with labels', () => { - expect( - posthogEEModule.mobileReplay?.transformEventToWeb({ - type: 2, - data: { - wireframes: [ - { - id: 12359, - width: 100, - height: 30, - type: 'input', - inputType: 'checkbox', - label: 'i will wrap the checkbox', - }, - ], - }, - timestamp: 1, - }) - ).toMatchSnapshot() - }) - - test('web_view with URL', () => { - expect( - posthogEEModule.mobileReplay?.transformEventToWeb({ - type: 2, - data: { - wireframes: [ - { - id: 12365, - width: 100, - height: 30, - type: 'web_view', - url: 'https://example.com', - }, - ], - }, - timestamp: 1, - }) - ).toMatchSnapshot() - }) - - test('progress rating', () => { - expect( - posthogEEModule.mobileReplay?.transformEventToWeb({ - type: 2, - data: { - wireframes: [ - { - id: 12365, - width: 100, - height: 30, - type: 'input', - inputType: 'progress', - style: { bar: 'rating' }, - max: '12', - value: '6.5', - }, - ], - }, - timestamp: 1, - }) - ).toMatchSnapshot() - }) - - test('open keyboard custom event', () => { - expect( - posthogEEModule.mobileReplay?.transformEventToWeb({ - timestamp: 1, - type: EventType.Custom, - data: { tag: 'keyboard', payload: { open: true, height: 150 } }, - }) - ).toMatchSnapshot() - }) - - test('isolated add mutation', () => { - expect( - posthogEEModule.mobileReplay?.transformEventToWeb({ - timestamp: 1, - type: EventType.IncrementalSnapshot, - data: { - source: 0, - adds: [ - { - parentId: 54321, - wireframe: { - id: 12365, - width: 100, - height: 30, - type: 'input', - inputType: 'progress', - style: { bar: 'rating' }, - max: '12', - value: '6.5', - }, - }, - ], - }, - }) - ).toMatchSnapshot() - }) - - test('isolated remove mutation', () => { - expect( - posthogEEModule.mobileReplay?.transformEventToWeb({ - timestamp: 1, - type: EventType.IncrementalSnapshot, - data: { - source: 0, - removes: [{ parentId: 54321, id: 12345 }], - }, - }) - ).toMatchSnapshot() - }) - - test('isolated update mutation', () => { - expect( - posthogEEModule.mobileReplay?.transformEventToWeb({ - timestamp: 1, - type: EventType.IncrementalSnapshot, - data: { - source: 0, - texts: [], - attributes: [], - updates: [ - { - parentId: 54321, - wireframe: { - id: 12365, - width: 100, - height: 30, - type: 'input', - inputType: 'progress', - style: { bar: 'rating' }, - max: '12', - value: '6.5', - }, - }, - ], - }, - }) - ).toMatchSnapshot() - }) - - test('closed keyboard custom event', () => { - expect( - posthogEEModule.mobileReplay?.transformEventToWeb({ - timestamp: 1, - type: EventType.Custom, - data: { tag: 'keyboard', payload: { open: false } }, - }) - ).toMatchSnapshot() - }) - - test('radio_group', () => { - expect( - posthogEEModule.mobileReplay?.transformEventToWeb({ - type: 2, - data: { - wireframes: [ - { - id: 54321, - width: 100, - height: 30, - type: 'radio_group', - timestamp: 12345, - childNodes: [ - { - id: 12345, - width: 100, - height: 30, - type: 'input', - inputType: 'radio', - checked: true, - label: 'first', - }, - { - id: 12346, - width: 100, - height: 30, - type: 'input', - inputType: 'radio', - checked: false, - label: 'second', - }, - { - id: 12347, - width: 100, - height: 30, - type: 'text', - text: 'to check that only radios are given a name', - }, - ], - }, - ], - }, - timestamp: 1, - }) - ).toMatchSnapshot() - }) - test.each([ - { - id: 12346, - width: 100, - height: 30, - type: 'input', - inputType: 'text', - value: 'hello', - }, - { - id: 12347, - width: 100, - height: 30, - type: 'input', - inputType: 'text', - // without value - }, - { - id: 12348, - width: 100, - height: 30, - type: 'input', - inputType: 'password', - // without value - }, - { - id: 12349, - width: 100, - height: 30, - type: 'input', - inputType: 'email', - // without value - }, - { - id: 12350, - width: 100, - height: 30, - type: 'input', - inputType: 'number', - // without value - }, - { - id: 12351, - width: 100, - height: 30, - type: 'input', - inputType: 'search', - // without value - }, - { - id: 12352, - width: 100, - height: 30, - type: 'input', - inputType: 'tel', - disabled: true, - }, - { - id: 12352, - width: 100, - height: 30, - type: 'input', - inputType: 'url', - value: 'https://example.io', - disabled: false, - }, - { - id: 123123, - width: 100, - height: 30, - type: 'radio_group', - // oh, oh, no child nodes - }, - { - id: 12354, - width: 100, - height: 30, - type: 'radio group', - childNodes: [ - { - id: 12355, - width: 100, - height: 30, - type: 'input', - inputType: 'radio', - checked: true, - label: 'first', - }, - { - id: 12356, - width: 100, - height: 30, - type: 'input', - inputType: 'radio', - checked: false, - label: 'second', - }, - ], - }, - { - id: 12357, - width: 100, - height: 30, - type: 'input', - inputType: 'checkbox', - checked: true, - label: 'first', - }, - { - id: 12357, - width: 100, - height: 30, - type: 'input', - inputType: 'checkbox', - checked: false, - label: 'second', - }, - { - id: 12357, - width: 100, - height: 30, - type: 'input', - inputType: 'checkbox', - checked: true, - disabled: true, - label: 'third', - }, - { - id: 12357, - width: 100, - height: 30, - type: 'input', - inputType: 'checkbox', - checked: true, - disabled: false, - // no label - }, - { - id: 12357, - width: 100, - height: 30, - type: 'input', - inputType: 'toggle', - checked: true, - label: 'first', - }, - { - id: 12357, - width: 100, - height: 30, - type: 'input', - inputType: 'toggle', - checked: false, - label: 'second', - }, - { - id: 12357, - width: 100, - height: 30, - type: 'input', - inputType: 'toggle', - checked: true, - disabled: true, - label: 'third', - }, - { - id: 12357, - width: 100, - height: 30, - type: 'input', - inputType: 'toggle', - checked: true, - disabled: false, - // no label - }, - { - id: 12358, - width: 100, - height: 30, - type: 'input', - inputType: 'button', - value: 'click me', - }, - { - id: 12363, - width: 100, - height: 30, - type: 'input', - inputType: 'textArea', - value: 'hello', - }, - { - id: 12364, - width: 100, - height: 30, - type: 'input', - inputType: 'textArea', - }, - { - id: 12365, - width: 100, - height: 30, - type: 'input', - inputType: 'select', - value: 'hello', - options: ['hello', 'world'], - }, - { - id: 12365, - width: 100, - height: 30, - type: 'input', - // missing input type - should be ignored - // inputType: 'select', - value: 'hello', - options: ['hello', 'world'], - }, - { - id: 12365, - width: 100, - height: 30, - type: 'input', - inputType: 'progress', - style: { bar: 'circular' }, - }, - { - id: 12365, - width: 100, - height: 30, - type: 'input', - inputType: 'progress', - style: { bar: 'horizontal' }, - }, - { - id: 12365, - width: 100, - height: 30, - type: 'input', - inputType: 'progress', - style: { bar: 'horizontal' }, - value: 0.75, - }, - { - id: 12365, - width: 100, - height: 30, - type: 'input', - inputType: 'progress', - style: { bar: 'horizontal' }, - value: 0.75, - max: 2.5, - }, - { - id: 12365, - width: 100, - height: 30, - type: 'placeholder', - label: 'hello', - }, - { - id: 12365, - width: 100, - height: 30, - type: 'web_view', - }, - ])('$type - $inputType - $value', (testCase) => { - expect( - posthogEEModule.mobileReplay?.transformEventToWeb({ - type: 2, - data: { - wireframes: [testCase], - }, - timestamp: 1, - }) - ).toMatchSnapshot() - }) - }) - }) - - describe('separate status and navbar from other wireframes', () => { - it('no-op', () => { - expect(stripBarsFromWireframes([])).toEqual({ - appNodes: [], - statusBar: undefined, - navigationBar: undefined, - }) - }) - - it('top-level status-bar', () => { - const statusBar = fakeWireframe('status_bar') - expect(stripBarsFromWireframes([statusBar])).toEqual({ appNodes: [], statusBar, navigationBar: undefined }) - }) - - it('top-level nav-bar', () => { - const navBar = fakeWireframe('navigation_bar') - expect(stripBarsFromWireframes([navBar])).toEqual({ - appNodes: [], - statusBar: undefined, - navigationBar: navBar, - }) - }) - - it('nested nav-bar', () => { - const navBar = fakeWireframe('navigation_bar') - const sourceWithNavBar = [ - fakeWireframe('div', [fakeWireframe('div'), fakeWireframe('div', [navBar, fakeWireframe('div')])]), - ] - expect(stripBarsFromWireframes(sourceWithNavBar)).toEqual({ - appNodes: [fakeWireframe('div', [fakeWireframe('div'), fakeWireframe('div', [fakeWireframe('div')])])], - statusBar: undefined, - navigationBar: navBar, - }) - }) - }) -}) diff --git a/ee/frontend/mobile-replay/transformer/colors.ts b/ee/frontend/mobile-replay/transformer/colors.ts deleted file mode 100644 index 56a54b23d72..00000000000 --- a/ee/frontend/mobile-replay/transformer/colors.ts +++ /dev/null @@ -1,51 +0,0 @@ -// from https://gist.github.com/t1grok/a0f6d04db569890bcb57 - -interface rgb { - r: number - g: number - b: number -} -interface yuv { - y: number - u: number - v: number -} - -function hexToRgb(hexColor: string): rgb | null { - const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i - hexColor = hexColor.replace(shorthandRegex, function (_, r, g, b) { - return r + r + g + g + b + b - }) - - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hexColor) - return result - ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16), - } - : null -} - -function rgbToYuv(rgbColor: rgb): yuv { - let y, u, v - - y = rgbColor.r * 0.299 + rgbColor.g * 0.587 + rgbColor.b * 0.114 - u = rgbColor.r * -0.168736 + rgbColor.g * -0.331264 + rgbColor.b * 0.5 + 128 - v = rgbColor.r * 0.5 + rgbColor.g * -0.418688 + rgbColor.b * -0.081312 + 128 - - y = Math.floor(y) - u = Math.floor(u) - v = Math.floor(v) - - return { y: y, u: u, v: v } -} - -export const isLight = (hexColor: string): boolean => { - const rgbColor = hexToRgb(hexColor) - if (!rgbColor) { - return false - } - const yuvColor = rgbToYuv(rgbColor) - return yuvColor.y > 128 -} diff --git a/ee/frontend/mobile-replay/transformer/screen-chrome.ts b/ee/frontend/mobile-replay/transformer/screen-chrome.ts deleted file mode 100644 index 16274ca8538..00000000000 --- a/ee/frontend/mobile-replay/transformer/screen-chrome.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { - keyboardEvent, - NodeType, - serializedNodeWithId, - wireframeNavigationBar, - wireframeStatusBar, -} from '../mobile.types' -import { isLight } from './colors' -import { - _isPositiveInteger, - BACKGROUND, - KEYBOARD_ID, - makePlaceholderElement, - NAVIGATION_BAR_ID, - STATUS_BAR_ID, -} from './transformers' -import { ConversionContext, ConversionResult } from './types' -import { asStyleString, makeStylesString } from './wireframeStyle' - -export let navigationBackgroundColor: string | undefined = undefined -export let navigationColor: string | undefined = undefined - -function spacerDiv(idSequence: Generator): serializedNodeWithId { - const spacerId = idSequence.next().value - return { - type: NodeType.Element, - tagName: 'div', - attributes: { - style: 'width: 5px;', - 'data-rrweb-id': spacerId, - }, - id: spacerId, - childNodes: [], - } -} - -function makeFakeNavButton(icon: string, context: ConversionContext): serializedNodeWithId { - return { - type: NodeType.Element, - tagName: 'div', - attributes: {}, - id: context.idSequence.next().value, - childNodes: [ - { - type: NodeType.Text, - textContent: icon, - id: context.idSequence.next().value, - }, - ], - } -} - -export function makeNavigationBar( - wireframe: wireframeNavigationBar, - _children: serializedNodeWithId[], - context: ConversionContext -): ConversionResult | null { - const _id = wireframe.id || NAVIGATION_BAR_ID - - const backArrowTriangle = makeFakeNavButton('◀', context) - const homeCircle = makeFakeNavButton('⚪', context) - const screenButton = makeFakeNavButton('⬜️', context) - - navigationBackgroundColor = wireframe.style?.backgroundColor - navigationColor = isLight(navigationBackgroundColor || BACKGROUND) ? 'black' : 'white' - - return { - result: { - type: NodeType.Element, - tagName: 'div', - attributes: { - style: asStyleString([ - makeStylesString(wireframe), - 'display:flex', - 'flex-direction:row', - 'align-items:center', - 'justify-content:space-around', - 'color:' + navigationColor, - ]), - 'data-rrweb-id': _id, - }, - id: _id, - childNodes: [backArrowTriangle, homeCircle, screenButton], - }, - context, - } -} - -/** - * tricky: we need to accept children because that's the interface of converters, but we don't use them - */ -export function makeStatusBar( - wireframe: wireframeStatusBar, - _children: serializedNodeWithId[], - context: ConversionContext -): ConversionResult { - const clockId = context.idSequence.next().value - // convert the wireframe timestamp to a date time, then get just the hour and minute of the time from that - const clockTime = context.timestamp - ? new Date(context.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - : '' - - const clockFontColor = isLight(wireframe.style?.backgroundColor || '#ffffff') ? 'black' : 'white' - - const clock: serializedNodeWithId = { - type: NodeType.Element, - tagName: 'div', - attributes: { - 'data-rrweb-id': clockId, - }, - id: clockId, - childNodes: [ - { - type: NodeType.Text, - textContent: clockTime, - id: context.idSequence.next().value, - }, - ], - } - - return { - result: { - type: NodeType.Element, - tagName: 'div', - attributes: { - style: asStyleString([ - makeStylesString(wireframe, { color: clockFontColor }), - 'display:flex', - 'flex-direction:row', - 'align-items:center', - ]), - 'data-rrweb-id': STATUS_BAR_ID, - }, - id: STATUS_BAR_ID, - childNodes: [spacerDiv(context.idSequence), clock], - }, - context, - } -} - -export function makeOpenKeyboardPlaceholder( - mobileCustomEvent: keyboardEvent & { - timestamp: number - delay?: number - }, - context: ConversionContext -): ConversionResult | null { - if (!mobileCustomEvent.data.payload.open) { - return null - } - - const shouldAbsolutelyPosition = - _isPositiveInteger(mobileCustomEvent.data.payload.x) || _isPositiveInteger(mobileCustomEvent.data.payload.y) - - return makePlaceholderElement( - { - id: KEYBOARD_ID, - type: 'placeholder', - label: 'keyboard', - height: mobileCustomEvent.data.payload.height, - width: _isPositiveInteger(mobileCustomEvent.data.payload.width) - ? mobileCustomEvent.data.payload.width - : '100vw', - style: { - backgroundColor: navigationBackgroundColor, - color: navigationBackgroundColor ? navigationColor : undefined, - }, - }, - [], - { - timestamp: context.timestamp, - idSequence: context.idSequence, - styleOverride: { - ...(shouldAbsolutelyPosition ? {} : { bottom: true }), - }, - } - ) -} diff --git a/ee/frontend/mobile-replay/transformer/transformers.ts b/ee/frontend/mobile-replay/transformer/transformers.ts deleted file mode 100644 index c209a6f58ce..00000000000 --- a/ee/frontend/mobile-replay/transformer/transformers.ts +++ /dev/null @@ -1,1412 +0,0 @@ -import { - addedNodeMutation, - customEvent, - EventType, - fullSnapshotEvent, - incrementalSnapshotEvent, - IncrementalSource, - metaEvent, - mutationData, - removedNodeMutation, -} from '@rrweb/types' -import { captureMessage } from '@sentry/react' -import { isObject } from 'lib/utils' -import { PLACEHOLDER_SVG_DATA_IMAGE_URL } from 'scenes/session-recordings/player/rrweb' - -import { - attributes, - documentNode, - elementNode, - fullSnapshotEvent as MobileFullSnapshotEvent, - keyboardEvent, - metaEvent as MobileMetaEvent, - MobileIncrementalSnapshotEvent, - MobileNodeMutation, - MobileNodeType, - NodeType, - serializedNodeWithId, - textNode, - wireframe, - wireframeButton, - wireframeCheckBox, - wireframeDiv, - wireframeImage, - wireframeInputComponent, - wireframeNavigationBar, - wireframePlaceholder, - wireframeProgress, - wireframeRadio, - wireframeRadioGroup, - wireframeRectangle, - wireframeScreenshot, - wireframeSelect, - wireframeStatusBar, - wireframeText, - wireframeToggle, -} from '../mobile.types' -import { makeNavigationBar, makeOpenKeyboardPlaceholder, makeStatusBar } from './screen-chrome' -import { ConversionContext, ConversionResult } from './types' -import { - asStyleString, - makeBodyStyles, - makeColorStyles, - makeDeterminateProgressStyles, - makeHTMLStyles, - makeIndeterminateProgressStyles, - makeMinimalStyles, - makePositionStyles, - makeStylesString, -} from './wireframeStyle' - -export const BACKGROUND = '#f3f4ef' -const FOREGROUND = '#35373e' - -/** - * generates a sequence of ids - * from 100 to 9,999,999 - * the transformer reserves ids in the range 0 to 9,999,999 - * we reserve a range of ids because we need nodes to have stable ids across snapshots - * in order for incremental snapshots to work - * some mobile elements have to be wrapped in other elements in order to be styled correctly - * which means the web version of a mobile replay will use ids that don't exist in the mobile replay, - * and we need to ensure they don't clash - * ----- - * id is typed as a number in rrweb - * and there's a few places in their code where rrweb uses a check for `id === -1` to bail out of processing - * so, it's safest to assume that id is expected to be a positive integer - */ -function* ids(): Generator { - let i = 100 - while (i < 9999999) { - yield i++ - } -} - -let globalIdSequence = ids() - -// there are some fixed ids that we need to use for fixed elements or artificial mutations -const DOCUMENT_ID = 1 -const HTML_DOC_TYPE_ID = 2 -const HTML_ELEMENT_ID = 3 -const HEAD_ID = 4 -const BODY_ID = 5 -// the nav bar should always be the last item in the body so that it is at the top of the stack -const NAVIGATION_BAR_PARENT_ID = 7 -export const NAVIGATION_BAR_ID = 8 -// the keyboard so that it is still before the nav bar -const KEYBOARD_PARENT_ID = 9 -export const KEYBOARD_ID = 10 -export const STATUS_BAR_PARENT_ID = 11 -export const STATUS_BAR_ID = 12 - -function isKeyboardEvent(x: unknown): x is keyboardEvent { - return isObject(x) && 'data' in x && isObject(x.data) && 'tag' in x.data && x.data.tag === 'keyboard' -} - -export function _isPositiveInteger(id: unknown): id is number { - return typeof id === 'number' && id > 0 && id % 1 === 0 -} - -function _isNullish(x: unknown): x is null | undefined { - return x === null || x === undefined -} - -function isRemovedNodeMutation(x: addedNodeMutation | removedNodeMutation): x is removedNodeMutation { - return isObject(x) && 'id' in x -} - -export const makeCustomEvent = ( - mobileCustomEvent: (customEvent | keyboardEvent) & { - timestamp: number - delay?: number - } -): (customEvent | incrementalSnapshotEvent) & { - timestamp: number - delay?: number -} => { - if (isKeyboardEvent(mobileCustomEvent)) { - // keyboard events are handled as incremental snapshots to add or remove a keyboard from the DOM - // TODO eventually we can pass something to makeIncrementalEvent here - const adds: addedNodeMutation[] = [] - const removes = [] - if (mobileCustomEvent.data.payload.open) { - const keyboardPlaceHolder = makeOpenKeyboardPlaceholder(mobileCustomEvent, { - timestamp: mobileCustomEvent.timestamp, - idSequence: globalIdSequence, - }) - if (keyboardPlaceHolder) { - adds.push({ - parentId: KEYBOARD_PARENT_ID, - nextId: null, - node: keyboardPlaceHolder.result, - }) - // mutations seem not to want a tree of nodes to add - // so even though `keyboardPlaceholder` is a tree with content - // we have to add the text content as well - adds.push({ - parentId: keyboardPlaceHolder.result.id, - nextId: null, - node: { - type: NodeType.Text, - id: globalIdSequence.next().value, - textContent: 'keyboard', - }, - }) - } else { - captureMessage('Failed to create keyboard placeholder', { extra: { mobileCustomEvent } }) - } - } else { - removes.push({ - parentId: KEYBOARD_PARENT_ID, - id: KEYBOARD_ID, - }) - } - const mutation: mutationData = { adds, attributes: [], removes, source: IncrementalSource.Mutation, texts: [] } - return { - type: EventType.IncrementalSnapshot, - data: mutation, - timestamp: mobileCustomEvent.timestamp, - } - } - return mobileCustomEvent -} - -export const makeMetaEvent = ( - mobileMetaEvent: MobileMetaEvent & { - timestamp: number - } -): metaEvent & { - timestamp: number - delay?: number -} => ({ - type: EventType.Meta, - data: { - href: mobileMetaEvent.data.href || '', // the replay doesn't use the href, so we safely ignore any absence - // mostly we need width and height in order to size the viewport - width: mobileMetaEvent.data.width, - height: mobileMetaEvent.data.height, - }, - timestamp: mobileMetaEvent.timestamp, -}) - -export function makeDivElement( - wireframe: wireframeDiv, - children: serializedNodeWithId[], - context: ConversionContext -): ConversionResult | null { - const _id = _isPositiveInteger(wireframe.id) ? wireframe.id : context.idSequence.next().value - return { - result: { - type: NodeType.Element, - tagName: 'div', - attributes: { - style: asStyleString([makeStylesString(wireframe), 'overflow:hidden', 'white-space:nowrap']), - 'data-rrweb-id': _id, - }, - id: _id, - childNodes: children, - }, - context, - } -} - -function makeTextElement( - wireframe: wireframeText, - children: serializedNodeWithId[], - context: ConversionContext -): ConversionResult | null { - if (wireframe.type !== 'text') { - console.error('Passed incorrect wireframe type to makeTextElement') - return null - } - - // because we might have to style the text, we always wrap it in a div - // and apply styles to that - const id = context.idSequence.next().value - - const childNodes = [...children] - if (!_isNullish(wireframe.text)) { - childNodes.unshift({ - type: NodeType.Text, - textContent: wireframe.text, - // since the text node is wrapped, we assign it a synthetic id - id, - }) - } - - return { - result: { - type: NodeType.Element, - tagName: 'div', - attributes: { - style: asStyleString([makeStylesString(wireframe), 'overflow:hidden', 'white-space:normal']), - 'data-rrweb-id': wireframe.id, - }, - id: wireframe.id, - childNodes, - }, - context, - } -} - -function makeWebViewElement( - wireframe: wireframe, - children: serializedNodeWithId[], - context: ConversionContext -): ConversionResult | null { - const labelledWireframe: wireframePlaceholder = { ...wireframe } as wireframePlaceholder - if ('url' in wireframe) { - labelledWireframe.label = wireframe.url - } - - return makePlaceholderElement(labelledWireframe, children, context) -} - -export function makePlaceholderElement( - wireframe: wireframe, - children: serializedNodeWithId[], - context: ConversionContext -): ConversionResult | null { - const txt = 'label' in wireframe && wireframe.label ? wireframe.label : wireframe.type || 'PLACEHOLDER' - return { - result: { - type: NodeType.Element, - tagName: 'div', - attributes: { - style: makeStylesString(wireframe, { - verticalAlign: 'center', - horizontalAlign: 'center', - backgroundColor: wireframe.style?.backgroundColor || BACKGROUND, - color: wireframe.style?.color || FOREGROUND, - backgroundImage: PLACEHOLDER_SVG_DATA_IMAGE_URL, - backgroundSize: 'auto', - backgroundRepeat: 'unset', - ...context.styleOverride, - }), - 'data-rrweb-id': wireframe.id, - }, - id: wireframe.id, - childNodes: [ - { - type: NodeType.Text, - // since the text node is wrapped, we assign it a synthetic id - id: context.idSequence.next().value, - textContent: txt, - }, - ...children, - ], - }, - context, - } -} - -export function dataURIOrPNG(src: string): string { - // replace all new lines in src - src = src.replace(/\r?\n|\r/g, '') - if (!src.startsWith('data:image/')) { - return 'data:image/png;base64,' + src - } - return src -} - -function makeImageElement( - wireframe: wireframeImage | wireframeScreenshot, - children: serializedNodeWithId[], - context: ConversionContext -): ConversionResult | null { - if (!wireframe.base64) { - return makePlaceholderElement(wireframe, children, context) - } - - const src = dataURIOrPNG(wireframe.base64) - return { - result: { - type: NodeType.Element, - tagName: 'img', - attributes: { - src: src, - width: wireframe.width, - height: wireframe.height, - style: makeStylesString(wireframe), - 'data-rrweb-id': wireframe.id, - }, - id: wireframe.id, - childNodes: children, - }, - context, - } -} - -function inputAttributes(wireframe: T): attributes { - const attributes = { - style: makeStylesString(wireframe), - type: wireframe.inputType, - ...(wireframe.disabled ? { disabled: wireframe.disabled } : {}), - 'data-rrweb-id': wireframe.id, - } - - switch (wireframe.inputType) { - case 'checkbox': - return { - ...attributes, - style: null, // checkboxes are styled by being combined with a label - ...(wireframe.checked ? { checked: wireframe.checked } : {}), - } - case 'toggle': - return { - ...attributes, - style: null, // toggle are styled by being combined with a label - ...(wireframe.checked ? { checked: wireframe.checked } : {}), - } - case 'radio': - return { - ...attributes, - style: null, // radio buttons are styled by being combined with a label - ...(wireframe.checked ? { checked: wireframe.checked } : {}), - // radio value defaults to the string "on" if not specified - // we're not really submitting the form, so it doesn't matter 🤞 - // radio name is used to correctly uncheck values when one is checked - // mobile doesn't really have it, and we will be checking based on snapshots, - // so we can ignore it for now - } - case 'button': - return { - ...attributes, - } - case 'text_area': - return { - ...attributes, - value: wireframe.value || '', - } - case 'progress': - return { - ...attributes, - // indeterminate when omitted - value: wireframe.value || null, - // defaults to 1 when omitted - max: wireframe.max || null, - type: null, // progress has no type attribute - } - default: - return { - ...attributes, - value: wireframe.value || '', - } - } -} - -function makeButtonElement( - wireframe: wireframeButton, - children: serializedNodeWithId[], - context: ConversionContext -): ConversionResult | null { - const buttonText: textNode | null = wireframe.value - ? { - type: NodeType.Text, - textContent: wireframe.value, - } - : null - - return { - result: { - type: NodeType.Element, - tagName: 'button', - attributes: inputAttributes(wireframe), - id: wireframe.id, - childNodes: buttonText ? [{ ...buttonText, id: context.idSequence.next().value }, ...children] : children, - }, - context, - } -} - -function makeSelectOptionElement( - option: string, - selected: boolean, - context: ConversionContext -): ConversionResult { - const optionId = context.idSequence.next().value - return { - result: { - type: NodeType.Element, - tagName: 'option', - attributes: { - ...(selected ? { selected: selected } : {}), - 'data-rrweb-id': optionId, - }, - id: optionId, - childNodes: [ - { - type: NodeType.Text, - textContent: option, - id: context.idSequence.next().value, - }, - ], - }, - context, - } -} - -function makeSelectElement( - wireframe: wireframeSelect, - children: serializedNodeWithId[], - context: ConversionContext -): ConversionResult | null { - const selectOptions: serializedNodeWithId[] = [] - if (wireframe.options) { - let optionContext = context - for (let i = 0; i < wireframe.options.length; i++) { - const option = wireframe.options[i] - const conversion = makeSelectOptionElement(option, wireframe.value === option, optionContext) - selectOptions.push(conversion.result) - optionContext = conversion.context - } - } - return { - result: { - type: NodeType.Element, - tagName: 'select', - attributes: inputAttributes(wireframe), - id: wireframe.id, - childNodes: [...selectOptions, ...children], - }, - context, - } -} - -function groupRadioButtons(children: serializedNodeWithId[], radioGroupName: string): serializedNodeWithId[] { - return children.map((child) => { - if (child.type === NodeType.Element && child.tagName === 'input' && child.attributes.type === 'radio') { - return { - ...child, - attributes: { - ...child.attributes, - name: radioGroupName, - 'data-rrweb-id': child.id, - }, - } - } - return child - }) -} - -function makeRadioGroupElement( - wireframe: wireframeRadioGroup, - children: serializedNodeWithId[], - context: ConversionContext -): ConversionResult | null { - const radioGroupName = 'radio_group_' + wireframe.id - return { - result: { - type: NodeType.Element, - tagName: 'div', - attributes: { - style: makeStylesString(wireframe), - 'data-rrweb-id': wireframe.id, - }, - id: wireframe.id, - childNodes: groupRadioButtons(children, radioGroupName), - }, - context, - } -} - -function makeStar(title: string, path: string, context: ConversionContext): serializedNodeWithId { - const svgId = context.idSequence.next().value - const titleId = context.idSequence.next().value - const pathId = context.idSequence.next().value - return { - type: NodeType.Element, - tagName: 'svg', - isSVG: true, - attributes: { - style: asStyleString(['height: 100%', 'overflow-clip-margin: content-box', 'overflow:hidden']), - viewBox: '0 0 24 24', - fill: 'currentColor', - 'data-rrweb-id': svgId, - }, - id: svgId, - childNodes: [ - { - type: NodeType.Element, - tagName: 'title', - isSVG: true, - attributes: { - 'data-rrweb-id': titleId, - }, - id: titleId, - childNodes: [ - { - type: NodeType.Text, - textContent: title, - id: context.idSequence.next().value, - }, - ], - }, - { - type: NodeType.Element, - tagName: 'path', - isSVG: true, - attributes: { - d: path, - 'data-rrweb-id': pathId, - }, - id: pathId, - childNodes: [], - }, - ], - } -} - -function filledStar(context: ConversionContext): serializedNodeWithId { - return makeStar( - 'filled star', - 'M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z', - context - ) -} - -function halfStar(context: ConversionContext): serializedNodeWithId { - return makeStar( - 'half-filled star', - 'M12,15.4V6.1L13.71,10.13L18.09,10.5L14.77,13.39L15.76,17.67M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z', - context - ) -} - -function emptyStar(context: ConversionContext): serializedNodeWithId { - return makeStar( - 'empty star', - 'M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z', - context - ) -} - -function makeRatingBar( - wireframe: wireframeProgress, - children: serializedNodeWithId[], - context: ConversionContext -): ConversionResult | null { - // max is the number of stars... and value is the number of stars to fill - - // deliberate double equals, because we want to allow null and undefined - if (wireframe.value == null || wireframe.max == null) { - return makePlaceholderElement(wireframe, children, context) - } - - const numberOfFilledStars = Math.floor(wireframe.value) - const numberOfHalfStars = wireframe.value - numberOfFilledStars > 0 ? 1 : 0 - const numberOfEmptyStars = wireframe.max - numberOfFilledStars - numberOfHalfStars - - const filledStars = Array(numberOfFilledStars) - .fill(undefined) - .map(() => filledStar(context)) - const halfStars = Array(numberOfHalfStars) - .fill(undefined) - .map(() => halfStar(context)) - const emptyStars = Array(numberOfEmptyStars) - .fill(undefined) - .map(() => emptyStar(context)) - - const ratingBarId = context.idSequence.next().value - const ratingBar = { - type: NodeType.Element, - tagName: 'div', - id: ratingBarId, - attributes: { - style: asStyleString([ - makeColorStyles(wireframe), - 'position: relative', - 'display: flex', - 'flex-direction: row', - 'padding: 2px 4px', - ]), - 'data-rrweb-id': ratingBarId, - }, - childNodes: [...filledStars, ...halfStars, ...emptyStars], - } as serializedNodeWithId - - return { - result: { - type: NodeType.Element, - tagName: 'div', - attributes: { - style: makeStylesString(wireframe), - 'data-rrweb-id': wireframe.id, - }, - id: wireframe.id, - childNodes: [ratingBar, ...children], - }, - context, - } -} - -function makeProgressElement( - wireframe: wireframeProgress, - children: serializedNodeWithId[], - context: ConversionContext -): ConversionResult | null { - if (wireframe.style?.bar === 'circular') { - // value needs to be expressed as a number between 0 and 100 - const max = wireframe.max || 1 - let value = wireframe.value || null - if (_isPositiveInteger(value) && value <= max) { - value = (value / max) * 100 - } else { - value = null - } - - const styleOverride = { - color: wireframe.style?.color || FOREGROUND, - backgroundColor: wireframe.style?.backgroundColor || BACKGROUND, - } - - // if not _isPositiveInteger(value) then we render a spinner, - // so we need to add a style element with the spin keyframe - const stylingChildren: serializedNodeWithId[] = _isPositiveInteger(value) - ? [] - : [ - { - type: NodeType.Element, - tagName: 'style', - attributes: { - type: 'text/css', - }, - id: context.idSequence.next().value, - childNodes: [ - { - type: NodeType.Text, - textContent: `@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`, - id: context.idSequence.next().value, - }, - ], - }, - ] - - const wrappingDivId = context.idSequence.next().value - return { - result: { - type: NodeType.Element, - tagName: 'div', - attributes: { - style: makeMinimalStyles(wireframe), - 'data-rrweb-id': wireframe.id, - }, - id: wireframe.id, - childNodes: [ - { - type: NodeType.Element, - tagName: 'div', - attributes: { - // with no provided value we render a spinner - style: _isPositiveInteger(value) - ? makeDeterminateProgressStyles(wireframe, styleOverride) - : makeIndeterminateProgressStyles(wireframe, styleOverride), - 'data-rrweb-id': wrappingDivId, - }, - id: wrappingDivId, - childNodes: stylingChildren, - }, - ...children, - ], - }, - context, - } - } else if (wireframe.style?.bar === 'rating') { - return makeRatingBar(wireframe, children, context) - } - return { - result: { - type: NodeType.Element, - tagName: 'progress', - attributes: inputAttributes(wireframe), - id: wireframe.id, - childNodes: children, - }, - context, - } -} - -function makeToggleParts(wireframe: wireframeToggle, context: ConversionContext): serializedNodeWithId[] { - const togglePosition = wireframe.checked ? 'right' : 'left' - const defaultColor = wireframe.checked ? '#1d4aff' : BACKGROUND - const sliderPartId = context.idSequence.next().value - const handlePartId = context.idSequence.next().value - return [ - { - type: NodeType.Element, - tagName: 'div', - attributes: { - 'data-toggle-part': 'slider', - style: asStyleString([ - 'position:absolute', - 'top:33%', - 'left:5%', - 'display:inline-block', - 'width:75%', - 'height:33%', - 'opacity: 0.2', - 'border-radius:7.5%', - `background-color:${wireframe.style?.color || defaultColor}`, - ]), - 'data-rrweb-id': sliderPartId, - }, - id: sliderPartId, - childNodes: [], - }, - { - type: NodeType.Element, - tagName: 'div', - attributes: { - 'data-toggle-part': 'handle', - style: asStyleString([ - 'position:absolute', - 'top:1.5%', - `${togglePosition}:5%`, - 'display:flex', - 'align-items:center', - 'justify-content:center', - 'width:40%', - 'height:75%', - 'cursor:inherit', - 'border-radius:50%', - `background-color:${wireframe.style?.color || defaultColor}`, - `border:2px solid ${wireframe.style?.borderColor || wireframe.style?.color || defaultColor}`, - ]), - 'data-rrweb-id': handlePartId, - }, - id: handlePartId, - childNodes: [], - }, - ] -} - -function makeToggleElement( - wireframe: wireframeToggle, - context: ConversionContext -): ConversionResult< - elementNode & { - id: number - } -> | null { - const isLabelled = 'label' in wireframe - const wrappingDivId = context.idSequence.next().value - return { - result: { - type: NodeType.Element, - tagName: 'div', - attributes: { - // if labelled take up available space, otherwise use provided positioning - style: isLabelled ? asStyleString(['height:100%', 'flex:1']) : makePositionStyles(wireframe), - 'data-rrweb-id': wireframe.id, - }, - id: wireframe.id, - childNodes: [ - { - type: NodeType.Element, - tagName: 'div', - attributes: { - // relative position, fills parent - style: asStyleString(['position:relative', 'width:100%', 'height:100%']), - 'data-rrweb-id': wrappingDivId, - }, - id: wrappingDivId, - childNodes: makeToggleParts(wireframe, context), - }, - ], - }, - context, - } -} - -function makeLabelledInput( - wireframe: wireframeCheckBox | wireframeRadio | wireframeToggle, - theInputElement: serializedNodeWithId, - context: ConversionContext -): ConversionResult { - const theLabel: serializedNodeWithId = { - type: NodeType.Text, - textContent: wireframe.label || '', - id: context.idSequence.next().value, - } - - const orderedChildren = wireframe.inputType === 'toggle' ? [theLabel, theInputElement] : [theInputElement, theLabel] - - const labelId = context.idSequence.next().value - return { - result: { - type: NodeType.Element, - tagName: 'label', - attributes: { - style: makeStylesString(wireframe), - 'data-rrweb-id': labelId, - }, - id: labelId, - childNodes: orderedChildren, - }, - context, - } -} - -function makeInputElement( - wireframe: wireframeInputComponent, - children: serializedNodeWithId[], - context: ConversionContext -): ConversionResult | null { - if (!wireframe.inputType) { - return null - } - - if (wireframe.inputType === 'button') { - return makeButtonElement(wireframe, children, context) - } - - if (wireframe.inputType === 'select') { - return makeSelectElement(wireframe, children, context) - } - - if (wireframe.inputType === 'progress') { - return makeProgressElement(wireframe, children, context) - } - - const theInputElement: ConversionResult | null = - wireframe.inputType === 'toggle' - ? makeToggleElement(wireframe, context) - : { - result: { - type: NodeType.Element, - tagName: 'input', - attributes: inputAttributes(wireframe), - id: wireframe.id, - childNodes: children, - }, - context, - } - - if (!theInputElement) { - return null - } - - if ('label' in wireframe) { - return makeLabelledInput(wireframe, theInputElement.result, theInputElement.context) - } - // when labelled no styles are needed, when un-labelled as here - we add the styling in. - ;(theInputElement.result as elementNode).attributes.style = makeStylesString(wireframe) - return theInputElement -} - -function makeRectangleElement( - wireframe: wireframeRectangle, - children: serializedNodeWithId[], - context: ConversionContext -): ConversionResult | null { - return { - result: { - type: NodeType.Element, - tagName: 'div', - attributes: { - style: makeStylesString(wireframe), - 'data-rrweb-id': wireframe.id, - }, - id: wireframe.id, - childNodes: children, - }, - context, - } -} - -function chooseConverter( - wireframe: T -): ( - wireframe: T, - children: serializedNodeWithId[], - context: ConversionContext -) => ConversionResult | null { - // in theory type is always present - // but since this is coming over the wire we can't really be sure, - // and so we default to div - const converterType: MobileNodeType = wireframe.type || 'div' - const converterMapping: Record< - MobileNodeType, - (wireframe: T, children: serializedNodeWithId[]) => ConversionResult | null - > = { - // KLUDGE: TS can't tell that the wireframe type of each function is safe based on the converter type - text: makeTextElement as any, - image: makeImageElement as any, - rectangle: makeRectangleElement as any, - div: makeDivElement as any, - input: makeInputElement as any, - radio_group: makeRadioGroupElement as any, - web_view: makeWebViewElement as any, - placeholder: makePlaceholderElement as any, - status_bar: makeStatusBar as any, - navigation_bar: makeNavigationBar as any, - screenshot: makeImageElement as any, - } - return converterMapping[converterType] -} - -function convertWireframe( - wireframe: wireframe, - context: ConversionContext -): ConversionResult | null { - const children = convertWireframesFor(wireframe.childWireframes, context) - const converted = chooseConverter(wireframe)?.(wireframe, children.result, children.context) - return converted || null -} - -function convertWireframesFor( - wireframes: wireframe[] | undefined, - context: ConversionContext -): ConversionResult { - if (!wireframes) { - return { result: [], context } - } - - const result: serializedNodeWithId[] = [] - for (const wireframe of wireframes) { - const converted = convertWireframe(wireframe, context) - if (converted) { - result.push(converted.result) - context = converted.context - } - } - return { result, context } -} - -function isMobileIncrementalSnapshotEvent(x: unknown): x is MobileIncrementalSnapshotEvent { - const isIncrementalSnapshot = isObject(x) && 'type' in x && x.type === EventType.IncrementalSnapshot - if (!isIncrementalSnapshot) { - return false - } - const hasData = isObject(x) && 'data' in x - const data = hasData ? x.data : null - - const hasMutationSource = isObject(data) && 'source' in data && data.source === IncrementalSource.Mutation - - const adds = isObject(data) && 'adds' in data && Array.isArray(data.adds) ? data.adds : null - const updates = isObject(data) && 'updates' in data && Array.isArray(data.updates) ? data.updates : null - - const hasUpdatedWireframe = !!updates && updates.length > 0 && isObject(updates[0]) && 'wireframe' in updates[0] - const hasAddedWireframe = !!adds && adds.length > 0 && isObject(adds[0]) && 'wireframe' in adds[0] - - return hasMutationSource && (hasAddedWireframe || hasUpdatedWireframe) -} - -function chooseParentId(nodeType: MobileNodeType, providedParentId: number): number { - return nodeType === 'screenshot' ? BODY_ID : providedParentId -} - -function makeIncrementalAdd(add: MobileNodeMutation, context: ConversionContext): addedNodeMutation[] | null { - const converted = convertWireframe(add.wireframe, context) - - if (!converted) { - return null - } - - const addition: addedNodeMutation = { - parentId: chooseParentId(add.wireframe.type, add.parentId), - nextId: null, - node: converted.result, - } - const adds: addedNodeMutation[] = [] - if (addition) { - const flattened = flattenMutationAdds(addition) - flattened.forEach((x) => adds.push(x)) - return adds - } - return null -} - -/** - * When processing an update we remove the entire item, and then add it back in. - */ -function makeIncrementalRemoveForUpdate(update: MobileNodeMutation): removedNodeMutation { - return { - parentId: chooseParentId(update.wireframe.type, update.parentId), - id: update.wireframe.id, - } -} - -function isNode(x: unknown): x is serializedNodeWithId { - // KLUDGE: really we should check that x.type is valid, but we're safe enough already - return isObject(x) && 'type' in x && 'id' in x -} - -function isNodeWithChildren(x: unknown): x is elementNode | documentNode { - return isNode(x) && 'childNodes' in x && Array.isArray(x.childNodes) -} - -/** - * when creating incremental adds we have to flatten the node tree structure - * there's no point, then keeping those child nodes in place - */ -function cloneWithoutChildren(converted: addedNodeMutation): addedNodeMutation { - const cloned = { ...converted } - const clonedNode: serializedNodeWithId = { ...converted.node } - if (isNodeWithChildren(clonedNode)) { - clonedNode.childNodes = [] - } - cloned.node = clonedNode - return cloned -} - -function flattenMutationAdds(converted: addedNodeMutation): addedNodeMutation[] { - const flattened: addedNodeMutation[] = [] - - flattened.push(cloneWithoutChildren(converted)) - - const node: unknown = converted.node - const newParentId = converted.node.id - if (isNodeWithChildren(node)) { - node.childNodes.forEach((child) => { - flattened.push( - cloneWithoutChildren({ - parentId: newParentId, - nextId: null, - node: child, - }) - ) - if (isNodeWithChildren(child)) { - flattened.push(...flattenMutationAdds({ parentId: newParentId, nextId: null, node: child })) - } - }) - } - return flattened -} - -/** - * each update wireframe carries the entire tree because we don't want to diff on the client - * that means that we might create multiple mutations for the same node - * we only want to add it once, so we dedupe the mutations - * the app guarantees that for a given ID that is present more than once in a single snapshot - * every instance of that ID is identical - * it might change in the next snapshot but for a single incremental snapshot there is one - * and only one version of any given ID - */ -function dedupeMutations(mutations: T[]): T[] { - // KLUDGE: it's slightly yucky to stringify everything but since synthetic nodes - // introduce a new id, we can't just compare the id - const seen = new Set() - - // in case later mutations are the ones we want to keep, we reverse the array - // this does help with the deduping, so, it's likely that the view for a single ID - // is not consistent over a snapshot, but it's cheap to reverse so :YOLO: - return mutations - .reverse() - .filter((mutation: addedNodeMutation | removedNodeMutation) => { - let toCompare: string - if (isRemovedNodeMutation(mutation)) { - toCompare = JSON.stringify(mutation) - } else { - // if this is a synthetic addition, then we need to ignore the id, - // since duplicates won't have duplicate ids - toCompare = JSON.stringify({ - ...mutation.node, - id: 0, - }) - } - - if (seen.has(toCompare)) { - return false - } - seen.add(toCompare) - return true - }) - .reverse() -} - -/** - * We want to ensure that any events don't use id = 0. - * They must always represent a valid ID from the dom, so we swap in the body id when the id = 0. - * - * For "removes", we don't need to do anything, the id of the element to be removed remains valid. We won't try and remove other elements that we added during transformation in order to show that element. - * - * "adds" are converted from wireframes to nodes and converted to `incrementalSnapshotEvent.adds` - * - * "updates" are converted to a remove and an add. - * - */ -export const makeIncrementalEvent = ( - mobileEvent: (MobileIncrementalSnapshotEvent | incrementalSnapshotEvent) & { - timestamp: number - delay?: number - } -): incrementalSnapshotEvent & { - timestamp: number - delay?: number -} => { - const converted = mobileEvent as unknown as incrementalSnapshotEvent & { - timestamp: number - delay?: number - } - if ('id' in converted.data && converted.data.id === 0) { - converted.data.id = BODY_ID - } - - if (isMobileIncrementalSnapshotEvent(mobileEvent)) { - const adds: addedNodeMutation[] = [] - const removes: removedNodeMutation[] = mobileEvent.data.removes || [] - if ('adds' in mobileEvent.data && Array.isArray(mobileEvent.data.adds)) { - const addsContext = { - timestamp: mobileEvent.timestamp, - idSequence: globalIdSequence, - } - - mobileEvent.data.adds.forEach((add) => { - makeIncrementalAdd(add, addsContext)?.forEach((x) => adds.push(x)) - }) - } - if ('updates' in mobileEvent.data && Array.isArray(mobileEvent.data.updates)) { - const updatesContext = { - timestamp: mobileEvent.timestamp, - idSequence: globalIdSequence, - } - const updateAdditions: addedNodeMutation[] = [] - mobileEvent.data.updates.forEach((update) => { - const removal = makeIncrementalRemoveForUpdate(update) - if (removal) { - removes.push(removal) - } - makeIncrementalAdd(update, updatesContext)?.forEach((x) => updateAdditions.push(x)) - }) - dedupeMutations(updateAdditions).forEach((x) => adds.push(x)) - } - - converted.data = { - source: IncrementalSource.Mutation, - attributes: [], - texts: [], - adds: dedupeMutations(adds), - // TODO: this assumes that removes are processed before adds 🤞 - removes: dedupeMutations(removes), - } - } - - return converted -} - -function makeKeyboardParent(): serializedNodeWithId { - return { - type: NodeType.Element, - tagName: 'div', - attributes: { - 'data-render-reason': 'a fixed placeholder to contain the keyboard in the correct stacking position', - 'data-rrweb-id': KEYBOARD_PARENT_ID, - }, - id: KEYBOARD_PARENT_ID, - childNodes: [], - } -} - -function makeStatusBarNode( - statusBar: wireframeStatusBar | undefined, - context: ConversionContext -): serializedNodeWithId { - const childNodes = statusBar ? convertWireframesFor([statusBar], context).result : [] - return { - type: NodeType.Element, - tagName: 'div', - attributes: { - 'data-rrweb-id': STATUS_BAR_PARENT_ID, - }, - id: STATUS_BAR_PARENT_ID, - childNodes, - } -} - -function makeNavBarNode( - navigationBar: wireframeNavigationBar | undefined, - context: ConversionContext -): serializedNodeWithId { - const childNodes = navigationBar ? convertWireframesFor([navigationBar], context).result : [] - return { - type: NodeType.Element, - tagName: 'div', - attributes: { - 'data-rrweb-id': NAVIGATION_BAR_PARENT_ID, - }, - id: NAVIGATION_BAR_PARENT_ID, - childNodes, - } -} - -function stripBarsFromWireframe(wireframe: wireframe): { - wireframe: wireframe | undefined - statusBar: wireframeStatusBar | undefined - navBar: wireframeNavigationBar | undefined -} { - if (wireframe.type === 'status_bar') { - return { wireframe: undefined, statusBar: wireframe, navBar: undefined } - } else if (wireframe.type === 'navigation_bar') { - return { wireframe: undefined, statusBar: undefined, navBar: wireframe } - } - let statusBar: wireframeStatusBar | undefined - let navBar: wireframeNavigationBar | undefined - const wireframeToReturn: wireframe | undefined = { ...wireframe } - wireframeToReturn.childWireframes = [] - for (const child of wireframe.childWireframes || []) { - const { - wireframe: childWireframe, - statusBar: childStatusBar, - navBar: childNavBar, - } = stripBarsFromWireframe(child) - statusBar = statusBar || childStatusBar - navBar = navBar || childNavBar - if (childWireframe) { - wireframeToReturn.childWireframes.push(childWireframe) - } - } - return { wireframe: wireframeToReturn, statusBar, navBar } -} - -/** - * We want to be able to place the status bar and navigation bar in the correct stacking order. - * So, we lift them out of the tree, and return them separately. - */ -export function stripBarsFromWireframes(wireframes: wireframe[]): { - statusBar: wireframeStatusBar | undefined - navigationBar: wireframeNavigationBar | undefined - appNodes: wireframe[] -} { - let statusBar: wireframeStatusBar | undefined - let navigationBar: wireframeNavigationBar | undefined - const copiedNodes: wireframe[] = [] - - wireframes.forEach((w) => { - const matches = stripBarsFromWireframe(w) - if (matches.statusBar) { - statusBar = matches.statusBar - } - if (matches.navBar) { - navigationBar = matches.navBar - } - if (matches.wireframe) { - copiedNodes.push(matches.wireframe) - } - }) - return { statusBar, navigationBar, appNodes: copiedNodes } -} - -export const makeFullEvent = ( - mobileEvent: MobileFullSnapshotEvent & { - timestamp: number - delay?: number - } -): fullSnapshotEvent & { - timestamp: number - delay?: number -} => { - // we can restart the id sequence on each full snapshot - globalIdSequence = ids() - - if (!('wireframes' in mobileEvent.data)) { - return mobileEvent as unknown as fullSnapshotEvent & { - timestamp: number - delay?: number - } - } - - const conversionContext = { - timestamp: mobileEvent.timestamp, - idSequence: globalIdSequence, - } - - const { statusBar, navigationBar, appNodes } = stripBarsFromWireframes(mobileEvent.data.wireframes) - - const nodeGroups = { - appNodes: convertWireframesFor(appNodes, conversionContext).result || [], - statusBarNode: makeStatusBarNode(statusBar, conversionContext), - navBarNode: makeNavBarNode(navigationBar, conversionContext), - } - - return { - type: EventType.FullSnapshot, - timestamp: mobileEvent.timestamp, - data: { - node: { - type: NodeType.Document, - childNodes: [ - { - type: NodeType.DocumentType, - name: 'html', - publicId: '', - systemId: '', - id: HTML_DOC_TYPE_ID, - }, - { - type: NodeType.Element, - tagName: 'html', - attributes: { style: makeHTMLStyles(), 'data-rrweb-id': HTML_ELEMENT_ID }, - id: HTML_ELEMENT_ID, - childNodes: [ - { - type: NodeType.Element, - tagName: 'head', - attributes: { 'data-rrweb-id': HEAD_ID }, - id: HEAD_ID, - childNodes: [makeCSSReset(conversionContext)], - }, - { - type: NodeType.Element, - tagName: 'body', - attributes: { style: makeBodyStyles(), 'data-rrweb-id': BODY_ID }, - id: BODY_ID, - childNodes: [ - // in the order they should stack if they ever clash - // lower is higher in the stacking context - ...nodeGroups.appNodes, - makeKeyboardParent(), - nodeGroups.navBarNode, - nodeGroups.statusBarNode, - ], - }, - ], - }, - ], - id: DOCUMENT_ID, - }, - initialOffset: { - top: 0, - left: 0, - }, - }, - } -} - -function makeCSSReset(context: ConversionContext): serializedNodeWithId { - // we need to normalize CSS so browsers don't do unexpected things - return { - type: NodeType.Element, - tagName: 'style', - attributes: { - type: 'text/css', - }, - id: context.idSequence.next().value, - childNodes: [ - { - type: NodeType.Text, - textContent: ` - body { - margin: unset; - } - input, button, select, textarea { - font: inherit; - margin: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - padding-block: 0 !important; - } - .input:focus { - outline: none; - } - img { - border-style: none; - } - `, - id: context.idSequence.next().value, - }, - ], - } -} diff --git a/ee/frontend/mobile-replay/transformer/types.ts b/ee/frontend/mobile-replay/transformer/types.ts deleted file mode 100644 index 3ba93d6fc2e..00000000000 --- a/ee/frontend/mobile-replay/transformer/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { MobileStyles } from '../mobile.types' - -export interface ConversionResult { - result: T - context: ConversionContext -} - -export interface ConversionContext { - timestamp: number - idSequence: Generator - styleOverride?: StyleOverride -} - -// StyleOverride is defined here and not in the schema -// because these are overrides that the transformer is allowed to make -// not that clients are allowed to request -export type StyleOverride = MobileStyles & { bottom?: true; backgroundRepeat?: 'no-repeat' | 'unset' } diff --git a/ee/frontend/mobile-replay/transformer/wireframeStyle.ts b/ee/frontend/mobile-replay/transformer/wireframeStyle.ts deleted file mode 100644 index 1719060589d..00000000000 --- a/ee/frontend/mobile-replay/transformer/wireframeStyle.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { wireframe, wireframeProgress } from '../mobile.types' -import { dataURIOrPNG } from './transformers' -import { StyleOverride } from './types' - -function ensureTrailingSemicolon(styles: string): string { - return styles.endsWith(';') ? styles : styles + ';' -} - -function stripTrailingSemicolon(styles: string): string { - return styles.endsWith(';') ? styles.slice(0, -1) : styles -} - -export function asStyleString(styleParts: string[]): string { - if (styleParts.length === 0) { - return '' - } - return ensureTrailingSemicolon( - styleParts - .map(stripTrailingSemicolon) - .filter((x) => !!x) - .join(';') - ) -} - -function isNumber(candidate: unknown): candidate is number { - return typeof candidate === 'number' -} - -function isString(candidate: unknown): candidate is string { - return typeof candidate === 'string' -} - -function isUnitLike(candidate: unknown): candidate is string | number { - return isNumber(candidate) || (isString(candidate) && candidate.length > 0) -} - -function ensureUnit(value: string | number): string { - return isNumber(value) ? `${value}px` : value.replace(/px$/g, '') + 'px' -} - -function makeBorderStyles(wireframe: wireframe, styleOverride?: StyleOverride): string { - const styleParts: string[] = [] - - const combinedStyles = { - ...wireframe.style, - ...styleOverride, - } - - if (isUnitLike(combinedStyles.borderWidth)) { - const borderWidth = ensureUnit(combinedStyles.borderWidth) - styleParts.push(`border-width: ${borderWidth}`) - } - if (isUnitLike(combinedStyles.borderRadius)) { - const borderRadius = ensureUnit(combinedStyles.borderRadius) - styleParts.push(`border-radius: ${borderRadius}`) - } - if (combinedStyles?.borderColor) { - styleParts.push(`border-color: ${combinedStyles.borderColor}`) - } - - if (styleParts.length > 0) { - styleParts.push(`border-style: solid`) - } - - return asStyleString(styleParts) -} - -export function makeDimensionStyles(wireframe: wireframe): string { - const styleParts: string[] = [] - - if (wireframe.width === '100vw') { - styleParts.push(`width: 100vw`) - } else if (isNumber(wireframe.width)) { - styleParts.push(`width: ${ensureUnit(wireframe.width)}`) - } - - if (isNumber(wireframe.height)) { - styleParts.push(`height: ${ensureUnit(wireframe.height)}`) - } - - return asStyleString(styleParts) -} - -export function makePositionStyles(wireframe: wireframe, styleOverride?: StyleOverride): string { - const styleParts: string[] = [] - - styleParts.push(makeDimensionStyles(wireframe)) - - if (styleOverride?.bottom) { - styleParts.push(`bottom: 0`) - styleParts.push(`position: fixed`) - } else { - const posX = wireframe.x || 0 - const posY = wireframe.y || 0 - if (isNumber(posX) || isNumber(posY)) { - styleParts.push(`position: fixed`) - if (isNumber(posX)) { - styleParts.push(`left: ${ensureUnit(posX)}`) - } - if (isNumber(posY)) { - styleParts.push(`top: ${ensureUnit(posY)}`) - } - } - } - - if (styleOverride?.['z-index']) { - styleParts.push(`z-index: ${styleOverride['z-index']}`) - } - - return asStyleString(styleParts) -} - -function makeLayoutStyles(wireframe: wireframe, styleOverride?: StyleOverride): string { - const styleParts: string[] = [] - - const combinedStyles = { - ...wireframe.style, - ...styleOverride, - } - - if (combinedStyles.verticalAlign) { - styleParts.push( - `align-items: ${{ top: 'flex-start', center: 'center', bottom: 'flex-end' }[combinedStyles.verticalAlign]}` - ) - } - if (combinedStyles.horizontalAlign) { - styleParts.push( - `justify-content: ${ - { left: 'flex-start', center: 'center', right: 'flex-end' }[combinedStyles.horizontalAlign] - }` - ) - } - - if (styleParts.length) { - styleParts.push(`display: flex`) - } - - if (isUnitLike(combinedStyles.paddingLeft)) { - styleParts.push(`padding-left: ${ensureUnit(combinedStyles.paddingLeft)}`) - } - if (isUnitLike(combinedStyles.paddingRight)) { - styleParts.push(`padding-right: ${ensureUnit(combinedStyles.paddingRight)}`) - } - if (isUnitLike(combinedStyles.paddingTop)) { - styleParts.push(`padding-top: ${ensureUnit(combinedStyles.paddingTop)}`) - } - if (isUnitLike(combinedStyles.paddingBottom)) { - styleParts.push(`padding-bottom: ${ensureUnit(combinedStyles.paddingBottom)}`) - } - - return asStyleString(styleParts) -} - -function makeFontStyles(wireframe: wireframe, styleOverride?: StyleOverride): string { - const styleParts: string[] = [] - - const combinedStyles = { - ...wireframe.style, - ...styleOverride, - } - - if (isUnitLike(combinedStyles.fontSize)) { - styleParts.push(`font-size: ${ensureUnit(combinedStyles?.fontSize)}`) - } - - if (combinedStyles.fontFamily) { - styleParts.push(`font-family: ${combinedStyles.fontFamily}`) - } - - return asStyleString(styleParts) -} - -export function makeIndeterminateProgressStyles(wireframe: wireframeProgress, styleOverride?: StyleOverride): string { - const combinedStyles = { - ...wireframe.style, - ...styleOverride, - } - - return asStyleString([ - makeBackgroundStyles(wireframe, styleOverride), - makePositionStyles(wireframe), - `border: 4px solid ${combinedStyles.borderColor || combinedStyles.color || 'transparent'};`, - `border-radius: 50%;border-top: 4px solid #fff;`, - `animation: spin 2s linear infinite;`, - ]) -} - -export function makeDeterminateProgressStyles(wireframe: wireframeProgress, styleOverride?: StyleOverride): string { - const combinedStyles = { - ...wireframe.style, - ...styleOverride, - } - - const radialGradient = `radial-gradient(closest-side, white 80%, transparent 0 99.9%, white 0)` - const conicGradient = `conic-gradient(${combinedStyles.color || 'black'} calc(${wireframe.value} * 1%), ${ - combinedStyles.backgroundColor - } 0)` - - return asStyleString([ - makeBackgroundStyles(wireframe, styleOverride), - makePositionStyles(wireframe), - 'border-radius: 50%', - - `background: ${radialGradient}, ${conicGradient}`, - ]) -} - -/** - * normally use makeStylesString instead, but sometimes you need styles without any colors applied - * */ -export function makeMinimalStyles(wireframe: wireframe, styleOverride?: StyleOverride): string { - return asStyleString([ - makePositionStyles(wireframe, styleOverride), - makeLayoutStyles(wireframe, styleOverride), - makeFontStyles(wireframe, styleOverride), - ]) -} - -export function makeBackgroundStyles(wireframe: wireframe, styleOverride?: StyleOverride): string { - let styleParts: string[] = [] - - const combinedStyles = { - ...wireframe.style, - ...styleOverride, - } - - if (combinedStyles.backgroundColor) { - styleParts.push(`background-color: ${combinedStyles.backgroundColor}`) - } - - if (combinedStyles.backgroundImage) { - const backgroundImageURL = combinedStyles.backgroundImage.startsWith('url(') - ? combinedStyles.backgroundImage - : `url('${dataURIOrPNG(combinedStyles.backgroundImage)}')` - styleParts = styleParts.concat([ - `background-image: ${backgroundImageURL}`, - `background-size: ${combinedStyles.backgroundSize || 'contain'}`, - `background-repeat: ${combinedStyles.backgroundRepeat || 'no-repeat'}`, - ]) - } - - return asStyleString(styleParts) -} - -export function makeColorStyles(wireframe: wireframe, styleOverride?: StyleOverride): string { - const combinedStyles = { - ...wireframe.style, - ...styleOverride, - } - - const styleParts = [makeBackgroundStyles(wireframe, styleOverride), makeBorderStyles(wireframe, styleOverride)] - if (combinedStyles.color) { - styleParts.push(`color: ${combinedStyles.color}`) - } - - return asStyleString(styleParts) -} - -export function makeStylesString(wireframe: wireframe, styleOverride?: StyleOverride): string { - return asStyleString([makeColorStyles(wireframe, styleOverride), makeMinimalStyles(wireframe, styleOverride)]) -} - -export function makeHTMLStyles(): string { - return 'height: 100vh; width: 100vw;' -} - -export function makeBodyStyles(): string { - return 'height: 100vh; width: 100vw;' -} diff --git a/ee/management/commands/materialize_columns.py b/ee/management/commands/materialize_columns.py deleted file mode 100644 index c1ca3b3fd22..00000000000 --- a/ee/management/commands/materialize_columns.py +++ /dev/null @@ -1,102 +0,0 @@ -import logging - -from django.core.management.base import BaseCommand - -from ee.clickhouse.materialized_columns.analyze import ( - logger, - materialize_properties_task, -) -from ee.clickhouse.materialized_columns.columns import DEFAULT_TABLE_COLUMN -from posthog.settings import ( - MATERIALIZE_COLUMNS_ANALYSIS_PERIOD_HOURS, - MATERIALIZE_COLUMNS_BACKFILL_PERIOD_DAYS, - MATERIALIZE_COLUMNS_MAX_AT_ONCE, - MATERIALIZE_COLUMNS_MINIMUM_QUERY_TIME, -) - - -class Command(BaseCommand): - help = "Materialize properties into columns in clickhouse" - - def add_arguments(self, parser): - parser.add_argument("--dry-run", action="store_true", help="Print plan instead of executing it") - - parser.add_argument( - "--property", - help="Properties to materialize. Skips analysis. Allows multiple arguments --property abc '$.abc.def'", - nargs="+", - ) - parser.add_argument( - "--property-table", - type=str, - default="events", - choices=["events", "person"], - help="Table of --property", - ) - parser.add_argument( - "--table-column", - help="The column to which --property should be materialised from.", - default=DEFAULT_TABLE_COLUMN, - ) - parser.add_argument( - "--backfill-period", - type=int, - default=MATERIALIZE_COLUMNS_BACKFILL_PERIOD_DAYS, - help="How many days worth of data to backfill. 0 to disable. Same as MATERIALIZE_COLUMNS_BACKFILL_PERIOD_DAYS env variable.", - ) - - parser.add_argument( - "--min-query-time", - type=int, - default=MATERIALIZE_COLUMNS_MINIMUM_QUERY_TIME, - help="Minimum query time (ms) before a query if considered for optimization. Same as MATERIALIZE_COLUMNS_MINIMUM_QUERY_TIME env variable.", - ) - parser.add_argument( - "--analyze-period", - type=int, - default=MATERIALIZE_COLUMNS_ANALYSIS_PERIOD_HOURS, - help="How long of a time period to analyze. Same as MATERIALIZE_COLUMNS_ANALYSIS_PERIOD_HOURS env variable.", - ) - parser.add_argument( - "--analyze-team-id", - type=int, - default=None, - help="Analyze queries only for a specific team_id", - ) - parser.add_argument( - "--max-columns", - type=int, - default=MATERIALIZE_COLUMNS_MAX_AT_ONCE, - help="Max number of columns to materialize via single invocation. Same as MATERIALIZE_COLUMNS_MAX_AT_ONCE env variable.", - ) - - def handle(self, *args, **options): - logger.setLevel(logging.INFO) - - if options["dry_run"]: - logger.warn("Dry run: No changes to the tables will be made!") - - if options.get("property"): - logger.info(f"Materializing column. table={options['property_table']}, property_name={options['property']}") - - materialize_properties_task( - columns_to_materialize=[ - ( - options["property_table"], - options["table_column"], - prop, - ) - for prop in options.get("property") - ], - backfill_period_days=options["backfill_period"], - dry_run=options["dry_run"], - ) - else: - materialize_properties_task( - time_to_analyze_hours=options["analyze_period"], - maximum=options["max_columns"], - min_query_time=options["min_query_time"], - backfill_period_days=options["backfill_period"], - dry_run=options["dry_run"], - team_id_to_analyze=options["analyze_team_id"], - ) diff --git a/ee/migrations/0001_initial.py b/ee/migrations/0001_initial.py deleted file mode 100644 index 5b668bc772b..00000000000 --- a/ee/migrations/0001_initial.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 3.0.7 on 2020-08-07 09:15 - - -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies: list = [] - - operations = [ - migrations.CreateModel( - name="License", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("plan", models.CharField(max_length=200)), - ("valid_until", models.DateTimeField()), - ("key", models.CharField(max_length=200)), - ], - ), - ] diff --git a/ee/migrations/0002_hook.py b/ee/migrations/0002_hook.py deleted file mode 100644 index 36516d048a3..00000000000 --- a/ee/migrations/0002_hook.py +++ /dev/null @@ -1,59 +0,0 @@ -# Generated by Django 3.0.6 on 2020-08-18 12:10 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - -import posthog.models.utils - - -class Migration(migrations.Migration): - dependencies = [ - ("posthog", "0082_personalapikey"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("ee", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="Hook", - fields=[ - ("created", models.DateTimeField(auto_now_add=True)), - ("updated", models.DateTimeField(auto_now=True)), - ( - "event", - models.CharField(db_index=True, max_length=64, verbose_name="Event"), - ), - ("target", models.URLField(max_length=255, verbose_name="Target URL")), - ( - "id", - models.CharField( - default=posthog.models.utils.generate_random_token, - max_length=50, - primary_key=True, - serialize=False, - ), - ), - ("resource_id", models.IntegerField(blank=True, null=True)), - ( - "team", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="rest_hooks", - to="posthog.Team", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="rest_hooks", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "abstract": False, - }, - ), - ] diff --git a/ee/migrations/0003_license_max_users.py b/ee/migrations/0003_license_max_users.py deleted file mode 100644 index 6760baca0c2..00000000000 --- a/ee/migrations/0003_license_max_users.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.0.11 on 2021-04-14 00:20 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("ee", "0002_hook"), - ] - - operations = [ - migrations.AddField( - model_name="license", - name="max_users", - field=models.IntegerField(default=None, null=True), - ), - ] diff --git a/ee/migrations/0004_enterpriseeventdefinition_enterprisepropertydefinition.py b/ee/migrations/0004_enterpriseeventdefinition_enterprisepropertydefinition.py deleted file mode 100644 index cd0d2b6b586..00000000000 --- a/ee/migrations/0004_enterpriseeventdefinition_enterprisepropertydefinition.py +++ /dev/null @@ -1,108 +0,0 @@ -# Generated by Django 3.1.8 on 2021-06-02 19:42 - -import django.contrib.postgres.fields -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("posthog", "0156_insight_short_id"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("ee", "0003_license_max_users"), - ] - - operations = [ - migrations.CreateModel( - name="EnterprisePropertyDefinition", - fields=[ - ( - "propertydefinition_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="posthog.propertydefinition", - ), - ), - ("description", models.CharField(blank=True, max_length=400)), - ( - "tags", - django.contrib.postgres.fields.ArrayField( - base_field=models.CharField(max_length=32), - blank=True, - default=list, - null=True, - size=None, - ), - ), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "updated_by", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "abstract": False, - }, - bases=("posthog.propertydefinition",), - ), - migrations.CreateModel( - name="EnterpriseEventDefinition", - fields=[ - ( - "eventdefinition_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="posthog.eventdefinition", - ), - ), - ("description", models.CharField(blank=True, max_length=400)), - ( - "tags", - django.contrib.postgres.fields.ArrayField( - base_field=models.CharField(max_length=32), - blank=True, - default=list, - null=True, - size=None, - ), - ), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "owner", - models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="event_definitions", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "updated_by", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "abstract": False, - }, - bases=("posthog.eventdefinition",), - ), - ] diff --git a/ee/migrations/0005_project_based_permissioning.py b/ee/migrations/0005_project_based_permissioning.py deleted file mode 100644 index d785637d17d..00000000000 --- a/ee/migrations/0005_project_based_permissioning.py +++ /dev/null @@ -1,63 +0,0 @@ -# Generated by Django 3.2.5 on 2021-09-10 11:39 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - -import posthog.models.utils - - -class Migration(migrations.Migration): - dependencies = [ - ("posthog", "0170_project_based_permissioning"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("ee", "0004_enterpriseeventdefinition_enterprisepropertydefinition"), - ] - - operations = [ - migrations.CreateModel( - name="ExplicitTeamMembership", - fields=[ - ( - "id", - models.UUIDField( - default=posthog.models.utils.UUIDT, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "level", - models.PositiveSmallIntegerField(choices=[(1, "member"), (8, "administrator")], default=1), - ), - ("joined_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "team", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="explicit_memberships", - related_query_name="explicit_membership", - to="posthog.team", - ), - ), - ( - "parent_membership", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="explicit_team_memberships", - related_query_name="explicit_team_membership", - to="posthog.organizationmembership", - ), - ), - ], - ), - migrations.AddConstraint( - model_name="explicitteammembership", - constraint=models.UniqueConstraint( - fields=("team", "parent_membership"), - name="unique_explicit_team_membership", - ), - ), - ] diff --git a/ee/migrations/0006_event_definition_verification.py b/ee/migrations/0006_event_definition_verification.py deleted file mode 100644 index c86f415d3f2..00000000000 --- a/ee/migrations/0006_event_definition_verification.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 3.2.5 on 2022-01-17 20:13 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("ee", "0005_project_based_permissioning"), - ] - - operations = [ - migrations.AddField( - model_name="enterpriseeventdefinition", - name="verified", - field=models.BooleanField(blank=True, default=False), - ), - migrations.AddField( - model_name="enterpriseeventdefinition", - name="verified_at", - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name="enterpriseeventdefinition", - name="verified_by", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="verifying_user", - to=settings.AUTH_USER_MODEL, - ), - ), - ] diff --git a/ee/migrations/0007_dashboard_permissions.py b/ee/migrations/0007_dashboard_permissions.py deleted file mode 100644 index 015498bfca9..00000000000 --- a/ee/migrations/0007_dashboard_permissions.py +++ /dev/null @@ -1,67 +0,0 @@ -# Generated by Django 3.2.5 on 2022-01-31 20:50 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - -import posthog.models.utils - - -class Migration(migrations.Migration): - dependencies = [ - ("posthog", "0203_dashboard_permissions"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("ee", "0006_event_definition_verification"), - ] - - operations = [ - migrations.CreateModel( - name="DashboardPrivilege", - fields=[ - ( - "id", - models.UUIDField( - default=posthog.models.utils.UUIDT, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "level", - models.PositiveSmallIntegerField( - choices=[ - (21, "Everyone in the project can edit"), - (37, "Only those invited to this dashboard can edit"), - ] - ), - ), - ("added_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "dashboard", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="privileges", - related_query_name="privilege", - to="posthog.dashboard", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="explicit_dashboard_privileges", - related_query_name="explicit_dashboard_privilege", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.AddConstraint( - model_name="dashboardprivilege", - constraint=models.UniqueConstraint( - fields=("dashboard", "user"), name="unique_explicit_dashboard_privilege" - ), - ), - ] diff --git a/ee/migrations/0008_null_definition_descriptions.py b/ee/migrations/0008_null_definition_descriptions.py deleted file mode 100644 index 1172813b25a..00000000000 --- a/ee/migrations/0008_null_definition_descriptions.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.2.5 on 2022-02-15 20:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("ee", "0007_dashboard_permissions"), - ] - - operations = [ - migrations.AlterField( - model_name="enterpriseeventdefinition", - name="description", - field=models.TextField(blank=True, default="", null=True), - ), - migrations.AlterField( - model_name="enterprisepropertydefinition", - name="description", - field=models.TextField(blank=True, default="", null=True), - ), - ] diff --git a/ee/migrations/0009_deprecated_old_tags.py b/ee/migrations/0009_deprecated_old_tags.py deleted file mode 100644 index c01f76cfd6c..00000000000 --- a/ee/migrations/0009_deprecated_old_tags.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.2.5 on 2022-02-17 18:11 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("ee", "0008_null_definition_descriptions"), - ] - - operations = [ - migrations.RenameField( - model_name="enterpriseeventdefinition", - old_name="tags", - new_name="deprecated_tags", - ), - migrations.RenameField( - model_name="enterprisepropertydefinition", - old_name="tags", - new_name="deprecated_tags", - ), - ] diff --git a/ee/migrations/0010_migrate_definitions_tags.py b/ee/migrations/0010_migrate_definitions_tags.py deleted file mode 100644 index 687d7460447..00000000000 --- a/ee/migrations/0010_migrate_definitions_tags.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.2.5 on 2022-01-28 19:21 -from django.db import migrations - - -def forwards(apps, schema_editor): - pass - - -def reverse(apps, schema_editor): - pass - - -class Migration(migrations.Migration): - dependencies = [ - ("ee", "0009_deprecated_old_tags"), - ("posthog", "0213_deprecated_old_tags"), - ] - - operations = [ - migrations.RunPython(forwards, reverse), - ] diff --git a/ee/migrations/0011_add_tags_back.py b/ee/migrations/0011_add_tags_back.py deleted file mode 100644 index 0f5d2ff4f21..00000000000 --- a/ee/migrations/0011_add_tags_back.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 3.2.5 on 2022-02-18 18:22 - -import django.contrib.postgres.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("ee", "0010_migrate_definitions_tags"), - ] - - operations = [ - migrations.AddField( - model_name="enterpriseeventdefinition", - name="tags", - field=django.contrib.postgres.fields.ArrayField( - base_field=models.CharField(max_length=32), - blank=True, - default=None, - null=True, - size=None, - ), - ), - migrations.AddField( - model_name="enterprisepropertydefinition", - name="tags", - field=django.contrib.postgres.fields.ArrayField( - base_field=models.CharField(max_length=32), - blank=True, - default=None, - null=True, - size=None, - ), - ), - ] diff --git a/ee/migrations/0012_migrate_tags_v2.py b/ee/migrations/0012_migrate_tags_v2.py deleted file mode 100644 index 540cd281338..00000000000 --- a/ee/migrations/0012_migrate_tags_v2.py +++ /dev/null @@ -1,143 +0,0 @@ -# Generated by Django 3.2.5 on 2022-03-02 22:44 -from typing import Any - -from django.core.paginator import Paginator -from django.db import migrations -from django.db.models import Q - -from posthog.models.tag import tagify - - -def forwards(apps, schema_editor): - import structlog - - logger = structlog.get_logger(__name__) - logger.info("ee/0012_migrate_tags_v2_start") - - Tag = apps.get_model("posthog", "Tag") - TaggedItem = apps.get_model("posthog", "TaggedItem") - EnterpriseEventDefinition = apps.get_model("ee", "EnterpriseEventDefinition") - EnterprisePropertyDefinition = apps.get_model("ee", "EnterprisePropertyDefinition") - - createables: list[tuple[Any, Any]] = [] - batch_size = 1_000 - - # Collect event definition tags and taggeditems - event_definition_paginator = Paginator( - EnterpriseEventDefinition.objects.exclude( - Q(deprecated_tags__isnull=True) | Q(deprecated_tags=[]), - ) - .order_by("created_at") - .values_list("deprecated_tags", "team_id", "id"), - batch_size, - ) - - for event_definition_page in event_definition_paginator.page_range: - logger.info( - "event_definition_tag_batch_get_start", - limit=batch_size, - offset=(event_definition_page - 1) * batch_size, - ) - event_definitions = iter(event_definition_paginator.get_page(event_definition_page)) - for tags, team_id, event_definition_id in event_definitions: - unique_tags = {tagify(t) for t in tags if isinstance(t, str) and t.strip() != ""} - for tag in unique_tags: - temp_tag = Tag(name=tag, team_id=team_id) - createables.append( - ( - temp_tag, - TaggedItem(event_definition_id=event_definition_id, tag_id=temp_tag.id), - ) - ) - - logger.info("event_definition_tag_get_end", tags_count=len(createables)) - num_event_definition_tags = len(createables) - - # Collect property definition tags and taggeditems - property_definition_paginator = Paginator( - EnterprisePropertyDefinition.objects.exclude( - Q(deprecated_tags__isnull=True) | Q(deprecated_tags=[]), - ) - .order_by("updated_at") - .values_list("deprecated_tags", "team_id", "id"), - batch_size, - ) - - for property_definition_page in property_definition_paginator.page_range: - logger.info( - "property_definition_tag_batch_get_start", - limit=batch_size, - offset=(property_definition_page - 1) * batch_size, - ) - property_definitions = iter(property_definition_paginator.get_page(property_definition_page)) - for tags, team_id, property_definition_id in property_definitions: - unique_tags = {tagify(t) for t in tags if isinstance(t, str) and t.strip() != ""} - for tag in unique_tags: - temp_tag = Tag(name=tag, team_id=team_id) - createables.append( - ( - temp_tag, - TaggedItem( - property_definition_id=property_definition_id, - tag_id=temp_tag.id, - ), - ) - ) - - logger.info( - "property_definition_tag_get_end", - tags_count=len(createables) - num_event_definition_tags, - ) - - # Consistent ordering to make independent runs non-deterministic - createables = sorted(createables, key=lambda pair: pair[0].name) - - # Attempts to create tags in bulk while ignoring conflicts. bulk_create does not return any data - # about which tags were ignored and created, so we must take care of this manually. - tags_to_create = [tag for (tag, _) in createables] - Tag.objects.bulk_create(tags_to_create, ignore_conflicts=True, batch_size=batch_size) - logger.info("tags_bulk_created") - - # Associate tag ids with tagged_item objects in batches. Best case scenario all tags are new. Worst case - # scenario, all tags already exist and get is made for every tag. - for offset in range(0, len(tags_to_create), batch_size): - logger.info("tagged_item_batch_create_start", limit=batch_size, offset=offset) - batch = tags_to_create[offset : (offset + batch_size)] - - # Find tags that were created, and not already existing - created_tags = Tag.objects.in_bulk([t.id for t in batch]) - - # Tags that are in `tags_to_create` but not in `created_tags` are tags that already exist - # in the db and must be fetched individually. - createable_batch = createables[offset : (offset + batch_size)] - for tag, tagged_item in createable_batch: - if tag.id in created_tags: - tagged_item.tag_id = created_tags[tag.id].id - else: - tagged_item.tag_id = Tag.objects.filter(name=tag.name, team_id=tag.team_id).first().id - - # Create tag <-> item relationships, ignoring conflicts - TaggedItem.objects.bulk_create( - [tagged_item for (_, tagged_item) in createable_batch], - ignore_conflicts=True, - batch_size=batch_size, - ) - - logger.info("ee/0012_migrate_tags_v2_end") - - -def reverse(apps, schema_editor): - TaggedItem = apps.get_model("posthog", "TaggedItem") - TaggedItem.objects.filter(Q(event_definition_id__isnull=False) | Q(property_definition_id__isnull=False)).delete() - # Cascade deletes tag objects - - -class Migration(migrations.Migration): - atomic = False - - dependencies = [ - ("ee", "0011_add_tags_back"), - ("posthog", "0218_uniqueness_constraint_tagged_items"), - ] - - operations = [migrations.RunPython(forwards, reverse)] diff --git a/ee/migrations/0013_silence_deprecated_tags_warnings.py b/ee/migrations/0013_silence_deprecated_tags_warnings.py deleted file mode 100644 index c27f29ef350..00000000000 --- a/ee/migrations/0013_silence_deprecated_tags_warnings.py +++ /dev/null @@ -1,47 +0,0 @@ -# Generated by Django 3.2.13 on 2022-06-23 16:11 - -import django.contrib.postgres.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("ee", "0012_migrate_tags_v2"), - ] - - operations = [ - migrations.RenameField( - model_name="enterpriseeventdefinition", - old_name="tags", - new_name="deprecated_tags_v2", - ), - migrations.RenameField( - model_name="enterprisepropertydefinition", - old_name="tags", - new_name="deprecated_tags_v2", - ), - migrations.AlterField( - model_name="enterpriseeventdefinition", - name="deprecated_tags_v2", - field=django.contrib.postgres.fields.ArrayField( - base_field=models.CharField(max_length=32), - blank=True, - db_column="tags", - default=None, - null=True, - size=None, - ), - ), - migrations.AlterField( - model_name="enterprisepropertydefinition", - name="deprecated_tags_v2", - field=django.contrib.postgres.fields.ArrayField( - base_field=models.CharField(max_length=32), - blank=True, - db_column="tags", - default=None, - null=True, - size=None, - ), - ), - ] diff --git a/ee/migrations/0014_roles_memberships_and_resource_access.py b/ee/migrations/0014_roles_memberships_and_resource_access.py deleted file mode 100644 index dd5b0a74688..00000000000 --- a/ee/migrations/0014_roles_memberships_and_resource_access.py +++ /dev/null @@ -1,201 +0,0 @@ -# Generated by Django 3.2.16 on 2022-11-23 17:34 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - -import posthog.models.utils - - -class Migration(migrations.Migration): - dependencies = [ - ("posthog", "0280_fix_async_deletion_team"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("ee", "0013_silence_deprecated_tags_warnings"), - ] - - operations = [ - migrations.CreateModel( - name="Role", - fields=[ - ( - "id", - models.UUIDField( - default=posthog.models.utils.UUIDT, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("name", models.CharField(max_length=200)), - ( - "feature_flags_access_level", - models.PositiveSmallIntegerField( - choices=[(21, "Can only view"), (37, "Can always edit")], - default=37, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ( - "created_by", - models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="roles", - related_query_name="role", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "organization", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="roles", - related_query_name="role", - to="posthog.organization", - ), - ), - ], - ), - migrations.CreateModel( - name="RoleMembership", - fields=[ - ( - "id", - models.UUIDField( - default=posthog.models.utils.UUIDT, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("joined_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "role", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="roles", - related_query_name="role", - to="ee.role", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="role_memberships", - related_query_name="role_membership", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.CreateModel( - name="OrganizationResourceAccess", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "resource", - models.CharField( - choices=[ - ("feature flags", "feature flags"), - ("experiments", "experiments"), - ("cohorts", "cohorts"), - ("data management", "data management"), - ("session recordings", "session recordings"), - ("insights", "insights"), - ("dashboards", "dashboards"), - ], - max_length=32, - ), - ), - ( - "access_level", - models.PositiveSmallIntegerField( - choices=[(21, "Can only view"), (37, "Can always edit")], - default=37, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "created_by", - models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "organization", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="resource_access", - to="posthog.organization", - ), - ), - ], - ), - migrations.CreateModel( - name="FeatureFlagRoleAccess", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("added_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "feature_flag", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="access", - related_query_name="access", - to="posthog.featureflag", - ), - ), - ( - "role", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="feature_flag_access", - related_query_name="feature_flag_access", - to="ee.role", - ), - ), - ], - ), - migrations.AddConstraint( - model_name="rolemembership", - constraint=models.UniqueConstraint(fields=("role", "user"), name="unique_user_and_role"), - ), - migrations.AddConstraint( - model_name="role", - constraint=models.UniqueConstraint(fields=("organization", "name"), name="unique_role_name"), - ), - migrations.AddConstraint( - model_name="organizationresourceaccess", - constraint=models.UniqueConstraint( - fields=("organization", "resource"), - name="unique resource per organization", - ), - ), - migrations.AddConstraint( - model_name="featureflagroleaccess", - constraint=models.UniqueConstraint(fields=("role", "feature_flag"), name="unique_feature_flag_and_role"), - ), - ] diff --git a/ee/migrations/0015_add_verified_properties.py b/ee/migrations/0015_add_verified_properties.py deleted file mode 100644 index c61c980ba4e..00000000000 --- a/ee/migrations/0015_add_verified_properties.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 3.2.18 on 2023-06-07 10:39 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("ee", "0014_roles_memberships_and_resource_access"), - ] - - operations = [ - migrations.AddField( - model_name="enterprisepropertydefinition", - name="verified", - field=models.BooleanField(blank=True, default=False), - ), - migrations.AddField( - model_name="enterprisepropertydefinition", - name="verified_at", - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name="enterprisepropertydefinition", - name="verified_by", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="property_verifying_user", - to=settings.AUTH_USER_MODEL, - ), - ), - ] diff --git a/ee/migrations/0016_rolemembership_organization_member.py b/ee/migrations/0016_rolemembership_organization_member.py deleted file mode 100644 index d366581f31c..00000000000 --- a/ee/migrations/0016_rolemembership_organization_member.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 4.1.13 on 2024-03-14 13:40 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - dependencies = [ - ("posthog", "0397_projects_backfill"), - ("ee", "0015_add_verified_properties"), - ] - - operations = [ - migrations.AddField( - model_name="rolemembership", - name="organization_member", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="role_memberships", - related_query_name="role_membership", - to="posthog.organizationmembership", - ), - ), - ] diff --git a/ee/migrations/__init__.py b/ee/migrations/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/models/__init__.py b/ee/models/__init__.py deleted file mode 100644 index fd87f76bd54..00000000000 --- a/ee/models/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from .dashboard_privilege import DashboardPrivilege -from .event_definition import EnterpriseEventDefinition -from .explicit_team_membership import ExplicitTeamMembership -from .feature_flag_role_access import FeatureFlagRoleAccess -from .hook import Hook -from .license import License -from .property_definition import EnterprisePropertyDefinition -from .role import Role, RoleMembership - -__all__ = [ - "EnterpriseEventDefinition", - "ExplicitTeamMembership", - "DashboardPrivilege", - "Hook", - "License", - "Role", - "RoleMembership", - "EnterprisePropertyDefinition", - "FeatureFlagRoleAccess", -] diff --git a/ee/models/dashboard_privilege.py b/ee/models/dashboard_privilege.py deleted file mode 100644 index 40c48ec9ca9..00000000000 --- a/ee/models/dashboard_privilege.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.db import models - -from posthog.models.dashboard import Dashboard -from posthog.models.utils import UUIDModel, sane_repr - - -# We call models that grant a user access to some resource (which isn't a grouping of users) a "privilege" -class DashboardPrivilege(UUIDModel): - dashboard: models.ForeignKey = models.ForeignKey( - "posthog.Dashboard", - on_delete=models.CASCADE, - related_name="privileges", - related_query_name="privilege", - ) - user: models.ForeignKey = models.ForeignKey( - "posthog.User", - on_delete=models.CASCADE, - related_name="explicit_dashboard_privileges", - related_query_name="explicit_dashboard_privilege", - ) - level: models.PositiveSmallIntegerField = models.PositiveSmallIntegerField( - choices=Dashboard.RestrictionLevel.choices - ) - added_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) - updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) - - class Meta: - constraints = [ - models.UniqueConstraint(fields=["dashboard", "user"], name="unique_explicit_dashboard_privilege") - ] - - __repr__ = sane_repr("dashboard", "user", "level") diff --git a/ee/models/event_definition.py b/ee/models/event_definition.py deleted file mode 100644 index 408fbcec43c..00000000000 --- a/ee/models/event_definition.py +++ /dev/null @@ -1,35 +0,0 @@ -from django.contrib.postgres.fields import ArrayField -from django.db import models - -from posthog.models.event_definition import EventDefinition - - -class EnterpriseEventDefinition(EventDefinition): - owner = models.ForeignKey( - "posthog.User", - null=True, - on_delete=models.SET_NULL, - related_name="event_definitions", - ) - description: models.TextField = models.TextField(blank=True, null=True, default="") - updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) - updated_by = models.ForeignKey("posthog.User", null=True, on_delete=models.SET_NULL, blank=True) - verified: models.BooleanField = models.BooleanField(default=False, blank=True) - verified_at: models.DateTimeField = models.DateTimeField(null=True, blank=True) - verified_by = models.ForeignKey( - "posthog.User", - null=True, - on_delete=models.SET_NULL, - blank=True, - related_name="verifying_user", - ) - - # Deprecated in favour of app-wide tagging model. See EnterpriseTaggedItem - deprecated_tags: ArrayField = ArrayField(models.CharField(max_length=32), null=True, blank=True, default=list) - deprecated_tags_v2: ArrayField = ArrayField( - models.CharField(max_length=32), - null=True, - blank=True, - default=None, - db_column="tags", - ) diff --git a/ee/models/explicit_team_membership.py b/ee/models/explicit_team_membership.py deleted file mode 100644 index 2e41101cb3b..00000000000 --- a/ee/models/explicit_team_membership.py +++ /dev/null @@ -1,49 +0,0 @@ -from django.db import models - -from posthog.models.utils import UUIDModel, sane_repr -from posthog.models.organization import OrganizationMembership - - -# We call models that grant a user access to some grouping of users a "membership" -class ExplicitTeamMembership(UUIDModel): - class Level(models.IntegerChoices): - """Keep in sync with OrganizationMembership.Level (only difference being organizations having an Owner).""" - - MEMBER = 1, "member" - ADMIN = 8, "administrator" - - team: models.ForeignKey = models.ForeignKey( - "posthog.Team", - on_delete=models.CASCADE, - related_name="explicit_memberships", - related_query_name="explicit_membership", - ) - parent_membership: models.ForeignKey = models.ForeignKey( - "posthog.OrganizationMembership", - on_delete=models.CASCADE, - related_name="explicit_team_memberships", - related_query_name="explicit_team_membership", - ) - level: models.PositiveSmallIntegerField = models.PositiveSmallIntegerField( - default=Level.MEMBER, choices=Level.choices - ) - joined_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) - updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) - - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["team", "parent_membership"], - name="unique_explicit_team_membership", - ) - ] - - def __str__(self): - return str(self.Level(self.level)) - - @property - def effective_level(self) -> "OrganizationMembership.Level": - """If organization level is higher than project level, then that takes precedence over explicit project level.""" - return max(self.level, self.parent_membership.level) - - __repr__ = sane_repr("team", "parent_membership", "level") diff --git a/ee/models/feature_flag_role_access.py b/ee/models/feature_flag_role_access.py deleted file mode 100644 index 003ede1b25e..00000000000 --- a/ee/models/feature_flag_role_access.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.db import models - - -class FeatureFlagRoleAccess(models.Model): - feature_flag: models.ForeignKey = models.ForeignKey( - "posthog.FeatureFlag", - on_delete=models.CASCADE, - related_name="access", - related_query_name="access", - ) - role: models.ForeignKey = models.ForeignKey( - "Role", - on_delete=models.CASCADE, - related_name="feature_flag_access", - related_query_name="feature_flag_access", - ) - added_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) - updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) - - class Meta: - constraints = [models.UniqueConstraint(fields=["role", "feature_flag"], name="unique_feature_flag_and_role")] diff --git a/ee/models/hook.py b/ee/models/hook.py deleted file mode 100644 index 7cfaf22b9f2..00000000000 --- a/ee/models/hook.py +++ /dev/null @@ -1,47 +0,0 @@ -import json - -from django.core.exceptions import ValidationError -from django.db import models -from django.db.models.signals import post_delete, post_save -from django.dispatch.dispatcher import receiver - -from posthog.models.signals import mutable_receiver -from posthog.models.utils import generate_random_token -from posthog.redis import get_client - - -HOOK_EVENTS = ["action_performed"] - - -class Hook(models.Model): - id = models.CharField(primary_key=True, max_length=50, default=generate_random_token) - user = models.ForeignKey("posthog.User", related_name="rest_hooks", on_delete=models.CASCADE) - team = models.ForeignKey("posthog.Team", related_name="rest_hooks", on_delete=models.CASCADE) - event = models.CharField("Event", max_length=64, db_index=True) - resource_id = models.IntegerField(null=True, blank=True) - target = models.URLField("Target URL", max_length=255) - created = models.DateTimeField(auto_now_add=True) - updated = models.DateTimeField(auto_now=True) - - def clean(self): - """Validation for events.""" - if self.event not in HOOK_EVENTS: - raise ValidationError("Invalid hook event {evt}.".format(evt=self.event)) - - -@receiver(post_save, sender=Hook) -def hook_saved(sender, instance: Hook, created, **kwargs): - if instance.event == "action_performed": - get_client().publish( - "reload-action", - json.dumps({"teamId": instance.team_id, "actionId": instance.resource_id}), - ) - - -@mutable_receiver(post_delete, sender=Hook) -def hook_deleted(sender, instance: Hook, **kwargs): - if instance.event == "action_performed": - get_client().publish( - "drop-action", - json.dumps({"teamId": instance.team_id, "actionId": instance.resource_id}), - ) diff --git a/ee/models/license.py b/ee/models/license.py deleted file mode 100644 index 283d9fd7087..00000000000 --- a/ee/models/license.py +++ /dev/null @@ -1,119 +0,0 @@ -from typing import Optional - -from django.contrib.auth import get_user_model -from django.db import models -from django.db.models import Q -from django.db.models.signals import post_save -from django.dispatch.dispatcher import receiver -from django.utils import timezone -from rest_framework import exceptions, status - -from posthog.constants import AvailableFeature -from posthog.models.utils import sane_repr -from posthog.tasks.tasks import sync_all_organization_available_product_features - - -class LicenseError(exceptions.APIException): - """ - Exception raised for licensing errors. - """ - - default_type = "license_error" - default_code = "license_error" - status_code = status.HTTP_400_BAD_REQUEST - default_detail = "There was a problem with your current license." - - def __init__(self, code, detail): - self.code = code - self.detail = exceptions._get_error_details(detail, code) - - -class LicenseManager(models.Manager): - def first_valid(self) -> Optional["License"]: - """Return the highest valid license or cloud licenses if any""" - valid_licenses = list(self.filter(Q(valid_until__gte=timezone.now()) | Q(plan="cloud"))) - if not valid_licenses: - return None - return max( - valid_licenses, - key=lambda license: License.PLAN_TO_SORTING_VALUE.get(license.plan, 0), - ) - - -class License(models.Model): - objects: LicenseManager = LicenseManager() - - created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) - plan: models.CharField = models.CharField(max_length=200) - valid_until: models.DateTimeField = models.DateTimeField() - key: models.CharField = models.CharField(max_length=200) - # DEPRECATED: This is no longer used - max_users: models.IntegerField = models.IntegerField(default=None, null=True) # None = no restriction - - # NOTE: Remember to update the Billing Service as well. Long-term it will be the source of truth. - SCALE_PLAN = "scale" - SCALE_FEATURES = [ - AvailableFeature.ZAPIER, - AvailableFeature.ORGANIZATIONS_PROJECTS, - AvailableFeature.SOCIAL_SSO, - AvailableFeature.INGESTION_TAXONOMY, - AvailableFeature.PATHS_ADVANCED, - AvailableFeature.CORRELATION_ANALYSIS, - AvailableFeature.GROUP_ANALYTICS, - AvailableFeature.TAGGING, - AvailableFeature.BEHAVIORAL_COHORT_FILTERING, - AvailableFeature.WHITE_LABELLING, - AvailableFeature.SUBSCRIPTIONS, - AvailableFeature.APP_METRICS, - AvailableFeature.RECORDINGS_PLAYLISTS, - AvailableFeature.RECORDINGS_FILE_EXPORT, - AvailableFeature.RECORDINGS_PERFORMANCE, - ] - - ENTERPRISE_PLAN = "enterprise" - ENTERPRISE_FEATURES = [ - *SCALE_FEATURES, - AvailableFeature.ADVANCED_PERMISSIONS, - AvailableFeature.PROJECT_BASED_PERMISSIONING, - AvailableFeature.SAML, - AvailableFeature.SSO_ENFORCEMENT, - AvailableFeature.ROLE_BASED_ACCESS, - ] - PLANS = {SCALE_PLAN: SCALE_FEATURES, ENTERPRISE_PLAN: ENTERPRISE_FEATURES} - # The higher the plan, the higher its sorting value - sync with front-end licenseLogic - PLAN_TO_SORTING_VALUE = {SCALE_PLAN: 10, ENTERPRISE_PLAN: 20} - - @property - def available_features(self) -> list[AvailableFeature]: - return self.PLANS.get(self.plan, []) - - @property - def is_v2_license(self) -> bool: - return self.key and len(self.key.split("::")) == 2 - - __repr__ = sane_repr("key", "plan", "valid_until") - - -def get_licensed_users_available() -> Optional[int]: - """ - Returns the number of user slots available that can be created based on the instance's current license. - Not relevant for cloud users. - `None` means unlimited users. - """ - - license = License.objects.first_valid() - from posthog.models import OrganizationInvite - - if license: - if license.max_users is None: - return None - - users_left = license.max_users - get_user_model().objects.count() - OrganizationInvite.objects.count() - return max(users_left, 0) - - return None - - -@receiver(post_save, sender=License) -def license_saved(sender, instance, created, raw, using, **kwargs): - sync_all_organization_available_product_features() diff --git a/ee/models/organization_resource_access.py b/ee/models/organization_resource_access.py deleted file mode 100644 index 201cb354177..00000000000 --- a/ee/models/organization_resource_access.py +++ /dev/null @@ -1,43 +0,0 @@ -from django.db import models - -from posthog.models.organization import Organization - - -class OrganizationResourceAccess(models.Model): - class AccessLevel(models.IntegerChoices): - """Level for which a role or user can edit or view resources""" - - CAN_ONLY_VIEW = 21, "Can only view" - CAN_ALWAYS_EDIT = 37, "Can always edit" - - class Resources(models.TextChoices): - FEATURE_FLAGS = "feature flags", "feature flags" - EXPERIMENTS = "experiments", "experiments" - COHORTS = "cohorts", "cohorts" - DATA_MANAGEMENT = "data management", "data management" - SESSION_RECORDINGS = "session recordings", "session recordings" - INSIGHTS = "insights", "insights" - DASHBOARDS = "dashboards", "dashboards" - - resource: models.CharField = models.CharField(max_length=32, choices=Resources.choices) - access_level: models.PositiveSmallIntegerField = models.PositiveSmallIntegerField( - default=AccessLevel.CAN_ALWAYS_EDIT, choices=AccessLevel.choices - ) - organization: models.ForeignKey = models.ForeignKey( - Organization, on_delete=models.CASCADE, related_name="resource_access" - ) - created_by: models.ForeignKey = models.ForeignKey( - "posthog.User", - on_delete=models.SET_NULL, - null=True, - ) - created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) - updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) - - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["organization", "resource"], - name="unique resource per organization", - ) - ] diff --git a/ee/models/property_definition.py b/ee/models/property_definition.py deleted file mode 100644 index a93b8e95751..00000000000 --- a/ee/models/property_definition.py +++ /dev/null @@ -1,30 +0,0 @@ -from django.contrib.postgres.fields import ArrayField -from django.db import models - -from posthog.models.property_definition import PropertyDefinition - - -class EnterprisePropertyDefinition(PropertyDefinition): - description: models.TextField = models.TextField(blank=True, null=True, default="") - updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) - updated_by = models.ForeignKey("posthog.User", null=True, on_delete=models.SET_NULL, blank=True) - - verified: models.BooleanField = models.BooleanField(default=False, blank=True) - verified_at: models.DateTimeField = models.DateTimeField(null=True, blank=True) - verified_by = models.ForeignKey( - "posthog.User", - null=True, - on_delete=models.SET_NULL, - blank=True, - related_name="property_verifying_user", - ) - - # Deprecated in favour of app-wide tagging model. See EnterpriseTaggedItem - deprecated_tags: ArrayField = ArrayField(models.CharField(max_length=32), null=True, blank=True, default=list) - deprecated_tags_v2: ArrayField = ArrayField( - models.CharField(max_length=32), - null=True, - blank=True, - default=None, - db_column="tags", - ) diff --git a/ee/models/role.py b/ee/models/role.py deleted file mode 100644 index 00880812b9d..00000000000 --- a/ee/models/role.py +++ /dev/null @@ -1,58 +0,0 @@ -from django.db import models - -from ee.models.organization_resource_access import OrganizationResourceAccess -from posthog.models.utils import UUIDModel - - -class Role(UUIDModel): - name: models.CharField = models.CharField(max_length=200) - organization: models.ForeignKey = models.ForeignKey( - "posthog.Organization", - on_delete=models.CASCADE, - related_name="roles", - related_query_name="role", - ) - feature_flags_access_level: models.PositiveSmallIntegerField = models.PositiveSmallIntegerField( - default=OrganizationResourceAccess.AccessLevel.CAN_ALWAYS_EDIT, - choices=OrganizationResourceAccess.AccessLevel.choices, - ) - created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) - created_by: models.ForeignKey = models.ForeignKey( - "posthog.User", - on_delete=models.SET_NULL, - related_name="roles", - related_query_name="role", - null=True, - ) - - class Meta: - constraints = [models.UniqueConstraint(fields=["organization", "name"], name="unique_role_name")] - - -class RoleMembership(UUIDModel): - role: models.ForeignKey = models.ForeignKey( - "Role", - on_delete=models.CASCADE, - related_name="roles", - related_query_name="role", - ) - # TODO: Eventually remove this as we only need the organization membership - user: models.ForeignKey = models.ForeignKey( - "posthog.User", - on_delete=models.CASCADE, - related_name="role_memberships", - related_query_name="role_membership", - ) - - organization_member: models.ForeignKey = models.ForeignKey( - "posthog.OrganizationMembership", - on_delete=models.CASCADE, - related_name="role_memberships", - related_query_name="role_membership", - null=True, - ) - joined_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) - updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) - - class Meta: - constraints = [models.UniqueConstraint(fields=["role", "user"], name="unique_user_and_role")] diff --git a/ee/models/test/__init__.py b/ee/models/test/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/models/test/test_event_definition_model.py b/ee/models/test/test_event_definition_model.py deleted file mode 100644 index 253de5d9c1e..00000000000 --- a/ee/models/test/test_event_definition_model.py +++ /dev/null @@ -1,19 +0,0 @@ -import pytest - -from ee.models.event_definition import EnterpriseEventDefinition -from posthog.test.base import BaseTest - - -class TestEventDefinition(BaseTest): - def test_errors_on_invalid_verified_by_type(self): - with pytest.raises(ValueError): - EnterpriseEventDefinition.objects.create( - team=self.team, - name="enterprise event", - owner=self.user, - verified_by="Not user id", # type: ignore - ) - - def test_default_verified_false(self): - eventDef = EnterpriseEventDefinition.objects.create(team=self.team, name="enterprise event", owner=self.user) - assert eventDef.verified is False diff --git a/ee/models/test/test_property_definition_model.py b/ee/models/test/test_property_definition_model.py deleted file mode 100644 index 25ede95c04d..00000000000 --- a/ee/models/test/test_property_definition_model.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - -from ee.models.property_definition import EnterprisePropertyDefinition -from posthog.test.base import BaseTest - - -class TestPropertyDefinition(BaseTest): - def test_errors_on_invalid_verified_by_type(self): - with pytest.raises(ValueError): - EnterprisePropertyDefinition.objects.create( - team=self.team, - name="enterprise property", - verified_by="Not user id", # type: ignore - ) - - def test_default_verified_false(self): - definition = EnterprisePropertyDefinition.objects.create(team=self.team, name="enterprise property") - assert definition.verified is False diff --git a/ee/pytest.ini b/ee/pytest.ini deleted file mode 100644 index 4af882084e4..00000000000 --- a/ee/pytest.ini +++ /dev/null @@ -1,11 +0,0 @@ -[pytest] -env = - DEBUG=1 - TEST=1 -DJANGO_SETTINGS_MODULE = posthog.settings -addopts = -p no:warnings --reuse-db - -markers = - ee - clickhouse_only - skip_on_multitenancy diff --git a/ee/session_recordings/__init__.py b/ee/session_recordings/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/session_recordings/ai/__init__.py b/ee/session_recordings/ai/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/session_recordings/ai/embeddings_queries.py b/ee/session_recordings/ai/embeddings_queries.py deleted file mode 100644 index 2034a9f1901..00000000000 --- a/ee/session_recordings/ai/embeddings_queries.py +++ /dev/null @@ -1,109 +0,0 @@ -from django.conf import settings - - -from posthog.models import Team -from posthog.clickhouse.client import sync_execute - -BATCH_FLUSH_SIZE = settings.REPLAY_EMBEDDINGS_BATCH_SIZE -MIN_DURATION_INCLUDE_SECONDS = settings.REPLAY_EMBEDDINGS_MIN_DURATION_SECONDS - - -def fetch_errors_by_session_without_embeddings(team_id: int, offset=0) -> list[str]: - query = """ - WITH embedded_sessions AS ( - SELECT - session_id - FROM - session_replay_embeddings - WHERE - team_id = %(team_id)s - -- don't load all data for all time - AND generation_timestamp > now() - INTERVAL 7 DAY - AND source_type = 'error' - ) - SELECT log_source_id, message - FROM log_entries - PREWHERE - team_id = %(team_id)s - AND level = 'error' - AND log_source = 'session_replay' - AND timestamp <= now() - AND timestamp >= now() - INTERVAL 7 DAY - AND log_source_id NOT IN embedded_sessions - LIMIT %(batch_flush_size)s - -- when running locally the offset is used for paging - -- when running in celery the offset is not used - OFFSET %(offset)s - """ - - return sync_execute( - query, - { - "team_id": team_id, - "batch_flush_size": BATCH_FLUSH_SIZE, - "offset": offset, - }, - ) - - -def fetch_recordings_without_embeddings(team_id: int, offset=0) -> list[str]: - team = Team.objects.get(id=team_id) - - query = """ - WITH embedding_ids AS - ( - SELECT - session_id - FROM - session_replay_embeddings - WHERE - team_id = %(team_id)s - -- don't load all data for all time - AND generation_timestamp > now() - INTERVAL 7 DAY - ), - replay_with_events AS - ( - SELECT - distinct $session_id - FROM - events - WHERE - team_id = %(team_id)s - -- don't load all data for all time - AND timestamp > now() - INTERVAL 7 DAY - AND timestamp < now() - AND $session_id IS NOT NULL AND $session_id != '' - ) - SELECT session_id - FROM - session_replay_events - WHERE - session_id NOT IN embedding_ids - AND team_id = %(team_id)s - -- must be a completed session - AND min_first_timestamp < now() - INTERVAL 1 DAY - -- let's not load all data for all time - -- will definitely need to do something about this length of time - AND min_first_timestamp > now() - INTERVAL 7 DAY - AND session_id IN replay_with_events - GROUP BY session_id - HAVING dateDiff('second', min(min_first_timestamp), max(max_last_timestamp)) > %(min_duration_include_seconds)s - ORDER BY rand() - LIMIT %(batch_flush_size)s - -- when running locally the offset is used for paging - -- when running in celery the offset is not used - OFFSET %(offset)s - """ - - return [ - x[0] - for x in sync_execute( - query, - { - "team_id": team.pk, - "batch_flush_size": BATCH_FLUSH_SIZE, - "offset": offset, - "min_duration_include_seconds": MIN_DURATION_INCLUDE_SECONDS, - }, - ) - ] diff --git a/ee/session_recordings/ai/embeddings_runner.py b/ee/session_recordings/ai/embeddings_runner.py deleted file mode 100644 index 324311c6f0f..00000000000 --- a/ee/session_recordings/ai/embeddings_runner.py +++ /dev/null @@ -1,302 +0,0 @@ -import json -import tiktoken -import datetime -import pytz - -from typing import Any, Optional - -from abc import ABC, abstractmethod -from prometheus_client import Histogram, Counter -from structlog import get_logger -from openai import OpenAI - -from posthog.models import Team -from posthog.clickhouse.client import sync_execute - -from posthog.session_recordings.queries.session_replay_events import SessionReplayEvents -from ee.session_recordings.ai.utils import ( - SessionSummaryPromptData, - reduce_elements_chain, - simplify_window_id, - format_dates, - collapse_sequence_of_events, - only_pageview_urls, -) - -_encoding: Optional[tiktoken.Encoding] = None - - -def get_encoding() -> tiktoken.Encoding: - global _encoding - if not _encoding: - # NOTE: This does an API request so we want to ensure we load it lazily and not at startup - # tiktoken.encoding_for_model(model_name) specifies encoder - # model_name = "text-embedding-3-small" for this usecase - _encoding = tiktoken.get_encoding("cl100k_base") - return _encoding - - -MAX_TOKENS_FOR_MODEL = 8191 - -RECORDING_EMBEDDING_TOKEN_COUNT = Histogram( - "posthog_session_recordings_recording_embedding_token_count", - "Token count for individual recordings generated during embedding", - buckets=[0, 100, 500, 1000, 2000, 3000, 4000, 5000, 6000, 8000, 10000], - labelnames=["source_type"], -) - -GENERATE_RECORDING_EMBEDDING_TIMING = Histogram( - "posthog_session_recordings_generate_recording_embedding", - "Time spent generating recording embeddings for a single session", - buckets=[0.1, 0.2, 0.3, 0.4, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20], - labelnames=["source_type"], -) - -SESSION_EMBEDDINGS_GENERATED = Counter( - "posthog_session_recordings_embeddings_generated", - "Number of session embeddings generated", - labelnames=["source_type"], -) - -SESSION_EMBEDDINGS_FAILED = Counter( - "posthog_session_recordings_embeddings_failed", - "Instance of an embedding request to open AI (and its surrounding work) failing and being swallowed", - labelnames=["source_type"], -) - -SESSION_EMBEDDINGS_FATAL_FAILED = Counter( - "posthog_session_recordings_embeddings_fatal_failed", - "Instance of the embeddings task failing and raising an exception", - labelnames=["source_type"], -) - -SESSION_EMBEDDINGS_WRITTEN_TO_CLICKHOUSE = Counter( - "posthog_session_recordings_embeddings_written_to_clickhouse", - "Number of session embeddings written to Clickhouse", - labelnames=["source_type"], -) - -SESSION_SKIPPED_WHEN_GENERATING_EMBEDDINGS = Counter( - "posthog_session_recordings_skipped_when_generating_embeddings", - "Number of sessions skipped when generating embeddings", - labelnames=["source_type", "reason"], -) - -SESSION_EMBEDDINGS_FAILED_TO_CLICKHOUSE = Counter( - "posthog_session_recordings_embeddings_failed_to_clickhouse", - "Number of session embeddings failed to Clickhouse", - labelnames=["source_type"], -) - - -logger = get_logger(__name__) - - -class EmbeddingPreparation(ABC): - source_type: str - - @staticmethod - @abstractmethod - def prepare(item, team) -> tuple[str, str]: - raise NotImplementedError() - - -class SessionEmbeddingsRunner(ABC): - team: Team - openai_client: Any - - def __init__(self, team: Team): - self.team = team - self.openai_client = OpenAI() - - def run(self, items: list[Any], embeddings_preparation: type[EmbeddingPreparation]) -> None: - source_type = embeddings_preparation.source_type - - try: - batched_embeddings = [] - - for item in items: - try: - logger.info( - f"generating embedding input for item", - flow="embeddings", - item=json.dumps(item), - source_type=source_type, - ) - - result = embeddings_preparation.prepare(item, self.team) - - if result: - session_id, input = result - - logger.info( - f"generating embedding for item", - flow="embeddings", - session_id=session_id, - source_type=source_type, - ) - - with GENERATE_RECORDING_EMBEDDING_TIMING.labels(source_type=source_type).time(): - embeddings = self._embed(input, source_type=source_type) - - logger.info( - f"generated embedding for item", - flow="embeddings", - session_id=session_id, - source_type=source_type, - ) - - if embeddings: - SESSION_EMBEDDINGS_GENERATED.labels(source_type=source_type).inc() - batched_embeddings.append( - { - "team_id": self.team.pk, - "session_id": session_id, - "embeddings": embeddings, - "source_type": source_type, - "input": input, - } - ) - # we don't want to fail the whole batch if only a single recording fails - except Exception as e: - SESSION_EMBEDDINGS_FAILED.labels(source_type=source_type).inc() - logger.exception( - f"embed individual item error", - flow="embeddings", - error=e, - source_type=source_type, - ) - # so we swallow errors here - - if len(batched_embeddings) > 0: - self._flush_embeddings_to_clickhouse(embeddings=batched_embeddings, source_type=source_type) - except Exception as e: - # but we don't swallow errors within the wider task itself - # if something is failing here then we're most likely having trouble with ClickHouse - SESSION_EMBEDDINGS_FATAL_FAILED.labels(source_type=source_type).inc() - logger.exception(f"embed items fatal error", flow="embeddings", error=e, source_type=source_type) - raise - - def _embed(self, input: str, source_type: str): - token_count = self._num_tokens_for_input(input) - RECORDING_EMBEDDING_TOKEN_COUNT.labels(source_type=source_type).observe(token_count) - if token_count > MAX_TOKENS_FOR_MODEL: - logger.error( - f"embedding input exceeds max token count for model", - flow="embeddings", - input=json.dumps(input), - source_type=source_type, - ) - SESSION_SKIPPED_WHEN_GENERATING_EMBEDDINGS.labels( - source_type=source_type, reason="token_count_too_high" - ).inc() - return None - - return ( - self.openai_client.embeddings.create( - input=input, - model="text-embedding-3-small", - ) - .data[0] - .embedding - ) - - def _num_tokens_for_input(self, string: str) -> int: - """Returns the number of tokens in a text string.""" - return len(get_encoding().encode(string)) - - def _flush_embeddings_to_clickhouse(self, embeddings: list[dict[str, Any]], source_type: str) -> None: - try: - sync_execute( - "INSERT INTO session_replay_embeddings (session_id, team_id, embeddings, source_type, input) VALUES", - embeddings, - ) - SESSION_EMBEDDINGS_WRITTEN_TO_CLICKHOUSE.labels(source_type=source_type).inc(len(embeddings)) - except Exception as e: - logger.exception(f"flush embeddings error", flow="embeddings", error=e, source_type=source_type) - SESSION_EMBEDDINGS_FAILED_TO_CLICKHOUSE.labels(source_type=source_type).inc(len(embeddings)) - raise - - -class ErrorEmbeddingsPreparation(EmbeddingPreparation): - source_type = "error" - - @staticmethod - def prepare(item: tuple[str, str], _): - session_id = item[0] - error_message = item[1] - return session_id, error_message - - -class SessionEventsEmbeddingsPreparation(EmbeddingPreparation): - source_type = "session" - - @staticmethod - def prepare(session_id: str, team: Team): - eight_days_ago = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=8) - session_metadata = SessionReplayEvents().get_metadata( - session_id=str(session_id), team=team, recording_start_time=eight_days_ago - ) - if not session_metadata: - logger.error(f"no session metadata found for session", flow="embeddings", session_id=session_id) - SESSION_SKIPPED_WHEN_GENERATING_EMBEDDINGS.labels( - source_type=SessionEventsEmbeddingsPreparation.source_type, reason="metadata_missing" - ).inc() - return None - - session_events = SessionReplayEvents().get_events( - session_id=str(session_id), - team=team, - metadata=session_metadata, - events_to_ignore=[ - "$feature_flag_called", - ], - ) - - if not session_events or not session_events[0] or not session_events[1]: - logger.error(f"no events found for session", flow="embeddings", session_id=session_id) - SESSION_SKIPPED_WHEN_GENERATING_EMBEDDINGS.labels( - source_type=SessionEventsEmbeddingsPreparation.source_type, reason="events_missing" - ).inc() - return None - - processed_sessions = collapse_sequence_of_events( - only_pageview_urls( - format_dates( - reduce_elements_chain( - simplify_window_id( - SessionSummaryPromptData(columns=session_events[0], results=session_events[1]) - ) - ), - start=datetime.datetime(1970, 1, 1, tzinfo=pytz.UTC), # epoch timestamp - ) - ) - ) - - logger.info(f"collapsed events for session", flow="embeddings", session_id=session_id) - - processed_sessions_index = processed_sessions.column_index("event") - current_url_index = processed_sessions.column_index("$current_url") - elements_chain_index = processed_sessions.column_index("elements_chain") - - input = ( - str(session_metadata) - + "\n" - + "\n".join( - SessionEventsEmbeddingsPreparation._compact_result( - event_name=result[processed_sessions_index] if processed_sessions_index is not None else "", - current_url=result[current_url_index] if current_url_index is not None else "", - elements_chain=result[elements_chain_index] if elements_chain_index is not None else "", - ) - for result in processed_sessions.results - ) - ) - - return session_id, input - - @staticmethod - def _compact_result(event_name: str, current_url: int, elements_chain: dict[str, str] | str) -> str: - elements_string = ( - elements_chain if isinstance(elements_chain, str) else ", ".join(str(e) for e in elements_chain) - ) - return f"{event_name} {current_url} {elements_string}" diff --git a/ee/session_recordings/ai/error_clustering.py b/ee/session_recordings/ai/error_clustering.py deleted file mode 100644 index 0e03a755f41..00000000000 --- a/ee/session_recordings/ai/error_clustering.py +++ /dev/null @@ -1,99 +0,0 @@ -from prometheus_client import Histogram -from django.conf import settings -from posthog.clickhouse.client import sync_execute -from posthog.models import Team -from sklearn.cluster import DBSCAN -import pandas as pd -import numpy as np -from posthog.session_recordings.models.session_recording_event import SessionRecordingViewed -from datetime import date - -CLUSTER_REPLAY_ERRORS_TIMING = Histogram( - "posthog_session_recordings_cluster_replay_errors", - "Time spent clustering the embeddings of replay errors", - buckets=[0.5, 1, 2, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60], -) - -CLUSTER_REPLAY_ERRORS_CLUSTER_COUNT = Histogram( - "posthog_session_recordings_errors_cluster_count", - "Count of clusters identified from error messages per team", - buckets=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20, 25, 30, 35, 40, 45, 50], - labelnames=["team_id"], -) - -DBSCAN_EPS = settings.REPLAY_EMBEDDINGS_CLUSTERING_DBSCAN_EPS -DBSCAN_MIN_SAMPLES = settings.REPLAY_EMBEDDINGS_CLUSTERING_DBSCAN_MIN_SAMPLES - - -def error_clustering(team: Team): - results = fetch_error_embeddings(team.pk) - - if not results: - return [] - - df = pd.DataFrame(results, columns=["session_id", "error", "embeddings", "timestamp"]) - - df["cluster"] = cluster_embeddings(df["embeddings"].tolist()) - - CLUSTER_REPLAY_ERRORS_CLUSTER_COUNT.labels(team_id=team.pk).observe(df["cluster"].nunique()) - - return construct_response(df, team) - - -def fetch_error_embeddings(team_id: int): - query = """ - SELECT - session_id, input, embeddings, generation_timestamp - FROM - session_replay_embeddings - WHERE - team_id = %(team_id)s - -- don't load all data for all time - AND generation_timestamp > now() - INTERVAL 7 DAY - AND source_type = 'error' - AND input != '' - """ - - return sync_execute( - query, - {"team_id": team_id}, - ) - - -def cluster_embeddings(embeddings): - dbscan = DBSCAN(eps=DBSCAN_EPS, min_samples=DBSCAN_MIN_SAMPLES) - with CLUSTER_REPLAY_ERRORS_TIMING.time(): - dbscan.fit(embeddings) - return dbscan.labels_ - - -def construct_response(df: pd.DataFrame, team: Team): - viewed_session_ids = list( - SessionRecordingViewed.objects.filter(team=team, session_id__in=df["session_id"].unique()) - .values_list("session_id", flat=True) - .distinct() - ) - - clusters = [] - for cluster, rows in df.groupby("cluster"): - session_ids = rows["session_id"].unique() - sample = rows.sample(n=1)[["session_id", "error"]].to_dict("records")[0] - - date_series = ( - rows.groupby([rows["timestamp"].dt.date]) - .size() - .reindex(pd.date_range(end=date.today(), periods=7), fill_value=0) - ) - sparkline = dict(zip(date_series.index.astype(str), date_series)) - clusters.append( - { - "cluster": cluster, - "sample": sample.get("error"), - "session_ids": np.random.choice(session_ids, size=DBSCAN_MIN_SAMPLES - 1), - "occurrences": rows.size, - "sparkline": sparkline, - "unique_sessions": len(session_ids), - "viewed": len(np.intersect1d(session_ids, viewed_session_ids, assume_unique=True)), - } - ) - return clusters diff --git a/ee/session_recordings/ai/similar_recordings.py b/ee/session_recordings/ai/similar_recordings.py deleted file mode 100644 index a267459cc80..00000000000 --- a/ee/session_recordings/ai/similar_recordings.py +++ /dev/null @@ -1,57 +0,0 @@ -from prometheus_client import Histogram - -from posthog.clickhouse.client import sync_execute -from posthog.models.team import Team -from posthog.session_recordings.models.session_recording import SessionRecording - -FIND_RECORDING_NEIGHBOURS_TIMING = Histogram( - "posthog_session_recordings_find_recording_neighbours", - "Time spent finding the most similar recording embeddings for a single session", -) - - -def similar_recordings(recording: SessionRecording, team: Team): - with FIND_RECORDING_NEIGHBOURS_TIMING.time(): - similar_embeddings = closest_embeddings(session_id=recording.session_id, team_id=team.pk) - - # TODO: join session recording context (person, duration, etc) to show in frontend - - return similar_embeddings - - -def closest_embeddings(session_id: str, team_id: int): - query = """ - WITH ( - SELECT - argMax(embeddings, generation_timestamp) as target_embeddings - FROM - session_replay_embeddings - WHERE - team_id = %(team_id)s - -- don't load all data for all time - AND generation_timestamp > now() - INTERVAL 7 DAY - AND session_id = %(session_id)s - group by session_id - LIMIT 1 - ) as target_embeddings - SELECT - session_id, - -- distance function choice based on https://help.openai.com/en/articles/6824809-embeddings-frequently-asked-questions - -- OpenAI normalizes embeddings so L2 should produce the same score but is slightly slower - cosineDistance(embeddings, target_embeddings) AS similarity_score - FROM session_replay_embeddings - WHERE - team_id = %(team_id)s - -- don't load all data for all time - AND generation_timestamp > now() - INTERVAL 7 DAY - -- skip the target recording - AND session_id != %(session_id)s - ORDER BY similarity_score ASC - -- only return a max number of results - LIMIT %(limit)s; - """ - - return sync_execute( - query, - {"team_id": team_id, "session_id": session_id, "limit": 3}, - ) diff --git a/ee/session_recordings/ai/utils.py b/ee/session_recordings/ai/utils.py deleted file mode 100644 index 1b7770a1361..00000000000 --- a/ee/session_recordings/ai/utils.py +++ /dev/null @@ -1,243 +0,0 @@ -import dataclasses -from datetime import datetime - -from typing import Any - -from posthog.models.element import chain_to_elements -from hashlib import shake_256 - - -@dataclasses.dataclass -class SessionSummaryPromptData: - # we may allow customisation of columns included in the future, - # and we alter the columns present as we process the data - # so want to stay as loose as possible here - columns: list[str] = dataclasses.field(default_factory=list) - results: list[list[Any]] = dataclasses.field(default_factory=list) - # in order to reduce the number of tokens in the prompt - # we replace URLs with a placeholder and then pass this mapping of placeholder to URL into the prompt - url_mapping: dict[str, str] = dataclasses.field(default_factory=dict) - - def is_empty(self) -> bool: - return not self.columns or not self.results - - def column_index(self, column: str) -> int | None: - for i, c in enumerate(self.columns): - if c == column: - return i - return None - - -def reduce_elements_chain(session_events: SessionSummaryPromptData) -> SessionSummaryPromptData: - if session_events.is_empty(): - return session_events - - # find elements_chain column index - elements_chain_index = session_events.column_index("elements_chain") - - reduced_results = [] - for result in session_events.results: - if elements_chain_index is None: - reduced_results.append(result) - continue - - elements_chain: str | None = result[elements_chain_index] - if not elements_chain: - reduced_results.append(result) - continue - - # the elements chain has lots of information that we don't need - elements = [e for e in chain_to_elements(elements_chain) if e.tag_name in e.USEFUL_ELEMENTS] - - result_list = list(result) - result_list[elements_chain_index] = [{"tag": e.tag_name, "text": e.text, "href": e.href} for e in elements] - reduced_results.append(result_list) - - return dataclasses.replace(session_events, results=reduced_results) - - -def simplify_window_id(session_events: SessionSummaryPromptData) -> SessionSummaryPromptData: - if session_events.is_empty(): - return session_events - - # find window_id column index - window_id_index = session_events.column_index("$window_id") - - window_id_mapping: dict[str, int] = {} - simplified_results = [] - for result in session_events.results: - if window_id_index is None: - simplified_results.append(result) - continue - - window_id: str | None = result[window_id_index] - if not window_id: - simplified_results.append(result) - continue - - if window_id not in window_id_mapping: - window_id_mapping[window_id] = len(window_id_mapping) + 1 - - result_list = list(result) - result_list[window_id_index] = window_id_mapping[window_id] - simplified_results.append(result_list) - - return dataclasses.replace(session_events, results=simplified_results) - - -def only_pageview_urls(session_events: SessionSummaryPromptData) -> SessionSummaryPromptData: - """ - including the url with every event is a lot of duplication, - so we remove it from all events except pageviews - """ - if session_events.is_empty(): - return session_events - - # find url column index - url_index = session_events.column_index("$current_url") - event_index = session_events.column_index("event") - - pageview_results = [] - for result in session_events.results: - if url_index is None or event_index is None: - pageview_results.append(result) - continue - - url: str | None = result[url_index] - event: str | None = result[event_index] - if not url: - pageview_results.append(result) - continue - if event == "$pageview": - pageview_results.append(result) - continue - - # otherwise we hash the url, so we have ~one token per event - # this would mean sessions with multiple events that only - # differ by URL should still have some distance between them - result[url_index] = shake_256(url.encode("utf-8")).hexdigest(4) - pageview_results.append(result) - - return dataclasses.replace(session_events, results=pageview_results) - - -def deduplicate_urls(session_events: SessionSummaryPromptData) -> SessionSummaryPromptData: - if session_events.is_empty(): - return session_events - - # find url column index - url_index = session_events.column_index("$current_url") - - url_mapping: dict[str, str] = {} - deduplicated_results = [] - for result in session_events.results: - if url_index is None: - deduplicated_results.append(result) - continue - - url: str | None = result[url_index] - if not url: - deduplicated_results.append(result) - continue - - if url not in url_mapping: - url_mapping[url] = f"url_{len(url_mapping) + 1}" - - result_list = list(result) - result_list[url_index] = url_mapping[url] - deduplicated_results.append(result_list) - - return dataclasses.replace(session_events, results=deduplicated_results, url_mapping=url_mapping) - - -def format_dates(session_events: SessionSummaryPromptData, start: datetime) -> SessionSummaryPromptData: - if session_events.is_empty(): - return session_events - - # find timestamp column index - timestamp_index = session_events.column_index("timestamp") - - if timestamp_index is None: - # no timestamp column so nothing to do - return session_events - - del session_events.columns[timestamp_index] # remove timestamp column from columns - session_events.columns.append("milliseconds_since_start") # add new column to columns at end - - formatted_results = [] - for result in session_events.results: - timestamp: datetime | None = result[timestamp_index] - if not timestamp: - formatted_results.append(result) - continue - - result_list = list(result) - # remove list item at timestamp_index - del result_list[timestamp_index] - # insert milliseconds since reference date - result_list.append(int((timestamp - start).total_seconds() * 1000)) - formatted_results.append(result_list) - - return dataclasses.replace(session_events, results=formatted_results) - - -def collapse_sequence_of_events(session_events: SessionSummaryPromptData) -> SessionSummaryPromptData: - # assumes the list is ordered by timestamp - if session_events.is_empty(): - return session_events - - # find the event column index - event_index = session_events.column_index("event") - - # find the window id column index - window_id_index = session_events.column_index("$window_id") - - event_repetition_count_index: int | None = None - # we only append this new column, if we need to add it below - - # now enumerate the results finding sequences of events with the same event and collapsing them to a single item - collapsed_results = [] - for i, result in enumerate(session_events.results): - if event_index is None: - collapsed_results.append(result) - continue - - event: str | None = result[event_index] - if not event: - collapsed_results.append(result) - continue - - if i == 0: - collapsed_results.append(result) - continue - - # we need to collapse into the last item added into collapsed results - # as we're going to amend it in place - previous_result = collapsed_results[len(collapsed_results) - 1] - previous_event: str | None = previous_result[event_index] - if not previous_event: - collapsed_results.append(result) - continue - - event_matches = previous_event == event - window_matches = previous_result[window_id_index] == result[window_id_index] if window_id_index else True - - if event_matches and window_matches: - # collapse the event into the previous result - if event_repetition_count_index is None: - # we need to add the column - event_repetition_count_index = len(session_events.columns) - session_events.columns.append("event_repetition_count") - previous_result_list = list(previous_result) - try: - existing_repetition_count = previous_result_list[event_repetition_count_index] or 0 - previous_result_list[event_repetition_count_index] = existing_repetition_count + 1 - except IndexError: - previous_result_list.append(2) - - collapsed_results[len(collapsed_results) - 1] = previous_result_list - else: - result.append(None) # there is no event repetition count - collapsed_results.append(result) - - return dataclasses.replace(session_events, results=collapsed_results) diff --git a/ee/session_recordings/persistence_tasks.py b/ee/session_recordings/persistence_tasks.py deleted file mode 100644 index b9181e361ba..00000000000 --- a/ee/session_recordings/persistence_tasks.py +++ /dev/null @@ -1,42 +0,0 @@ -from datetime import timedelta - -import structlog -from celery import shared_task -from django.utils import timezone -from prometheus_client import Counter - -from ee.session_recordings.session_recording_extensions import persist_recording -from posthog.session_recordings.models.session_recording import SessionRecording -from posthog.tasks.utils import CeleryQueue - -logger = structlog.get_logger(__name__) - -REPLAY_NEEDS_PERSISTENCE_COUNTER = Counter( - "snapshot_persist_persistence_task_queued", - "Count of session recordings that need to be persisted", - # we normally avoid team label but not all teams pin recordings so there shouldn't be _too_ many labels here - labelnames=["team_id"], -) - - -@shared_task( - ignore_result=True, - queue=CeleryQueue.SESSION_REPLAY_PERSISTENCE.value, -) -def persist_single_recording(id: str, team_id: int) -> None: - persist_recording(id, team_id) - - -@shared_task( - ignore_result=True, - queue=CeleryQueue.SESSION_REPLAY_PERSISTENCE.value, -) -def persist_finished_recordings() -> None: - one_day_old = timezone.now() - timedelta(hours=24) - finished_recordings = SessionRecording.objects.filter(created_at__lte=one_day_old, object_storage_path=None) - - logger.info("Persisting finished recordings", count=finished_recordings.count()) - - for recording in finished_recordings: - REPLAY_NEEDS_PERSISTENCE_COUNTER.labels(team_id=recording.team_id).inc() - persist_single_recording.delay(recording.session_id, recording.team_id) diff --git a/ee/session_recordings/queries/__init__.py b/ee/session_recordings/queries/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/session_recordings/queries/test/__init__.py b/ee/session_recordings/queries/test/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/session_recordings/queries/test/__snapshots__/test_session_recording_list_from_filters.ambr b/ee/session_recordings/queries/test/__snapshots__/test_session_recording_list_from_filters.ambr deleted file mode 100644 index fd0b7d5358f..00000000000 --- a/ee/session_recordings/queries/test/__snapshots__/test_session_recording_list_from_filters.ambr +++ /dev/null @@ -1,1509 +0,0 @@ -# serializer version: 1 -# name: TestClickhouseSessionRecordingsListFromFilters.test_effect_of_poe_settings_on_query_generated_0_test_poe_v1_still_falls_back_to_person_subquery - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, %(hogql_val_0)s)) AS start_time, - max(toTimeZone(s.max_last_timestamp, %(hogql_val_1)s)) AS end_time, - dateDiff(%(hogql_val_2)s, start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_3)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_4)s), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_5)s), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 2), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_6)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, %(hogql_val_7)s), now64(6, %(hogql_val_8)s)), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_9)s), toDateTime64('2020-12-24 23:58:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, %(hogql_val_10)s), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(mat_pp_rgInternal, ''), 'null'), %(hogql_val_11)s), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 50000 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_effect_of_poe_settings_on_query_generated_1_test_poe_being_unavailable_we_fall_back_to_person_subquery - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, %(hogql_val_0)s)) AS start_time, - max(toTimeZone(s.max_last_timestamp, %(hogql_val_1)s)) AS end_time, - dateDiff(%(hogql_val_2)s, start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_3)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_4)s), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_5)s), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.distinct_id, - (SELECT person_distinct_ids.distinct_id AS distinct_id - FROM - (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_distinct_ids___person_id, argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, person_distinct_id2.distinct_id AS distinct_id - FROM person_distinct_id2 - WHERE equals(person_distinct_id2.team_id, 2) - GROUP BY person_distinct_id2.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id2.is_deleted, person_distinct_id2.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids - LEFT JOIN - (SELECT argMax(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, %(hogql_val_6)s), ''), 'null'), '^"|"$', ''), person.version) AS properties___rgInternal, person.id AS id - FROM person - WHERE equals(person.team_id, 2) - GROUP BY person.id - HAVING and(ifNull(equals(argMax(person.is_deleted, person.version), 0), 0), ifNull(less(argMax(toTimeZone(person.created_at, %(hogql_val_7)s), person.version), plus(now64(6, %(hogql_val_8)s), toIntervalDay(1))), 0)) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids__person ON equals(person_distinct_ids.person_distinct_ids___person_id, person_distinct_ids__person.id) - WHERE ifNull(equals(person_distinct_ids__person.properties___rgInternal, %(hogql_val_9)s), 0)))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 50000 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_effect_of_poe_settings_on_query_generated_2_test_poe_being_unavailable_we_fall_back_to_person_subquery_but_still_use_mat_props - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, %(hogql_val_0)s)) AS start_time, - max(toTimeZone(s.max_last_timestamp, %(hogql_val_1)s)) AS end_time, - dateDiff(%(hogql_val_2)s, start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_3)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_4)s), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_5)s), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.distinct_id, - (SELECT person_distinct_ids.distinct_id AS distinct_id - FROM - (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_distinct_ids___person_id, argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, person_distinct_id2.distinct_id AS distinct_id - FROM person_distinct_id2 - WHERE equals(person_distinct_id2.team_id, 2) - GROUP BY person_distinct_id2.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id2.is_deleted, person_distinct_id2.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids - LEFT JOIN - (SELECT argMax(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, %(hogql_val_6)s), ''), 'null'), '^"|"$', ''), person.version) AS properties___rgInternal, person.id AS id - FROM person - WHERE equals(person.team_id, 2) - GROUP BY person.id - HAVING and(ifNull(equals(argMax(person.is_deleted, person.version), 0), 0), ifNull(less(argMax(toTimeZone(person.created_at, %(hogql_val_7)s), person.version), plus(now64(6, %(hogql_val_8)s), toIntervalDay(1))), 0)) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids__person ON equals(person_distinct_ids.person_distinct_ids___person_id, person_distinct_ids__person.id) - WHERE ifNull(equals(person_distinct_ids__person.properties___rgInternal, %(hogql_val_9)s), 0)))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 50000 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_effect_of_poe_settings_on_query_generated_3_test_allow_denormalised_props_fix_does_not_stop_all_poe_processing - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, %(hogql_val_0)s)) AS start_time, - max(toTimeZone(s.max_last_timestamp, %(hogql_val_1)s)) AS end_time, - dateDiff(%(hogql_val_2)s, start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_3)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_4)s), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_5)s), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 2), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_6)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, %(hogql_val_7)s), now64(6, %(hogql_val_8)s)), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_9)s), toDateTime64('2020-12-24 23:58:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, %(hogql_val_10)s), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(mat_pp_rgInternal, ''), 'null'), %(hogql_val_11)s), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 50000 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_effect_of_poe_settings_on_query_generated_4_test_poe_v2_available_person_properties_are_used_in_replay_listing - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, %(hogql_val_0)s)) AS start_time, - max(toTimeZone(s.max_last_timestamp, %(hogql_val_1)s)) AS end_time, - dateDiff(%(hogql_val_2)s, start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_3)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_4)s), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_5)s), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 2), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_6)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, %(hogql_val_7)s), now64(6, %(hogql_val_8)s)), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_9)s), toDateTime64('2020-12-24 23:58:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, %(hogql_val_10)s), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(mat_pp_rgInternal, ''), 'null'), %(hogql_val_11)s), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 50000 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_00_poe_v2_and_materialized_columns_allowed_with_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_00_poe_v2_and_materialized_columns_allowed_with_materialization.1 - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 2), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), now64(6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-24 23:58:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(mat_pp_email, ''), 'null'), 'bla'), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_01_poe_v2_and_materialized_columns_allowed_without_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_01_poe_v2_and_materialized_columns_allowed_without_materialization.1 - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 2), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), now64(6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-24 23:58:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(mat_pp_email, ''), 'null'), 'bla'), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_02_poe_v2_and_materialized_columns_off_with_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_02_poe_v2_and_materialized_columns_off_with_materialization.1 - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 2), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), now64(6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-24 23:58:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(mat_pp_email, ''), 'null'), 'bla'), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_03_poe_v2_and_materialized_columns_off_without_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_03_poe_v2_and_materialized_columns_off_without_materialization.1 - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 2), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), now64(6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-24 23:58:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(mat_pp_email, ''), 'null'), 'bla'), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_04_poe_off_and_materialized_columns_allowed_with_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_04_poe_off_and_materialized_columns_allowed_with_materialization.1 - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.distinct_id, - (SELECT person_distinct_ids.distinct_id AS distinct_id - FROM - (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_distinct_ids___person_id, argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, person_distinct_id2.distinct_id AS distinct_id - FROM person_distinct_id2 - WHERE equals(person_distinct_id2.team_id, 2) - GROUP BY person_distinct_id2.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id2.is_deleted, person_distinct_id2.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids - LEFT JOIN - (SELECT person.id AS id, nullIf(nullIf(person.pmat_email, ''), 'null') AS properties___email - FROM person - WHERE and(equals(person.team_id, 2), ifNull(in(tuple(person.id, person.version), - (SELECT person.id AS id, max(person.version) AS version - FROM person - WHERE equals(person.team_id, 2) - GROUP BY person.id - HAVING and(ifNull(equals(argMax(person.is_deleted, person.version), 0), 0), ifNull(less(argMax(toTimeZone(person.created_at, 'UTC'), person.version), plus(now64(6, 'UTC'), toIntervalDay(1))), 0)))), 0)) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids__person ON equals(person_distinct_ids.person_distinct_ids___person_id, person_distinct_ids__person.id) - WHERE ifNull(equals(person_distinct_ids__person.properties___email, 'bla'), 0)))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_05_poe_off_and_materialized_columns_allowed_without_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_05_poe_off_and_materialized_columns_allowed_without_materialization.1 - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.distinct_id, - (SELECT person_distinct_ids.distinct_id AS distinct_id - FROM - (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_distinct_ids___person_id, argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, person_distinct_id2.distinct_id AS distinct_id - FROM person_distinct_id2 - WHERE equals(person_distinct_id2.team_id, 2) - GROUP BY person_distinct_id2.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id2.is_deleted, person_distinct_id2.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids - LEFT JOIN - (SELECT person.id AS id, nullIf(nullIf(person.pmat_email, ''), 'null') AS properties___email - FROM person - WHERE and(equals(person.team_id, 2), ifNull(in(tuple(person.id, person.version), - (SELECT person.id AS id, max(person.version) AS version - FROM person - WHERE equals(person.team_id, 2) - GROUP BY person.id - HAVING and(ifNull(equals(argMax(person.is_deleted, person.version), 0), 0), ifNull(less(argMax(toTimeZone(person.created_at, 'UTC'), person.version), plus(now64(6, 'UTC'), toIntervalDay(1))), 0)))), 0)) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids__person ON equals(person_distinct_ids.person_distinct_ids___person_id, person_distinct_ids__person.id) - WHERE ifNull(equals(person_distinct_ids__person.properties___email, 'bla'), 0)))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_06_poe_off_and_materialized_columns_not_allowed_with_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_06_poe_off_and_materialized_columns_not_allowed_with_materialization.1 - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.distinct_id, - (SELECT person_distinct_ids.distinct_id AS distinct_id - FROM - (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_distinct_ids___person_id, argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, person_distinct_id2.distinct_id AS distinct_id - FROM person_distinct_id2 - WHERE equals(person_distinct_id2.team_id, 2) - GROUP BY person_distinct_id2.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id2.is_deleted, person_distinct_id2.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids - LEFT JOIN - (SELECT person.id AS id, nullIf(nullIf(person.pmat_email, ''), 'null') AS properties___email - FROM person - WHERE and(equals(person.team_id, 2), ifNull(in(tuple(person.id, person.version), - (SELECT person.id AS id, max(person.version) AS version - FROM person - WHERE equals(person.team_id, 2) - GROUP BY person.id - HAVING and(ifNull(equals(argMax(person.is_deleted, person.version), 0), 0), ifNull(less(argMax(toTimeZone(person.created_at, 'UTC'), person.version), plus(now64(6, 'UTC'), toIntervalDay(1))), 0)))), 0)) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids__person ON equals(person_distinct_ids.person_distinct_ids___person_id, person_distinct_ids__person.id) - WHERE ifNull(equals(person_distinct_ids__person.properties___email, 'bla'), 0)))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_07_poe_off_and_materialized_columns_not_allowed_without_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_07_poe_off_and_materialized_columns_not_allowed_without_materialization.1 - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.distinct_id, - (SELECT person_distinct_ids.distinct_id AS distinct_id - FROM - (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_distinct_ids___person_id, argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, person_distinct_id2.distinct_id AS distinct_id - FROM person_distinct_id2 - WHERE equals(person_distinct_id2.team_id, 2) - GROUP BY person_distinct_id2.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id2.is_deleted, person_distinct_id2.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids - LEFT JOIN - (SELECT person.id AS id, nullIf(nullIf(person.pmat_email, ''), 'null') AS properties___email - FROM person - WHERE and(equals(person.team_id, 2), ifNull(in(tuple(person.id, person.version), - (SELECT person.id AS id, max(person.version) AS version - FROM person - WHERE equals(person.team_id, 2) - GROUP BY person.id - HAVING and(ifNull(equals(argMax(person.is_deleted, person.version), 0), 0), ifNull(less(argMax(toTimeZone(person.created_at, 'UTC'), person.version), plus(now64(6, 'UTC'), toIntervalDay(1))), 0)))), 0)) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids__person ON equals(person_distinct_ids.person_distinct_ids___person_id, person_distinct_ids__person.id) - WHERE ifNull(equals(person_distinct_ids__person.properties___email, 'bla'), 0)))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_08_poe_v1_and_materialized_columns_allowed_with_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_08_poe_v1_and_materialized_columns_allowed_with_materialization.1 - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 2), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), now64(6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-24 23:58:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(mat_pp_email, ''), 'null'), 'bla'), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_09_poe_v1_and_materialized_columns_allowed_without_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_09_poe_v1_and_materialized_columns_allowed_without_materialization.1 - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 2), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), now64(6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-24 23:58:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(mat_pp_email, ''), 'null'), 'bla'), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_10_poe_v1_and_not_materialized_columns_not_allowed_with_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_10_poe_v1_and_not_materialized_columns_not_allowed_with_materialization.1 - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 2), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), now64(6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-24 23:58:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(mat_pp_email, ''), 'null'), 'bla'), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_11_poe_v1_and_not_materialized_columns_not_allowed_without_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_event_filter_with_person_properties_materialized_11_poe_v1_and_not_materialized_columns_not_allowed_without_materialization.1 - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 2), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), now64(6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-24 23:58:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(mat_pp_email, ''), 'null'), 'bla'), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_person_id_filter_00_poe_v2_and_materialized_columns_allowed_with_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT DISTINCT events.`$session_id` AS `$session_id` - FROM events - WHERE and(equals(events.team_id, 2), equals(events.person_id, '00000000-0000-0000-0000-000000000000'), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), notEmpty(events.`$session_id`)))), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_person_id_filter_01_poe_v2_and_materialized_columns_allowed_without_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT DISTINCT events.`$session_id` AS `$session_id` - FROM events - WHERE and(equals(events.team_id, 2), equals(events.person_id, '00000000-0000-0000-0000-000000000000'), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), notEmpty(events.`$session_id`)))), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_person_id_filter_02_poe_v2_and_materialized_columns_off_with_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT DISTINCT events.`$session_id` AS `$session_id` - FROM events - WHERE and(equals(events.team_id, 2), equals(events.person_id, '00000000-0000-0000-0000-000000000000'), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), notEmpty(events.`$session_id`)))), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_person_id_filter_03_poe_v2_and_materialized_columns_off_without_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT DISTINCT events.`$session_id` AS `$session_id` - FROM events - WHERE and(equals(events.team_id, 2), equals(events.person_id, '00000000-0000-0000-0000-000000000000'), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), notEmpty(events.`$session_id`)))), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_person_id_filter_04_poe_off_and_materialized_columns_allowed_with_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), in(s.distinct_id, - (SELECT person_distinct_ids.distinct_id AS distinct_id - FROM - (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, person_distinct_id2.distinct_id AS distinct_id - FROM person_distinct_id2 - WHERE equals(person_distinct_id2.team_id, 2) - GROUP BY person_distinct_id2.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id2.is_deleted, person_distinct_id2.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids - WHERE ifNull(equals(person_distinct_ids.person_id, '00000000-0000-0000-0000-000000000000'), 0))), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_person_id_filter_05_poe_off_and_materialized_columns_allowed_without_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), in(s.distinct_id, - (SELECT person_distinct_ids.distinct_id AS distinct_id - FROM - (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, person_distinct_id2.distinct_id AS distinct_id - FROM person_distinct_id2 - WHERE equals(person_distinct_id2.team_id, 2) - GROUP BY person_distinct_id2.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id2.is_deleted, person_distinct_id2.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids - WHERE ifNull(equals(person_distinct_ids.person_id, '00000000-0000-0000-0000-000000000000'), 0))), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_person_id_filter_06_poe_off_and_materialized_columns_not_allowed_with_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), in(s.distinct_id, - (SELECT person_distinct_ids.distinct_id AS distinct_id - FROM - (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, person_distinct_id2.distinct_id AS distinct_id - FROM person_distinct_id2 - WHERE equals(person_distinct_id2.team_id, 2) - GROUP BY person_distinct_id2.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id2.is_deleted, person_distinct_id2.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids - WHERE ifNull(equals(person_distinct_ids.person_id, '00000000-0000-0000-0000-000000000000'), 0))), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_person_id_filter_07_poe_off_and_materialized_columns_not_allowed_without_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), in(s.distinct_id, - (SELECT person_distinct_ids.distinct_id AS distinct_id - FROM - (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, person_distinct_id2.distinct_id AS distinct_id - FROM person_distinct_id2 - WHERE equals(person_distinct_id2.team_id, 2) - GROUP BY person_distinct_id2.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id2.is_deleted, person_distinct_id2.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS person_distinct_ids - WHERE ifNull(equals(person_distinct_ids.person_id, '00000000-0000-0000-0000-000000000000'), 0))), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_person_id_filter_08_poe_v1_and_materialized_columns_allowed_with_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT DISTINCT events.`$session_id` AS `$session_id` - FROM events - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id) - WHERE and(equals(events.team_id, 2), ifNull(equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), '00000000-0000-0000-0000-000000000000'), 0), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), notEmpty(events.`$session_id`)))), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_person_id_filter_09_poe_v1_and_materialized_columns_allowed_without_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT DISTINCT events.`$session_id` AS `$session_id` - FROM events - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id) - WHERE and(equals(events.team_id, 2), ifNull(equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), '00000000-0000-0000-0000-000000000000'), 0), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), notEmpty(events.`$session_id`)))), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_person_id_filter_10_poe_v1_and_not_materialized_columns_not_allowed_with_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT DISTINCT events.`$session_id` AS `$session_id` - FROM events - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id) - WHERE and(equals(events.team_id, 2), ifNull(equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), '00000000-0000-0000-0000-000000000000'), 0), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), notEmpty(events.`$session_id`)))), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromFilters.test_person_id_filter_11_poe_v1_and_not_materialized_columns_not_allowed_without_materialization - ''' - SELECT s.session_id AS session_id, - any(s.team_id), - any(s.distinct_id), - min(toTimeZone(s.min_first_timestamp, 'UTC')) AS start_time, - max(toTimeZone(s.max_last_timestamp, 'UTC')) AS end_time, - dateDiff('SECOND', start_time, end_time) AS duration, - argMinMerge(s.first_url) AS first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - divide(sum(s.active_milliseconds), 1000) AS active_seconds, - minus(duration, active_seconds) AS inactive_seconds, - sum(s.console_log_count) AS console_log_count, - sum(s.console_warn_count) AS console_warn_count, - sum(s.console_error_count) AS console_error_count - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 2), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), in(s.session_id, - (SELECT DISTINCT events.`$session_id` AS `$session_id` - FROM events - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 2) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id) - WHERE and(equals(events.team_id, 2), ifNull(equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), '00000000-0000-0000-0000-000000000000'), 0), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), notEmpty(events.`$session_id`)))), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-25 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0)) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 SETTINGS readonly=2, - max_execution_time=60, - allow_experimental_object_type=1, - format_csv_allow_double_quotes=0, - max_ast_elements=4000000, - max_expanded_ast_elements=4000000, - max_bytes_before_external_group_by=0 - ''' -# --- diff --git a/ee/session_recordings/queries/test/__snapshots__/test_session_recording_list_from_session_replay.ambr b/ee/session_recordings/queries/test/__snapshots__/test_session_recording_list_from_session_replay.ambr deleted file mode 100644 index d8d0967eb98..00000000000 --- a/ee/session_recordings/queries/test/__snapshots__/test_session_recording_list_from_session_replay.ambr +++ /dev/null @@ -1,1699 +0,0 @@ -# serializer version: 1 -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_effect_of_poe_settings_on_query_generated_0_test_poe_v1_still_falls_back_to_person_subquery - ''' - -- running in PoE Mode: person_id_no_override_properties_on_events - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = %(team_id)s -- regardless of what other filters are applied - -- limit by storage TTL - - AND s.min_first_timestamp >= %(clamped_to_storage_ttl)s -- we can filter on the pre-aggregated timestamp columns - -- because any not-the-lowest min value is _more_ greater than the min value - -- and any not-the-highest max value is _less_ lower than the max value - - AND s.min_first_timestamp >= %(start_time)s - AND s.min_first_timestamp <= %(end_time)s - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - INNER JOIN - (SELECT id - FROM person - WHERE team_id = %(team_id)s - AND id IN - (SELECT id - FROM person - WHERE team_id = %(team_id)s - AND (has(%(vperson_filter_pre__0)s, replaceRegexpAll(JSONExtractRaw(properties, %(kperson_filter_pre__0)s), '^"|"$', ''))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(%(vpersonquery_person_filter_fin__0)s, replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), %(kpersonquery_person_filter_fin__0)s), '^"|"$', ''))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = %(team_id)s - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT %(limit)s - OFFSET %(offset)s - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_effect_of_poe_settings_on_query_generated_1_test_poe_being_unavailable_we_fall_back_to_person_subquery - ''' - -- running in PoE Mode: disabled - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = %(team_id)s -- regardless of what other filters are applied - -- limit by storage TTL - - AND s.min_first_timestamp >= %(clamped_to_storage_ttl)s -- we can filter on the pre-aggregated timestamp columns - -- because any not-the-lowest min value is _more_ greater than the min value - -- and any not-the-highest max value is _less_ lower than the max value - - AND s.min_first_timestamp >= %(start_time)s - AND s.min_first_timestamp <= %(end_time)s - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - INNER JOIN - (SELECT id - FROM person - WHERE team_id = %(team_id)s - AND id IN - (SELECT id - FROM person - WHERE team_id = %(team_id)s - AND (has(%(vperson_filter_pre__0)s, replaceRegexpAll(JSONExtractRaw(properties, %(kperson_filter_pre__0)s), '^"|"$', ''))) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(%(vpersonquery_person_filter_fin__0)s, replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), %(kpersonquery_person_filter_fin__0)s), '^"|"$', ''))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = %(team_id)s - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT %(limit)s - OFFSET %(offset)s - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_effect_of_poe_settings_on_query_generated_2_test_allow_denormalised_props_fix_does_not_stop_all_poe_processing - ''' - -- running in PoE Mode: person_id_override_properties_on_events - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = %(team_id)s -- regardless of what other filters are applied - -- limit by storage TTL - - AND s.min_first_timestamp >= %(clamped_to_storage_ttl)s -- we can filter on the pre-aggregated timestamp columns - -- because any not-the-lowest min value is _more_ greater than the min value - -- and any not-the-highest max value is _less_ lower than the max value - - AND s.min_first_timestamp >= %(start_time)s - AND s.min_first_timestamp <= %(end_time)s - AND s.session_id in - (select `$session_id` as session_id - from - (-- running in PoE Mode: person_id_override_properties_on_events - SELECT `$session_id` - FROM events e -- sometimes we have to join on persons so we can access e.g. person_props in filters - PREWHERE team_id = %(team_id)s -- regardless of what other filters are applied - -- limit by storage TTL - - AND e.timestamp >= %(clamped_to_storage_ttl)s -- make sure we don't get the occasional unexpected future event - - AND e.timestamp <= now() -- and then any time filter for the events query - - AND timestamp >= %(event_start_time)s - AND timestamp <= %(event_end_time)s - WHERE notEmpty(`$session_id`) - AND (has(%(vglobal_0)s, "mat_pp_rgInternal"))-- other times we can check distinct id against a sub query which should be faster than joining - - GROUP BY `$session_id` - HAVING 1=1) as session_events_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT %(limit)s - OFFSET %(offset)s - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_effect_of_poe_settings_on_query_generated_3_test_poe_v2_available_person_properties_are_used_in_replay_listing - ''' - -- running in PoE Mode: person_id_override_properties_on_events - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = %(team_id)s -- regardless of what other filters are applied - -- limit by storage TTL - - AND s.min_first_timestamp >= %(clamped_to_storage_ttl)s -- we can filter on the pre-aggregated timestamp columns - -- because any not-the-lowest min value is _more_ greater than the min value - -- and any not-the-highest max value is _less_ lower than the max value - - AND s.min_first_timestamp >= %(start_time)s - AND s.min_first_timestamp <= %(end_time)s - AND s.session_id in - (select `$session_id` as session_id - from - (-- running in PoE Mode: person_id_override_properties_on_events - SELECT `$session_id` - FROM events e -- sometimes we have to join on persons so we can access e.g. person_props in filters - PREWHERE team_id = %(team_id)s -- regardless of what other filters are applied - -- limit by storage TTL - - AND e.timestamp >= %(clamped_to_storage_ttl)s -- make sure we don't get the occasional unexpected future event - - AND e.timestamp <= now() -- and then any time filter for the events query - - AND timestamp >= %(event_start_time)s - AND timestamp <= %(event_end_time)s - WHERE notEmpty(`$session_id`) - AND (has(%(vglobal_0)s, "mat_pp_rgInternal"))-- other times we can check distinct id against a sub query which should be faster than joining - - GROUP BY `$session_id` - HAVING 1=1) as session_events_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT %(limit)s - OFFSET %(offset)s - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_00_poe_v2_and_materialized_columns_allowed_with_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_00_poe_v2_and_materialized_columns_allowed_with_materialization.1 - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.session_id in - (select `$session_id` as session_id - from - (SELECT `$session_id` - FROM events e PREWHERE team_id = 2 - AND e.timestamp >= '2020-12-11 13:46:23' - AND e.timestamp <= now() - AND timestamp >= '2020-12-24 12:00:00' - AND timestamp <= '2021-01-02 01:46:23' - WHERE notEmpty(`$session_id`) - AND (has(['bla'], "mat_pp_email")) - GROUP BY `$session_id` - HAVING 1=1) as session_events_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_01_poe_v2_and_materialized_columns_allowed_without_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_01_poe_v2_and_materialized_columns_allowed_without_materialization.1 - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.session_id in - (select `$session_id` as session_id - from - (SELECT `$session_id` - FROM events e PREWHERE team_id = 2 - AND e.timestamp >= '2020-12-11 13:46:23' - AND e.timestamp <= now() - AND timestamp >= '2020-12-24 12:00:00' - AND timestamp <= '2021-01-02 01:46:23' - WHERE notEmpty(`$session_id`) - AND (has(['bla'], "mat_pp_email")) - GROUP BY `$session_id` - HAVING 1=1) as session_events_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_02_poe_v2_and_materialized_columns_off_with_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_02_poe_v2_and_materialized_columns_off_with_materialization.1 - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.session_id in - (select `$session_id` as session_id - from - (SELECT `$session_id` - FROM events e PREWHERE team_id = 2 - AND e.timestamp >= '2020-12-11 13:46:23' - AND e.timestamp <= now() - AND timestamp >= '2020-12-24 12:00:00' - AND timestamp <= '2021-01-02 01:46:23' - WHERE notEmpty(`$session_id`) - AND (has(['bla'], "mat_pp_email")) - GROUP BY `$session_id` - HAVING 1=1) as session_events_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_03_poe_v2_and_materialized_columns_off_without_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_03_poe_v2_and_materialized_columns_off_without_materialization.1 - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.session_id in - (select `$session_id` as session_id - from - (SELECT `$session_id` - FROM events e PREWHERE team_id = 2 - AND e.timestamp >= '2020-12-11 13:46:23' - AND e.timestamp <= now() - AND timestamp >= '2020-12-24 12:00:00' - AND timestamp <= '2021-01-02 01:46:23' - WHERE notEmpty(`$session_id`) - AND (has(['bla'], "mat_pp_email")) - GROUP BY `$session_id` - HAVING 1=1) as session_events_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_04_poe_off_and_materialized_columns_allowed_with_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_04_poe_off_and_materialized_columns_allowed_with_materialization.1 - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['bla'], "pmat_email")) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['bla'], argMax(person."pmat_email", version))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_05_poe_off_and_materialized_columns_allowed_without_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_05_poe_off_and_materialized_columns_allowed_without_materialization.1 - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['bla'], "pmat_email")) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['bla'], argMax(person."pmat_email", version))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_06_poe_off_and_materialized_columns_not_allowed_with_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_06_poe_off_and_materialized_columns_not_allowed_with_materialization.1 - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['bla'], "pmat_email")) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['bla'], argMax(person."pmat_email", version))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_07_poe_off_and_materialized_columns_not_allowed_without_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_07_poe_off_and_materialized_columns_not_allowed_without_materialization.1 - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['bla'], "pmat_email")) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['bla'], argMax(person."pmat_email", version))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_08_poe_v1_and_materialized_columns_allowed_with_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_08_poe_v1_and_materialized_columns_allowed_with_materialization.1 - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['bla'], "pmat_email")) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['bla'], argMax(person."pmat_email", version))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_09_poe_v1_and_materialized_columns_allowed_without_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_09_poe_v1_and_materialized_columns_allowed_without_materialization.1 - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['bla'], "pmat_email")) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['bla'], argMax(person."pmat_email", version))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_10_poe_v1_and_not_materialized_columns_not_allowed_with_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_10_poe_v1_and_not_materialized_columns_not_allowed_with_materialization.1 - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['bla'], "pmat_email")) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['bla'], argMax(person."pmat_email", version))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_11_poe_v1_and_not_materialized_columns_not_allowed_without_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_event_filter_with_person_properties_materialized_11_poe_v1_and_not_materialized_columns_not_allowed_without_materialization.1 - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - INNER JOIN - (SELECT id - FROM person - WHERE team_id = 2 - AND id IN - (SELECT id - FROM person - WHERE team_id = 2 - AND (has(['bla'], "pmat_email")) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (has(['bla'], argMax(person."pmat_email", version))) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 2 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_person_id_filter_00_poe_v2_and_materialized_columns_allowed_with_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND person_id = '00000000-0000-0000-0000-000000000000') as all_distinct_ids_that_might_match_a_person - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0 - AND current_person_id = '00000000-0000-0000-0000-000000000000') as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_person_id_filter_01_poe_v2_and_materialized_columns_allowed_without_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND person_id = '00000000-0000-0000-0000-000000000000') as all_distinct_ids_that_might_match_a_person - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0 - AND current_person_id = '00000000-0000-0000-0000-000000000000') as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_person_id_filter_02_poe_v2_and_materialized_columns_off_with_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND person_id = '00000000-0000-0000-0000-000000000000') as all_distinct_ids_that_might_match_a_person - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0 - AND current_person_id = '00000000-0000-0000-0000-000000000000') as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_person_id_filter_03_poe_v2_and_materialized_columns_off_without_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND person_id = '00000000-0000-0000-0000-000000000000') as all_distinct_ids_that_might_match_a_person - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0 - AND current_person_id = '00000000-0000-0000-0000-000000000000') as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_person_id_filter_04_poe_off_and_materialized_columns_allowed_with_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND person_id = '00000000-0000-0000-0000-000000000000') as all_distinct_ids_that_might_match_a_person - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0 - AND current_person_id = '00000000-0000-0000-0000-000000000000') as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_person_id_filter_05_poe_off_and_materialized_columns_allowed_without_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND person_id = '00000000-0000-0000-0000-000000000000') as all_distinct_ids_that_might_match_a_person - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0 - AND current_person_id = '00000000-0000-0000-0000-000000000000') as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_person_id_filter_06_poe_off_and_materialized_columns_not_allowed_with_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND person_id = '00000000-0000-0000-0000-000000000000') as all_distinct_ids_that_might_match_a_person - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0 - AND current_person_id = '00000000-0000-0000-0000-000000000000') as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_person_id_filter_07_poe_off_and_materialized_columns_not_allowed_without_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.distinct_id in - (select distinct_id - from - (SELECT distinct_id, - argMax(person_id, version) as current_person_id - FROM person_distinct_id2 as pdi - WHERE team_id = 2 - AND distinct_id IN - (SELECT distinct_id - FROM person_distinct_id2 - WHERE team_id = 2 - AND person_id = '00000000-0000-0000-0000-000000000000') as all_distinct_ids_that_might_match_a_person - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0 - AND current_person_id = '00000000-0000-0000-0000-000000000000') as session_persons_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_person_id_filter_08_poe_v1_and_materialized_columns_allowed_with_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.session_id in - (select `$session_id` as session_id - from - (SELECT `$session_id` - FROM events e PREWHERE team_id = 2 - AND e.timestamp >= '2020-12-11 13:46:23' - AND e.timestamp <= now() - AND timestamp >= '2020-12-24 12:00:00' - AND timestamp <= '2021-01-02 01:46:23' - WHERE notEmpty(`$session_id`) - AND person_id = '00000000-0000-0000-0000-000000000000' - GROUP BY `$session_id` - HAVING 1=1) as session_events_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_person_id_filter_09_poe_v1_and_materialized_columns_allowed_without_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.session_id in - (select `$session_id` as session_id - from - (SELECT `$session_id` - FROM events e PREWHERE team_id = 2 - AND e.timestamp >= '2020-12-11 13:46:23' - AND e.timestamp <= now() - AND timestamp >= '2020-12-24 12:00:00' - AND timestamp <= '2021-01-02 01:46:23' - WHERE notEmpty(`$session_id`) - AND person_id = '00000000-0000-0000-0000-000000000000' - GROUP BY `$session_id` - HAVING 1=1) as session_events_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_person_id_filter_10_poe_v1_and_not_materialized_columns_not_allowed_with_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.session_id in - (select `$session_id` as session_id - from - (SELECT `$session_id` - FROM events e PREWHERE team_id = 2 - AND e.timestamp >= '2020-12-11 13:46:23' - AND e.timestamp <= now() - AND timestamp >= '2020-12-24 12:00:00' - AND timestamp <= '2021-01-02 01:46:23' - WHERE notEmpty(`$session_id`) - AND person_id = '00000000-0000-0000-0000-000000000000' - GROUP BY `$session_id` - HAVING 1=1) as session_events_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromSessionReplay.test_person_id_filter_11_poe_v1_and_not_materialized_columns_not_allowed_without_materialization - ''' - - SELECT s.session_id, - any(s.team_id), - any(s.distinct_id), - min(s.min_first_timestamp) as start_time, - max(s.max_last_timestamp) as end_time, - dateDiff('SECOND', start_time, end_time) as duration, - argMinMerge(s.first_url) as first_url, - sum(s.click_count), - sum(s.keypress_count), - sum(s.mouse_activity_count), - sum(s.active_milliseconds)/1000 as active_seconds, - duration-active_seconds as inactive_seconds, - sum(s.console_log_count) as console_log_count, - sum(s.console_warn_count) as console_warn_count, - sum(s.console_error_count) as console_error_count - FROM session_replay_events s - WHERE s.team_id = 2 - AND s.min_first_timestamp >= '2020-12-11 13:46:23' - AND s.min_first_timestamp >= '2020-12-25 00:00:00' - AND s.min_first_timestamp <= '2021-01-01 13:46:23' - AND s.session_id in - (select `$session_id` as session_id - from - (SELECT `$session_id` - FROM events e PREWHERE team_id = 2 - AND e.timestamp >= '2020-12-11 13:46:23' - AND e.timestamp <= now() - AND timestamp >= '2020-12-24 12:00:00' - AND timestamp <= '2021-01-02 01:46:23' - WHERE notEmpty(`$session_id`) - AND person_id = '00000000-0000-0000-0000-000000000000' - GROUP BY `$session_id` - HAVING 1=1) as session_events_sub_query) - GROUP BY session_id - HAVING 1=1 - ORDER BY start_time DESC - LIMIT 51 - OFFSET 0 - ''' -# --- diff --git a/ee/session_recordings/queries/test/test_session_recording_list_from_filters.py b/ee/session_recordings/queries/test/test_session_recording_list_from_filters.py deleted file mode 100644 index da1007eaee9..00000000000 --- a/ee/session_recordings/queries/test/test_session_recording_list_from_filters.py +++ /dev/null @@ -1,365 +0,0 @@ -from itertools import product -from uuid import uuid4 - -from dateutil.relativedelta import relativedelta -from django.utils.timezone import now -from freezegun import freeze_time -from parameterized import parameterized - -from ee.clickhouse.materialized_columns.columns import materialize -from posthog.clickhouse.client import sync_execute -from posthog.hogql.ast import CompareOperation, And, SelectQuery -from posthog.hogql.base import Expr -from posthog.hogql.context import HogQLContext -from posthog.hogql.printer import print_ast -from posthog.models import Person -from posthog.models.filters import SessionRecordingsFilter -from posthog.schema import PersonsOnEventsMode -from posthog.session_recordings.queries.session_recording_list_from_filters import ( - SessionRecordingListFromFilters, -) -from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary -from posthog.session_recordings.sql.session_replay_event_sql import TRUNCATE_SESSION_REPLAY_EVENTS_TABLE_SQL -from posthog.test.base import ( - APIBaseTest, - ClickhouseTestMixin, - QueryMatchingTest, - snapshot_clickhouse_queries, - _create_event, -) - - -# The HogQL pair of TestClickhouseSessionRecordingsListFromSessionReplay can be renamed when delete the old one -@freeze_time("2021-01-01T13:46:23") -class TestClickhouseSessionRecordingsListFromFilters(ClickhouseTestMixin, APIBaseTest, QueryMatchingTest): - def _print_query(self, query: SelectQuery) -> str: - return print_ast( - query, - HogQLContext(team_id=self.team.pk, enable_select_queries=True), - "clickhouse", - pretty=True, - ) - - def tearDown(self) -> None: - sync_execute(TRUNCATE_SESSION_REPLAY_EVENTS_TABLE_SQL()) - - @property - def base_time(self): - return (now() - relativedelta(hours=1)).replace(microsecond=0, second=0) - - def create_event( - self, - distinct_id, - timestamp, - team=None, - event_name="$pageview", - properties=None, - ): - if team is None: - team = self.team - if properties is None: - properties = {"$os": "Windows 95", "$current_url": "aloha.com/2"} - return _create_event( - team=team, - event=event_name, - timestamp=timestamp, - distinct_id=distinct_id, - properties=properties, - ) - - @parameterized.expand( - [ - [ - "test_poe_v1_still_falls_back_to_person_subquery", - True, - False, - False, - PersonsOnEventsMode.PERSON_ID_NO_OVERRIDE_PROPERTIES_ON_EVENTS, - True, - ], - [ - "test_poe_being_unavailable_we_fall_back_to_person_subquery", - False, - False, - False, - PersonsOnEventsMode.DISABLED, - True, - ], - [ - "test_poe_being_unavailable_we_fall_back_to_person_subquery_but_still_use_mat_props", - False, - False, - False, - PersonsOnEventsMode.DISABLED, - False, - ], - [ - "test_allow_denormalised_props_fix_does_not_stop_all_poe_processing", - False, - True, - False, - PersonsOnEventsMode.PERSON_ID_OVERRIDE_PROPERTIES_ON_EVENTS, - False, - ], - [ - "test_poe_v2_available_person_properties_are_used_in_replay_listing", - False, - True, - True, - PersonsOnEventsMode.PERSON_ID_OVERRIDE_PROPERTIES_ON_EVENTS, - False, - ], - ] - ) - def test_effect_of_poe_settings_on_query_generated( - self, - _name: str, - poe_v1: bool, - poe_v2: bool, - allow_denormalized_props: bool, - expected_poe_mode: PersonsOnEventsMode, - unmaterialized_person_column_used: bool, - ) -> None: - with self.settings( - PERSON_ON_EVENTS_OVERRIDE=poe_v1, - PERSON_ON_EVENTS_V2_OVERRIDE=poe_v2, - ALLOW_DENORMALIZED_PROPS_IN_LISTING=allow_denormalized_props, - ): - assert self.team.person_on_events_mode == expected_poe_mode - materialize("events", "rgInternal", table_column="person_properties") - - filter = SessionRecordingsFilter( - team=self.team, - data={ - "properties": [ - { - "key": "rgInternal", - "value": ["false"], - "operator": "exact", - "type": "person", - } - ] - }, - ) - session_recording_list_instance = SessionRecordingListFromFilters( - filter=filter, team=self.team, hogql_query_modifiers=None - ) - - hogql_parsed_select = session_recording_list_instance.get_query() - printed_query = self._print_query(hogql_parsed_select) - - person_filtering_expr = self._matching_person_filter_expr_from(hogql_parsed_select) - - if poe_v1 or poe_v2: - # when poe is off we will join to events, so we can get person properties directly off them - self._assert_is_events_person_filter(person_filtering_expr) - - assert "ifNull(equals(nullIf(nullIf(mat_pp_rgInternal, ''), 'null')" in printed_query - else: - # when poe is off we join to person_distinct_ids, so we can get persons, so we can query their properties - self._assert_is_pdi_filter(person_filtering_expr) - - if unmaterialized_person_column_used: - assert ( - "argMax(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, %(hogql_val_6)s), ''), 'null'), '^\"|\"$', ''), person.version) AS properties___rgInternal" - in printed_query - ) - else: - # we should use materialized column - # assert ( - # "argMax(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, %(hogql_val_6)s), ''), 'null'), '^\"|\"$', ''), person.version) AS properties___rgInternal" - # not in printed_query - # ) - # TODO frustratingly this doesn't pass - but since we're migrating to PoE maybe we can ignore it - pass - - self.assertQueryMatchesSnapshot(printed_query) - - def _assert_is_pdi_filter(self, person_filtering_expr: list[Expr]) -> None: - assert person_filtering_expr[0].right.select_from.table.chain == ["person_distinct_ids"] - assert person_filtering_expr[0].right.where.left.chain == ["person", "properties", "rgInternal"] - - def _assert_is_events_person_filter(self, person_filtering_expr: list[Expr]) -> None: - assert person_filtering_expr[0].right.select_from.table.chain == ["events"] - event_person_condition = [ - x - for x in person_filtering_expr[0].right.where.exprs - if isinstance(x, CompareOperation) and x.left.chain == ["person", "properties", "rgInternal"] - ] - assert len(event_person_condition) == 1 - - def _matching_person_filter_expr_from(self, hogql_parsed_select: SelectQuery) -> list[Expr]: - where_conditions: list[Expr] = hogql_parsed_select.where.exprs - ands = [x for x in where_conditions if isinstance(x, And)] - assert len(ands) == 1 - and_comparisons = [x for x in ands[0].exprs if isinstance(x, CompareOperation)] - assert len(and_comparisons) == 1 - assert isinstance(and_comparisons[0].right, SelectQuery) - return and_comparisons - - settings_combinations = [ - ["poe v2 and materialized columns allowed", False, True, True], - ["poe v2 and materialized columns off", False, True, False], - ["poe off and materialized columns allowed", False, False, True], - ["poe off and materialized columns not allowed", False, False, False], - ["poe v1 and materialized columns allowed", True, False, True], - ["poe v1 and not materialized columns not allowed", True, False, False], - ] - - # Options for "materialize person columns" - materialization_options = [ - [" with materialization", True], - [" without materialization", False], - ] - - # Expand the parameter list to the product of all combinations with "materialize person columns" - # e.g. [a, b] x [c, d] = [a, c], [a, d], [b, c], [b, d] - test_case_combinations = [ - [f"{name}{mat_option}", poe_v1, poe, mat_columns, mat_person] - for (name, poe_v1, poe, mat_columns), (mat_option, mat_person) in product( - settings_combinations, materialization_options - ) - ] - - @parameterized.expand(test_case_combinations) - @snapshot_clickhouse_queries - def test_event_filter_with_person_properties_materialized( - self, - _name: str, - poe1_enabled: bool, - poe2_enabled: bool, - allow_denormalised_props: bool, - materialize_person_props: bool, - ) -> None: - # KLUDGE: I couldn't figure out how to use @also_test_with_materialized_columns(person_properties=["email"]) - # KLUDGE: and the parameterized.expand decorator at the same time, so we generate test case combos - # KLUDGE: for materialization on and off to test both sides the way the decorator would have - if materialize_person_props: - materialize("events", "email", table_column="person_properties") - materialize("person", "email") - - with self.settings( - PERSON_ON_EVENTS_OVERRIDE=poe1_enabled, - PERSON_ON_EVENTS_V2_OVERRIDE=poe2_enabled, - ALLOW_DENORMALIZED_PROPS_IN_LISTING=allow_denormalised_props, - ): - user_one = "test_event_filter_with_person_properties-user" - user_two = "test_event_filter_with_person_properties-user2" - session_id_one = f"test_event_filter_with_person_properties-1-{str(uuid4())}" - session_id_two = f"test_event_filter_with_person_properties-2-{str(uuid4())}" - - Person.objects.create(team=self.team, distinct_ids=[user_one], properties={"email": "bla"}) - Person.objects.create(team=self.team, distinct_ids=[user_two], properties={"email": "bla2"}) - - self._add_replay_with_pageview(session_id_one, user_one) - produce_replay_summary( - distinct_id=user_one, - session_id=session_id_one, - first_timestamp=(self.base_time + relativedelta(seconds=30)), - team_id=self.team.id, - ) - self._add_replay_with_pageview(session_id_two, user_two) - produce_replay_summary( - distinct_id=user_two, - session_id=session_id_two, - first_timestamp=(self.base_time + relativedelta(seconds=30)), - team_id=self.team.id, - ) - - match_everyone_filter = SessionRecordingsFilter( - team=self.team, - data={"properties": []}, - ) - - session_recording_list_instance = SessionRecordingListFromFilters( - filter=match_everyone_filter, team=self.team, hogql_query_modifiers=None - ) - (session_recordings, _, _) = session_recording_list_instance.run() - - assert sorted([x["session_id"] for x in session_recordings]) == sorted([session_id_one, session_id_two]) - - match_bla_filter = SessionRecordingsFilter( - team=self.team, - data={ - "properties": [ - { - "key": "email", - "value": ["bla"], - "operator": "exact", - "type": "person", - } - ] - }, - ) - - session_recording_list_instance = SessionRecordingListFromFilters( - filter=match_bla_filter, team=self.team, hogql_query_modifiers=None - ) - (session_recordings, _, _) = session_recording_list_instance.run() - - assert len(session_recordings) == 1 - assert session_recordings[0]["session_id"] == session_id_one - - def _add_replay_with_pageview(self, session_id: str, user: str) -> None: - self.create_event( - user, - self.base_time, - properties={"$session_id": session_id, "$window_id": str(uuid4())}, - ) - produce_replay_summary( - distinct_id=user, - session_id=session_id, - first_timestamp=self.base_time, - team_id=self.team.id, - ) - - @parameterized.expand(test_case_combinations) - @snapshot_clickhouse_queries - def test_person_id_filter( - self, - _name: str, - poe2_enabled: bool, - poe1_enabled: bool, - allow_denormalised_props: bool, - materialize_person_props: bool, - ) -> None: - # KLUDGE: I couldn't figure out how to use @also_test_with_materialized_columns(person_properties=["email"]) - # KLUDGE: and the parameterized.expand decorator at the same time, so we generate test case combos - # KLUDGE: for materialization on and off to test both sides the way the decorator would have - if materialize_person_props: - # it shouldn't matter to this test whether any column is materialized - # but let's keep the tests in this file similar so we flush out any unexpected interactions - materialize("events", "email", table_column="person_properties") - materialize("person", "email") - - with self.settings( - PERSON_ON_EVENTS_OVERRIDE=poe1_enabled, - PERSON_ON_EVENTS_V2_OVERRIDE=poe2_enabled, - ALLOW_DENORMALIZED_PROPS_IN_LISTING=allow_denormalised_props, - ): - three_user_ids = ["person-1-distinct-1", "person-1-distinct-2", "person-2"] - session_id_one = f"test_person_id_filter-session-one" - session_id_two = f"test_person_id_filter-session-two" - session_id_three = f"test_person_id_filter-session-three" - - p = Person.objects.create( - team=self.team, - distinct_ids=[three_user_ids[0], three_user_ids[1]], - properties={"email": "bla"}, - ) - Person.objects.create( - team=self.team, - distinct_ids=[three_user_ids[2]], - properties={"email": "bla2"}, - ) - - self._add_replay_with_pageview(session_id_one, three_user_ids[0]) - self._add_replay_with_pageview(session_id_two, three_user_ids[1]) - self._add_replay_with_pageview(session_id_three, three_user_ids[2]) - - filter = SessionRecordingsFilter(team=self.team, data={"person_uuid": str(p.uuid)}) - session_recording_list_instance = SessionRecordingListFromFilters( - filter=filter, team=self.team, hogql_query_modifiers=None - ) - (session_recordings, _, _) = session_recording_list_instance.run() - assert sorted([r["session_id"] for r in session_recordings]) == sorted([session_id_two, session_id_one]) diff --git a/ee/session_recordings/session_recording_extensions.py b/ee/session_recordings/session_recording_extensions.py deleted file mode 100644 index 0b3d9300f2f..00000000000 --- a/ee/session_recordings/session_recording_extensions.py +++ /dev/null @@ -1,196 +0,0 @@ -# EE extended functions for SessionRecording model -import gzip -import json -from datetime import timedelta, datetime -from typing import Optional, cast - -import structlog -from django.utils import timezone -from prometheus_client import Histogram, Counter -from sentry_sdk import capture_exception, capture_message - -from posthog import settings -from posthog.session_recordings.models.metadata import PersistedRecordingV1 -from posthog.session_recordings.models.session_recording import SessionRecording -from posthog.session_recordings.session_recording_helpers import decompress -from posthog.storage import object_storage - -logger = structlog.get_logger(__name__) - -SNAPSHOT_PERSIST_TIME_HISTOGRAM = Histogram( - "snapshot_persist_time_seconds", - "We persist recording snapshots from S3, how long does that take?", -) - -SNAPSHOT_PERSIST_SUCCESS_COUNTER = Counter( - "snapshot_persist_success", - "Count of session recordings that were successfully persisted", -) - -SNAPSHOT_PERSIST_FAILURE_COUNTER = Counter( - "snapshot_persist_failure", - "Count of session recordings that failed to be persisted", -) - -SNAPSHOT_PERSIST_TOO_YOUNG_COUNTER = Counter( - "snapshot_persist_too_young", - "Count of session recordings that were too young to be persisted", -) - -MINIMUM_AGE_FOR_RECORDING = timedelta(hours=24) - - -# TODO rename this... -def save_recording_with_new_content(recording: SessionRecording, content: str) -> str: - if not settings.OBJECT_STORAGE_ENABLED: - return "" - - logger.info( - "re-saving recording file into 2023-08-01 LTS storage format", - recording_id=recording.session_id, - team_id=recording.team_id, - ) - - target_prefix = recording.build_object_storage_path("2023-08-01") - - start = int(cast(datetime, recording.start_time).timestamp() * 1000) - end = int(cast(datetime, recording.end_time).timestamp() * 1000) - new_path = f"{target_prefix}/{start}-{end}" - - zipped_content = gzip.compress(content.encode("utf-8")) - object_storage.write( - new_path, - zipped_content, - extras={"ContentType": "application/json", "ContentEncoding": "gzip"}, - ) - - recording.storage_version = "2023-08-01" - recording.object_storage_path = target_prefix - recording.save() - - return new_path - - -class InvalidRecordingForPersisting(Exception): - pass - - -def persist_recording(recording_id: str, team_id: int) -> None: - """Persist a recording to the S3""" - - logger.info("Persisting recording: init", recording_id=recording_id, team_id=team_id) - - if not settings.OBJECT_STORAGE_ENABLED: - return - - recording = SessionRecording.objects.select_related("team").get(session_id=recording_id, team_id=team_id) - - if not recording: - raise Exception(f"Recording {recording_id} not found") - - if recording.deleted: - logger.info( - "Persisting recording: skipping as recording is deleted", - recording_id=recording_id, - team_id=team_id, - ) - return - - logger.info( - "Persisting recording: loading metadata...", - recording_id=recording_id, - team_id=team_id, - ) - - recording.load_metadata() - - if not recording.start_time or timezone.now() < recording.start_time + MINIMUM_AGE_FOR_RECORDING: - # Recording is too recent to be persisted. - # We can save the metadata as it is still useful for querying, but we can't move to S3 yet. - logger.info( - "Persisting recording: skipping as recording start time is less than MINIMUM_AGE_FOR_RECORDING", - recording_id=recording_id, - team_id=team_id, - ) - SNAPSHOT_PERSIST_TOO_YOUNG_COUNTER.inc() - recording.save() - return - - target_prefix = recording.build_object_storage_path("2023-08-01") - source_prefix = recording.build_blob_ingestion_storage_path() - # if snapshots are already in blob storage, then we can just copy the files between buckets - with SNAPSHOT_PERSIST_TIME_HISTOGRAM.time(): - copied_count = object_storage.copy_objects(source_prefix, target_prefix) - - if copied_count > 0: - recording.storage_version = "2023-08-01" - recording.object_storage_path = target_prefix - recording.save() - SNAPSHOT_PERSIST_SUCCESS_COUNTER.inc() - logger.info( - "Persisting recording: done!", - recording_id=recording_id, - team_id=team_id, - source="s3", - ) - return - else: - SNAPSHOT_PERSIST_FAILURE_COUNTER.inc() - logger.error( - "No snapshots found to copy in S3 when persisting a recording", - recording_id=recording_id, - team_id=team_id, - target_prefix=target_prefix, - source_prefix=source_prefix, - ) - raise InvalidRecordingForPersisting("Could not persist recording: " + recording_id) - - -def load_persisted_recording(recording: SessionRecording) -> Optional[PersistedRecordingV1]: - """Load a persisted recording from S3""" - - logger.info( - "Persisting recording load: reading from S3...", - recording_id=recording.session_id, - storage_version=recording.storage_version, - path=recording.object_storage_path, - ) - - # originally storage version was written to the stored content - # some stored content is stored over multiple files, so we can't rely on that - # future recordings will have the storage version on the model - # and will not be loaded here - if not recording.storage_version: - try: - content = object_storage.read(str(recording.object_storage_path)) - decompressed = json.loads(decompress(content)) if content else None - logger.info( - "Persisting recording load: loaded!", - recording_id=recording.session_id, - path=recording.object_storage_path, - ) - - return decompressed - except object_storage.ObjectStorageError as ose: - capture_exception(ose) - logger.error( - "session_recording.object-storage-load-error", - recording_id=recording.session_id, - path=recording.object_storage_path, - version="2022-12-22", - exception=ose, - exc_info=True, - ) - - capture_message( - "session_recording.load_persisted_recording.unexpected_recording_storage_version", - extras={ - "recording_id": recording.session_id, - "storage_version": recording.storage_version, - "path": recording.object_storage_path, - }, - tags={ - "team_id": recording.team_id, - }, - ) - return None diff --git a/ee/session_recordings/session_recording_playlist.py b/ee/session_recordings/session_recording_playlist.py deleted file mode 100644 index 80d88ac0fa0..00000000000 --- a/ee/session_recordings/session_recording_playlist.py +++ /dev/null @@ -1,268 +0,0 @@ -import json -from typing import Any, Optional - -import structlog -from django.db.models import Q, QuerySet -from django.utils.timezone import now -from django_filters.rest_framework import DjangoFilterBackend -from loginas.utils import is_impersonated_session -from rest_framework import request, response, serializers, viewsets -from rest_framework.decorators import action -from rest_framework.exceptions import PermissionDenied - -from posthog.api.forbid_destroy_model import ForbidDestroyModel -from posthog.api.routing import TeamAndOrgViewSetMixin -from posthog.api.shared import UserBasicSerializer -from posthog.constants import SESSION_RECORDINGS_FILTER_IDS, AvailableFeature -from posthog.models import ( - SessionRecording, - SessionRecordingPlaylist, - SessionRecordingPlaylistItem, - Team, - User, -) -from posthog.models.activity_logging.activity_log import ( - Change, - Detail, - changes_between, - log_activity, -) -from posthog.models.filters.session_recordings_filter import SessionRecordingsFilter -from posthog.models.team.team import check_is_feature_available_for_team -from posthog.models.utils import UUIDT -from posthog.rate_limit import ( - ClickHouseBurstRateThrottle, - ClickHouseSustainedRateThrottle, -) -from posthog.session_recordings.session_recording_api import list_recordings_response -from posthog.utils import relative_date_parse - -logger = structlog.get_logger(__name__) - - -def log_playlist_activity( - activity: str, - playlist: SessionRecordingPlaylist, - playlist_id: int, - playlist_short_id: str, - organization_id: UUIDT, - team_id: int, - user: User, - was_impersonated: bool, - changes: Optional[list[Change]] = None, -) -> None: - """ - Insight id and short_id are passed separately as some activities (like delete) alter the Insight instance - - The experiments feature creates insights without a name, this does not log those - """ - - playlist_name: Optional[str] = playlist.name if playlist.name else playlist.derived_name - if playlist_name: - log_activity( - organization_id=organization_id, - team_id=team_id, - user=user, - was_impersonated=was_impersonated, - item_id=playlist_id, - scope="SessionRecordingPlaylist", - activity=activity, - detail=Detail(name=playlist_name, changes=changes, short_id=playlist_short_id), - ) - - -class SessionRecordingPlaylistSerializer(serializers.ModelSerializer): - class Meta: - model = SessionRecordingPlaylist - fields = [ - "id", - "short_id", - "name", - "derived_name", - "description", - "pinned", - "created_at", - "created_by", - "deleted", - "filters", - "last_modified_at", - "last_modified_by", - ] - read_only_fields = [ - "id", - "short_id", - "team", - "created_at", - "created_by", - "last_modified_at", - "last_modified_by", - ] - - created_by = UserBasicSerializer(read_only=True) - last_modified_by = UserBasicSerializer(read_only=True) - - def create(self, validated_data: dict, *args, **kwargs) -> SessionRecordingPlaylist: - request = self.context["request"] - team = self.context["get_team"]() - - self._check_can_create_playlist(team) - - created_by = validated_data.pop("created_by", request.user) - playlist = SessionRecordingPlaylist.objects.create( - team=team, - created_by=created_by, - last_modified_by=request.user, - **validated_data, - ) - - log_playlist_activity( - activity="created", - playlist=playlist, - playlist_id=playlist.id, - playlist_short_id=playlist.short_id, - organization_id=self.context["request"].user.current_organization_id, - team_id=team.id, - user=self.context["request"].user, - was_impersonated=is_impersonated_session(self.context["request"]), - ) - - return playlist - - def update(self, instance: SessionRecordingPlaylist, validated_data: dict, **kwargs) -> SessionRecordingPlaylist: - try: - before_update = SessionRecordingPlaylist.objects.get(pk=instance.id) - except SessionRecordingPlaylist.DoesNotExist: - before_update = None - - if validated_data.keys() & SessionRecordingPlaylist.MATERIAL_PLAYLIST_FIELDS: - instance.last_modified_at = now() - instance.last_modified_by = self.context["request"].user - - updated_playlist = super().update(instance, validated_data) - changes = changes_between("SessionRecordingPlaylist", previous=before_update, current=updated_playlist) - - log_playlist_activity( - activity="updated", - playlist=updated_playlist, - playlist_id=updated_playlist.id, - playlist_short_id=updated_playlist.short_id, - organization_id=self.context["request"].user.current_organization_id, - team_id=self.context["team_id"], - user=self.context["request"].user, - was_impersonated=is_impersonated_session(self.context["request"]), - changes=changes, - ) - - return updated_playlist - - def _check_can_create_playlist(self, team: Team) -> bool: - playlist_count = SessionRecordingPlaylist.objects.filter(deleted=False, team=team).count() - if not check_is_feature_available_for_team(team.pk, AvailableFeature.RECORDINGS_PLAYLISTS, playlist_count): - raise PermissionDenied("You have hit the limit for playlists for this team.") - return True - - -class SessionRecordingPlaylistViewSet(TeamAndOrgViewSetMixin, ForbidDestroyModel, viewsets.ModelViewSet): - scope_object = "session_recording_playlist" - queryset = SessionRecordingPlaylist.objects.all() - serializer_class = SessionRecordingPlaylistSerializer - throttle_classes = [ClickHouseBurstRateThrottle, ClickHouseSustainedRateThrottle] - filter_backends = [DjangoFilterBackend] - filterset_fields = ["short_id", "created_by"] - lookup_field = "short_id" - - def safely_get_queryset(self, queryset) -> QuerySet: - if not self.action.endswith("update"): - # Soft-deleted insights can be brought back with a PATCH request - queryset = queryset.filter(deleted=False) - - queryset = queryset.select_related("created_by", "last_modified_by", "team") - if self.action == "list": - queryset = queryset.filter(deleted=False) - queryset = self._filter_request(self.request, queryset) - - order = self.request.GET.get("order", None) - if order: - queryset = queryset.order_by(order) - else: - queryset = queryset.order_by("-last_modified_at") - - return queryset - - def _filter_request(self, request: request.Request, queryset: QuerySet) -> QuerySet: - filters = request.GET.dict() - - for key in filters: - if key == "user": - queryset = queryset.filter(created_by=request.user) - elif key == "pinned": - queryset = queryset.filter(pinned=True) - elif key == "date_from": - queryset = queryset.filter( - last_modified_at__gt=relative_date_parse(request.GET["date_from"], self.team.timezone_info) - ) - elif key == "date_to": - queryset = queryset.filter( - last_modified_at__lt=relative_date_parse(request.GET["date_to"], self.team.timezone_info) - ) - elif key == "search": - queryset = queryset.filter( - Q(name__icontains=request.GET["search"]) | Q(derived_name__icontains=request.GET["search"]) - ) - elif key == "session_recording_id": - queryset = queryset.filter(playlist_items__recording_id=request.GET["session_recording_id"]) - return queryset - - # As of now, you can only "update" a session recording by adding or removing a recording from a static playlist - @action(methods=["GET"], detail=True, url_path="recordings") - def recordings(self, request: request.Request, *args: Any, **kwargs: Any) -> response.Response: - playlist = self.get_object() - playlist_items = list( - SessionRecordingPlaylistItem.objects.filter(playlist=playlist) - .exclude(deleted=True) - .order_by("-created_at") - .values_list("recording_id", flat=True) - ) - - filter = SessionRecordingsFilter(request=request, team=self.team) - filter = filter.shallow_clone({SESSION_RECORDINGS_FILTER_IDS: json.dumps(playlist_items)}) - - return list_recordings_response(filter, request, self.get_serializer_context()) - - # As of now, you can only "update" a session recording by adding or removing a recording from a static playlist - @action( - methods=["POST", "DELETE"], - detail=True, - url_path="recordings/(?P[^/.]+)", - ) - def modify_recordings( - self, - request: request.Request, - session_recording_id: str, - *args: Any, - **kwargs: Any, - ) -> response.Response: - playlist = self.get_object() - - # TODO: Maybe we need to save the created_at date here properly to help with filtering - if request.method == "POST": - recording, _ = SessionRecording.objects.get_or_create( - session_id=session_recording_id, - team=self.team, - defaults={"deleted": False}, - ) - playlist_item, created = SessionRecordingPlaylistItem.objects.get_or_create( - playlist=playlist, recording=recording - ) - - return response.Response({"success": True}) - - if request.method == "DELETE": - playlist_item = SessionRecordingPlaylistItem.objects.get(playlist=playlist, recording=session_recording_id) # type: ignore - - if playlist_item: - playlist_item.delete() - - return response.Response({"success": True}) - - raise NotImplementedError() diff --git a/ee/session_recordings/session_summary/__init__.py b/ee/session_recordings/session_summary/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/session_recordings/session_summary/summarize_session.py b/ee/session_recordings/session_summary/summarize_session.py deleted file mode 100644 index 2e6b525d124..00000000000 --- a/ee/session_recordings/session_summary/summarize_session.py +++ /dev/null @@ -1,148 +0,0 @@ -import openai - -from prometheus_client import Histogram - -from posthog.api.activity_log import ServerTimingsGathered -from posthog.models import User, Team -from posthog.session_recordings.models.session_recording import SessionRecording - -from posthog.session_recordings.queries.session_replay_events import SessionReplayEvents - -from posthog.utils import get_instance_region - -from ee.session_recordings.ai.utils import ( - SessionSummaryPromptData, - reduce_elements_chain, - simplify_window_id, - deduplicate_urls, - format_dates, - collapse_sequence_of_events, -) - -TOKENS_IN_PROMPT_HISTOGRAM = Histogram( - "posthog_session_summary_tokens_in_prompt_histogram", - "histogram of the number of tokens in the prompt used to generate a session summary", - buckets=[ - 0, - 10, - 50, - 100, - 500, - 1000, - 2000, - 3000, - 4000, - 5000, - 6000, - 7000, - 8000, - 10000, - 20000, - 30000, - 40000, - 50000, - 100000, - 128000, - float("inf"), - ], -) - - -def summarize_recording(recording: SessionRecording, user: User, team: Team): - timer = ServerTimingsGathered() - - with timer("get_metadata"): - session_metadata = SessionReplayEvents().get_metadata(session_id=str(recording.session_id), team=team) - if not session_metadata: - raise ValueError(f"no session metadata found for session_id {recording.session_id}") - - with timer("get_events"): - session_events = SessionReplayEvents().get_events( - session_id=str(recording.session_id), - team=team, - metadata=session_metadata, - events_to_ignore=[ - "$feature_flag_called", - ], - ) - if not session_events or not session_events[0] or not session_events[1]: - raise ValueError(f"no events found for session_id {recording.session_id}") - - # convert session_metadata to a Dict from a TypedDict - # so that we can amend its values freely - session_metadata_dict = dict(session_metadata) - - del session_metadata_dict["distinct_id"] - start_time = session_metadata["start_time"] - session_metadata_dict["start_time"] = start_time.isoformat() - session_metadata_dict["end_time"] = session_metadata["end_time"].isoformat() - - with timer("generate_prompt"): - prompt_data = deduplicate_urls( - collapse_sequence_of_events( - format_dates( - reduce_elements_chain( - simplify_window_id( - SessionSummaryPromptData(columns=session_events[0], results=session_events[1]) - ) - ), - start=start_time, - ) - ) - ) - - instance_region = get_instance_region() or "HOBBY" - - with timer("openai_completion"): - result = openai.chat.completions.create( - # model="gpt-4-1106-preview", # allows 128k tokens - # model="gpt-4", # allows 8k tokens - model="gpt-4o", # allows 128k tokens - temperature=0.7, - messages=[ - { - "role": "system", - "content": """ - Session Replay is PostHog's tool to record visits to web sites and apps. - We also gather events that occur like mouse clicks and key presses. - You write two or three sentence concise and simple summaries of those sessions based on a prompt. - You are more likely to mention errors or things that look like business success such as checkout events. - You don't help with other knowledge.""", - }, - { - "role": "user", - "content": f"""the session metadata I have is {session_metadata_dict}. - it gives an overview of activity and duration""", - }, - { - "role": "user", - "content": f""" - URLs associated with the events can be found in this mapping {prompt_data.url_mapping}. - """, - }, - { - "role": "user", - "content": f"""the session events I have are {prompt_data.results}. - with columns {prompt_data.columns}. - they give an idea of what happened and when, - if present the elements_chain extracted from the html can aid in understanding - but should not be directly used in your response""", - }, - { - "role": "user", - "content": """ - generate a two or three sentence summary of the session. - use as concise and simple language as is possible. - assume a reading age of around 12 years old. - generate no text other than the summary.""", - }, - ], - user=f"{instance_region}/{user.pk}", # allows 8k tokens - ) - - usage = result.usage.prompt_tokens if result.usage else None - if usage: - TOKENS_IN_PROMPT_HISTOGRAM.observe(usage) - - content: str = result.choices[0].message.content or "" - return {"content": content, "timings": timer.get_all_timings()} diff --git a/ee/session_recordings/session_summary/test/__init__.py b/ee/session_recordings/session_summary/test/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/session_recordings/session_summary/test/test_summarize_session.py b/ee/session_recordings/session_summary/test/test_summarize_session.py deleted file mode 100644 index 3cc69df02b1..00000000000 --- a/ee/session_recordings/session_summary/test/test_summarize_session.py +++ /dev/null @@ -1,116 +0,0 @@ -from datetime import datetime, UTC - -from dateutil.parser import isoparse - -from ee.session_recordings.session_summary.summarize_session import ( - format_dates, - simplify_window_id, - deduplicate_urls, - collapse_sequence_of_events, - SessionSummaryPromptData, -) -from posthog.test.base import BaseTest - - -class TestSummarizeSessions(BaseTest): - def test_format_dates_as_millis_since_start(self) -> None: - processed = format_dates( - SessionSummaryPromptData( - columns=["event", "timestamp"], - results=[ - ["$pageview", isoparse("2021-01-01T00:00:00Z")], - ["$pageview", isoparse("2021-01-01T00:00:01Z")], - ["$pageview", isoparse("2021-01-01T00:00:02Z")], - ], - ), - datetime(2021, 1, 1, 0, 0, 0, tzinfo=UTC), - ) - assert processed.columns == ["event", "milliseconds_since_start"] - assert processed.results == [["$pageview", 0], ["$pageview", 1000], ["$pageview", 2000]] - - def test_simplify_window_id(self) -> None: - processed = simplify_window_id( - SessionSummaryPromptData( - columns=["event", "timestamp", "$window_id"], - results=[ - ["$pageview-1-1", isoparse("2021-01-01T00:00:00Z"), "window-the-first"], - ["$pageview-1-2", isoparse("2021-01-01T00:00:01Z"), "window-the-first"], - ["$pageview-2-1", isoparse("2021-01-01T00:00:02Z"), "window-the-second"], - ["$pageview-4-1", isoparse("2021-01-01T00:00:02Z"), "window-the-fourth"], - ["$pageview-3-1", isoparse("2021-01-01T00:00:02Z"), "window-the-third"], - ["$pageview-1-3", isoparse("2021-01-01T00:00:02Z"), "window-the-first"], - ], - ) - ) - - assert processed.columns == ["event", "timestamp", "$window_id"] - assert processed.results == [ - ["$pageview-1-1", isoparse("2021-01-01T00:00:00Z"), 1], - ["$pageview-1-2", isoparse("2021-01-01T00:00:01Z"), 1], - ["$pageview-2-1", isoparse("2021-01-01T00:00:02Z"), 2], - # window the fourth has index 3... - # in reality these are mapping from UUIDs - # and this apparent switch of number wouldn't stand out - ["$pageview-4-1", isoparse("2021-01-01T00:00:02Z"), 3], - ["$pageview-3-1", isoparse("2021-01-01T00:00:02Z"), 4], - ["$pageview-1-3", isoparse("2021-01-01T00:00:02Z"), 1], - ] - - def test_collapse_sequence_of_events(self) -> None: - processed = collapse_sequence_of_events( - SessionSummaryPromptData( - columns=["event", "timestamp", "$window_id"], - results=[ - # these collapse because they're a sequence - ["$pageview", isoparse("2021-01-01T00:00:00Z"), 1], - ["$pageview", isoparse("2021-01-01T01:00:00Z"), 1], - ["$pageview", isoparse("2021-01-01T02:00:00Z"), 1], - ["$pageview", isoparse("2021-01-01T03:00:00Z"), 1], - # these don't collapse because they're different windows - ["$autocapture", isoparse("2021-01-01T00:00:00Z"), 1], - ["$autocapture", isoparse("2021-01-01T01:00:00Z"), 2], - # these don't collapse because they're not a sequence - ["$a", isoparse("2021-01-01T01:00:00Z"), 2], - ["$b", isoparse("2021-01-01T01:00:00Z"), 2], - ["$c", isoparse("2021-01-01T01:00:00Z"), 2], - ], - ) - ) - assert processed.columns == ["event", "timestamp", "$window_id", "event_repetition_count"] - assert processed.results == [ - ["$pageview", isoparse("2021-01-01T00:00:00Z"), 1, 4], - ["$autocapture", isoparse("2021-01-01T00:00:00Z"), 1, None], - ["$autocapture", isoparse("2021-01-01T01:00:00Z"), 2, None], - ["$a", isoparse("2021-01-01T01:00:00Z"), 2, None], - ["$b", isoparse("2021-01-01T01:00:00Z"), 2, None], - ["$c", isoparse("2021-01-01T01:00:00Z"), 2, None], - ] - - def test_deduplicate_ids(self) -> None: - processed = deduplicate_urls( - SessionSummaryPromptData( - columns=["event", "$current_url"], - results=[ - ["$pageview-one", "https://example.com/one"], - ["$pageview-two", "https://example.com/two"], - ["$pageview-one", "https://example.com/one"], - ["$pageview-one", "https://example.com/one"], - ["$pageview-two", "https://example.com/two"], - ["$pageview-three", "https://example.com/three"], - ], - ) - ) - assert processed.columns == ["event", "$current_url"] - assert processed.results == [ - ["$pageview-one", "url_1"], - ["$pageview-two", "url_2"], - ["$pageview-one", "url_1"], - ["$pageview-one", "url_1"], - ["$pageview-two", "url_2"], - ["$pageview-three", "url_3"], - ] - assert processed.url_mapping == { - "https://example.com/one": "url_1", - "https://example.com/two": "url_2", - "https://example.com/three": "url_3", - } diff --git a/ee/session_recordings/test/__init__.py b/ee/session_recordings/test/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/session_recordings/test/test_session_recording_extensions.py b/ee/session_recordings/test/test_session_recording_extensions.py deleted file mode 100644 index e425213f747..00000000000 --- a/ee/session_recordings/test/test_session_recording_extensions.py +++ /dev/null @@ -1,172 +0,0 @@ -import gzip -from datetime import timedelta, datetime, UTC -from secrets import token_urlsafe -from unittest.mock import patch, MagicMock -from uuid import uuid4 - -from boto3 import resource -from botocore.config import Config -from freezegun import freeze_time - -from ee.session_recordings.session_recording_extensions import ( - load_persisted_recording, - persist_recording, - save_recording_with_new_content, -) -from posthog.models.signals import mute_selected_signals -from posthog.session_recordings.models.session_recording import SessionRecording -from posthog.session_recordings.queries.test.session_replay_sql import ( - produce_replay_summary, -) -from posthog.settings import ( - OBJECT_STORAGE_ENDPOINT, - OBJECT_STORAGE_ACCESS_KEY_ID, - OBJECT_STORAGE_SECRET_ACCESS_KEY, - OBJECT_STORAGE_BUCKET, -) -from posthog.storage.object_storage import write, list_objects -from posthog.test.base import APIBaseTest, ClickhouseTestMixin - -long_url = f"https://app.posthog.com/my-url?token={token_urlsafe(600)}" - - -TEST_BUCKET = "test_storage_bucket-TestSessionRecordingExtensions" - - -class TestSessionRecordingExtensions(ClickhouseTestMixin, APIBaseTest): - def teardown_method(self, method) -> None: - s3 = resource( - "s3", - endpoint_url=OBJECT_STORAGE_ENDPOINT, - aws_access_key_id=OBJECT_STORAGE_ACCESS_KEY_ID, - aws_secret_access_key=OBJECT_STORAGE_SECRET_ACCESS_KEY, - config=Config(signature_version="s3v4"), - region_name="us-east-1", - ) - bucket = s3.Bucket(OBJECT_STORAGE_BUCKET) - bucket.objects.filter(Prefix=TEST_BUCKET).delete() - - def test_does_not_persist_too_recent_recording(self): - recording = SessionRecording.objects.create( - team=self.team, - session_id=f"test_does_not_persist_too_recent_recording-s1-{uuid4()}", - ) - - produce_replay_summary( - team_id=self.team.pk, - session_id=recording.session_id, - distinct_id="distinct_id_1", - first_timestamp=recording.created_at, - last_timestamp=recording.created_at, - ) - persist_recording(recording.session_id, recording.team_id) - recording.refresh_from_db() - - assert not recording.object_storage_path - - def test_can_build_different_object_storage_paths(self) -> None: - produce_replay_summary( - session_id="test_can_build_different_object_storage_paths-s1", - team_id=self.team.pk, - ) - recording: SessionRecording = SessionRecording.objects.create( - team=self.team, - session_id="test_can_build_different_object_storage_paths-s1", - ) - assert ( - recording.build_object_storage_path("2022-12-22") - == f"session_recordings_lts/team-{self.team.pk}/session-test_can_build_different_object_storage_paths-s1" - ) - assert ( - recording.build_object_storage_path("2023-08-01") - == f"session_recordings_lts/team_id/{self.team.pk}/session_id/test_can_build_different_object_storage_paths-s1/data" - ) - - def test_persists_recording_from_blob_ingested_storage(self): - with self.settings(OBJECT_STORAGE_SESSION_RECORDING_BLOB_INGESTION_FOLDER=TEST_BUCKET): - two_minutes_ago = (datetime.now() - timedelta(minutes=2)).replace(tzinfo=UTC) - - with freeze_time(two_minutes_ago): - session_id = f"test_persists_recording_from_blob_ingested_storage-s1-{uuid4()}" - - produce_replay_summary( - session_id=session_id, - team_id=self.team.pk, - first_timestamp=(two_minutes_ago - timedelta(hours=48)).isoformat(), - last_timestamp=(two_minutes_ago - timedelta(hours=46)).isoformat(), - distinct_id="distinct_id_1", - first_url="https://app.posthog.com/my-url", - ) - - # this recording already has several files stored from Mr. Blobby - # these need to be written before creating the recording object - for file in ["a", "b", "c"]: - blob_path = f"{TEST_BUCKET}/team_id/{self.team.pk}/session_id/{session_id}/data" - file_name = f"{blob_path}/{file}" - write(file_name, f"my content-{file}".encode()) - - recording: SessionRecording = SessionRecording.objects.create(team=self.team, session_id=session_id) - - assert recording.created_at == two_minutes_ago - - persist_recording(recording.session_id, recording.team_id) - recording.refresh_from_db() - - assert ( - recording.object_storage_path - == f"session_recordings_lts/team_id/{self.team.pk}/session_id/{recording.session_id}/data" - ) - assert recording.start_time == recording.created_at - timedelta(hours=48) - assert recording.end_time == recording.created_at - timedelta(hours=46) - - assert recording.storage_version == "2023-08-01" - assert recording.distinct_id == "distinct_id_1" - assert recording.duration == 7200 - assert recording.click_count == 0 - assert recording.keypress_count == 0 - assert recording.start_url == "https://app.posthog.com/my-url" - - # recordings which were blob ingested can not be loaded with this mechanism - assert load_persisted_recording(recording) is None - - stored_objects = list_objects(recording.build_object_storage_path("2023-08-01")) - assert stored_objects == [ - f"{recording.build_object_storage_path('2023-08-01')}/a", - f"{recording.build_object_storage_path('2023-08-01')}/b", - f"{recording.build_object_storage_path('2023-08-01')}/c", - ] - - @patch("ee.session_recordings.session_recording_extensions.object_storage.write") - def test_can_save_content_to_new_location(self, mock_write: MagicMock): - # mute selected signals so the post create signal does not try to persist the recording - with self.settings(OBJECT_STORAGE_SESSION_RECORDING_BLOB_INGESTION_FOLDER=TEST_BUCKET), mute_selected_signals(): - session_id = f"{uuid4()}" - - recording = SessionRecording.objects.create( - team=self.team, - session_id=session_id, - start_time=datetime.fromtimestamp(12345), - end_time=datetime.fromtimestamp(12346), - object_storage_path="some_starting_value", - # None, but that would trigger the persistence behavior, and we don't want that - storage_version="None", - ) - - new_key = save_recording_with_new_content(recording, "the new content") - - recording.refresh_from_db() - - expected_path = f"session_recordings_lts/team_id/{self.team.pk}/session_id/{recording.session_id}/data" - assert new_key == f"{expected_path}/12345000-12346000" - - assert recording.object_storage_path == expected_path - assert recording.storage_version == "2023-08-01" - - mock_write.assert_called_with( - f"{expected_path}/12345000-12346000", - gzip.compress(b"the new content"), - extras={ - "ContentEncoding": "gzip", - "ContentType": "application/json", - }, - ) diff --git a/ee/session_recordings/test/test_session_recording_playlist.py b/ee/session_recordings/test/test_session_recording_playlist.py deleted file mode 100644 index 0ec14e0decb..00000000000 --- a/ee/session_recordings/test/test_session_recording_playlist.py +++ /dev/null @@ -1,363 +0,0 @@ -from datetime import datetime, timedelta, UTC -from unittest import mock -from unittest.mock import MagicMock, patch -from uuid import uuid4 - -from boto3 import resource -from botocore.config import Config -from django.test import override_settings -from freezegun import freeze_time -from rest_framework import status - -from ee.api.test.base import APILicensedTest -from ee.api.test.fixtures.available_product_features import AVAILABLE_PRODUCT_FEATURES -from posthog.models import SessionRecording, SessionRecordingPlaylistItem -from posthog.models.user import User -from posthog.session_recordings.models.session_recording_playlist import ( - SessionRecordingPlaylist, -) -from posthog.session_recordings.queries.test.session_replay_sql import ( - produce_replay_summary, -) -from posthog.settings import ( - OBJECT_STORAGE_ACCESS_KEY_ID, - OBJECT_STORAGE_BUCKET, - OBJECT_STORAGE_ENDPOINT, - OBJECT_STORAGE_SECRET_ACCESS_KEY, -) - -TEST_BUCKET = "test_storage_bucket-ee.TestSessionRecordingPlaylist" - - -@override_settings( - OBJECT_STORAGE_SESSION_RECORDING_BLOB_INGESTION_FOLDER=TEST_BUCKET, - OBJECT_STORAGE_SESSION_RECORDING_LTS_FOLDER=f"{TEST_BUCKET}_lts", -) -class TestSessionRecordingPlaylist(APILicensedTest): - def teardown_method(self, method) -> None: - s3 = resource( - "s3", - endpoint_url=OBJECT_STORAGE_ENDPOINT, - aws_access_key_id=OBJECT_STORAGE_ACCESS_KEY_ID, - aws_secret_access_key=OBJECT_STORAGE_SECRET_ACCESS_KEY, - config=Config(signature_version="s3v4"), - region_name="us-east-1", - ) - bucket = s3.Bucket(OBJECT_STORAGE_BUCKET) - bucket.objects.filter(Prefix=TEST_BUCKET).delete() - - def test_list_playlists(self): - response = self.client.get(f"/api/projects/{self.team.id}/session_recording_playlists") - assert response.status_code == status.HTTP_200_OK - assert response.json() == { - "count": 0, - "next": None, - "previous": None, - "results": [], - } - - def test_creates_playlist(self): - response = self.client.post( - f"/api/projects/{self.team.id}/session_recording_playlists", - data={"name": "test"}, - ) - assert response.status_code == status.HTTP_201_CREATED - assert response.json() == { - "id": response.json()["id"], - "short_id": response.json()["short_id"], - "name": "test", - "derived_name": None, - "description": "", - "pinned": False, - "created_at": mock.ANY, - "created_by": response.json()["created_by"], - "deleted": False, - "filters": {}, - "last_modified_at": mock.ANY, - "last_modified_by": response.json()["last_modified_by"], - } - - def test_creates_too_many_playlists(self): - limit = 0 - self.organization.available_product_features = AVAILABLE_PRODUCT_FEATURES - self.organization.save() - for feature in AVAILABLE_PRODUCT_FEATURES: - if "key" in feature and feature["key"] == "recordings_playlists": - limit = int(feature["limit"]) - for _ in range(limit): - response = self.client.post( - f"/api/projects/{self.team.id}/session_recording_playlists", - data={"name": "test"}, - ) - assert response.status_code == status.HTTP_201_CREATED - response = self.client.post( - f"/api/projects/{self.team.id}/session_recording_playlists", - data={"name": "test"}, - ) - assert response.status_code == status.HTTP_403_FORBIDDEN - - def test_gets_individual_playlist_by_shortid(self): - create_response = self.client.post(f"/api/projects/{self.team.id}/session_recording_playlists") - response = self.client.get( - f"/api/projects/{self.team.id}/session_recording_playlists/{create_response.json()['short_id']}" - ) - - assert response.json()["short_id"] == create_response.json()["short_id"] - - def test_updates_playlist(self): - short_id = self.client.post(f"/api/projects/{self.team.id}/session_recording_playlists/").json()["short_id"] - - with freeze_time("2022-01-02"): - response = self.client.patch( - f"/api/projects/{self.team.id}/session_recording_playlists/{short_id}", - { - "name": "changed name", - "description": "changed description", - "filters": {"events": [{"id": "test"}]}, - "pinned": True, - }, - ) - - assert response.json()["short_id"] == short_id - assert response.json()["name"] == "changed name" - assert response.json()["description"] == "changed description" - assert response.json()["filters"] == {"events": [{"id": "test"}]} - assert response.json()["created_at"] == mock.ANY - assert response.json()["last_modified_at"] == "2022-01-02T00:00:00Z" - - def test_rejects_updates_to_readonly_playlist_properties(self): - short_id = self.client.post(f"/api/projects/{self.team.id}/session_recording_playlists/").json()["short_id"] - - response = self.client.patch( - f"/api/projects/{self.team.id}/session_recording_playlists/{short_id}", - {"short_id": "something else", "pinned": True}, - ) - - assert response.json()["short_id"] == short_id - assert response.json()["pinned"] - - def test_filters_based_on_params(self): - other_user = User.objects.create_and_join(self.organization, "other@posthog.com", "password") - playlist1 = SessionRecordingPlaylist.objects.create(team=self.team, name="playlist", created_by=self.user) - playlist2 = SessionRecordingPlaylist.objects.create(team=self.team, pinned=True, created_by=self.user) - playlist3 = SessionRecordingPlaylist.objects.create(team=self.team, name="my playlist", created_by=other_user) - - results = self.client.get( - f"/api/projects/{self.team.id}/session_recording_playlists?search=my", - ).json()["results"] - - assert len(results) == 1 - assert results[0]["short_id"] == playlist3.short_id - - results = self.client.get( - f"/api/projects/{self.team.id}/session_recording_playlists?search=playlist", - ).json()["results"] - - assert len(results) == 2 - assert results[0]["short_id"] == playlist3.short_id - assert results[1]["short_id"] == playlist1.short_id - - results = self.client.get( - f"/api/projects/{self.team.id}/session_recording_playlists?user=true", - ).json()["results"] - - assert len(results) == 2 - assert results[0]["short_id"] == playlist2.short_id - assert results[1]["short_id"] == playlist1.short_id - - results = self.client.get( - f"/api/projects/{self.team.id}/session_recording_playlists?pinned=true", - ).json()["results"] - - assert len(results) == 1 - assert results[0]["short_id"] == playlist2.short_id - - results = self.client.get( - f"/api/projects/{self.team.id}/session_recording_playlists?created_by={other_user.id}", - ).json()["results"] - - assert len(results) == 1 - assert results[0]["short_id"] == playlist3.short_id - - @patch("ee.session_recordings.session_recording_extensions.object_storage.copy_objects") - def test_get_pinned_recordings_for_playlist(self, mock_copy_objects: MagicMock) -> None: - mock_copy_objects.return_value = 2 - - playlist = SessionRecordingPlaylist.objects.create(team=self.team, name="playlist", created_by=self.user) - - session_one = f"test_fetch_playlist_recordings-session1-{uuid4()}" - session_two = f"test_fetch_playlist_recordings-session2-{uuid4()}" - three_days_ago = (datetime.now() - timedelta(days=3)).replace(tzinfo=UTC) - - produce_replay_summary( - team_id=self.team.id, - session_id=session_one, - distinct_id="123", - first_timestamp=three_days_ago, - last_timestamp=three_days_ago, - ) - - produce_replay_summary( - team_id=self.team.id, - session_id=session_two, - distinct_id="123", - first_timestamp=three_days_ago, - last_timestamp=three_days_ago, - ) - - # Create playlist items - self.client.post( - f"/api/projects/{self.team.id}/session_recording_playlists/{playlist.short_id}/recordings/{session_one}" - ) - self.client.post( - f"/api/projects/{self.team.id}/session_recording_playlists/{playlist.short_id}/recordings/{session_two}" - ) - self.client.post( - f"/api/projects/{self.team.id}/session_recording_playlists/{playlist.short_id}/recordings/session-missing" - ) - - # Test get recordings - result = self.client.get( - f"/api/projects/{self.team.id}/session_recording_playlists/{playlist.short_id}/recordings" - ).json() - assert len(result["results"]) == 2 - assert {x["id"] for x in result["results"]} == {session_one, session_two} - - @patch("ee.session_recordings.session_recording_extensions.object_storage.list_objects") - @patch("ee.session_recordings.session_recording_extensions.object_storage.copy_objects") - def test_fetch_playlist_recordings(self, mock_copy_objects: MagicMock, mock_list_objects: MagicMock) -> None: - # all sessions have been blob ingested and had data to copy into the LTS storage location - mock_copy_objects.return_value = 1 - - playlist1 = SessionRecordingPlaylist.objects.create( - team=self.team, - name="playlist1", - created_by=self.user, - ) - playlist2 = SessionRecordingPlaylist.objects.create( - team=self.team, - name="playlist2", - created_by=self.user, - ) - - session_one = f"test_fetch_playlist_recordings-session1-{uuid4()}" - session_two = f"test_fetch_playlist_recordings-session2-{uuid4()}" - three_days_ago = (datetime.now() - timedelta(days=3)).replace(tzinfo=UTC) - - for session_id in [session_one, session_two]: - produce_replay_summary( - team_id=self.team.id, - session_id=session_id, - distinct_id="123", - first_timestamp=three_days_ago, - last_timestamp=three_days_ago, - ) - - self.client.post( - f"/api/projects/{self.team.id}/session_recording_playlists/{playlist1.short_id}/recordings/{session_one}", - ) - self.client.post( - f"/api/projects/{self.team.id}/session_recording_playlists/{playlist1.short_id}/recordings/{session_two}", - ) - self.client.post( - f"/api/projects/{self.team.id}/session_recording_playlists/{playlist2.short_id}/recordings/{session_one}", - ) - - result = self.client.get( - f"/api/projects/{self.team.id}/session_recording_playlists/{playlist1.short_id}/recordings", - ).json() - - assert len(result["results"]) == 2 - assert result["results"][0]["id"] == session_one - assert result["results"][1]["id"] == session_two - - # Test get recordings - result = self.client.get( - f"/api/projects/{self.team.id}/session_recording_playlists/{playlist2.short_id}/recordings", - ).json() - - assert len(result["results"]) == 1 - assert result["results"][0]["id"] == session_one - - def test_add_remove_static_playlist_items(self): - playlist1 = SessionRecordingPlaylist.objects.create( - team=self.team, - name="playlist1", - created_by=self.user, - ) - playlist2 = SessionRecordingPlaylist.objects.create( - team=self.team, - name="playlist2", - created_by=self.user, - ) - - recording1_session_id = "1" - recording2_session_id = "2" - - # Add recording 1 to playlist 1 - result = self.client.post( - f"/api/projects/{self.team.id}/session_recording_playlists/{playlist1.short_id}/recordings/{recording1_session_id}", - ).json() - assert result["success"] - playlist_item = SessionRecordingPlaylistItem.objects.filter( - playlist_id=playlist1.id, session_id=recording1_session_id - ) - assert playlist_item is not None - - # Add recording 2 to playlist 1 - result = self.client.post( - f"/api/projects/{self.team.id}/session_recording_playlists/{playlist1.short_id}/recordings/{recording2_session_id}", - ).json() - assert result["success"] - playlist_item = SessionRecordingPlaylistItem.objects.filter( - playlist_id=playlist1.id, session_id=recording2_session_id - ) - assert playlist_item is not None - - # Add recording 2 to playlist 2 - result = self.client.post( - f"/api/projects/{self.team.id}/session_recording_playlists/{playlist2.short_id}/recordings/{recording2_session_id}", - ).json() - assert result["success"] - playlist_item = SessionRecordingPlaylistItem.objects.filter( - playlist_id=playlist2.id, session_id=recording2_session_id - ) - assert playlist_item is not None - - session_recording_obj_1 = SessionRecording.get_or_build(team=self.team, session_id=recording1_session_id) - assert session_recording_obj_1 - - session_recording_obj_2 = SessionRecording.get_or_build(team=self.team, session_id=recording2_session_id) - assert session_recording_obj_2 - - # Delete playlist items - result = self.client.delete( - f"/api/projects/{self.team.id}/session_recording_playlists/{playlist1.short_id}/recordings/{recording1_session_id}", - ).json() - assert result["success"] - assert ( - SessionRecordingPlaylistItem.objects.filter( - playlist_id=playlist1.id, session_id=recording1_session_id - ).count() - == 0 - ) - result = self.client.delete( - f"/api/projects/{self.team.id}/session_recording_playlists/{playlist1.short_id}/recordings/{recording2_session_id}", - ).json() - assert result["success"] - assert ( - SessionRecordingPlaylistItem.objects.filter( - playlist_id=playlist1.id, session_id=recording2_session_id - ).count() - == 0 - ) - result = self.client.delete( - f"/api/projects/{self.team.id}/session_recording_playlists/{playlist2.short_id}/recordings/{recording2_session_id}", - ).json() - assert result["success"] - assert ( - SessionRecordingPlaylistItem.objects.filter( - playlist_id=playlist2.id, session_id=recording1_session_id - ).count() - == 0 - ) diff --git a/ee/settings.py b/ee/settings.py deleted file mode 100644 index 9844074a956..00000000000 --- a/ee/settings.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Django settings for PostHog Enterprise Edition. -""" - -import os - -from posthog.settings import AUTHENTICATION_BACKENDS, DEMO, SITE_URL, DEBUG -from posthog.settings.utils import get_from_env -from posthog.utils import str_to_bool - - -# SSO -AUTHENTICATION_BACKENDS = [ - *AUTHENTICATION_BACKENDS, - "ee.api.authentication.MultitenantSAMLAuth", - "social_core.backends.google.GoogleOAuth2", -] - -# SAML base attributes -SOCIAL_AUTH_SAML_SP_ENTITY_ID = SITE_URL -SOCIAL_AUTH_SAML_SECURITY_CONFIG = { - "wantAttributeStatement": False, # AttributeStatement is optional in the specification - "requestedAuthnContext": False, # do not explicitly request a password login, also allow multifactor and others -} -# Attributes below are required for the SAML integration from social_core to work properly -SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = "" -SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = "" -SOCIAL_AUTH_SAML_ORG_INFO = {"en-US": {"name": "posthog", "displayname": "PostHog", "url": "https://posthog.com"}} -SOCIAL_AUTH_SAML_TECHNICAL_CONTACT = { - "givenName": "PostHog Support", - "emailAddress": "hey@posthog.com", -} -SOCIAL_AUTH_SAML_SUPPORT_CONTACT = SOCIAL_AUTH_SAML_TECHNICAL_CONTACT - - -# Google SSO -SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = os.getenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY") -SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.getenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET") -if "SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS" in os.environ: - SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: list[str] = os.environ[ - "SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS" - ].split(",") -elif DEMO: - # Only PostHog team members can use social auth in the demo environment - # This is because in the demo env social signups get is_staff=True to facilitate instance management - SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS = ["posthog.com"] - -# Schedule to run column materialization on. Follows crontab syntax. -# Use empty string to prevent from materializing -MATERIALIZE_COLUMNS_SCHEDULE_CRON = get_from_env("MATERIALIZE_COLUMNS_SCHEDULE_CRON", "0 5 * * SAT") -# Minimum query time before a query if considered for optimization by adding materialized columns -MATERIALIZE_COLUMNS_MINIMUM_QUERY_TIME = get_from_env("MATERIALIZE_COLUMNS_MINIMUM_QUERY_TIME", 40000, type_cast=int) -# How many hours backwards to look for queries to optimize -MATERIALIZE_COLUMNS_ANALYSIS_PERIOD_HOURS = get_from_env( - "MATERIALIZE_COLUMNS_ANALYSIS_PERIOD_HOURS", 7 * 24, type_cast=int -) -# How big of a timeframe to backfill when materializing event properties. 0 for no backfilling -MATERIALIZE_COLUMNS_BACKFILL_PERIOD_DAYS = get_from_env("MATERIALIZE_COLUMNS_BACKFILL_PERIOD_DAYS", 0, type_cast=int) -# Maximum number of columns to materialize at once. Avoids running into resource bottlenecks (storage + ingest + backfilling). -MATERIALIZE_COLUMNS_MAX_AT_ONCE = get_from_env("MATERIALIZE_COLUMNS_MAX_AT_ONCE", 100, type_cast=int) - -BILLING_SERVICE_URL = get_from_env("BILLING_SERVICE_URL", "https://billing.posthog.com") - -# Whether to enable the admin portal. Default false for self-hosted as if not setup properly can pose security issues. -ADMIN_PORTAL_ENABLED = get_from_env("ADMIN_PORTAL_ENABLED", DEMO or DEBUG, type_cast=str_to_bool) - -PARALLEL_ASSET_GENERATION_MAX_TIMEOUT_MINUTES = get_from_env( - "PARALLEL_ASSET_GENERATION_MAX_TIMEOUT_MINUTES", 10.0, type_cast=float -) - -HOOK_HOG_FUNCTION_TEAMS = get_from_env("HOOK_HOG_FUNCTION_TEAMS", "", type_cast=str) diff --git a/ee/tasks/__init__.py b/ee/tasks/__init__.py deleted file mode 100644 index cf44299a71f..00000000000 --- a/ee/tasks/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -from ee.session_recordings.persistence_tasks import ( - persist_finished_recordings, - persist_single_recording, -) -from .subscriptions import ( - deliver_subscription_report, - handle_subscription_value_change, - schedule_all_subscriptions, -) -from .replay import ( - embed_batch_of_recordings_task, - generate_recordings_embeddings_batch, - generate_replay_embedding_error_clusters, - cluster_replay_error_embeddings, -) - -# As our EE tasks are not included at startup for Celery, we need to ensure they are declared here so that they are imported by posthog/settings/celery.py - -__all__ = [ - "persist_single_recording", - "persist_finished_recordings", - "schedule_all_subscriptions", - "deliver_subscription_report", - "handle_subscription_value_change", - "embed_batch_of_recordings_task", - "generate_recordings_embeddings_batch", - "generate_replay_embedding_error_clusters", - "cluster_replay_error_embeddings", -] diff --git a/ee/tasks/auto_rollback_feature_flag.py b/ee/tasks/auto_rollback_feature_flag.py deleted file mode 100644 index f676f91d0c4..00000000000 --- a/ee/tasks/auto_rollback_feature_flag.py +++ /dev/null @@ -1,85 +0,0 @@ -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo - -from celery import shared_task - -from ee.api.sentry_stats import get_stats_for_timerange -from posthog.models.feature_flag import FeatureFlag -from posthog.models.filters.filter import Filter -from posthog.models.team import Team -from posthog.queries.trends.trends import Trends - - -def check_flags_to_rollback(): - flags_with_threshold = FeatureFlag.objects.exclude(rollback_conditions__isnull=True).exclude( - rollback_conditions__exact=[] - ) - - for feature_flag in flags_with_threshold: - check_feature_flag_rollback_conditions(feature_flag_id=feature_flag.pk) - - -@shared_task(ignore_result=True, max_retries=2) -def check_feature_flag_rollback_conditions(feature_flag_id: int) -> None: - flag: FeatureFlag = FeatureFlag.objects.get(pk=feature_flag_id) - - if any(check_condition(condition, flag) for condition in flag.rollback_conditions): - flag.performed_rollback = True - flag.active = False - flag.save() - - -def calculate_rolling_average(threshold_metric: dict, team: Team, timezone: str) -> float: - curr = datetime.now(tz=ZoneInfo(timezone)) - - rolling_average_days = 7 - - filter = Filter( - data={ - **threshold_metric, - "date_from": (curr - timedelta(days=rolling_average_days)).strftime("%Y-%m-%d %H:%M:%S.%f"), - "date_to": curr.strftime("%Y-%m-%d %H:%M:%S.%f"), - }, - team=team, - ) - trends_query = Trends() - result = trends_query.run(filter, team) - - if not len(result): - return False - - data = result[0]["data"] - - return sum(data) / rolling_average_days - - -def check_condition(rollback_condition: dict, feature_flag: FeatureFlag) -> bool: - if rollback_condition["threshold_type"] == "sentry": - created_date = feature_flag.created_at - base_start_date = created_date.strftime("%Y-%m-%dT%H:%M:%S") - base_end_date = (created_date + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S") - - current_time = datetime.utcnow() - target_end_date = current_time.strftime("%Y-%m-%dT%H:%M:%S") - target_start_date = (current_time - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S") - - base, target = get_stats_for_timerange(base_start_date, base_end_date, target_start_date, target_end_date) - - if rollback_condition["operator"] == "lt": - return target < float(rollback_condition["threshold"]) * base - else: - return target > float(rollback_condition["threshold"]) * base - - elif rollback_condition["threshold_type"] == "insight": - rolling_average = calculate_rolling_average( - rollback_condition["threshold_metric"], - feature_flag.team, - feature_flag.team.timezone, - ) - - if rollback_condition["operator"] == "lt": - return rolling_average < rollback_condition["threshold"] - else: - return rolling_average > rollback_condition["threshold"] - - return False diff --git a/ee/tasks/materialized_columns.py b/ee/tasks/materialized_columns.py deleted file mode 100644 index 0e33d0d7cf7..00000000000 --- a/ee/tasks/materialized_columns.py +++ /dev/null @@ -1,60 +0,0 @@ -from celery.utils.log import get_task_logger - -from ee.clickhouse.materialized_columns.columns import ( - TRIM_AND_EXTRACT_PROPERTY, - ColumnName, - get_materialized_columns, -) -from posthog.client import sync_execute -from posthog.settings import CLICKHOUSE_CLUSTER, CLICKHOUSE_DATABASE - -logger = get_task_logger(__name__) - - -def mark_all_materialized() -> None: - if any_ongoing_mutations(): - logger.info("There are running mutations, skipping marking as materialized") - return - - for ( - table, - property_name, - table_column, - column_name, - ) in get_materialized_columns_with_default_expression(): - updated_table = "sharded_events" if table == "events" else table - - # :TRICKY: On cloud, we ON CLUSTER updates to events/sharded_events but not to persons. Why? ¯\_(ツ)_/¯ - execute_on_cluster = f"ON CLUSTER '{CLICKHOUSE_CLUSTER}'" if table == "events" else "" - - sync_execute( - f""" - ALTER TABLE {updated_table} - {execute_on_cluster} - MODIFY COLUMN - {column_name} VARCHAR MATERIALIZED {TRIM_AND_EXTRACT_PROPERTY.format(table_column=table_column)} - """, - {"property": property_name}, - ) - - -def get_materialized_columns_with_default_expression(): - for table in ["events", "person"]: - materialized_columns = get_materialized_columns(table, use_cache=False) - for (property_name, table_column), column_name in materialized_columns.items(): - if is_default_expression(table, column_name): - yield table, property_name, table_column, column_name - - -def any_ongoing_mutations() -> bool: - running_mutations_count = sync_execute("SELECT count(*) FROM system.mutations WHERE is_done = 0")[0][0] - return running_mutations_count > 0 - - -def is_default_expression(table: str, column_name: ColumnName) -> bool: - updated_table = "sharded_events" if table == "events" else table - column_query = sync_execute( - "SELECT default_kind FROM system.columns WHERE table = %(table)s AND name = %(name)s AND database = %(database)s", - {"table": updated_table, "name": column_name, "database": CLICKHOUSE_DATABASE}, - ) - return len(column_query) > 0 and column_query[0][0] == "DEFAULT" diff --git a/ee/tasks/replay.py b/ee/tasks/replay.py deleted file mode 100644 index fcf57196c2d..00000000000 --- a/ee/tasks/replay.py +++ /dev/null @@ -1,99 +0,0 @@ -from typing import Any - -import structlog -from celery import shared_task - -from ee.session_recordings.ai.embeddings_queries import ( - fetch_errors_by_session_without_embeddings, - fetch_recordings_without_embeddings, -) -from ee.session_recordings.ai.embeddings_runner import ( - SessionEmbeddingsRunner, - ErrorEmbeddingsPreparation, - SessionEventsEmbeddingsPreparation, -) -from ee.session_recordings.ai.error_clustering import error_clustering -from posthog import settings -from posthog.models import Team -from posthog.tasks.utils import CeleryQueue -from django.core.cache import cache - -logger = structlog.get_logger(__name__) - - -# rate limits are per worker, and this task makes multiple calls to open AI -# we currently are allowed 500 calls per minute, so let's rate limit each worker -# to much less than that -@shared_task(ignore_result=False, queue=CeleryQueue.SESSION_REPLAY_EMBEDDINGS.value, rate_limit="75/m") -def embed_batch_of_recordings_task(recordings: list[Any], team_id: int) -> None: - try: - team = Team.objects.get(id=team_id) - runner = SessionEmbeddingsRunner(team=team) - - runner.run(recordings, embeddings_preparation=SessionEventsEmbeddingsPreparation) - - results = fetch_errors_by_session_without_embeddings(team.pk) - runner.run(results, embeddings_preparation=ErrorEmbeddingsPreparation) - except Team.DoesNotExist: - logger.info(f"[embed_batch_of_recordings_task] Team {team} does not exist. Skipping.") - pass - - -@shared_task(ignore_result=True) -def generate_recordings_embeddings_batch() -> None: - # see https://docs.celeryq.dev/en/stable/userguide/canvas.html - # we have three jobs to do here - # 1. get a batch of recordings - # 2. for each recording - ideally in parallel - generate an embedding - # 3. update CH with the embeddings in one update operation - # in Celery that's a chain of tasks - # with step 2 being a group of tasks - # chord( - # embed_single_recording.si(recording.session_id, recording.team_id) - # for recording in fetch_recordings_without_embeddings(int(team)) - # )(generate_recordings_embeddings_batch_on_complete.si()) - # but even the docs call out performance impact of synchronising tasks - # - # so, for now, we'll do that naively - - for team_id in settings.REPLAY_EMBEDDINGS_ALLOWED_TEAMS: - try: - recordings = fetch_recordings_without_embeddings(int(team_id)) - logger.info( - f"[generate_recordings_embeddings_batch] Fetched {len(recordings)} recordings", - recordings=recordings, - flow="embeddings", - team_id=team_id, - ) - embed_batch_of_recordings_task.si(recordings, int(team_id)).apply_async() - except Exception as e: - logger.error(f"[generate_recordings_embeddings_batch] Error: {e}.", exc_info=True, error=e) - pass - - -@shared_task(ignore_result=True) -def generate_replay_embedding_error_clusters() -> None: - for team_id in settings.REPLAY_EMBEDDINGS_ALLOWED_TEAMS: - try: - cluster_replay_error_embeddings.si(int(team_id)).apply_async() - except Exception as e: - logger.error(f"[generate_replay_error_clusters] Error: {e}.", exc_info=True, error=e) - pass - - -@shared_task(ignore_result=True, queue=CeleryQueue.SESSION_REPLAY_EMBEDDINGS.value) -def cluster_replay_error_embeddings(team_id: int) -> None: - try: - team = Team.objects.get(id=team_id) - clusters = error_clustering(team) - - cache.set(f"cluster_errors_{team.pk}", clusters, settings.CACHED_RESULTS_TTL) - - logger.info( - f"[generate_replay_error_clusters] Completed for team", - flow="embeddings", - team_id=team_id, - ) - except Team.DoesNotExist: - logger.info(f"[generate_replay_error_clusters] Team {team} does not exist. Skipping.") - pass diff --git a/ee/tasks/send_license_usage.py b/ee/tasks/send_license_usage.py deleted file mode 100644 index 72ad3f171ca..00000000000 --- a/ee/tasks/send_license_usage.py +++ /dev/null @@ -1,103 +0,0 @@ -import posthoganalytics -import requests -from dateutil.relativedelta import relativedelta -from django.utils import timezone -from django.utils.timezone import now - -from ee.models.license import License -from posthog.client import sync_execute -from posthog.models import User -from posthog.settings import SITE_URL - - -def send_license_usage(): - license = License.objects.first_valid() - user = User.objects.filter(is_active=True).first() - - if not license: - return - - # New type of license key for billing - if license.is_v2_license: - return - - try: - date_from = (timezone.now() - relativedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) - date_to = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) - - events_count = sync_execute( - "select count(1) from events where timestamp >= %(date_from)s and timestamp < %(date_to)s and not startsWith(event, '$$')", - {"date_from": date_from, "date_to": date_to}, - )[0][0] - response = requests.post( - "https://license.posthog.com/licenses/usage", - data={ - "date": date_from.strftime("%Y-%m-%d"), - "key": license.key, - "events_count": events_count, - }, - ) - - if response.status_code == 404 and response.json().get("code") == "not_found": - license.valid_until = now() - relativedelta(hours=1) - license.save() - - if response.status_code == 400 and response.json().get("code") == "already_sent": - return - - if response.json().get("valid_until"): - license.valid_until = response.json()["valid_until"] - license.save() - - if not response.ok: - posthoganalytics.capture( - user.distinct_id, # type: ignore - "send license usage data error", - { - "error": response.content, - "status_code": response.status_code, - "date": date_from.strftime("%Y-%m-%d"), - "events_count": events_count, - "organization_name": user.current_organization.name, # type: ignore - }, - groups={ - "organization": str(user.current_organization.id), # type: ignore - "instance": SITE_URL, - }, - ) - response.raise_for_status() - return - else: - posthoganalytics.capture( - user.distinct_id, # type: ignore - "send license usage data", - { - "date": date_from.strftime("%Y-%m-%d"), - "events_count": events_count, - "license_keys": [license.key for license in License.objects.all()], - "organization_name": user.current_organization.name, # type: ignore - }, - groups={ - "organization": str(user.current_organization.id), # type: ignore - "instance": SITE_URL, - }, - ) - except Exception as err: - try: - posthoganalytics.capture( - user.distinct_id, # type: ignore - "send license usage data error", - { - "error": str(err), - "date": date_from.strftime("%Y-%m-%d"), - "organization_name": user.current_organization.name, # type: ignore - }, - groups={ - "organization": str(user.current_organization.id), # type: ignore - "instance": SITE_URL, - }, - ) - raise err - except: - # If the posthoganalytics call errors, just throw the original error rather than that error - raise err diff --git a/ee/tasks/slack.py b/ee/tasks/slack.py deleted file mode 100644 index c3e2a41422b..00000000000 --- a/ee/tasks/slack.py +++ /dev/null @@ -1,103 +0,0 @@ -import re -from typing import Any -from urllib.parse import urlparse - -import structlog -from django.conf import settings - -from ee.tasks.subscriptions.subscription_utils import generate_assets -from posthog.models.exported_asset import ExportedAsset -from posthog.models.integration import Integration, SlackIntegration -from posthog.models.sharing_configuration import SharingConfiguration - -logger = structlog.get_logger(__name__) - - -SHARED_LINK_REGEX = r"\/(?:shared_dashboard|shared|embedded)\/(.+)" - - -def _block_for_asset(asset: ExportedAsset) -> dict: - image_url = asset.get_public_content_url() - alt_text = None - if asset.insight: - alt_text = asset.insight.name or asset.insight.derived_name - - if settings.DEBUG: - image_url = "https://source.unsplash.com/random" - - return {"type": "image", "image_url": image_url, "alt_text": alt_text} - - -def _handle_slack_event(event_payload: Any) -> None: - slack_team_id = event_payload.get("team_id") - channel = event_payload.get("event").get("channel") - message_ts = event_payload.get("event").get("message_ts") - unfurl_id = event_payload.get("event").get("unfurl_id") - source = event_payload.get("event").get("source") - links_to_unfurl = event_payload.get("event").get("links") - - unfurls = {} - - for link_obj in links_to_unfurl: - link = link_obj.get("url") - parsed = urlparse(link) - matches = re.search(SHARED_LINK_REGEX, parsed.path) - - if matches: - share_token = matches[1] - - # First we try and get the sharingconfig for the given link - try: - sharing_config: SharingConfiguration = SharingConfiguration.objects.get( - access_token=share_token, enabled=True - ) - except SharingConfiguration.DoesNotExist: - logger.info("No SharingConfiguration found") - continue - - team_id = sharing_config.team_id - - # Now we try and get the SlackIntegration for the specificed PostHog team and Slack Team - try: - integration = Integration.objects.get(kind="slack", team=team_id, config__team__id=slack_team_id) - slack_integration = SlackIntegration(integration) - - except Integration.DoesNotExist: - logger.info("No SlackIntegration found for this team") - continue - - # With both the integration and the resource we are good to go!! - - insights, assets = generate_assets(sharing_config, 1) - - if assets: - unfurls[link] = { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": insights[0].name or insights[0].derived_name, - }, - "accessory": _block_for_asset(assets[0]), - } - ] - } - - if unfurls: - try: - slack_integration.client.chat_unfurl(unfurls=unfurls, unfurl_id=unfurl_id, source=source, channel="", ts="") - except Exception: - # NOTE: This is temporary as a test to understand if the channel and ts are actually required as the docs are not clear - slack_integration.client.chat_unfurl( - unfurls=unfurls, - unfurl_id=unfurl_id, - source=source, - channel=channel, - ts=message_ts, - ) - raise - - -def handle_slack_event(payload: Any) -> None: - return _handle_slack_event(payload) diff --git a/ee/tasks/subscriptions/__init__.py b/ee/tasks/subscriptions/__init__.py deleted file mode 100644 index 7ca0e06e6d5..00000000000 --- a/ee/tasks/subscriptions/__init__.py +++ /dev/null @@ -1,169 +0,0 @@ -from datetime import datetime, timedelta -from typing import Optional - -import structlog -from celery import shared_task -from prometheus_client import Counter -from sentry_sdk import capture_exception, capture_message - -from ee.tasks.subscriptions.email_subscriptions import send_email_subscription_report -from ee.tasks.subscriptions.slack_subscriptions import send_slack_subscription_report -from ee.tasks.subscriptions.subscription_utils import generate_assets -from posthog import settings -from posthog.models.subscription import Subscription -from posthog.tasks.utils import CeleryQueue - -logger = structlog.get_logger(__name__) - -SUBSCRIPTION_QUEUED = Counter( - "subscription_queued", - "A subscription was queued for delivery", - labelnames=["destination"], -) -SUBSCRIPTION_SUCCESS = Counter( - "subscription_send_success", - "A subscription was sent successfully", - labelnames=["destination"], -) -SUBSCRIPTION_FAILURE = Counter( - "subscription_send_failure", - "A subscription failed to send", - labelnames=["destination"], -) - - -def _deliver_subscription_report( - subscription_id: int, - previous_value: Optional[str] = None, - invite_message: Optional[str] = None, -) -> None: - subscription = ( - Subscription.objects.prefetch_related("dashboard__insights") - .select_related("created_by", "insight", "dashboard") - .get(pk=subscription_id) - ) - - is_new_subscription_target = False - if previous_value is not None: - # If previous_value is set we are triggering a "new" or "invite" message - is_new_subscription_target = subscription.target_value != previous_value - - if not is_new_subscription_target: - # Same value as before so nothing to do - return - - insights, assets = generate_assets(subscription) - - if not assets: - capture_message( - "No assets are in this subscription", - tags={"subscription_id": subscription.id}, - ) - return - - if subscription.target_type == "email": - SUBSCRIPTION_QUEUED.labels(destination="email").inc() - - # Send emails - emails = subscription.target_value.split(",") - if is_new_subscription_target: - previous_emails = previous_value.split(",") if previous_value else [] - emails = list(set(emails) - set(previous_emails)) - - for email in emails: - try: - send_email_subscription_report( - email, - subscription, - assets, - invite_message=invite_message or "" if is_new_subscription_target else None, - total_asset_count=len(insights), - ) - except Exception as e: - SUBSCRIPTION_FAILURE.labels(destination="email").inc() - logger.error( - "sending subscription failed", - subscription_id=subscription.id, - next_delivery_date=subscription.next_delivery_date, - destination=subscription.target_type, - exc_info=True, - ) - capture_exception(e) - - SUBSCRIPTION_SUCCESS.labels(destination="email").inc() - - elif subscription.target_type == "slack": - SUBSCRIPTION_QUEUED.labels(destination="slack").inc() - - try: - send_slack_subscription_report( - subscription, - assets, - total_asset_count=len(insights), - is_new_subscription=is_new_subscription_target, - ) - SUBSCRIPTION_SUCCESS.labels(destination="slack").inc() - except Exception as e: - SUBSCRIPTION_FAILURE.labels(destination="slack").inc() - logger.error( - "sending subscription failed", - subscription_id=subscription.id, - next_delivery_date=subscription.next_delivery_date, - destination=subscription.target_type, - exc_info=True, - ) - capture_exception(e) - else: - raise NotImplementedError(f"{subscription.target_type} is not supported") - - if not is_new_subscription_target: - subscription.set_next_delivery_date(subscription.next_delivery_date) - subscription.save(update_fields=["next_delivery_date"]) - - -@shared_task(queue=CeleryQueue.SUBSCRIPTION_DELIVERY.value) -def schedule_all_subscriptions() -> None: - """ - Schedule all past notifications (with a buffer) to be delivered - NOTE: This task is scheduled hourly just before the hour allowing for the 15 minute timedelta to cover - all upcoming hourly scheduled subscriptions - """ - now_with_buffer = datetime.utcnow() + timedelta(minutes=15) - subscriptions = ( - Subscription.objects.filter(next_delivery_date__lte=now_with_buffer, deleted=False) - .exclude(dashboard__deleted=True) - .exclude(insight__deleted=True) - .all() - ) - - for subscription in subscriptions: - logger.info( - "Scheduling subscription", - subscription_id=subscription.id, - next_delivery_date=subscription.next_delivery_date, - destination=subscription.target_type, - ) - deliver_subscription_report.delay(subscription.id) - - -report_timeout_seconds = settings.PARALLEL_ASSET_GENERATION_MAX_TIMEOUT_MINUTES * 60 * 1.5 - - -@shared_task( - soft_time_limit=report_timeout_seconds, - time_limit=report_timeout_seconds + 10, - queue=CeleryQueue.SUBSCRIPTION_DELIVERY.value, -) -def deliver_subscription_report(subscription_id: int) -> None: - return _deliver_subscription_report(subscription_id) - - -@shared_task( - soft_time_limit=report_timeout_seconds, - time_limit=report_timeout_seconds + 10, - queue=CeleryQueue.SUBSCRIPTION_DELIVERY.value, -) -def handle_subscription_value_change( - subscription_id: int, previous_value: str, invite_message: Optional[str] = None -) -> None: - return _deliver_subscription_report(subscription_id, previous_value, invite_message) diff --git a/ee/tasks/subscriptions/email_subscriptions.py b/ee/tasks/subscriptions/email_subscriptions.py deleted file mode 100644 index 39e342bcec1..00000000000 --- a/ee/tasks/subscriptions/email_subscriptions.py +++ /dev/null @@ -1,67 +0,0 @@ -import uuid -from typing import Optional - -import structlog - -from ee.tasks.subscriptions.subscription_utils import UTM_TAGS_BASE -from posthog.email import EmailMessage -from posthog.models.exported_asset import ExportedAsset -from posthog.models.subscription import Subscription, get_unsubscribe_token -from posthog.utils import absolute_uri - -logger = structlog.get_logger(__name__) - - -def send_email_subscription_report( - email: str, - subscription: Subscription, - assets: list[ExportedAsset], - invite_message: Optional[str] = None, - total_asset_count: Optional[int] = None, -) -> None: - utm_tags = f"{UTM_TAGS_BASE}&utm_medium=email" - - inviter = subscription.created_by - is_invite = invite_message is not None - self_invite = inviter.email == email - - subject = "PostHog Report" - invite_summary = None - - resource_info = subscription.resource_info - if not resource_info: - raise NotImplementedError("This type of subscription resource is not supported") - - subject = f"PostHog {resource_info.kind} report - {resource_info.name}" - campaign_key = f"{resource_info.kind.lower()}_subscription_report_{subscription.next_delivery_date.isoformat()}" - - unsubscribe_url = absolute_uri(f"/unsubscribe?token={get_unsubscribe_token(subscription, email)}&{utm_tags}") - - if is_invite: - invite_summary = f"This subscription is { subscription.summary }. The next subscription will be sent on { subscription.next_delivery_date.strftime('%A %B %d, %Y')}" - if self_invite: - subject = f"You have been subscribed to a PostHog {resource_info.kind}" - else: - subject = f"{inviter.first_name or 'Someone'} subscribed you to a PostHog {resource_info.kind}" - campaign_key = f"{resource_info.kind.lower()}_subscription_new_{uuid.uuid4()}" - - message = EmailMessage( - campaign_key=campaign_key, - subject=subject, - template_name="subscription_report", - template_context={ - "images": [x.get_public_content_url() for x in assets], - "resource_noun": resource_info.kind, - "resource_name": resource_info.name, - "resource_url": f"{resource_info.url}?{utm_tags}", - "subscription_url": f"{subscription.url}?{utm_tags}", - "unsubscribe_url": unsubscribe_url, - "inviter": inviter if is_invite else None, - "self_invite": self_invite, - "invite_message": invite_message, - "invite_summary": invite_summary, - "total_asset_count": total_asset_count, - }, - ) - message.add_recipient(email=email) - message.send() diff --git a/ee/tasks/subscriptions/slack_subscriptions.py b/ee/tasks/subscriptions/slack_subscriptions.py deleted file mode 100644 index 73643c7a97b..00000000000 --- a/ee/tasks/subscriptions/slack_subscriptions.py +++ /dev/null @@ -1,117 +0,0 @@ -import structlog -from django.conf import settings - -from posthog.models.exported_asset import ExportedAsset -from posthog.models.integration import Integration, SlackIntegration -from posthog.models.subscription import Subscription - -logger = structlog.get_logger(__name__) - -UTM_TAGS_BASE = "utm_source=posthog&utm_campaign=subscription_report" - - -def _block_for_asset(asset: ExportedAsset) -> dict: - image_url = asset.get_public_content_url() - alt_text = None - if asset.insight: - alt_text = asset.insight.name or asset.insight.derived_name - - if settings.DEBUG: - image_url = "https://source.unsplash.com/random" - - return {"type": "image", "image_url": image_url, "alt_text": alt_text} - - -def send_slack_subscription_report( - subscription: Subscription, - assets: list[ExportedAsset], - total_asset_count: int, - is_new_subscription: bool = False, -) -> None: - utm_tags = f"{UTM_TAGS_BASE}&utm_medium=slack" - - resource_info = subscription.resource_info - if not resource_info: - raise NotImplementedError("This type of subscription resource is not supported") - - integration = Integration.objects.filter(team=subscription.team, kind="slack").first() - - if not integration: - # TODO: Write error to subscription... - logger.error("No Slack integration found for team...") - return - - slack_integration = SlackIntegration(integration) - - channel = subscription.target_value.split("|")[0] - - first_asset, *other_assets = assets - - if is_new_subscription: - title = f"This channel has been subscribed to the {resource_info.kind} *{resource_info.name}* on PostHog! 🎉" - title += f"\nThis subscription is {subscription.summary}. The next one will be sent on {subscription.next_delivery_date.strftime('%A %B %d, %Y')}" - else: - title = f"Your subscription to the {resource_info.kind} *{resource_info.name}* is ready! 🎉" - - blocks = [] - - blocks.extend( - [ - {"type": "section", "text": {"type": "mrkdwn", "text": title}}, - _block_for_asset(first_asset), - ] - ) - - if other_assets: - blocks.append( - { - "type": "section", - "text": {"type": "mrkdwn", "text": "_See 🧵 for more Insights_"}, - } - ) - - blocks.extend( - [ - {"type": "divider"}, - { - "type": "actions", - "elements": [ - { - "type": "button", - "text": {"type": "plain_text", "text": "View in PostHog"}, - "url": f"{resource_info.url}?{utm_tags}", - }, - { - "type": "button", - "text": {"type": "plain_text", "text": "Manage Subscription"}, - "url": f"{subscription.url}?{utm_tags}", - }, - ], - }, - ] - ) - - message_res = slack_integration.client.chat_postMessage(channel=channel, blocks=blocks, text=title) - - thread_ts = message_res.get("ts") - - if thread_ts: - for asset in other_assets: - slack_integration.client.chat_postMessage( - channel=channel, thread_ts=thread_ts, blocks=[_block_for_asset(asset)] - ) - - if total_asset_count > len(assets): - slack_integration.client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - blocks=[ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"Showing {len(assets)} of {total_asset_count} Insights. <{resource_info.url}?{utm_tags}|View the rest in PostHog>", - }, - } - ], - ) diff --git a/ee/tasks/subscriptions/subscription_utils.py b/ee/tasks/subscriptions/subscription_utils.py deleted file mode 100644 index eb8afed13cb..00000000000 --- a/ee/tasks/subscriptions/subscription_utils.py +++ /dev/null @@ -1,69 +0,0 @@ -import datetime -from typing import Union -from django.conf import settings -import structlog -from celery import chain -from prometheus_client import Histogram - -from posthog.models.dashboard_tile import get_tiles_ordered_by_position -from posthog.models.exported_asset import ExportedAsset -from posthog.models.insight import Insight -from posthog.models.sharing_configuration import SharingConfiguration -from posthog.models.subscription import Subscription -from posthog.tasks import exporter -from posthog.utils import wait_for_parallel_celery_group - -logger = structlog.get_logger(__name__) - -UTM_TAGS_BASE = "utm_source=posthog&utm_campaign=subscription_report" -DEFAULT_MAX_ASSET_COUNT = 6 - -SUBSCRIPTION_ASSET_GENERATION_TIMER = Histogram( - "subscription_asset_generation_duration_seconds", - "Time spent generating assets for a subscription", - buckets=(1, 5, 10, 30, 60, 120, 240, 300, 360, 420, 480, 540, 600, float("inf")), -) - - -def generate_assets( - resource: Union[Subscription, SharingConfiguration], - max_asset_count: int = DEFAULT_MAX_ASSET_COUNT, -) -> tuple[list[Insight], list[ExportedAsset]]: - with SUBSCRIPTION_ASSET_GENERATION_TIMER.time(): - if resource.dashboard: - tiles = get_tiles_ordered_by_position(resource.dashboard) - insights = [tile.insight for tile in tiles if tile.insight] - elif resource.insight: - insights = [resource.insight] - else: - raise Exception("There are no insights to be sent for this Subscription") - - # Create all the assets we need - assets = [ - ExportedAsset( - team=resource.team, - export_format="image/png", - insight=insight, - dashboard=resource.dashboard, - ) - for insight in insights[:max_asset_count] - ] - ExportedAsset.objects.bulk_create(assets) - - if not assets: - return insights, assets - - # Wait for all assets to be exported - tasks = [exporter.export_asset.si(asset.id) for asset in assets] - # run them one after the other, so we don't exhaust celery workers - exports_expire = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta( - minutes=settings.PARALLEL_ASSET_GENERATION_MAX_TIMEOUT_MINUTES - ) - parallel_job = chain(*tasks).apply_async(expires=exports_expire, retry=False) - - wait_for_parallel_celery_group( - parallel_job, - expires=exports_expire, - ) - - return insights, assets diff --git a/ee/tasks/test/__init__.py b/ee/tasks/test/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/tasks/test/subscriptions/__init__.py b/ee/tasks/test/subscriptions/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ee/tasks/test/subscriptions/subscriptions_test_factory.py b/ee/tasks/test/subscriptions/subscriptions_test_factory.py deleted file mode 100644 index cbe268b76e9..00000000000 --- a/ee/tasks/test/subscriptions/subscriptions_test_factory.py +++ /dev/null @@ -1,19 +0,0 @@ -from datetime import datetime -from typing import Any - -from zoneinfo import ZoneInfo - -from posthog.models.subscription import Subscription - - -def create_subscription(**kwargs: Any) -> Subscription: - payload = { - "target_type": "email", - "target_value": "test1@posthog.com,test2@posthog.com", - "frequency": "daily", - "interval": 1, - "start_date": datetime(2022, 1, 1, 9, 0).replace(tzinfo=ZoneInfo("UTC")), - } - - payload.update(kwargs) - return Subscription.objects.create(**payload) diff --git a/ee/tasks/test/subscriptions/test_email_subscriptions.py b/ee/tasks/test/subscriptions/test_email_subscriptions.py deleted file mode 100644 index dbb6bca1161..00000000000 --- a/ee/tasks/test/subscriptions/test_email_subscriptions.py +++ /dev/null @@ -1,98 +0,0 @@ -from unittest.mock import MagicMock, patch - -from freezegun import freeze_time - -from ee.tasks.subscriptions.email_subscriptions import send_email_subscription_report -from ee.tasks.test.subscriptions.subscriptions_test_factory import create_subscription -from posthog.models.dashboard import Dashboard -from posthog.models.exported_asset import ExportedAsset -from posthog.models.insight import Insight -from posthog.models.instance_setting import set_instance_setting -from posthog.models.subscription import Subscription -from posthog.tasks.test.utils_email_tests import mock_email_messages -from posthog.test.base import APIBaseTest - - -def mock_ee_email_messages(MockEmailMessage: MagicMock): - return mock_email_messages(MockEmailMessage, path="ee/tasks/test/__emails__/") - - -@patch("ee.tasks.subscriptions.email_subscriptions.EmailMessage") -@freeze_time("2022-02-02T08:55:00.000Z") -class TestEmailSubscriptionsTasks(APIBaseTest): - subscription: Subscription - dashboard: Dashboard - insight: Insight - asset: ExportedAsset - - def setUp(self) -> None: - self.dashboard = Dashboard.objects.create(team=self.team, name="private dashboard", created_by=self.user) - self.insight = Insight.objects.create(team=self.team, short_id="123456", name="My Test subscription") - - set_instance_setting("EMAIL_HOST", "fake_host") - set_instance_setting("EMAIL_ENABLED", True) - - self.asset = ExportedAsset.objects.create(team=self.team, insight_id=self.insight.id, export_format="image/png") - self.subscription = create_subscription(team=self.team, insight=self.insight, created_by=self.user) - - def test_subscription_delivery(self, MockEmailMessage: MagicMock) -> None: - mocked_email_messages = mock_ee_email_messages(MockEmailMessage) - - send_email_subscription_report("test1@posthog.com", self.subscription, [self.asset]) - - assert len(mocked_email_messages) == 1 - assert mocked_email_messages[0].send.call_count == 1 - assert "is ready!" in mocked_email_messages[0].html_body - assert f"/exporter/export-my-test-subscription.png?token=ey" in mocked_email_messages[0].html_body - - def test_new_subscription_delivery(self, MockEmailMessage: MagicMock) -> None: - mocked_email_messages = mock_ee_email_messages(MockEmailMessage) - - send_email_subscription_report( - "test1@posthog.com", - self.subscription, - [self.asset], - invite_message="My invite message", - ) - - assert len(mocked_email_messages) == 1 - assert mocked_email_messages[0].send.call_count == 1 - - assert f"has subscribed you" in mocked_email_messages[0].html_body - assert "Someone subscribed you to a PostHog Insight" == mocked_email_messages[0].subject - assert "This subscription is sent every day. The next subscription will be sent on Wednesday February 02, 2022" - assert "My invite message" in mocked_email_messages[0].html_body - - def test_should_have_different_text_for_self(self, MockEmailMessage: MagicMock) -> None: - mocked_email_messages = mock_ee_email_messages(MockEmailMessage) - - send_email_subscription_report( - self.user.email, - self.subscription, - [self.asset], - invite_message="My invite message", - ) - - assert len(mocked_email_messages) == 1 - assert mocked_email_messages[0].send.call_count == 1 - assert "You have been subscribed" in mocked_email_messages[0].html_body - assert "You have been subscribed to a PostHog Insight" == mocked_email_messages[0].subject - - def test_sends_dashboard_subscription(self, MockEmailMessage: MagicMock) -> None: - mocked_email_messages = mock_ee_email_messages(MockEmailMessage) - - subscription = create_subscription(team=self.team, dashboard=self.dashboard, created_by=self.user) - - send_email_subscription_report( - self.user.email, - subscription, - [self.asset], - invite_message="My invite message", - total_asset_count=10, - ) - - assert len(mocked_email_messages) == 1 - assert mocked_email_messages[0].send.call_count == 1 - assert "You have been subscribed" in mocked_email_messages[0].html_body - assert "You have been subscribed to a PostHog Dashboard" == mocked_email_messages[0].subject - assert f"SHOWING 1 OF 10 DASHBOARD INSIGHTS" in mocked_email_messages[0].html_body diff --git a/ee/tasks/test/subscriptions/test_slack_subscriptions.py b/ee/tasks/test/subscriptions/test_slack_subscriptions.py deleted file mode 100644 index 758f74cd3b2..00000000000 --- a/ee/tasks/test/subscriptions/test_slack_subscriptions.py +++ /dev/null @@ -1,199 +0,0 @@ -from unittest.mock import MagicMock, patch - -from freezegun import freeze_time - -from ee.tasks.subscriptions.slack_subscriptions import send_slack_subscription_report -from ee.tasks.test.subscriptions.subscriptions_test_factory import create_subscription -from posthog.models.dashboard import Dashboard -from posthog.models.exported_asset import ExportedAsset -from posthog.models.insight import Insight -from posthog.models.integration import Integration -from posthog.models.subscription import Subscription -from posthog.test.base import APIBaseTest - - -@patch("ee.tasks.subscriptions.slack_subscriptions.SlackIntegration") -@freeze_time("2022-02-02T08:30:00.000Z") -class TestSlackSubscriptionsTasks(APIBaseTest): - subscription: Subscription - dashboard: Dashboard - insight: Insight - asset: ExportedAsset - integration: Integration - - def setUp(self) -> None: - self.dashboard = Dashboard.objects.create(team=self.team, name="private dashboard", created_by=self.user) - self.insight = Insight.objects.create(team=self.team, short_id="123456", name="My Test subscription") - self.asset = ExportedAsset.objects.create(team=self.team, insight_id=self.insight.id, export_format="image/png") - self.subscription = create_subscription( - team=self.team, - insight=self.insight, - created_by=self.user, - target_type="slack", - target_value="C12345|#test-channel", - ) - - self.integration = Integration.objects.create(team=self.team, kind="slack") - - def test_subscription_delivery(self, MockSlackIntegration: MagicMock) -> None: - mock_slack_integration = MagicMock() - MockSlackIntegration.return_value = mock_slack_integration - mock_slack_integration.client.chat_postMessage.return_value = {"ts": "1.234"} - - send_slack_subscription_report(self.subscription, [self.asset], 1) - - assert mock_slack_integration.client.chat_postMessage.call_count == 1 - post_message_calls = mock_slack_integration.client.chat_postMessage.call_args_list - first_call = post_message_calls[0].kwargs - - assert first_call["channel"] == "C12345" - assert first_call["text"] == "Your subscription to the Insight *My Test subscription* is ready! 🎉" - assert first_call["blocks"] == [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "Your subscription to the Insight *My Test subscription* is ready! 🎉", - }, - }, - { - "type": "image", - "image_url": post_message_calls[0].kwargs["blocks"][1]["image_url"], - "alt_text": "My Test subscription", - }, - {"type": "divider"}, - { - "type": "actions", - "elements": [ - { - "type": "button", - "text": {"type": "plain_text", "text": "View in PostHog"}, - "url": "http://localhost:8000/insights/123456?utm_source=posthog&utm_campaign=subscription_report&utm_medium=slack", - }, - { - "type": "button", - "text": {"type": "plain_text", "text": "Manage Subscription"}, - "url": f"http://localhost:8000/insights/123456/subscriptions/{self.subscription.id}?utm_source=posthog&utm_campaign=subscription_report&utm_medium=slack", - }, - ], - }, - ] - - def test_subscription_delivery_new(self, MockSlackIntegration: MagicMock) -> None: - mock_slack_integration = MagicMock() - MockSlackIntegration.return_value = mock_slack_integration - mock_slack_integration.client.chat_postMessage.return_value = {"ts": "1.234"} - - send_slack_subscription_report(self.subscription, [self.asset], 1, is_new_subscription=True) - - assert mock_slack_integration.client.chat_postMessage.call_count == 1 - post_message_calls = mock_slack_integration.client.chat_postMessage.call_args_list - first_call = post_message_calls[0].kwargs - - assert ( - first_call["text"] - == "This channel has been subscribed to the Insight *My Test subscription* on PostHog! 🎉\nThis subscription is sent every day. The next one will be sent on Wednesday February 02, 2022" - ) - - def test_subscription_dashboard_delivery(self, MockSlackIntegration: MagicMock) -> None: - mock_slack_integration = MagicMock() - MockSlackIntegration.return_value = mock_slack_integration - mock_slack_integration.client.chat_postMessage.return_value = {"ts": "1.234"} - - self.subscription = create_subscription( - team=self.team, - dashboard=self.dashboard, - created_by=self.user, - target_type="slack", - target_value="C12345|#test-channel", - ) - - send_slack_subscription_report(self.subscription, [self.asset, self.asset, self.asset], 10) - - assert mock_slack_integration.client.chat_postMessage.call_count == 4 - post_message_calls = mock_slack_integration.client.chat_postMessage.call_args_list - first_call = post_message_calls[0].kwargs - - assert first_call["channel"] == "C12345" - assert first_call["text"] == "Your subscription to the Dashboard *private dashboard* is ready! 🎉" - - assert first_call["blocks"] == [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "Your subscription to the Dashboard *private dashboard* is ready! 🎉", - }, - }, - { - "type": "image", - "image_url": post_message_calls[0].kwargs["blocks"][1]["image_url"], - "alt_text": "My Test subscription", - }, - { - "type": "section", - "text": {"type": "mrkdwn", "text": "_See 🧵 for more Insights_"}, - }, - {"type": "divider"}, - { - "type": "actions", - "elements": [ - { - "type": "button", - "text": {"type": "plain_text", "text": "View in PostHog"}, - "url": f"http://localhost:8000/dashboard/{self.dashboard.id}?utm_source=posthog&utm_campaign=subscription_report&utm_medium=slack", - }, - { - "type": "button", - "text": {"type": "plain_text", "text": "Manage Subscription"}, - "url": f"http://localhost:8000/dashboard/{self.dashboard.id}/subscriptions/{self.subscription.id}?utm_source=posthog&utm_campaign=subscription_report&utm_medium=slack", - }, - ], - }, - ] - - # Second call - other asset - second_call = post_message_calls[1].kwargs - assert second_call["channel"] == "C12345" - assert second_call["thread_ts"] == "1.234" - assert second_call["blocks"] == [ - { - "type": "image", - "image_url": second_call["blocks"][0]["image_url"], - "alt_text": "My Test subscription", - } - ] - - # Third call - other asset - third_call = post_message_calls[2].kwargs - assert third_call["blocks"] == [ - { - "type": "image", - "image_url": third_call["blocks"][0]["image_url"], - "alt_text": "My Test subscription", - } - ] - - # Fourth call - notice that more exists - fourth_call = post_message_calls[3].kwargs - assert fourth_call["blocks"] == [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"Showing 3 of 10 Insights. ", - }, - } - ] - - def test_subscription_delivery_missing_integration(self, MockSlackIntegration: MagicMock) -> None: - mock_slack_integration = MagicMock() - MockSlackIntegration.return_value = mock_slack_integration - - self.integration.delete() - - send_slack_subscription_report(self.subscription, [self.asset], 1) - - assert mock_slack_integration.client.chat_postMessage.call_count == 0 - - # TODO: Should we perhaps save something to say the Subscription failed? diff --git a/ee/tasks/test/subscriptions/test_subscriptions.py b/ee/tasks/test/subscriptions/test_subscriptions.py deleted file mode 100644 index 5f6db8011b2..00000000000 --- a/ee/tasks/test/subscriptions/test_subscriptions.py +++ /dev/null @@ -1,194 +0,0 @@ -from datetime import datetime -from unittest.mock import MagicMock, call, patch - -from zoneinfo import ZoneInfo -from freezegun import freeze_time - -from ee.tasks.subscriptions import ( - deliver_subscription_report, - handle_subscription_value_change, - schedule_all_subscriptions, -) -from ee.tasks.test.subscriptions.subscriptions_test_factory import create_subscription -from posthog.models.dashboard import Dashboard -from posthog.models.dashboard_tile import DashboardTile -from posthog.models.exported_asset import ExportedAsset -from posthog.models.insight import Insight -from posthog.models.instance_setting import set_instance_setting -from posthog.test.base import APIBaseTest - - -@patch("ee.tasks.subscriptions.send_slack_subscription_report") -@patch("ee.tasks.subscriptions.send_email_subscription_report") -@patch("ee.tasks.subscriptions.generate_assets") -@freeze_time("2022-02-02T08:55:00.000Z") -class TestSubscriptionsTasks(APIBaseTest): - dashboard: Dashboard - insight: Insight - tiles: list[DashboardTile] = None # type: ignore - asset: ExportedAsset - - def setUp(self) -> None: - self.dashboard = Dashboard.objects.create(team=self.team, name="private dashboard", created_by=self.user) - self.insight = Insight.objects.create(team=self.team, short_id="123456", name="My Test subscription") - self.asset = ExportedAsset.objects.create(team=self.team, insight_id=self.insight.id, export_format="image/png") - self.tiles = [] - for i in range(10): - insight = Insight.objects.create(team=self.team, short_id=f"{i}23456{i}", name=f"insight {i}") - self.tiles.append(DashboardTile.objects.create(dashboard=self.dashboard, insight=insight)) - - set_instance_setting("EMAIL_HOST", "fake_host") - set_instance_setting("EMAIL_ENABLED", True) - - @patch("ee.tasks.subscriptions.deliver_subscription_report") - def test_subscription_delivery_scheduling( - self, - mock_deliver_task: MagicMock, - mock_gen_assets: MagicMock, - mock_send_email: MagicMock, - mock_send_slack: MagicMock, - ) -> None: - with freeze_time("2022-02-02T08:30:00.000Z"): # Create outside of buffer before running - subscriptions = [ - create_subscription(team=self.team, insight=self.insight, created_by=self.user), - create_subscription(team=self.team, insight=self.insight, created_by=self.user), - create_subscription(team=self.team, dashboard=self.dashboard, created_by=self.user), - create_subscription( - team=self.team, - dashboard=self.dashboard, - created_by=self.user, - deleted=True, - ), - ] - # Modify a subscription to have its target time at least an hour ahead - subscriptions[2].start_date = datetime(2022, 1, 1, 10, 0).replace(tzinfo=ZoneInfo("UTC")) - subscriptions[2].save() - assert subscriptions[2].next_delivery_date == datetime(2022, 2, 2, 10, 0).replace(tzinfo=ZoneInfo("UTC")) - - schedule_all_subscriptions() - - self.assertCountEqual( - mock_deliver_task.delay.call_args_list, [call(subscriptions[0].id), call(subscriptions[1].id)] - ) - - @patch("ee.tasks.subscriptions.deliver_subscription_report") - def test_does_not_schedule_subscription_if_item_is_deleted( - self, - mock_deliver_task: MagicMock, - mock_gen_assets: MagicMock, - mock_send_email: MagicMock, - mock_send_slack: MagicMock, - ) -> None: - create_subscription( - team=self.team, - insight=self.insight, - created_by=self.user, - target_type="slack", - target_value="C12345|#test-channel", - ) - - create_subscription( - team=self.team, - dashboard=self.dashboard, - created_by=self.user, - target_type="slack", - target_value="C12345|#test-channel", - ) - - self.insight.deleted = True - self.insight.save() - self.dashboard.deleted = True - self.dashboard.save() - - schedule_all_subscriptions() - - assert mock_deliver_task.delay.call_count == 0 - - def test_deliver_subscription_report_email( - self, - mock_gen_assets: MagicMock, - mock_send_email: MagicMock, - mock_send_slack: MagicMock, - ) -> None: - subscription = create_subscription(team=self.team, insight=self.insight, created_by=self.user) - mock_gen_assets.return_value = [self.insight], [self.asset] - - deliver_subscription_report(subscription.id) - - assert mock_send_email.call_count == 2 - - assert mock_send_email.call_args_list == [ - call( - "test1@posthog.com", - subscription, - [self.asset], - invite_message=None, - total_asset_count=1, - ), - call( - "test2@posthog.com", - subscription, - [self.asset], - invite_message=None, - total_asset_count=1, - ), - ] - - def test_handle_subscription_value_change_email( - self, - mock_gen_assets: MagicMock, - mock_send_email: MagicMock, - mock_send_slack: MagicMock, - ) -> None: - subscription = create_subscription( - team=self.team, - insight=self.insight, - created_by=self.user, - target_value="test_existing@posthog.com,test_new@posthog.com", - ) - mock_gen_assets.return_value = [self.insight], [self.asset] - - handle_subscription_value_change( - subscription.id, - previous_value="test_existing@posthog.com", - invite_message="My invite message", - ) - - assert mock_send_email.call_count == 1 - - assert mock_send_email.call_args_list == [ - call( - "test_new@posthog.com", - subscription, - [self.asset], - invite_message="My invite message", - total_asset_count=1, - ) - ] - - def test_deliver_subscription_report_slack( - self, - mock_gen_assets: MagicMock, - mock_send_email: MagicMock, - mock_send_slack: MagicMock, - ) -> None: - subscription = create_subscription( - team=self.team, - insight=self.insight, - created_by=self.user, - target_type="slack", - target_value="C12345|#test-channel", - ) - mock_gen_assets.return_value = [self.insight], [self.asset] - - deliver_subscription_report(subscription.id) - - assert mock_send_slack.call_count == 1 - assert mock_send_slack.call_args_list == [ - call( - subscription, - [self.asset], - total_asset_count=1, - is_new_subscription=False, - ) - ] diff --git a/ee/tasks/test/subscriptions/test_subscriptions_utils.py b/ee/tasks/test/subscriptions/test_subscriptions_utils.py deleted file mode 100644 index edab23bbfb9..00000000000 --- a/ee/tasks/test/subscriptions/test_subscriptions_utils.py +++ /dev/null @@ -1,96 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest - -from ee.tasks.subscriptions.subscription_utils import ( - DEFAULT_MAX_ASSET_COUNT, - generate_assets, -) -from ee.tasks.test.subscriptions.subscriptions_test_factory import create_subscription -from posthog.models.dashboard import Dashboard -from posthog.models.dashboard_tile import DashboardTile -from posthog.models.exported_asset import ExportedAsset -from posthog.models.insight import Insight -from posthog.test.base import APIBaseTest - - -@patch("ee.tasks.subscriptions.subscription_utils.chain") -@patch("ee.tasks.subscriptions.subscription_utils.exporter.export_asset") -class TestSubscriptionsTasksUtils(APIBaseTest): - dashboard: Dashboard - insight: Insight - asset: ExportedAsset - tiles: list[DashboardTile] - - def setUp(self) -> None: - self.dashboard = Dashboard.objects.create(team=self.team, name="private dashboard", created_by=self.user) - self.insight = Insight.objects.create(team=self.team, short_id="123456", name="My Test subscription") - self.tiles = [] - for i in range(10): - insight = Insight.objects.create(team=self.team, short_id=f"insight-{i}", name="My Test subscription") - self.tiles.append(DashboardTile.objects.create(dashboard=self.dashboard, insight=insight)) - - self.subscription = create_subscription(team=self.team, insight=self.insight, created_by=self.user) - - def test_generate_assets_for_insight(self, mock_export_task: MagicMock, _mock_group: MagicMock) -> None: - with self.settings(PARALLEL_ASSET_GENERATION_MAX_TIMEOUT_MINUTES=1): - insights, assets = generate_assets(self.subscription) - - assert insights == [self.insight] - assert len(assets) == 1 - assert mock_export_task.si.call_count == 1 - - def test_generate_assets_for_dashboard(self, mock_export_task: MagicMock, _mock_group: MagicMock) -> None: - subscription = create_subscription(team=self.team, dashboard=self.dashboard, created_by=self.user) - - with self.settings(PARALLEL_ASSET_GENERATION_MAX_TIMEOUT_MINUTES=1): - insights, assets = generate_assets(subscription) - - assert len(insights) == len(self.tiles) - assert len(assets) == DEFAULT_MAX_ASSET_COUNT - assert mock_export_task.si.call_count == DEFAULT_MAX_ASSET_COUNT - - def test_raises_if_missing_resource(self, _mock_export_task: MagicMock, _mock_group: MagicMock) -> None: - subscription = create_subscription(team=self.team, created_by=self.user) - - with self.settings(PARALLEL_ASSET_GENERATION_MAX_TIMEOUT_MINUTES=1), pytest.raises(Exception) as e: - generate_assets(subscription) - - assert str(e.value) == "There are no insights to be sent for this Subscription" - - def test_excludes_deleted_insights_for_dashboard(self, mock_export_task: MagicMock, _mock_group: MagicMock) -> None: - for i in range(1, 10): - current_tile = self.tiles[i] - if current_tile.insight is None: - continue - current_tile.insight.deleted = True - current_tile.insight.save() - subscription = create_subscription(team=self.team, dashboard=self.dashboard, created_by=self.user) - - with self.settings(PARALLEL_ASSET_GENERATION_MAX_TIMEOUT_MINUTES=1): - insights, assets = generate_assets(subscription) - - assert len(insights) == 1 - assert len(assets) == 1 - assert mock_export_task.si.call_count == 1 - - def test_cancels_children_if_timed_out(self, _mock_export_task: MagicMock, mock_group: MagicMock) -> None: - # mock the group so that its children are never ready, - # and we capture calls to revoke - mock_running_exports = MagicMock() - mock_ready = MagicMock() - running_export_task = MagicMock() - - running_export_task.state = "PENDING" - - mock_ready.return_value = False - mock_group.return_value.apply_async.return_value = mock_running_exports - - mock_running_exports.children = [running_export_task] - mock_running_exports.ready = mock_ready - - with self.settings(PARALLEL_ASSET_GENERATION_MAX_TIMEOUT_MINUTES=0.01), pytest.raises(Exception) as e: - generate_assets(self.subscription) - - assert str(e.value) == "Timed out waiting for celery task to finish" - running_export_task.revoke.assert_called() diff --git a/ee/tasks/test/test_auto_rollback_feature_flag.py b/ee/tasks/test/test_auto_rollback_feature_flag.py deleted file mode 100644 index c9afe258500..00000000000 --- a/ee/tasks/test/test_auto_rollback_feature_flag.py +++ /dev/null @@ -1,205 +0,0 @@ -from unittest.mock import patch - -from freezegun import freeze_time - -from ee.tasks.auto_rollback_feature_flag import ( - calculate_rolling_average, - check_condition, - check_feature_flag_rollback_conditions, -) -from posthog.models.feature_flag import FeatureFlag -from posthog.test.base import APIBaseTest, ClickhouseTestMixin, _create_event - - -class AutoRollbackTest(ClickhouseTestMixin, APIBaseTest): - def test_calculate_rolling_average(self): - threshold_metric = { - "insight": "trends", - "events": [{"order": 0, "id": "$pageview"}], - } - - with freeze_time("2021-08-21T20:00:00.000Z"): - for _ in range(70): - _create_event( - event="$pageview", - distinct_id="1", - team=self.team, - timestamp="2021-08-21 05:00:00", - properties={"prop": 1}, - ) - - _create_event( - event="$pageview", - distinct_id="1", - team=self.team, - timestamp="2021-08-22 05:00:00", - properties={"prop": 1}, - ) - with freeze_time("2021-08-21T21:00:00.000Z"): - self.assertEqual( - calculate_rolling_average( - threshold_metric=threshold_metric, - team=self.team, - timezone="UTC", - ), - 10, # because we have 70 events in the last 7 days - ) - - with freeze_time("2021-08-22T21:00:00.000Z"): - self.assertEqual( - calculate_rolling_average( - threshold_metric=threshold_metric, - team=self.team, - timezone="UTC", - ), - 20, # because we have 140 events in the last 7 days - ) - - def test_check_condition(self): - rollback_condition = { - "threshold": 10, - "threshold_metric": { - "insight": "trends", - "events": [{"order": 0, "id": "$pageview"}], - }, - "operator": "lt", - "threshold_type": "insight", - } - - flag = FeatureFlag.objects.create( - team=self.team, - created_by=self.user, - key="test-ff", - rollout_percentage=50, - rollback_conditions=[rollback_condition], - ) - - self.assertEqual(check_condition(rollback_condition, flag), True) - - def test_check_condition_valid(self): - rollback_condition = { - "threshold": 15, - "threshold_metric": { - "insight": "trends", - "events": [{"order": 0, "id": "$pageview"}], - }, - "operator": "gt", - "threshold_type": "insight", - } - - for _ in range(70): - _create_event( - event="$pageview", - distinct_id="1", - team=self.team, - timestamp="2021-08-21 00:00:00", - properties={"prop": 1}, - ) - _create_event( - event="$pageview", - distinct_id="1", - team=self.team, - timestamp="2021-08-22 00:00:00", - properties={"prop": 1}, - ) - - with freeze_time("2021-08-21T20:00:00.000Z"): - flag = FeatureFlag.objects.create( - team=self.team, - created_by=self.user, - key="test-ff", - rollout_percentage=50, - rollback_conditions=[rollback_condition], - ) - - with freeze_time("2021-08-21T20:00:00.000Z"): - self.assertEqual(check_condition(rollback_condition, flag), False) - - # Go another day with 0 events - with freeze_time("2021-08-22T20:00:00.000Z"): - self.assertEqual(check_condition(rollback_condition, flag), True) - - def test_feature_flag_rolledback(self): - rollback_condition = { - "threshold": 15, - "threshold_metric": { - "insight": "trends", - "events": [{"order": 0, "id": "$pageview"}], - }, - "operator": "gt", - "threshold_type": "insight", - } - - for _ in range(70): - _create_event( - event="$pageview", - distinct_id="1", - team=self.team, - timestamp="2021-08-21 00:00:00", - properties={"prop": 1}, - ) - _create_event( - event="$pageview", - distinct_id="1", - team=self.team, - timestamp="2021-08-22 00:00:00", - properties={"prop": 1}, - ) - - with freeze_time("2021-08-21T00:00:00.000Z"): - flag = FeatureFlag.objects.create( - team=self.team, - created_by=self.user, - key="test-ff", - rollout_percentage=50, - rollback_conditions=[rollback_condition], - ) - - flag = FeatureFlag.objects.get(pk=flag.pk) - self.assertEqual(flag.performed_rollback, None) - self.assertEqual(flag.active, True) - - with freeze_time("2021-08-23T20:00:00.000Z"): - check_feature_flag_rollback_conditions(feature_flag_id=flag.pk) - flag = FeatureFlag.objects.get(pk=flag.pk) - self.assertEqual(flag.performed_rollback, True) - self.assertEqual(flag.active, False) - - @patch("ee.tasks.auto_rollback_feature_flag.get_stats_for_timerange") - def test_check_condition_sentry(self, stats_for_timerange): - rollback_condition = { - "threshold": 1.25, - "threshold_metric": {}, - "operator": "gt", - "threshold_type": "sentry", - } - - with freeze_time("2021-08-21T20:00:00.000Z"): - flag = FeatureFlag.objects.create( - team=self.team, - created_by=self.user, - key="test-ff", - rollout_percentage=50, - rollback_conditions=[rollback_condition], - ) - - stats_for_timerange.return_value = (100, 130) - with freeze_time("2021-08-23T20:00:00.000Z"): - self.assertEqual(check_condition(rollback_condition, flag), True) - stats_for_timerange.assert_called_once_with( - "2021-08-21T20:00:00", - "2021-08-22T20:00:00", - "2021-08-22T20:00:00", - "2021-08-23T20:00:00", - ) - - stats_for_timerange.reset_mock() - stats_for_timerange.return_value = (100, 120) - with freeze_time("2021-08-25T13:00:00.000Z"): - self.assertEqual(check_condition(rollback_condition, flag), False) - stats_for_timerange.assert_called_once_with( - "2021-08-21T20:00:00", - "2021-08-22T20:00:00", - "2021-08-24T13:00:00", - "2021-08-25T13:00:00", - ) diff --git a/ee/tasks/test/test_calculate_cohort.py b/ee/tasks/test/test_calculate_cohort.py deleted file mode 100644 index c5264bbe126..00000000000 --- a/ee/tasks/test/test_calculate_cohort.py +++ /dev/null @@ -1,535 +0,0 @@ -import json -import urllib.parse -from unittest.mock import patch - -from freezegun import freeze_time - -from posthog.client import sync_execute -from posthog.models.cohort import Cohort -from posthog.models.person import Person -from posthog.tasks.calculate_cohort import insert_cohort_from_insight_filter -from posthog.tasks.test.test_calculate_cohort import calculate_cohort_test_factory -from posthog.test.base import ClickhouseTestMixin, _create_event, _create_person - - -class TestClickhouseCalculateCohort(ClickhouseTestMixin, calculate_cohort_test_factory(_create_event, _create_person)): # type: ignore - @patch("posthog.tasks.calculate_cohort.insert_cohort_from_insight_filter.delay") - def test_create_stickiness_cohort(self, _insert_cohort_from_insight_filter): - _create_person(team_id=self.team.pk, distinct_ids=["blabla"]) - _create_event( - team=self.team, - event="$pageview", - distinct_id="blabla", - properties={"$math_prop": 1}, - timestamp="2021-01-01T12:00:00Z", - ) - response = self.client.post( - f"/api/projects/{self.team.id}/cohorts/?insight=STICKINESS&properties=%5B%5D&interval=day&display=ActionsLineGraph&events=%5B%7B%22id%22%3A%22%24pageview%22%2C%22name%22%3A%22%24pageview%22%2C%22type%22%3A%22events%22%2C%22order%22%3A0%7D%5D&shown_as=Stickiness&date_from=2021-01-01&entity_id=%24pageview&entity_type=events&stickiness_days=1&label=%24pageview", - {"name": "test", "is_static": True}, - ).json() - - cohort_id = response["id"] - - _insert_cohort_from_insight_filter.assert_called_once_with( - cohort_id, - { - "insight": "STICKINESS", - "properties": "[]", - "interval": "day", - "display": "ActionsLineGraph", - "events": '[{"id":"$pageview","name":"$pageview","type":"events","order":0}]', - "shown_as": "Stickiness", - "date_from": "2021-01-01", - "entity_id": "$pageview", - "entity_type": "events", - "stickiness_days": "1", - "label": "$pageview", - }, - ) - - insert_cohort_from_insight_filter( - cohort_id, - { - "date_from": "2021-01-01", - "events": [ - { - "id": "$pageview", - "type": "events", - "order": 0, - "name": "$pageview", - "custom_name": None, - "math": None, - "math_hogql": None, - "math_property": None, - "math_group_type_index": None, - "properties": [], - } - ], - "insight": "STICKINESS", - "interval": "day", - "selected_interval": 1, - "shown_as": "Stickiness", - "entity_id": "$pageview", - "entity_type": "events", - "entity_math": None, - }, - ) - cohort = Cohort.objects.get(pk=cohort_id) - people = Person.objects.filter(cohort__id=cohort.pk) - self.assertEqual(people.count(), 1) - self.assertEqual(cohort.count, 1) - - @patch("posthog.tasks.calculate_cohort.insert_cohort_from_insight_filter.delay") - def test_create_trends_cohort(self, _insert_cohort_from_insight_filter): - _create_person(team_id=self.team.pk, distinct_ids=["blabla"]) - with freeze_time("2021-01-01 00:06:34"): - _create_event( - team=self.team, - event="$pageview", - distinct_id="blabla", - properties={"$math_prop": 1}, - timestamp="2021-01-01T12:00:00Z", - ) - - with freeze_time("2021-01-02 00:06:34"): - _create_event( - team=self.team, - event="$pageview", - distinct_id="blabla", - properties={"$math_prop": 4}, - timestamp="2021-01-01T12:00:00Z", - ) - - response = self.client.post( - f"/api/projects/{self.team.id}/cohorts/?interval=day&display=ActionsLineGraph&events=%5B%7B%22id%22%3A%22%24pageview%22%2C%22name%22%3A%22%24pageview%22%2C%22type%22%3A%22events%22%2C%22order%22%3A0%7D%5D&properties=%5B%5D&entity_id=%24pageview&entity_type=events&date_from=2021-01-01&date_to=2021-01-01&label=%24pageview", - {"name": "test", "is_static": True}, - ).json() - cohort_id = response["id"] - _insert_cohort_from_insight_filter.assert_called_once_with( - cohort_id, - { - "interval": "day", - "display": "ActionsLineGraph", - "events": '[{"id":"$pageview","name":"$pageview","type":"events","order":0}]', - "properties": "[]", - "entity_id": "$pageview", - "entity_type": "events", - "date_from": "2021-01-01", - "date_to": "2021-01-01", - "label": "$pageview", - }, - ) - insert_cohort_from_insight_filter( - cohort_id, - { - "date_from": "2021-01-01", - "date_to": "2021-01-01", - "display": "ActionsLineGraph", - "events": [ - { - "id": "$pageview", - "type": "events", - "order": 0, - "name": "$pageview", - "math": None, - "math_hogql": None, - "math_property": None, - "math_group_type_index": None, - "properties": [], - } - ], - "entity_id": "$pageview", - "entity_type": "events", - "insight": "TRENDS", - "interval": "day", - }, - ) - cohort = Cohort.objects.get(pk=cohort_id) - people = Person.objects.filter(cohort__id=cohort.pk) - self.assertEqual(cohort.errors_calculating, 0) - self.assertEqual( - people.count(), - 1, - { - "a": sync_execute( - "select person_id from person_static_cohort where team_id = {} and cohort_id = {} ".format( - self.team.id, cohort.pk - ) - ), - "b": sync_execute( - "select person_id from person_static_cohort FINAL where team_id = {} and cohort_id = {} ".format( - self.team.id, cohort.pk - ) - ), - }, - ) - self.assertEqual(cohort.count, 1) - - @patch("posthog.tasks.calculate_cohort.insert_cohort_from_insight_filter.delay") - def test_create_trends_cohort_arg_test(self, _insert_cohort_from_insight_filter): - # prior to 8124, subtitute parameters was called on insight cohorting which caused '%' in LIKE arguments to be interepreted as a missing parameter - - _create_person(team_id=self.team.pk, distinct_ids=["blabla"]) - with freeze_time("2021-01-01 00:06:34"): - _create_event( - team=self.team, - event="$pageview", - distinct_id="blabla", - properties={"$domain": "https://app.posthog.com/123"}, - timestamp="2021-01-01T12:00:00Z", - ) - - with freeze_time("2021-01-02 00:06:34"): - _create_event( - team=self.team, - event="$pageview", - distinct_id="blabla", - properties={"$domain": "https://app.posthog.com/123"}, - timestamp="2021-01-01T12:00:00Z", - ) - - params = { - "date_from": "2021-01-01", - "date_to": "2021-01-01", - "display": "ActionsLineGraph", - "events": json.dumps([{"id": "$pageview", "name": "$pageview", "type": "events", "order": 0}]), - "entity_id": "$pageview", - "entity_type": "events", - "insight": "TRENDS", - "interval": "day", - "properties": json.dumps( - [ - { - "key": "$domain", - "value": "app.posthog.com", - "operator": "icontains", - "type": "event", - } - ] - ), - } - - response = self.client.post( - f"/api/projects/{self.team.id}/cohorts/?{urllib.parse.urlencode(params)}", - {"name": "test", "is_static": True}, - ).json() - cohort_id = response["id"] - - _insert_cohort_from_insight_filter.assert_called_once_with( - cohort_id, - { - "date_from": "2021-01-01", - "date_to": "2021-01-01", - "display": "ActionsLineGraph", - "events": '[{"id": "$pageview", "name": "$pageview", "type": "events", "order": 0}]', - "entity_id": "$pageview", - "entity_type": "events", - "insight": "TRENDS", - "interval": "day", - "properties": '[{"key": "$domain", "value": "app.posthog.com", "operator": "icontains", "type": "event"}]', - }, - ) - insert_cohort_from_insight_filter( - cohort_id, - { - "date_from": "2021-01-01", - "date_to": "2021-01-01", - "display": "ActionsLineGraph", - "events": [ - { - "id": "$pageview", - "type": "events", - "order": 0, - "name": "$pageview", - "math": None, - "math_hogql": None, - "math_property": None, - "math_group_type_index": None, - "properties": [], - } - ], - "properties": [ - { - "key": "$domain", - "value": "app.posthog.com", - "operator": "icontains", - "type": "event", - } - ], - "entity_id": "$pageview", - "entity_type": "events", - "insight": "TRENDS", - "interval": "day", - }, - ) - cohort = Cohort.objects.get(pk=cohort_id) - people = Person.objects.filter(cohort__id=cohort.pk) - self.assertEqual(cohort.errors_calculating, 0) - self.assertEqual( - people.count(), - 1, - { - "a": sync_execute( - "select person_id from person_static_cohort where team_id = {} and cohort_id = {} ".format( - self.team.id, cohort.pk - ) - ), - "b": sync_execute( - "select person_id from person_static_cohort FINAL where team_id = {} and cohort_id = {} ".format( - self.team.id, cohort.pk - ) - ), - }, - ) - self.assertEqual(cohort.count, 1) - - @patch("posthog.tasks.calculate_cohort.insert_cohort_from_insight_filter.delay") - def test_create_funnels_cohort(self, _insert_cohort_from_insight_filter): - _create_person(team_id=self.team.pk, distinct_ids=["blabla"]) - with freeze_time("2021-01-01 00:06:34"): - _create_event( - team=self.team, - event="$pageview", - distinct_id="blabla", - properties={"$math_prop": 1}, - timestamp="2021-01-01T12:00:00Z", - ) - - with freeze_time("2021-01-02 00:06:34"): - _create_event( - team=self.team, - event="$another_view", - distinct_id="blabla", - properties={"$math_prop": 4}, - timestamp="2021-01-02T12:00:00Z", - ) - - params = { - "insight": "FUNNELS", - "events": json.dumps( - [ - { - "id": "$pageview", - "math": None, - "name": "$pageview", - "type": "events", - "order": 0, - "properties": [], - "math_hogql": None, - "math_property": None, - }, - { - "id": "$another_view", - "math": None, - "name": "$another_view", - "type": "events", - "order": 1, - "properties": [], - "math_hogql": None, - "math_property": None, - }, - ] - ), - "display": "FunnelViz", - "interval": "day", - "layout": "horizontal", - "date_from": "2021-01-01", - "date_to": "2021-01-07", - "funnel_step": 1, - } - - response = self.client.post( - f"/api/projects/{self.team.id}/cohorts/?{urllib.parse.urlencode(params)}", - {"name": "test", "is_static": True}, - ).json() - - cohort_id = response["id"] - - _insert_cohort_from_insight_filter.assert_called_once_with( - cohort_id, - { - "insight": "FUNNELS", - "events": '[{"id": "$pageview", "math": null, "name": "$pageview", "type": "events", "order": 0, "properties": [], "math_hogql": null, "math_property": null}, {"id": "$another_view", "math": null, "name": "$another_view", "type": "events", "order": 1, "properties": [], "math_hogql": null, "math_property": null}]', - "display": "FunnelViz", - "interval": "day", - "layout": "horizontal", - "date_from": "2021-01-01", - "date_to": "2021-01-07", - "funnel_step": "1", - }, - ) - - insert_cohort_from_insight_filter(cohort_id, params) - - cohort = Cohort.objects.get(pk=cohort_id) - people = Person.objects.filter(cohort__id=cohort.pk) - self.assertEqual(cohort.errors_calculating, 0) - self.assertEqual(people.count(), 1) - self.assertEqual(cohort.count, 1) - - @patch("posthog.tasks.calculate_cohort.insert_cohort_from_insight_filter.delay") - def test_create_lifecycle_cohort(self, _insert_cohort_from_insight_filter): - def _create_events(data, event="$pageview"): - person_result = [] - for id, timestamps in data: - with freeze_time(timestamps[0]): - person_result.append( - _create_person( - team_id=self.team.pk, - distinct_ids=[id], - properties={ - "name": id, - **({"email": "test@posthog.com"} if id == "p1" else {}), - }, - ) - ) - for timestamp in timestamps: - _create_event(team=self.team, event=event, distinct_id=id, timestamp=timestamp) - return person_result - - people = _create_events( - data=[ - ( - "p1", - [ - "2020-01-11T12:00:00Z", - "2020-01-12T12:00:00Z", - "2020-01-13T12:00:00Z", - "2020-01-15T12:00:00Z", - "2020-01-17T12:00:00Z", - "2020-01-19T12:00:00Z", - ], - ), - ("p2", ["2020-01-09T12:00:00Z", "2020-01-12T12:00:00Z"]), - ("p3", ["2020-01-12T12:00:00Z"]), - ("p4", ["2020-01-15T12:00:00Z"]), - ] - ) - - query_params = { - "date_from": "2020-01-12T00:00:00Z", - "date_to": "2020-01-19T00:00:00Z", - "events": json.dumps([{"id": "$pageview", "type": "events", "order": 0}]), - "insight": "LIFECYCLE", - "interval": "day", - "shown_as": "Lifecycle", - "smoothing_intervals": 1, - "entity_id": "$pageview", - "entity_type": "events", - "entity_math": "total", - "target_date": "2020-01-13", - "entity_order": 0, - "lifecycle_type": "returning", - } - - response = self.client.post( - f"/api/cohort/?{urllib.parse.urlencode(query_params)}", - data={"is_static": True, "name": "lifecycle_static_cohort_returning"}, - ).json() - cohort_id = response["id"] - - _insert_cohort_from_insight_filter.assert_called_once_with( - cohort_id, - { - "date_from": "2020-01-12T00:00:00Z", - "date_to": "2020-01-19T00:00:00Z", - "events": '[{"id": "$pageview", "type": "events", "order": 0}]', - "insight": "LIFECYCLE", - "interval": "day", - "shown_as": "Lifecycle", - "smoothing_intervals": "1", - "entity_id": "$pageview", - "entity_type": "events", - "entity_math": "total", - "target_date": "2020-01-13", - "entity_order": "0", - "lifecycle_type": "returning", - }, - ) - - insert_cohort_from_insight_filter( - cohort_id, - { - "date_from": "2020-01-12T00:00:00Z", - "date_to": "2020-01-19T00:00:00Z", - "events": [{"id": "$pageview", "type": "events", "order": 0}], - "insight": "LIFECYCLE", - "interval": "day", - "shown_as": "Lifecycle", - "smoothing_intervals": "1", - "entity_id": "$pageview", - "entity_type": "events", - "entity_math": "total", - "target_date": "2020-01-13", - "entity_order": "0", - "lifecycle_type": "returning", - }, - ) - cohort = Cohort.objects.get(pk=response["id"]) - people_result = Person.objects.filter(cohort__id=cohort.pk).values_list("id", flat=True) - self.assertIn(people[0].id, people_result) - - query_params = { - "date_from": "2020-01-12T00:00:00Z", - "date_to": "2020-01-19T00:00:00Z", - "events": json.dumps([{"id": "$pageview", "type": "events", "order": 0}]), - "insight": "LIFECYCLE", - "interval": "day", - "shown_as": "Lifecycle", - "smoothing_intervals": 1, - "entity_id": "$pageview", - "entity_type": "events", - "entity_math": "total", - "target_date": "2020-01-13", - "entity_order": 0, - "lifecycle_type": "dormant", - } - response = self.client.post( - f"/api/cohort/?{urllib.parse.urlencode(query_params)}", - data={"is_static": True, "name": "lifecycle_static_cohort_dormant"}, - ).json() - cohort_id = response["id"] - - _insert_cohort_from_insight_filter.assert_called_with( - cohort_id, - { - "date_from": "2020-01-12T00:00:00Z", - "date_to": "2020-01-19T00:00:00Z", - "events": '[{"id": "$pageview", "type": "events", "order": 0}]', - "insight": "LIFECYCLE", - "interval": "day", - "shown_as": "Lifecycle", - "smoothing_intervals": "1", - "entity_id": "$pageview", - "entity_type": "events", - "entity_math": "total", - "target_date": "2020-01-13", - "entity_order": "0", - "lifecycle_type": "dormant", - }, - ) - self.assertEqual(_insert_cohort_from_insight_filter.call_count, 2) - - insert_cohort_from_insight_filter( - cohort_id, - { - "date_from": "2020-01-12T00:00:00Z", - "date_to": "2020-01-19T00:00:00Z", - "events": [{"id": "$pageview", "type": "events", "order": 0}], - "insight": "LIFECYCLE", - "interval": "day", - "shown_as": "Lifecycle", - "smoothing_intervals": "1", - "entity_id": "$pageview", - "entity_type": "events", - "entity_math": "total", - "target_date": "2020-01-13", - "entity_order": "0", - "lifecycle_type": "dormant", - }, - ) - - cohort = Cohort.objects.get(pk=response["id"]) - self.assertEqual(cohort.count, 2) - people_result = Person.objects.filter(cohort__id=cohort.pk).values_list("id", flat=True) - self.assertCountEqual([people[1].id, people[2].id], people_result) diff --git a/ee/tasks/test/test_send_license_usage.py b/ee/tasks/test/test_send_license_usage.py deleted file mode 100644 index 441179c2c3e..00000000000 --- a/ee/tasks/test/test_send_license_usage.py +++ /dev/null @@ -1,317 +0,0 @@ -from unittest.mock import ANY, Mock, patch - -from freezegun import freeze_time - -from ee.api.test.base import LicensedTestMixin -from ee.models.license import License -from ee.tasks.send_license_usage import send_license_usage -from posthog.models.team import Team -from posthog.test.base import ( - APIBaseTest, - ClickhouseDestroyTablesMixin, - _create_event, - flush_persons_and_events, -) - - -class SendLicenseUsageTest(LicensedTestMixin, ClickhouseDestroyTablesMixin, APIBaseTest): - @freeze_time("2021-10-10T23:01:00Z") - @patch("posthoganalytics.capture") - @patch("requests.post") - def test_send_license_usage(self, mock_post, mock_capture): - self.license.key = "legacy-key" - self.license.save() - team2 = Team.objects.create(organization=self.organization) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-08T14:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-09T12:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-09T13:01:01Z", - ) - _create_event( - event="$$internal_metrics_shouldnt_be_billed", - team=self.team, - distinct_id=1, - timestamp="2021-10-09T13:01:01Z", - ) - _create_event( - event="$pageview", - team=team2, - distinct_id=1, - timestamp="2021-10-09T14:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-10T14:01:01Z", - ) - flush_persons_and_events() - - mockresponse = Mock() - mock_post.return_value = mockresponse - mockresponse.json = lambda: {"ok": True, "valid_until": "2021-11-10T23:01:00Z"} - - send_license_usage() - mock_post.assert_called_once_with( - "https://license.posthog.com/licenses/usage", - data={"date": "2021-10-09", "key": self.license.key, "events_count": 3}, - ) - mock_capture.assert_called_once_with( - self.user.distinct_id, - "send license usage data", - { - "date": "2021-10-09", - "events_count": 3, - "license_keys": [self.license.key], - "organization_name": "Test", - }, - groups={"instance": ANY, "organization": str(self.organization.id)}, - ) - self.assertEqual(License.objects.get().valid_until.isoformat(), "2021-11-10T23:01:00+00:00") - - @freeze_time("2021-10-10T23:01:00Z") - @patch("posthoganalytics.capture") - @patch("ee.tasks.send_license_usage.sync_execute", side_effect=Exception()) - def test_send_license_error(self, mock_post, mock_capture): - self.license.key = "legacy-key" - self.license.save() - - team2 = Team.objects.create(organization=self.organization) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-08T14:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-09T12:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-09T13:01:01Z", - ) - _create_event( - event="$$internal_metrics_shouldnt_be_billed", - team=self.team, - distinct_id=1, - timestamp="2021-10-09T13:01:01Z", - ) - _create_event( - event="$pageview", - team=team2, - distinct_id=1, - timestamp="2021-10-09T14:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-10T14:01:01Z", - ) - flush_persons_and_events() - with self.assertRaises(Exception): - send_license_usage() - mock_capture.assert_called_once_with( - self.user.distinct_id, - "send license usage data error", - {"error": "", "date": "2021-10-09", "organization_name": "Test"}, - groups={"instance": ANY, "organization": str(self.organization.id)}, - ) - - @freeze_time("2021-10-10T23:01:00Z") - @patch("posthoganalytics.capture") - @patch("requests.post") - def test_send_license_usage_already_sent(self, mock_post, mock_capture): - self.license.key = "legacy-key" - self.license.save() - - team2 = Team.objects.create(organization=self.organization) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-08T14:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-09T12:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-09T13:01:01Z", - ) - _create_event( - event="$$internal_metrics_shouldnt_be_billed", - team=self.team, - distinct_id=1, - timestamp="2021-10-09T13:01:01Z", - ) - _create_event( - event="$pageview", - team=team2, - distinct_id=1, - timestamp="2021-10-09T14:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-10T14:01:01Z", - ) - mockresponse = Mock() - mock_post.return_value = mockresponse - mockresponse.ok = False - mockresponse.status_code = 400 - mockresponse.json = lambda: { - "code": "already_sent", - "error": "Usage data for this period has already been sent.", - } - flush_persons_and_events() - send_license_usage() - mock_capture.assert_not_called() - - @freeze_time("2021-10-10T23:01:00Z") - @patch("posthoganalytics.capture") - @patch("requests.post") - def test_send_license_not_found(self, mock_post, mock_capture): - self.license.key = "legacy-key" - self.license.save() - - team2 = Team.objects.create(organization=self.organization) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-08T14:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-09T12:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-09T13:01:01Z", - ) - _create_event( - event="$$internal_metrics_shouldnt_be_billed", - team=self.team, - distinct_id=1, - timestamp="2021-10-09T13:01:01Z", - ) - _create_event( - event="$pageview", - team=team2, - distinct_id=1, - timestamp="2021-10-09T14:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-10T14:01:01Z", - ) - flush_persons_and_events() - flush_persons_and_events() - - mockresponse = Mock() - mock_post.return_value = mockresponse - mockresponse.status_code = 404 - mockresponse.ok = False - mockresponse.json = lambda: {"code": "not_found"} - mockresponse.content = "" - - send_license_usage() - - mock_capture.assert_called_once_with( - self.user.distinct_id, - "send license usage data error", - { - "error": "", - "date": "2021-10-09", - "organization_name": "Test", - "status_code": 404, - "events_count": 3, - }, - groups={"instance": ANY, "organization": str(self.organization.id)}, - ) - self.assertEqual(License.objects.get().valid_until.isoformat(), "2021-10-10T22:01:00+00:00") - - @freeze_time("2021-10-10T23:01:00Z") - @patch("posthoganalytics.capture") - @patch("requests.post") - def test_send_license_not_triggered_for_v2_licenses(self, mock_post, mock_capture): - self.license.key = "billing-service::v2-key" - self.license.save() - - send_license_usage() - - assert mock_capture.call_count == 0 - - -class SendLicenseUsageNoLicenseTest(APIBaseTest): - @freeze_time("2021-10-10T23:01:00Z") - @patch("requests.post") - def test_no_license(self, mock_post): - # Same test, we just don't include the LicensedTestMixin so no license - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-08T14:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-09T12:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-09T13:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-09T14:01:01Z", - ) - _create_event( - event="$pageview", - team=self.team, - distinct_id=1, - timestamp="2021-10-10T14:01:01Z", - ) - - flush_persons_and_events() - - send_license_usage() - - self.assertEqual(mock_post.call_count, 0) diff --git a/ee/tasks/test/test_slack.py b/ee/tasks/test/test_slack.py deleted file mode 100644 index 64b227d7d1e..00000000000 --- a/ee/tasks/test/test_slack.py +++ /dev/null @@ -1,97 +0,0 @@ -from unittest.mock import MagicMock, patch - -from freezegun import freeze_time - -from ee.tasks.slack import handle_slack_event -from posthog import settings -from posthog.models.dashboard import Dashboard -from posthog.models.exported_asset import ExportedAsset -from posthog.models.insight import Insight -from posthog.models.integration import Integration -from posthog.models.sharing_configuration import SharingConfiguration -from posthog.models.subscription import Subscription -from posthog.test.base import APIBaseTest - - -def create_mock_unfurl_event(team_id: str, links: list[str]): - return { - "token": "XXYYZZ", - "team_id": team_id, - "api_app_id": "AXXXXXXXXX", - "event": { - "type": "link_shared", - "channel": "Cxxxxxx", - "is_bot_user_member": True, - "user": "Uxxxxxxx", - "message_ts": "123456789.9875", - "unfurl_id": "C123456.123456789.987501.1b90fa1278528ce6e2f6c5c2bfa1abc9a41d57d02b29d173f40399c9ffdecf4b", - "event_ts": "123456621.1855", - "source": "conversations_history", - "links": [{"domain": "app.posthog.com", "url": link} for link in links], - }, - "type": "event_callback", - "authed_users": ["UXXXXXXX1", "UXXXXXXX2"], - "event_id": "Ev08MFMKH6", - "event_time": 123456789, - } - - -@patch("ee.tasks.slack.generate_assets") -@patch("ee.tasks.slack.SlackIntegration") -@freeze_time("2022-01-01T12:00:00.000Z") -class TestSlackSubscriptionsTasks(APIBaseTest): - subscription: Subscription - dashboard: Dashboard - insight: Insight - asset: ExportedAsset - integration: Integration - - def setUp(self) -> None: - self.insight = Insight.objects.create(team=self.team, short_id="123456", name="My Test subscription") - self.sharingconfig = SharingConfiguration.objects.create(team=self.team, insight=self.insight, enabled=True) - self.integration = Integration.objects.create(team=self.team, kind="slack", config={"team": {"id": "T12345"}}) - self.asset = ExportedAsset.objects.create(team=self.team, export_format="image/png", insight=self.insight) - - def test_unfurl_event(self, MockSlackIntegration: MagicMock, mock_generate_assets: MagicMock) -> None: - mock_slack_integration = MagicMock() - MockSlackIntegration.return_value = mock_slack_integration - mock_generate_assets.return_value = ([self.insight], [self.asset]) - mock_slack_integration.client.chat_unfurl.return_value = {"ok": "true"} - - handle_slack_event( - create_mock_unfurl_event( - "T12345", - [ - f"{settings.SITE_URL}/shared/{self.sharingconfig.access_token}", - f"{settings.SITE_URL}/shared/not-found", - ], - ) - ) - - assert mock_slack_integration.client.chat_unfurl.call_count == 1 - post_message_calls = mock_slack_integration.client.chat_unfurl.call_args_list - first_call = post_message_calls[0].kwargs - - valid_url = f"{settings.SITE_URL}/shared/{self.sharingconfig.access_token}" - - assert first_call == { - "unfurls": { - valid_url: { - "blocks": [ - { - "type": "section", - "text": {"type": "mrkdwn", "text": "My Test subscription"}, - "accessory": { - "type": "image", - "image_url": first_call["unfurls"][valid_url]["blocks"][0]["accessory"]["image_url"], - "alt_text": "My Test subscription", - }, - } - ] - } - }, - "unfurl_id": "C123456.123456789.987501.1b90fa1278528ce6e2f6c5c2bfa1abc9a41d57d02b29d173f40399c9ffdecf4b", - "source": "conversations_history", - "channel": "", - "ts": "", - } diff --git a/ee/test/fixtures/performance_event_fixtures.py b/ee/test/fixtures/performance_event_fixtures.py deleted file mode 100644 index 54723fd5d5a..00000000000 --- a/ee/test/fixtures/performance_event_fixtures.py +++ /dev/null @@ -1,47 +0,0 @@ -import uuid -from datetime import datetime -from typing import Optional - -from posthog.kafka_client.client import ClickhouseProducer -from posthog.kafka_client.topics import KAFKA_PERFORMANCE_EVENTS -from posthog.models.performance.sql import PERFORMANCE_EVENT_DATA_TABLE -from posthog.utils import cast_timestamp_or_now - - -def create_performance_event( - team_id: int, - distinct_id: str, - session_id: str, - window_id: str = "window_1", - current_url: str = "https://posthog.com", - timestamp: Optional[datetime] = None, - entry_type="resource", - **kwargs, -) -> str: - timestamp_str = cast_timestamp_or_now(timestamp) - - data = { - "uuid": str(uuid.uuid4()), - "team_id": team_id, - "distinct_id": distinct_id, - "session_id": session_id, - "window_id": window_id, - "pageview_id": window_id, - "current_url": current_url, - "timestamp": timestamp_str, - "entry_type": entry_type, - "name": "https://posthog.com/static/js/1.0.0/PostHog.js", - } - - data.update(kwargs) - - selects = [f"%({x})s" for x in data.keys()] - sql = f""" -INSERT INTO {PERFORMANCE_EVENT_DATA_TABLE()} ({', '.join(data.keys()) }, _timestamp, _offset) -SELECT {', '.join(selects) }, now(), 0 -""" - - p = ClickhouseProducer() - p.produce(sql=sql, topic=KAFKA_PERFORMANCE_EVENTS, data=data) - - return str(uuid) diff --git a/ee/urls.py b/ee/urls.py deleted file mode 100644 index 8ab63372d69..00000000000 --- a/ee/urls.py +++ /dev/null @@ -1,96 +0,0 @@ -from typing import Any - -from django.conf import settings -from django.contrib import admin -from django.urls import include -from django.urls.conf import path -from rest_framework_extensions.routers import NestedRegistryItem - -from ee.api import integration -from posthog.api.routing import DefaultRouterPlusPlus - -from .api import ( - authentication, - billing, - dashboard_collaborator, - explicit_team_member, - feature_flag_role_access, - hooks, - license, - organization_resource_access, - role, - sentry_stats, - subscription, -) -from .session_recordings import session_recording_playlist - - -def extend_api_router( - root_router: DefaultRouterPlusPlus, - *, - projects_router: NestedRegistryItem, - organizations_router: NestedRegistryItem, - project_dashboards_router: NestedRegistryItem, - project_feature_flags_router: NestedRegistryItem, -) -> None: - root_router.register(r"billing", billing.BillingViewset, "billing") - root_router.register(r"license", license.LicenseViewSet) - root_router.register(r"integrations", integration.PublicIntegrationViewSet) - organization_roles_router = organizations_router.register( - r"roles", - role.RoleViewSet, - "organization_roles", - ["organization_id"], - ) - organization_roles_router.register( - r"role_memberships", - role.RoleMembershipViewSet, - "organization_role_memberships", - ["organization_id", "role_id"], - ) - project_feature_flags_router.register( - r"role_access", - feature_flag_role_access.FeatureFlagRoleAccessViewSet, - "feature_flag_role_access", - ["team_id", "feature_flag_id"], - ) - organizations_router.register( - r"resource_access", - organization_resource_access.OrganizationResourceAccessViewSet, - "organization_resource_access", - ["organization_id"], - ) - projects_router.register(r"hooks", hooks.HookViewSet, "project_hooks", ["team_id"]) - projects_router.register( - r"explicit_members", - explicit_team_member.ExplicitTeamMemberViewSet, - "project_explicit_members", - ["team_id"], - ) - project_dashboards_router.register( - r"collaborators", - dashboard_collaborator.DashboardCollaboratorViewSet, - "project_dashboard_collaborators", - ["team_id", "dashboard_id"], - ) - - projects_router.register(r"subscriptions", subscription.SubscriptionViewSet, "subscriptions", ["team_id"]) - projects_router.register( - r"session_recording_playlists", - session_recording_playlist.SessionRecordingPlaylistViewSet, - "project_session_recording_playlists", - ["team_id"], - ) - - -# The admin interface is disabled on self-hosted instances, as its misuse can be unsafe -admin_urlpatterns = ( - [path("admin/", include("loginas.urls")), path("admin/", admin.site.urls)] if settings.ADMIN_PORTAL_ENABLED else [] -) - - -urlpatterns: list[Any] = [ - path("api/saml/metadata/", authentication.saml_metadata_view), - path("api/sentry_stats/", sentry_stats.sentry_stats), - *admin_urlpatterns, -] From e19ba508a005b030489cdb6fca9d6e0ebcb2c752 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 20:46:13 +0000 Subject: [PATCH 2/2] chore(deps): bump dompurify from 3.0.6 to 3.1.3 Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.0.6 to 3.1.3. - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.0.6...3.1.3) --- updated-dependencies: - dependency-name: dompurify dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pnpm-lock.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e2bb4510fb..f8070b89368 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,7 +174,7 @@ dependencies: version: 1.11.11(patch_hash=lbfir4woetqmvzqg7l4q5mjtfq) dompurify: specifier: ^3.0.6 - version: 3.0.6 + version: 3.1.3 esbuild: specifier: ^0.19.8 version: 0.19.8 @@ -11631,8 +11631,8 @@ packages: domelementtype: 2.3.0 dev: false - /dompurify@3.0.6: - resolution: {integrity: sha512-ilkD8YEnnGh1zJ240uJsW7AzE+2qpbOUYjacomn3AvJ6J4JhKGSZ2nh4wUIXPZrEPppaCLx5jFe8T89Rk8tQ7w==} + /dompurify@3.1.3: + resolution: {integrity: sha512-5sOWYSNPaxz6o2MUPvtyxTTqR4D3L77pr5rUQoWgD5ROQtVIZQgJkXbo1DLlK3vj11YGw5+LnF4SYti4gZmwng==} dev: false /domutils@1.7.0: @@ -18255,7 +18255,7 @@ packages: react: '>=15' dependencies: react: 18.2.0 - unlayer-types: 1.65.0 + unlayer-types: 1.78.0 dev: false /react-error-boundary@3.1.4(react@18.2.0): @@ -20789,8 +20789,8 @@ packages: resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} engines: {node: '>= 10.0.0'} - /unlayer-types@1.65.0: - resolution: {integrity: sha512-fIeh/TtUhQ16A0oW3mHkcDekvhIbZbN+h0qVgBuVxjGnYME/Ma3saFRO4eKJll0YNyalvb9MdmSz0nyTgr/1/w==} + /unlayer-types@1.78.0: + resolution: {integrity: sha512-yw/341vpxMyE3GR2mk86HWGeF5F8ZVtMC+XgEi58Kf6xCq/b8qW+Bu0Bpo95lkzJkTF8DoyL4TTL506ePf14vg==} dev: false /unpipe@1.0.0:

1I&gSRyKAIgujlJ59iM` zlv|9)S0jD{U=cu!$9HYhq~%D9@woo&mgDi#mgDj0vdbaRmWe?*c0YO4F8Mn|S_(U( zqhNT#WZH_+&~tiOgol#7oF3zqkiOz9#3ur-1Bia|U&Ma~=&$nnns1LQ#r8Qmflqu6 z-_QGxEtj!v7`9BZ_TDfqi}VnD-=nRU1^jw3w;3{E3A<$cDZgY4J8Mi}yL8Ou#lz`v zXkX*Nd`dFL3@Yvl6PzOLlJ3TEeZ~=}D`Pi7-_Y3-+90wy%opH-^|WuGz6tD7(!Rl@ z9l1+UcPezZl5r#TZKRQG`vz?VHdmjcS`CFc_3tn#2fkO*eqg`;UQxeO=zkR#pWY==14x0*LvA7Sr zaIlNa^?;MPW6a|CF>{Nn;8F)$*0)jf4jO+OP2YrL{RTK^Q1=%2PCi3PV>W6YAj&|J zp=2^spScic;?deC$W@}=8-C*D==EA?ULTD3XuvrDyM5DwwT3hi>0;ZSpU`hII;YMk zpOl@=bRyj>sdNpbQjfGidjr?SQ(gBGMn;7)0=BELZvUC*R|{MaMXm6`1yrg-dIP`? zZLZrx`Q!mRkqU5PuEEB)KlHy6!zXR^N($ib;VY090$&pFHQM+FhyGW(5b^1NnE=6$ zd*0NgYmpXu1@X!F!R&rPb$QXa(izQM$sL|Ua{mR-kx!`K6KO`O0w=PxtCGn?P$e@O zEw3k}2$TNH(!PU^1DsF$k%!MNM8xY}sXZVEAllbJ#76^40W2*0i;p|SM%&}^yzFAMA@|ywpA(eMnEnfBS9K~=F7(5+br+$R5R1^7UwHZIK({F0R>VI5 zYzGKFe~}VC-!^}~lY*X05UpZal-w5r4&PIFz;AVhLm+~OuPXcPb=lt$p z(30OvFIZ~T2ct8y6Fc!+hU<0g6D^~^Zo)ZKhL@BV_dFR590ommIHHr*;o#_ zR2<4v;BuJR+7pY^kL>m6AmuWM=!u2`FCD>yoNnKnA$?u};{5?b0D|sI5uXFtx}Vcs z^lVGI1G~cMZl#|uDy=M?F{7-xRj2HiZ9Ed3uvF8Elp;ub z>F|e<1>;B>tVHdSF07esv&}c&K@_N$ys$AC&C2l`FUMBUDCXPm5&sGB3qZ7s z9$z`6-;mzW=DomHbdQ0xj~QixE8yO@cs+VMu=RLmr@uiL9BZ_%jrdJj5kYDr;#NdBr7h}aRa)hE zlE7G3M~b!Tomhu+Y4mPukt0d35w<-1F3g@l?O|STwV*X5FeavVi6R=mXW(a8*o1$oj+3fW#GET@_T4tP&{EL@sJm?kY_O3(xX27ig(H<}O z)h*qRwAjxmevkJr-v9p&-E5wRd0w`~wME@Sq&mk)RKR7X$;@s}LRYt>z?vhRom(kI?xcXVv_VG;-3MU0HQs#--h)aU?6~n4}ax+D{i%K zm22<6Y-!ORz+KCX1iz#yIyfvCO|UE&eMy)JqjzB0IVB>{IVClao{|=bgMm1c0lT*e zb@3dzJKkxVe}5of5ZCQTHk)#K%9NbFk(Ocq?|Z-$9?lmk^zl z0B$&+Wn|E~Y}EkuKm<10Q(;*%4K@WaCo({}8VTn|1Ij7Jwa*aW12_l}{D^-K_euc0 z04${cZu7%FM`gXlqj4v%zwIaD2l{u)HVRMblx&0XyLYJv3|Rwfe%+VROQbzGP>@2h zsml}V?MzjVNxSMP+1)$4=rE`1A%0scWOWkElY?kAcKTFxm~lFV*%1{quAw;0bcxg; z^YT5^IkYaRK^ay6Rs%#oc^mP+0J*llO6;>ScT8(wj1Go^|KuMH{Nr9&4|Ypi)it`b zROw1h(@Sq9ZZ9pwX`g$rs|^n)_=r+Fn|XSS;y0%=1D$M&pNJ{mkJ?Jbx(}_v1q;eN zF*+=MEt6Fshn@`&?zAhx<-@LWnRYr8mU*brdAVl4&)e}}^tpQwe*{nmu*;=A2_m0G zdXH`QzKbpA3p}kqr-j)~4j-pySQh+;Sl3TPd&UuR+azby3RK_IDjd*ng~1+X`NVhmcN|fbHP)8Xtt@D*v@^sWZ|!08tO~=DDOkNIz=d6Kr{2KQta3MGu_vsH3F) zP|L<*MmO1RYYv227mFa2*!eF2ZV`G`Vm?87ZCL(8$7#~TNDH}He6sPfmEU!n4Ueg3 z!9Lenl9Y^XeX(@FD8$QqC@eqUhp>+TNCAlQc1FB6K;$DnS$-}2lnSOt28Y0}pa*^h zA5$x()o>Wv0}`W$Z9=kb$pWZlOsHajAK#D^QP!Zz=~;)oL_dEI@!tRmABFmPJ>p*j zJRfsD^|#08tzWbprv=`7!`A2Fej{yiSurZhE-k;jtg!sjOUq_fW3+Bio^x+zm&?w# zp5v|O23tCa^p=2hc#3V=bh}2TBevXqL zpg4foPIJ=;S(a7wKs`PV4cIgS4#PYbN0H8QK@#uhIKyzN@|Y{~W@lW{V`fJ5X;kfz zxP)W`GxS6`dazUXMmmWa3;3RDf3RyGZ1#CZCndO|hMV0G=@X&jVWxlYm2ns|1BWrc zvDYKhm0u{-xY)kI1{m&&6vN6f_HQ;3SS`IE))_O&+go+UJV}^+@iS-UkaFuK+#%YpF)eUhz9gE5?6wcvx#e)%NgF9BWyh<^Gp z;@<#-enHruXANBVa}cb+JU`o-=bcNisNBs?O%`{6YAFV_>?KaN&}0)qDVn1fjRNPs zij-lpJuby^dNQ|%{hwyVoDWCO8`vkBU#I&eY{(JT95eifR_NGyblf0q}Pxxeud{F%FW8#ydM&_KiXXO8uFrL=EMyv6!W$H zMA{B5Dsz=Ik@kTE7Kr2J^Zj3QS>O=Wl)53^9U$_5{S`wx4QY{Y`1<;o`R75#p07=n zn#fdi6~#xqp?+cKy8t*vJwJ%}6M&5Xk?(VezX=e2#s6NuIA#+Zhhf>*k12_-C!hci zYe4P|#UZO@i1OW)0W!pRD+J>?AL2d@)k8kwX95NS1U;X@Z{;wgk6RD+qAsisKDO$> zn9H`)uBXx#iU-URmBJP<=X*&4&wn{^iy-RZS@2%u|F`NPJbzXP2#q%WL%`pf-;tk% z%J;YU-2%UK2p1^fKZesk9=Jp(MZ6MlYZ(6PKKDvzeF43~THcPADSUim?bxz|&rTicD5u=+2#mUO(vjrGOQiP%lG|I9NM>mGU* z3L%@!9>*un%_xwNDGxhvku#%5I8oZkf_>zqMf_UyCaGVEspZ=X{PGbB**-xT~{~O}x zf7_|w5$*6hq8xsw_2=-9@{{P7A%x;*?C&kz;f?$JHFHBmU zjsY-yLvwLvBiH(jg$$C`#&4Ndm{<$i0Z&UvXqAqF%o_zISleJ-4`0Aej$W^0=8$fisvRV)O?a zzwu?LKUBYMNWXrhNxuOE{;TnB0YHpD$H!l8;RbrCB!|;K4fusV;P!V6to;pXB|zX` zjrgh;oYJ0Gj~>^Lq8|t37;Y9CZTt;k_}BfSN}Z4BQZ|4E#-F-2&-2^1o8V828CJA@ zoXhF;eHEJLrkyKGh4Gp+5Fqfi_BYA-+>Fn1qr`q2wDFYypD6!7pl^8j73BxMlkW%i?eMUE+F;|W2R^~iw%UK&_1}!I zxt~gTyxxtSr$paT{WM_XEdkz>@2AalHuuvK8(-b2(Roxqtr6wl^-tP;Go45E(^^q} z;1%`MR{uXiJss6g8*IGwr%Z3le%ff`^?h@y^tSA$QocP80`JL>gW>#b*-ryD-Ui@3 zh4$6lPfKikzW@G{egJ_Os^ze5D%IHdN`Oz)|KIPY0$-?`HrV*;PKmxyH*K`>N#Fhx z{t5b;yQdT=;PqStd?!E7h48hSMx~&QcOCGaeESi2kLsRMiH%p<9jc$d-#^3fG8Qz? zN>J?Ec*g_pDbd@qdrGx7-nvtzw^jFSu<;t-og%$0yQkD><1GQ+lkcB`zs=oK3Uue~ zs}A^1zI};HVxb-ywDC#bhwA_D_s1F96zJUk@o_i}s63;eKdDY1JhlwWcR;XAQtB^a3$I>rzKRCxF14jrh60 z=~8u@>uQE~z-6;aXGnow9G`DrNDt8N3tj3A$OZ^}-4O2!5a)|p>H!$OAytELz6}=p zW?G(-I9sJ!8~@TU{ClBa*nsrQ0H*)PIvr{eN%|z{klx2T7Fy?Hy$pNoX|bQn^B;l( zLzNemPR4OjteTsBHc4jgS~YTw>RqeSHL(Ax97+-2_yuJ3HI$uIH1Ah16 z^e+7$;2k@&s-kT2^oypKO_6GCyt{!{$RR7g)}XI8E@cI!?2JCtH*mzlo4xVn8ANa()S#t>g zH$jv30|dUC@oqWb*yDtKNt9I_&GHG#3J4S&>bN9T@m8v1Ev62XpP@`8TMEj zw(pkNiSehnyju^P$*CyDal+e3xw9fsG6$_yU2D|Twb&3`qjr;&LCa;>rn^s0y<1M% zM0?uDZzHBdHP4i}dzcsQfFx}uN>J{|j!(FtM@^xnvZcFc*$4V@KGlOx{L%{lY&sc!wTG%{%p|J9P7Qy*=NnnS(DKdN z3w&cnk7G2lPSQq*jLmSI_Tg?7 zFM-RxI!sY!|7X;KO={FL>f}vo7fI>=yI$~{9#vyZKB9Nwy$M&-RElkq66H=Jr^D0) zHbA22WP-2J?hAQ&q+dhjNkn{du2Y%{5PaIXU6U?B`nY_bf@8Wk0t4yfrH$-Jdr=v# z_7}r)1NhdT=QAxVpOe@Bx5yy0cme*v0FHla82+7zkB`))2>?-!GI*TZgZHiLgX`^E z;m7`hWuhQBsGmECdhDYFa3InOwRV2J4u<%4%H;`L>;wk#@+=2_vCg?-I*bzl{tXc2 zS%~=UfPyyc9AtKCRUWbEK?xdc{D=OR^oK2if`d5y;}3=A+p~XgN#_AB1PJRAe zapet6Mg#k^`ST8f33MwN1?lQ~SyBL&e7uvL%)_i&!0xWLhdpR^WQ&J}B2LFP$JA%`-gE6}PH$rv-ouEumMfVZBmKw;>ZQ`YuEH*< zO=se7(P^lkY*ddl6*r++B?QOt{EC1}@P8`etI{-S4M3Em{a%+;f%k3m2N_vHR36ka zLIxSp&tNjhnUF!CnP{-{sRxePgje)ih=1yGNnZd&K8=Wfg!jeI@pVYJo~AjMP$r{! zCTA#_wk7IdF)vRXPDu^6+Y_@xosD!i`{WeJp#`t;ai!&Xzp(p6{UIAlW;;pY7N|>X zycNJ9`e9pmyV&!@aq-$$!x(urHr{Q(EBJp;ttveft4WUn1Ya(}8e%=t$8AT`E5=kq z-%(gvQ95b*jOo?W%bM?hHrn~*5}(ZlN!sG3(k0Ir(o6sgj4!R_WiDs7ff$Ota`sPS zIlq?zZ!eZcIC;763TCFhauiPcWM%X5sn*838+d~jR!M4t+;{{aUmTJrb;Kh8u9uFU zCqh_lq`(s#$LS5y5Wg?D#wASy%m4^}XPobpDv&-dzh_oe6ldy5&I-f)O4C&+IpIFG2bo3ahKUA&G_QzcGcEB4wes%-9{cF@8V+D=jxlxvMV0aWl9mdC{=qd z=Vv`|ihll&=LxK&Jm*jduzN^kV`IrMbShlFp1N>AO9=(;xK(uETAJm^v~{yj{uAX zu+Tamo3!SSq89zLW&UHXD6PO?o%=A!A-P#OW^R&X7OqlftyIlbYT8PsmMpASW^Gi= zdL?b6Vyh*uWmiH?3;dRC6D*fMCb0Pjg?}NtA&Z-!UEuGQm!}qVihli1>Y=Ez3>F($ z4e(HFY+2@1%N_;;1m1xXr#{puy7(iO=-m^kuPe9x^qjwpcKjMQLkogRK4}8 zxlxUV$?Iiu)@`zPnQY!BL%ly!fwHy-Jk@x?9;B@hoWl9A6?BMt{S5KlfP(-*ujcYe z@qknS3n!v?cANBy8odbgW}-r&XYN<8cHgL)^=kW#YDY=wcZb~lcGLXi0BnJ`n@VenofGWqX2OAq2{JIVwQksnzH7a)^p^&MBKdby{_(W@~Kx z`+*;sTfy!2Nil#90Kq5rMFG;i0W6$|Pm7M?Q|tVB4POk7b+>A`V591(SCcowVUW_} zFUoSN|A(UXZX!y1R>cLdE1gWN>I`1Q`LYi5w65P*!stHP`mH?y-BwL2{hpxR zAE)LMwEg4MuBWM-o;Xj4o_{Pqw77O)v0G}i@y`bSAb%y@hWL>tU6OwZ>Fe*sy9WVn z>BU;04P9oSktOF$G)hpf?852!XpIq))C>AJSm&2&rtxy^2TpNb;#%lG7Xoeuha^My1VoU)0SV(UG*rBa^ z4LP$aXW-6AZ@c}{k&jC})L%PNSQr_~1?wR@p9t)!ZKD5tn@p(Yi zYAz42t-;a+X*v(8WA|v$cVE1%B8OYWh=i)lprEDFQ zN(s^7H4neCLgNh1r`(7T{i6^+7jQ8^(0?W3Hvnz{uy7*!8(YyoWaj0iGp0{rmY1!T z5>|+XFlooyON&C3N%bVPKRHuhfhAG|w{1-&`W=9#3EeTRP zFxNw15+j+WCuV9s1>fl|eFVNR{oQDuThR<0d~j%|Q6mZl(KHwnX8-2}v{T*APSH$< z$U(g?FdmJ6Z#xO1VmxhJ8`{Nr2G`fe9b{Tn|q z!KZu71H|7%90y2d6Pa~@oZUpc2S{NP>2QE(O+-FGBAQ^G?Eo3oL=5YTOzRcijB6sQ zcynkU11i7*QNk%@#Q~bYD z5qEz?k(c@rMO=LkHXt<9aA*z8JBc>%MfN{0P~%DV`xmIYuLi1=Gv%{%B~DX@DZ^Fy z91^9e!%+6V@y! zc?~!tfith3$}aU~T38R<9IGL7j4#OKP5devzv{V#w)q=hCAm6*QC3f3MKD_36ozLL zRqLt1sPrm9gIYvek4ZMmviHwbm zk5L^Cj34ZR{wGXY#c9J0vND9iK-dQ|Yze7f6JsaQuP`puuQW>a`NWv4PcZ&X=os}! zYm8Y>aCXJ3_+^tSFs>|lsjPUsNC_+<6w@7bPVPuCWEmDqBsay8IPP51F#cxz! zgtv1Xp-Ch!)`%;N&5XnM@g#}aDesx6CKV=TCaEwZL9mqSmf{N(rX&Z{6lVaxqQ10D zmzprWLmHmx9Xhv9k^}57I^YU8i!uIO$;Y3#xR9Kbfp~ww5P&To(1VD}@xG(%Gm7EF z9$nZ7@-!bow)2k*tRbThA1WYdJ)?AoAq+W5PLtEsv>2GC^uc3B95s?qnTmrMVAl0jfAr9qe~RjZl?bit5CWY!F$&*sPs-T8hD}b{V=FnK=;u2*U{M-*HE| zv)y*;@Sm*hU=!|fLS`zbGus*c(H58?0Xqb1I3LG@M`HeA=jvx6JqKWiw)})z12B9& zRh3z4>a1)QV-80!wsw$KYs_6JH<|z>HFiD?z!|WSn#-v41=2fCGM}Q-sw(K+rXZhu zX#?^}Waj^PXt49iO$e1|7~&HEB>>Sb>T;l~M0$(uPgmG0673?qT+N4@8pws4327j= z!4M{s06_m1*oybSi>*_O(oJQy9>G{Un2v^-SIcEhjmRr#)A%#ujzpgn4G{Ar z^Y42)(gOi3%x`7?&i>ctj}4)9lT{D!NmDg>S{axRMaQ%pntKTrdVK)_3mCQclWUcX zdCKENY6Q_Rtq%ha6m3+c?Pa%0J&66OX4Ghz%IQX`mYRivJVcb2tpNpYi!%uRQ!2TR zm!}SN_OhvY0rB?%UjRgT8n1Lpzp-=@FHh_9s?7zlBU@XQr>X+`SttwNV(tg!bO%5z z^wW#9j(bV^KJo)>-|hTF(tcdNCEwg^r9?llY}HscfPYdU%PyrE*0ZzqoX^by|G@2BR$A{q;O_=oiD}mJ zT zkG##>ZLhsV#gD8uoNxU@l=Y^T>?JAIf8%TX2cC=YA6fI1eCxNx((ihXk!85Orccun zlrEm}T91`vz*?9%P^jDVKTljQ5F8DSk|2N{*asVCJL)%;8#8R}AFdZ}g1s^?Vr)qG4VSo;sW= z-fYwJ-y~^Uh?<}#o12MRPtJt*Nlo?XIUdyk(S*RtY03t|;_8FU!z5x45p@+Y7qNz+XDJ-A2DTBhgUre`eD^Dj0}cXY>K$V7+j1SRYRg%{kB$H2^}A10B|p6Zxh zGYt)GlMD@>2e@Dg!i2GiNVTxTOW$%zbKr*r_xgUQW+7Nz3!beiggpQZ%Z8#0VEOiU)ME5A+53`Z|R)xE9^ zXTFjqN5N@HEfXQUOwa=?BAzUFzAwXz8J>sQCA^&n0-^EdT*MaxRszI$a{%$^ROrh9 zEKKRj+j&$D|K!+0&pjc#TULLz+WDEYs;606yXMsfo6?%68__4&OLU1k`h>ck`7>^AdEgte`nv3TRqpkyk+H!zL&tkZ&nUy| zaM+dqyaJ2R%wR}=7H2`NZw??I7+{ zkH-r%>nK{!Cq$Vb2bIq9Q$&N50k0OlU@{AI+aYi}ub&3+J=cEsKH^dBeNrAk)X#Fn zR|DPxuuz@O>t%I^mi4pEUI!ZK+-|7gxm7>h4-!PR?-WZqyZkFk8)+vi&Yu^D#UJC# zz46woEqq;gE|=$m*66d%uqxposwCGMa?s#AO50H({x7KOOKNDjsF);J&B~++QN3YT zD+Xd-KHf#@ZrDk45Yrfk>9eyk+u2#~oEb!{^Pu(QfRVAs$mUA7^}S(ot6HDv>PCvm`FfIJIid+d z1m9-B{MJDVV=)Fg3|EGNpAGl$ddN%*&1?M-9|;%(5c66U;Ac2@yY0=?ya#cHur766!U*>pA$ zEv_48u?UPo-ADk0duS9d?dkMk%G4uP2;Rr(ZUjw2{`>>+h;-};0R-KjzobijkZ!$C zE7}L-HAXjAgiL9s{ZdAHJNCGYJqFB$b$<l3bBmcR9t`4T8BthyObm!BcfA zC%G_0vlDBRl=I1b_Sv z;L;Don>mnb29I)qs&9yNw-dKWTsl?}GSvap6Q8IuYLcMF(RGImLwGX0okT}@Gn|~y zud&1Pnk1EBM+&c`h83L7+zz33FbwhY022X%&bf%+4>)l<7+6_4m0KP}Ti8nG!d1$h zWUd<&6_jnMivVQ3hL!xP}Kf zUBQeHUE>g+3h0*^8h<~}!d@JpUnf5Px`y#_H*U{eEXWB|n-lXzTa*SCgwAbw+$; zedn+2{5PV>z&3% z=ioIYy1MXtr}3Rrtx8x+)Hq}C;;t?PR^s{!@1x&$`5$qrdrbEa<`DeDa+i}j zDLKf9iDASP{%*PrztMrVr{qP8sHn>B6CAZJ?KYk@ep9{9sXpi&iho~5;+}BEJ?>0> z!U-%7@Lxo%Cc__fI%WK({~8kes!M&(S(xX>t1mbOFlHYB=&9GUe?ZYt(uMGVr(Wk& z=R4ikF`}baxg!7MEQ()EuE=Msi>>Uo!3p&TN>vq)0vjs=G}0PIJIY@+^7DrM~F;oXm5f7cePzaVnoxo+8RBB3}kYzS((F9()#>$I{tI+uYQO3Lacs;)M-WXqu_hI7OPUNfgOFjM6c|?DT7!}48 zeIeCPkGNiD216%6Iprg>ap8|Csk*BP(A2aiS2-d)?6bSuh+y*TY=2Y?!78(RbYuea zLR+Qyk~<`*@kYX6Sn@B7Heys*=2ZRehyhSZ7P_N6xG3j^%{YhK973a=u>rQeE=;*7 zozM=XyW+^uIx+m!S!$=DFtQ)lH7++b$Cbxu=%>50e0lw8Y`&UasJXM8>A=;YPY;&= z?C$O!DqsMO?ityez>M1!3Za>#+v)Bz22i-VKh516-zD}+F#=IEI<>FH=(Bv|B_zoy zOu?2EFar5%$D|1SL=MD5;U=fTOnl7-KF+M`92#dD5PuKwF+j+9za#GH;*$~pEC~I| zvOX=xnWaL`yRD_1XOAn12=X?(fw^;Ng3*bpx9h5= zEY+#jYmM$t8~CbjzM{LHgH2hxb-d3c-&9DxiM*A-8hnnNq|IPr%4`U5y|HK;sG7`K zn?z6>uj&jPu1mY0^VT&v<2_^^alamaTD~Td~U?WO+I-xd68i z69E2+tN@P^>3cWx0^b8hb|aKTr=>I+rHld9wNLZ>_XD@cU+(IYynrZx$UhbF0>JRs zc>b;D89RSmmVlH|aUa2D*Z6WaPH!jDd`~J}NH7h@V}!G$wQk_j0DuXPkh6%0%>l)hq;bm`10SoiGTM z#OySe@}7p{HSoYRiB_{E&4YyAKxiCW_Z*_MGklY}X-_#1yACjFppuKA&wq~d)5s0g zXCC510JQ+Y&q>{UQWfAH01LZb`8Xo@*tlmYk1k$peG2B;?T}00EmTMi^PfBk(yhL(-PD;IuZ(+F^chk znYY#)1LXi__3YnGQ*F1&<9pu|xXJ1M-R$s?TYlJWSkLs09>+5tjglR1HoN*Od+c(P zC*7Em@KA6TiiwvJ! zD7*FO70-N2cv1-S?5_mA%B=2md86cyp8lUqB;A%pvof@UwQE z(>dP>v-KFT9C2NhzDnK^!&s&(x9Q3qdcyZ)+7Tn|4&At2HxC;Z$bRrwRnzcWc+`t9 z0Mri-IeKbptQipv8AbKN5Pc=toDfdfNX)N^GfQKtb=L2{rs(&-VQGZ* zAlfVZ4ATdh%N2~%cM~NKo+-#PL>Vq`CfavIA4R*dDfwc!Gw6d+Vk^-M0v~WBL7t|i zvF%0mc||dCKD6WwUO&6Rcg}enNRQN{KanN{;*CU)wZ69HhapvGPO3tm^+^kS)<4zQ zcn1SVu3c#tAU+LnDL~8@?7sb#NYD6$*LRrzgmC>x5j$~?nXF_qX7%ieY&E=wNXvYw zusN^NWF^Oe#bbfepQf@!FhcN6UaqacE!K~_5!br=Bqu=Re>38*1L}70{Ga=he~R&| zSw6BH6OAsrsI0PV=43`nE%}X4sux?vn2%tq0IRFV@Qw{$8+54-)Q!e!rh}pz!)TPl z<8fmDJsy8vq9n>mCR1AzC0>RTMk^L8$1$ZWHj8myL$EC?W-5~@zf~E^-E>2NM}QPF0R2U@O47x|ojEK=kMGIi_?y z(u)Bs2)Q)wL(X68W!U5MR{K#l-&@YaRx9AI;PsO^r(xp5<(_^ZT})Ltjl_b|<{we?IRE6yrswQzw{=TC+k zXp8@Bvw=%)HFmy5z}Y(ADR_61`C1yBhEPUQqm6$XaEWo|zla|MbU7_lum48;R=_F% z3&9U~y^4Kk>t)zud@H%o+8<@5wslBL$v2o>aq{f{;2Vi4Id`FUvBT!uOS)_C%NS{!KUK?s`;5~(@D#DrbnP;IJ59yR`qWftwwA8b|1(oQ zH&s^en!3YO5&z7368t)IkICMBZK^-Ozp47E>Cs)LSN9T^4pk~^6)$73V7G!#!(2yJ z5m@`69hj$~UCg~Y+l$MR$15q;rV#UHZ(@{SpO{i8T!+}aDEW}L8(;4bf3Nvdk-8$? z4It{Ta;hnvhIH$F=T_sWZ7k`m=`-0$$wgYZ)P%|$M$lL~LW`hFjdp%DVfn2<{1HGM zK;-ul;vWHqAAKG-eBKy4^HOe}$<7fo$WnqSByXY*xx<6xVf4LWRMk2_a}HiG{xIH6 zB}wcB)TS7bp*CeFBJmaE=8rf(0)0a5xd-BB0tNyEeas)lOr(!BZ(hz$JBp(mY+|bd zRp(jD)%`@eKO&WOf{N6@VGCKI>|~*XeL_Vo<2VX&MR!j3c0k=eL$HyEPpR=^PFEfB z6#dLSMU~z``h9>MjyaEWnTcJTEuB8Is<3$YS#zq{!IEy$I}zB-gsuzc6oDl0iCzA_ zp?uDK$=T+;>=Ah1`o3$k9i0jJ7fcuS*aBBxEeLG1@z;dm|FE4VeFfMFVEkqGX!T0m zi$waE_(j8O<}uHqVLPY4F%17Nh||*{8vq3UpPter6Y19L*k<~h@sA#k>MMGYVq;+s z#d!(yT~$&5(zjG;2YJ(~9;x;-p6_hn6zkW;h_3|H0oeS5a;cjU{|vAjz`~>Z_&7D= zJN{|C40|Xwd_5+}$?U{eTv^#->Hh$iQYs;r)RF{T+?c0TUaOh&v>DfGop5nuv8VE8 zkGa@0<7Q7M-uM@hFkJxF1J>a*+(DVFjD$X*FV%Ed4eCcL(Klug{ErpB;N=^CMyOrP zMEpv?e1It5^yi$?%}6f;u+W;n*2}O*+%d~%s}M$m@7gH62qqEo=V@8jYPgJ&zuc2` zrw4m==ivGfxSEfZ3sNl9+H^J|f6-4)@*bT%z6d_sOIwa!3Vg}=P|`0{&zB>99bh>? z@L@gT&jMZsu+UnsWut5U=yFUx*eb697(Yh9@K!PmZ_U#j*J@Gow7%DBbe^VPtK~>a zaJSp>tvhO;y9d>Nbkkqm`Y-MrUf*}JJ+1bVbS+V`7D_*W% z!BDwIB0dpN4iM# z^G&{1%Y=Qac^;%a<~&dGwH|aVHh339k}U+7@bRIcXvvZ^3dc-3s~OmILOJ{M6Bx+^ zzYjF>a_tAbqCJ@FG$|So4-n%)GU8c)*6sDE@qpiRkODh6-oa;v@RlK7377>CcxNMi z9pKpeYg|oY-`cQmQA8Gv3N`z=k}{VS9IJd3){pu_HKNs9q1L8j8}bn4`)72tKB3?8 z`e@*5Uf%I%h0cv!jQCxEPXMfaAgKKdeNqvi8o+{K`)ga!%3oX1d5iV^f(871)a1%J z71iZAQ>sd{$Km|rMRTyEuaz%?L&aKor1kuYSRWe|>!Y3ZEV7=7E8x*@vDSMB@qA4j zJ4o!;1o7yv$vUzFqg&TTGGGUp_BDA--a*u_N!$+N`kHvHXD92KBrR8RH0Rs0=N;Mc zwjBGhtl@@Y(noUi=T@w}F@`z}*RRz0TzAvJd!~cNxz&i24YY(BkJAA(1+#eybWUBc zs7s^iN*|o_{)5KtkX;|k-j8J0HaS9#!vT}6vX{9`Wb6G#c9_(Vq}vDM`z&;_WHaY8MTK1O2k``*ylI$%Vvw-&9gBL<4(7=V*QkKuf_9tT~!7w3dJLFh}u ze)WHOJvG|=XG~r*&ye;b-2`Cm5`os^-D?10Z$Ny8)q~}ZXfc*wqc2CB`_``i{-Jhu zH{wqNJ_3mP$0;SrJHRI;0a&Q9?++bI4zIE0a68}G-E&XF9j3yvN(kcRGqSQL+Y*a( z^bcU@b8=~Q8QQ63$(F*+*%k=vkl9CBPmlFtz59M1p}9YfDT?QYJ$+29N3j3g~bd_7;ESoSC1|}=MSU+#w=?Ux{@sf8b||lMEFQ%&gGYCYc%OYRy;G+h@oysnC+EV zBa5Dc(Q_h>dyd13V2VD$13fkrNV;x1l7;W@a-4GehAE^BXrW)>k$6lS@J)zAZW&+@ zP9vXA7ZDuFn?y&bw-IB8K8U1ZZR?R5f8gbvhO!8G=r+Xf1FQmw^4|BIN7{z8@fI(q zScf08ycHE?Gp!<54XiA`v}nKxtLPu2=uVXO5S13vRGQjR>)=pwaHz&iP-7|Sse65$ zD7FYYs%}+_OpXfZk!nIr2I_30au1=^)*c9}r;@##?!chXxs)>z9|0H*5OhyMyb{oQ zTt61wV`fddxNLGYZUx7d)Ug$cYa(lwUlQp#(AyPSn10X{xfmU6ikna16}JOe3^}tu z+g#F^x(^|^kJHzPymPHLk`zDKC!G)Y2q5}l;dBc~L!y-%Vn;d=dW z`Ym-#(XhfX-HIxwUyiOetZa@1?O6E~s9$qRt7dk~ZeD+};hMguuArorcDJ5Wt>oRS`E2Q{UlJzZ#*-hNvlE{|Nd+4{sy_>vFZd5ui zRPw(i{@tYbMkQ0kz1Eu+&l{BteESM9UnQ}xkfE=VMC;j$W3awmPvEQy!!3E#SNsqT z3_7A>45epGFL%$#W^fqMiVHnEim0w=dNu~=44OE`J5hmftf;#v9wjr{%GZ#ZyIq@R8K@KcHvy#q2&5Rim;3_j0>7>|Zx(rd^7x|2yl=#agklZ21ni|NmV zUdv9H))Sm5-NqhMaCnXudY!H@NIXy2G1Czcrm~b6*{wyYZrqh{n~{!tsLSnqop?IC z^Id*tu`kn`Z~7fx!>@ZaKjs?0?2X9q=VJhgjT#!67&A1wS6t`V{Dh2nu(IJ7-ac!F zhUA3Ji2H{@js%GI`6J@);XY{;fQ8Rr=IyfK+m`LKuHRDYd*7(zw9j5)?bB|+WmEFH zckgyu_Qc1S6F*#@D1IJ z_R_0;W{13v8J+WdMMP72VvpgRnEdFx7_UE1De`I&Su}Z-Z_r)7;Z(*om~*gCm`A)? zo-!KeAtJS88a3b71D2HNEd+jwaWpS(QVu=qf{7^^p1inc$+;MEn5goZhUEhOk$%No z#j?nU_Dz`H=nFj{q|gEIYB~x>EMe;=52vn_9fb5(M$$rkuGvWRtIZkC>7HVDxo;Fr z^Ph>|U5Qk0b|-5%8lVI7QZ)9W6SbjgZzEIB!?ETS)Rphdb9tTb5YHg@qr{iz&GUJ^ z?~sTd{yjA6vdD=s8PQ-+K)TRsAKUD9#1eZDKL|Js5aYFD1jciKkaNUm_;|+zo-uQ( zs>?1NU0!Yt)xk^tpSJe^tfJWd$9HCD%iY`WO(Q*_TzW$2gc6!`iGYCE2oMB`3DU$K z5Csth6%`vqMQo^8o+6?|!SYZP(PuAMussXU-rnQ?IWxP-4IuK~@B826?9P_EJ9FmD znKNh3IfF$sJM?q3H6|3T9Se9b%nEhXb7;&@Y`Op~(E>QYiv#R}TNgke=ns93o3CZ~ zrYDzgMfz31_W-XQTz)M2M8H!3+CCYs=-+;;X*(z`Q|+Mlztp^Ja=xo-DyAjpJNX{y z-xH?5qHD#!i~~BW6~%IYDNY>i2D8{V-FeK|#@k`qjQ`I1{lQ}LWH!7%9BpC|XKkW9 zs^`OSE6_d*+^@A=h(UQ;d2qA^an)ys2HB&Vl?Qv`UZh3qU?SAoOvG_aLBHnXp+Y;+ zEI$le8?33@GT6IacV1`|7=^(+!!|O4=|<2BwZtNtP<}Nx3uD2A@vPqO1r?oqQ z5AqE(1_k>YR{rqd5T6zI8AU*+70VCSbDp46Jm{L5Vjo{t<%} z#Oo|toH76t>L8wQCr6aR1_#XW==A_Aj$#7_5OwoeP{4^zCp6hn;2}lZQR( zP$f@xtCRJ*Jewi+C&;~zj5=>JNA6%eTnP3%gMxJujt$0uVy zOURSX-?I2E99NfMU>JOfrS4+GUt(Fi*yxwo=~s%9t3>B3MK`&}L&s>#K{`20BI6cC ze6g4lMGH-{J3VQf!u2PN*1XV6g_If4?Qe{T9Kvo7_J{^zhMfu3c$dn=l+KZ^d>AHm za&zqLIM}hZ(=h&NpwTm9bYght%|?8YndsZuC=TAjz6l2pgi~uH!w-bf6a5w*{VR^s znwPZb+`LHF*KQblkkIeM7j*K(L+URoH`6dNsX89wdad{$H z!cbRoBNG++YGwkj)=lIda|=dhMnd|>#GOQN38%g$GCZ6W8y!78r6k@t1?!flcFsto zA0z2xZ_I7~V;kk`;YeQySOW0c|ARX4XHVl8mCVE+FEmkp{+%;X$H-gFnM8K-Z(wagig%Pi<@aC&l4@+{%*z{ zJ}%{WJ+Ax_&dOBN56+cTJG_%a|X~h z?nhPL^}nj4yqL7Bb?r8i@!(A+CYL_>z8dd04CiN5%pKeR*ue|uRaVcXz>vw0%4rqv zeJ}<=)XQox(|Z03*g2H(OJMbZ!&~aGPaW2&!%#8^S!;Ilj|)8G)81u8ruCvT?gb~n zBX%&-R#<4>1rJgP>W(6^r;Ky{sIyaWV(*m}2T!*wxfsFeDrfBC+ z+Z@Pv6sam>C~>L_V5h-b@CvxVK!09poQuw>6fnx1VaUb5yMaiT!?Rx>0*Ch<4C_*> zde{W~7Q1+S6X`DizX7~@Xg5Bhbq5Rv(DsKr&wkRIP3vK|D=+Up41ac`-8Fb+CD#Xa zNYAPc=>v2~gs87ki0C(L$k%N0H<$+4!C@u?{e}-F3zntFu%dmw&$^vQH}G!$KmbF( z6LXE9m>x5OKo-#$bx-%Yga;4J5qv!Z+oZ1cYjVCGB2Hw6H@{Gpqwuf><;#~P)U?Nu zegUu>;I+FkVN@N?%XX-8AFkac$wEzv2$PP>)MN?P*QU1gBGbO%1;XJAMIH&GJ;ZX) z5F$TUIUldY(+hCAy1!|MK8b+|A2 z0l%u=4Wak+UhnI7GwoBItbf~ySXwss?PdBq%!fcFX{KWsuQB5}hB<39Y6p0#&zEDj z>J5DmcnRYMQG#ulq0(q+hM5~2t-njZ)ys`xxIeUPv{TNr%z%l21P%|tPzHvl{mkK| z)dre`nc+lRI;r^TP+#e*tTZRO3Hqm0ro#-z=;RZA(+Q+;C$kFH;{Nnf+le?~gG{o( z`7-g>g11P(i!uTyf3sBgH#a&t6WRTu*G6H6oD2sO;iWII(k;yY1koE&;^$$-0#E1r z5hp_M*7~QWc{h1%9+wbT>ab+hLYEl z>vdYq8JKJxS2Lz^K_$$aMpjIztRm5`j&D$k5*QPazNDkvcb5CmI@$L2TiS0%5zlq9 ze9a(X4+Fj@nscCoHdqAybHiZgp5mlB7`4MIC=(pN$jFJ3>PujEZ?vXAB0;szCRcumYi5@U=4A9AMpR`959SY8IIGcBA_a!^HT^G+SGaeF7LwsIr+m+kYFC z5QQ1d5s9i7v~45U^{}tev^Kmu!yB;GwO8frabjb=Pe6J;;8B2APX9^pqXTpY(DsSj z52m>NqP&RRDz}^~4l`bp<%H=_lTz+y>-BHR%l%xre+$APPCh0X5rrLefVHXR<^k5T zmZu(Ifm&|M;&0>!SY9o64ltvZ8*-0yNiCl)uZQK8dZxW=7Q{$_D5vxQGOc zk1d7TDPZXLGBY2j?Irr^!x6e+3h#{Bj1jC8VR{gzXid>mPsMbFZ3^Dm+Q{uZ)D(Q% z0FK$5H1fuD7_5Y$l17%*R$r{@WfSo2$@?!M{Q=+;fY%=B>7Q`^!x8!giFdiQW&?4F z;xiG?A3F`}rH^EOA@es#GL0srQOlR;2NVa0kg^X2po z3`B#W8s1EbMTm|gV5oRA%v+s!Vd z{|QK+tlCZMw-rA2PikssuJu#%f-}xNbUb#DoQUuoQ)eBE5oaT^z+DS>i?7X>1=|gi z9(njwmxqtIJY26X-lz^QsYAb)MG$x^dsROX7$J*G^ELft#0DyQP5(o@s#i5$-G!@# zujyBsuj<`i)33)_m)G=|c9mTc39ZleMc78n5H`Qdiac6qbKiG9-)BDi#SZ#&aOj0G z=ubvWwBo7uFy8Y?tLSlS07mZcTj9%eYaP2HJM%Aoev=u-&odni+u#Z%nw9Ww*II=u z5pT?Ngq>x4%%eB3hylyUHEemdaktg%E-QGa<$tWXk+Yg@qAS^VSvIbGfOrC!SR_;P zfiQ*o#KNxL#^~%3hExV{fExb?6Dv6a7xcwoYymjsRNf^Br@cyk83z}g=u_9;(t~(Y z8G7bo7J>T@5D2A(X}rt=MgBiTD68r9=q{E~n0}=RkF93tfc#;Yv>Q-q+uRR4YV~~; zJ=KFw%3OMiRzUv(SPJm?@YpG^O#s{ipe?_*qNmSaYTC}~gNmN=pS)Z?m)zbCqNka2 zVe&C^T9fg{L62Z)atOiY?br806U_JP{}SJlV)-J;vx0mVs)q{ zHS$_>-g~o)9=W60m)S-rkoN@SC0e15DFz ziF_w&-k_gwJ#5a*P$Va?lXb9DVFzY7S=J@Ye+f*=Ekwvz%`Px*W`VU}0{Qi9{8Jn= z0Eoe5aU*aA3r0V-6K~rI-R$Qt^S#D`uQDU`J8m?G%!gr>F~s;f`6OG|FL2{>paUUvrZ6A-4!(0zx2>A z`a1Trp8bm+{8=}C(MM}v`b*3dGYEaI#Rms`2vXJoYR7P-4Tsp2f%e$8&}bHgH(h7j z=MSPkjszfRqiJIT8DrOCh)|RDW{3%QJ2N|n7f`6ImMz61mg$4ha`)7(7$kQ0cmLhb zhKFwThjF%)-Qv%_+aJ8kZ~Vs@Y@wQn^*qWk%Q+1CpxbqDo!`hTZ5{^TS3~q5{sTkS zdlT}BcnN`yU&LI2B!|)I_T|a$h z^|bODXg20yc@)gAB0(d9{rVn5a)uA|Z_y&wn)N)`Ep%Ma4t2tDBM){D#e)VW>AQsx zpeAGmeMt8U`Q^3l@)~;XP#bM^@K}L*f>+|Cz=r{!)57%a2_UWw%X%YVza6ko)F&D6 z1Q2kOv_4b!-&FUZ1F68=cLT;h0^Ke3c09A2&WlWYPr!bKp3(6PerJb_z-g#98j3yUQx$x-E)YKOL~{g0 ziV8MD^yMjJQ&G=(H+Gz`^-iLjzLAUWdK~p@-~_?obc=$+p~>4icop8$ojNPnHTYN? zs>lg${h!vTN9cfbIbbfptN*u<-Us*zK-(bK{$QjV_f}p^+HF0IUaN^RN=x=i8ascw z^h!#v<$IKXwopoIr^tO&?jOVrs_J0}*bq;4-vV)df|Ttc)I-8=57Az`Gt>@TJmU@D zki96d$d!aKe(^iF{LH(GI|AaT2 zpRoV~4^6v-{L%|Vn99+Mk7s1WgKkVVMw0bRW-4oU)?@t zs5pzC1#75>7{7p*ahP>!T7@WMsceMV%V1_YA7;hvp!GGwatmhVs2&su>*&Z zi_s=TZIkn8ak=kLvuSJ1XSnuJxWMel1K5P!3LaAMGBmqJv!aHttKnCT`VR6Q!szt^ zaSdY${E)W%$tu6Cs~YL(Or&oBtOIy-6h8yB1?U5y?H4!hz_b^e($U_{tK|LJf89=% z+~d(zldiG}M;%r@dp`6(d3$xlBG%bnJyY(l6iFw;WqRQUXsT908+J z0vWtGm{xcNi)JBcRaScN?=Z$Rp69U;d~*H4pplN3n;<*VZ4f$KZ0)d-MmW3Qa@Mhn0fk+B$l2%r!>SGP??CCkSAiWdt zGQgvs)AyK~Q4L+2JMZMhDM+RtIWU%j$T2l@VEuCZ+=|o4qjC0w9Ly#pfyn1OL`#Ev z%t$ko7R=FeLvRDZpn!{5Iy41&+`#-2A`$D51B6MrlI= zMxH!7hij4E2zUbE(b1boe**XlK%3aC=>JZ4-7$GFX?NaX^n+t(o>pBkZ$6eOtU8Ut z%FQ^VVV&f>-TWQ)GH1Ja8@YF021mKd+{S*_v;WYC|E}AA=mBjb6y1v$EH>BUd`@S$?YpM2oEIQ{Dd1{=SI#X@+uCh7 zzZ*cCS5D8~B&mopx#}?GET2CG>(ij(4lQTAybOlnMWg*F+&EdbTy3wO!^6Dwje2&S zj-aXFUy%237>CT~df%&a^Pe#rvXFbRE7HdRDgj>pA3*vEz)Jwy%AQ&-kFUMjw7u6o zwn|?A><1fuT^=U*ZS8Sg_lLMY$1Y?&^K z^aW;-v4>*K;3~HIDplV@&xB92yaL@d(hmW?1bFzGKL@c+0c!xX?Qr|oy^l7 z{db3sBM@u$P|_xowfID7O;ZHrmLCz)Yq64`g1fB*s6Mt&@l zXWZ?S+~t^1L1orCUDhEean2iN_)WyV4TQ@n2m-ZL}yn#Ko|HuT}9-!S-a67t`1uRCv=MmXD--8`E-zo`z_QZxkk5uV3? z;Cv=KR#frr2nzwl3qtNgP&2^bdxZXxQM9;eCVDP+SbS8YJ?=Bs?ai9C67-|%^ulL9;f@aXZx z31KZTAI|`2>*BUwPtK4RlXssUsuv^2-)RHq&sk9E6~--fWmVNogj$>0vAm?Di`Q`{ zUF_QJ(2JdW9OlWcCA3_8EzC6tHwb@+{S8U`q6-N})vq#k9e397R7x*_ z{Tl9E%dOX0R??NAcctKP&tz+BZ9+BwyTvk{^j0rgDfe5s4_YG(gzaSC8_rLL^)$8>jswfxRqOP#VDB76v%&tVlhnP zVeL2+`ypb2J_vT)xK|DBgu_~~6p^csGAV5tS$3{9%3)+*kw05MKf8s!)lYyR{1Gg(Ae*F%6GJxIIjk{`4Kz)KJ6@= z&vyNFSGe+tTLf)YKh<9(x1V9vb1SDNcjw8o^;f}VLt7Fkz$zcal1os3#GCWaQTf@7 zZz&4bw3bn>eT?(Z0Pe_Ls? zFFy;qXFv_WYj>YMZE8z#eg%NGgZ;?dd^H@_yZt_bBn?21-VR`mrjD`f72V#WhhEV$ z_vp>FwE-CEn&IF5_8iR@(BS0Kb2Wav`F zVQPc6QFgw1|4{rk-+ljFq%Q(219G2>uk~olT{{+(<7&ood z*($x~N_e_mW!|o9e;|$Yp;VWNBKccjlh7jf_b9^LLd54JV{|M`k;$GP&> z#Lb77wOGW!-;uEn;Z}F0T zKi{SMUV_`#4Z#w-V@PkIcRB~E1jKKQH2Hqz|Wazxg0GJNT>CDboh?Vuj?o$t1TuaW*4@CU$a2cIvtw7>;e=MX@f$G=Z< z^^fjP8xAiXCI22cW@JMffbM0Jz6Oh65#>SGv;|S{#`4iGvmv`#J{XZUm#hJbDD?`1 zg17^|mcK&feCU+m@;j~%gpKE8TJMfJ2Q$e}^E+v$*TCzf zX6hpHiP@{)p$=2z$wawdAop$MzO~%nqO@2`)U)f=K|P>;qS99~`Sf{muWoEpzr0-C zQP&#w)IC0hp_{KOI6{dW!sTygzY~&7RN>=rjVqb1{RHo#@f)}iJoG+dS=j;(y{t)v$%*bBJgohMo>sk3N%!hEd zH{y!_u5j?~u(2qdSQ0K_>dbdnIDU6HDEB_InaG3XV^n0|n^;sRK=uw?7nW|@Lg)zj zW$301+r8Zkx6OZq6K{q~L_0C&gK%O`xTLGh z-@=je%>{amN%8Yp&>o0=1f|Zw9D6{NSTNIvcMjhcPHYL6JQhwo7%q7roVYt&a#uL9 zK3sAU$%`Z%{HViCqy$s{W>b>*M9TCk~zlH*6{!RZ(! zS$=UG_+S+PSQ`Ty{n6w}C>~*fFF1~8i^s{Sq+B7$)lPV3G2$FQSJ>#mb|;m>!L$1mgxAzxu#y z$d?&!7R-)k{hMV6vQjbw%~CP~=_x6JcuH;}=j8lE-nFbSQE)$Nk!XH)tC20uR(i{~ zcq-~;2NqhLrbxd^VtxdjO@vGvC8u`Z%q-O(G13Z4a6Y19hUjb+nn&KPl^~3Xz zE~{4@!at_h%o$uUbq0hxIhXP;HG4unSC~u5llx4$ALU&k=@YGTtk;-#p}9`j!Jy8G z?O+>J!y2!B5-BPK729J8k)YsoJ{0XEfGC5w8l8ZNz*zPOo^RzxvYv<})eCNRpA zYHQ4Wkz$OzMvtS&q9gTL=4_weoJ!-sQG6`AM;&ty7}pxGf#1N)gfX59R(q4n=a)3v zO)NmV7H~1ZlPfRSWooN%-Xfy#wD$=mUrOX)SMS<;9l~duioob854cj~?OfX$r6{Pq zGE32AVojAc=%DIvR{7bDZ{@o${}SnM0N(@L?U49L$=`JifQFURBHkKb%Yc3CE{Wws zGma^gfPku4ps}H`%K-N(=AytDOz;#D2w;+B@>-RT+KU_I*t?N_9q>88qo=l)pnn3K z4WRAefeJq_4r@wJlU@9*In2C@?2itzM{4LQA+FYm2bD4q(SgZ?e>}} zf`*+36fsSfZ1`56wkg&tzZgpqL=d+(MTn21iO-`&3~N_LT6`T%{1PoP+kV1s=jP2= zIRmcGS-$CdF{}Qb#r^&H5W{GRMo#k8<>cTKN`iY}Xqt2(!cSq-VIWow!xN>V#R4g< zb-#I@;fDzjznvS44SNdBIbgA-1e*+D3%LW`tXFvc9Qey81Y$FmB90hf`Laelcf1UK z#w%c-382k>N8xwHcUQ>sgLi}UKk4_AoOuYf@%Mu3;=C^YnphWSb@6s`{~}L1c`o_n zI$hp*%}#pYP9{%&Fq5urPyW#l>PPC`@A;A*dp`NG6OwN9mg#HpW*Trki0-!3;~mYn zvq4_^GJ8Agvx5a>Z`^Y`6FV4`sl_{(zMVyPux^qHR)g6zJT!Z$XtPY*hf57FoG72V z0nDi3(v_yX_90%@aP4RTux3i*8R+zLR*KS9!nacBtFc;R&h=u@4Z?T5 z(AQ#R)|Lp}IfQo?8yErxnFF}pBGeo!xPH!@FVLX4HZOFH%CZxCn#*{F~f=bzH2D^ULEFtn+;6Z38Rf`epIxia4}@ zcDPMuTjTqb#78Ne_4c12fKkRhW}l5_;2u-oWOB3TMLgquSgXZ%rM20a)>-fL0CW0@ z7N*GM8T&-(r^2^S=wE{4gyB!&=g#2FV~mxli6yCcTiOcF9lQ1M$fC~pJKP@cGrH(Q z`->?-zjM5pVrJtdj~k_14d3Iiu*US|O$_m~0*G;fPd*11Y{y>1AP=+>=Y=x@8Hj=M zZEE6edUczT+?a~@+Voz8o#X9qW#I{ML5Sg%mzt%^P2Z)aeuZh;DId$Xojo3LaJuRR z7(|{cW&ksb+27ILVr%+D#pjB1fw_dYtj?7>3GBa2#bvO+VAiQ7%xM#;&CI&qT5pv zo2il>N=e*DM=NTz*P}rS?I1-xip%PXda#;dRQ)9MVIKHPZd{ZaxHg6!lz8B9yfFpB zRt_8p0(_BYIIQYot8{@h2}c?aLTM ziT7F9AG?I<$C=llPl(;Z^fC6rRGHW+oRtCrW%$X6)c+9E|HUHpjH6m$0602N{%Ty> zWvsdSea1H4(jU8*>DRE#MI51K5W`{v(@V*_`_vBp*oREN6zgoo&W-wGFL9b=f6#<~ z5s59$#UR~>db!Lt`xpU?&U<1uh}*>w)T356jM7)LKp!|k=>FMQCha~JDV4(JL=4z( zFj7k6z7#!vr3rEKL!MEZ?#s~Ae;0^yaUIJp&GKdIS+gK$u4egt^03-S-VGem<^k5C zPjg=jz4?t+t3=C}NZdRX;^qc~es(Q*@}E=vR^8=|^5zprzY6#S;K>Kt6%j1}$N`lYT3GFJ!q77~9MG%6-cZq;|D;(!g&k(>7($a+d5it9*my zyWP?^Vg;oUu<&g~lgs210J}{&o!Xv{gn`&ih-j>7y?MDrI(|=E?enUfGmwAJo?!{n zs{z*oJpJh1NIwGb>=_omw_N@{SuU%YIc->VgJ463NVQmD_yETm7U{Hv#Z~5yT-)2M zh~EmzW6hilBZG28@+}rj6z5;L@qx=- z`#_mF?DijKK4a$GVg0*~mBGoO&#pscyLA*Yx@;YKt6MV*AW7N$LcJ&u3qo$40e{F& zdS_CF(pL6@%GYLmbCtTJy^plL3hRFZJi3^BC47+qYXP*~>FPai+SycY++U{N^87j_ z=Op8k^tOoLBPZzR!bBuVrBS%sxKw}HV7rZfae0_5_lcza*U9yb?dm=YcpW7we+G=gFb-CR?&4I7H*@2&c1sWn| zvm0|+W+=V)O}S|+Gca2i!ffFc8IhmUF-sW2Ea4yNk$vfy9gHo{)jv+RzDx(tU71__ zX|CPR*Uu=-6g^hvW?zyU24l=MF3HUlrC66ZdNEydF3vS-~eA&2g1!^+&sYuh(%a>TYSUyvjp6eHekh8O?yDcY7nzv^gagzwO%ld zyO=rM5fU!|Q)$-QpBAAbf?=h|V?2Vzq+`wW3@csFF3y^fncXZMtL)55%T9IDjMQnd z?5GnnqFqDTK?erM)P5$vs_1m`Rj_%G6sTzzBmF4g-vEzJpS>FE5CcN16`hv3emv8{Gxx&hknC%@vr%iZ+O|)yyrLI4$AIC8KLxm98%BdTDN-k2A(s%;j`qvyCm*0 z+UJEOFvdjp>db+hU}Fjf5#fq2kI_OAfxxizkyuqM82BcyJwH_sL|_C~e6zae_wM~C zFT#u0sf*UIp!z{=euQ_k$SN%xf359|lu19P7yXj%bD|-Pfneec6Thw$SkOpZpHXx} zMvv<=GL~eJn!6Vk1T}))Qxg!7I((Mi?ep{uSbzV(^Z}RV8z1FGL|eU3g!kncZ{~GB zP>}ekpl^mR$FE50Bf*a4n?r0aU&&Ynf0zbf7{N}!VgnEZ_c7}+?wsiRSr5$P^Z4}~ zJv7!dZ)d&8ob3WV`P3-h^9$5n_zS=zB%KBHU6|&^DLhc<)L#~(GswYo7Jrpz7B$PQ9%b!@F-(OnTd7Y1=ZGHo&D!V#+iq`riS(^Qus z^R-vytH(8s`I?IKY`}bgm#^7bKJ5aW|Ihg=4EFed-Jp7b> ztn%{?b_U_3_Z>N5N`HaU@KYFxu1>Uq<%#p5! zuPVpG4a&#xIUz4Tug<CX(e0lD8CL}D40zB8 z+ra}-+aFoP?8OXs3`7$}!ol<H~Q&a0uJEh(;PQFb+kW%~Wt#tgIG_h3!}j zE1a1yQZ_PbI*2Y+FkYO9;Y|t)&x%dUK`KVm4_LT8qoRZ8ddl$?_LMVDP928@F7w(9 zuzMmCF+0!A29+Wl!*BQi|C#A9=#ezBSI1ZoIpF=+0BHZC{2Cf5(uLiOW%6@zcMvYv z7zA< zI-Kmb|N1}Ezb5OcUGk&I#TDx)a`6^R97l0|QfX5Shu`&XpsyGY_+k-)PN zBZSbzh^rQL?qyaQdDA2Mqu&wLP-UDS1tR4rh_OQW*&%`lhWV!_r7Kx`S#fb(hlyJh zYK8#DbBHh08;J~D-A5KBWI4+Os%8$Jg5fux;17l-q3ZrdL;XR#%2$mryZ$1Q5SLpp zJ1&DkZ301wQ7m_>`;V%ZHNf{W_uBJF=iVIACIGy8IejhSsQ_}rATK=P_B^CSgyLp#(hLTP;a_y(s$(#@H zb@ZL$+r`2IA}d(rWV?fXN~9z9MUgYwK?>~1*`rR6M;utQa=TTqqg^bD{a3P#jv2)O z)96sf3+y&dzLP4NnIlbmDqZ(o#xhp0$@mIC6dpP=IY$$2k9FG+JLmRT zYo?a|%GZgo`wk8%;w+n6t5I)Q(SwFt35%Vx&Lad?}4$b4-A1@Vq4J2*eq z8V9E!(aRXezhGF%jla$@=M)^r@1=*=Gpl7978V~79fw7`V(+r(zZuz~;ZhG&J|bY8 zgXyv=v#0T!Wp}gA0lV56a=Q7>abiAHH~PzzQ)Mey=F&NxT#xkafCmAdyz~sx?*cqK zM(>y`kH|pXh%0;}Rtd+-Cs_2-JQeGU`;UjyZXTjPpL#nR_Zsi@i+%jhcIaM)l^#fm z(*sQU0x0&{0;vw`PK~@P|ADb%WWM&Ms{FOCYqWd(Z`M&r&R^@c4JIWGW>sxG*A4kK z;3G|aZ_WS7_Zl=BfBL<>?)U6l8q2rz4@>*%L{r-jApFC~g#Uo_Rk(hzJ!iwZ0LcYS z(zPWAe4`@Prn}`!`rb20zYYlBs`wwiXN*Dm6u@Z!+B~~KZ+s&!ChdMWjNY$_e3YCK zFQh+>Fe*uc{x>w@a@mEUtIdL|&Ggk~>}r#*HcREcA#JQSv#vJN6l9}L$@%rj8ep(+`wYDGkzy4y@MGyF*7ao0hYFf#U5kElZ=AXb%T3< zJ?p-WwO7RW8Fe!-fqtBcZ5&}9wV{ZunvPb@q07ahuJUL=z(_;d;Ku;E({Hu9W(u$S zfv*e~FKM^I))CMhK>Z5g^%$hb08RkV_E&hd7b!U)xq3mzjsE%4Vz?2Zb*x+}`%c41 zvW{hF&cM}X^lCF@wR!5*CYE6t_zjPK%~SUCQ@`bHRhhTafIA0nCE`@QP}2rNsh)xs zjJw$CGF7?mM1H+;y^8eWnT~b|z@yLiaqSDhpRSXWT!6y{fWvsId=$)&+r#`=GKLF~ zEii+#U{(xyDof>~czt6&PDJ{icP;IHfR~RMxHcDXFkX{Ck2T+_XI0mnRoyfKT>}#1 zvnw#pG;6}Mp~VlP%BvttrfAw2%<$(*tUxYw^HZOcpBG^ryf(wH-2(9P(;{MN>v4Wm z?bh=}ly#~;G&x(9yY=mj<*xnZQ2uZ^uB`zax!g@Y;FVW>C`Wx~Rnm7_+-M(my`@rI zKdSP~nLaf+mK=Sk-u=!mN#8jD<6AgLYJPx+H+nXN^COq%&n)O0iUEfY&bHLV6$I8vt$II)Vr5+h_M%CGTe+wp||HlfcUjDSB>JiXIcX_&Tf@=#(xq zr!O@JX`*y-VEUrKAVvBJgXQpiO&bE`JeCUAv{D2M#y#i(@>F?x+|gK`{~dpSZsF+V zxz_z&UDEevoSPsf~3u7r(7{-{1O9g?D=28AzWGSPbyw z@pq8^4DbVhHc$TWj`CvCuF93i7hsi!nmI+M!M~#Vw2n1%PAghigy<$kQ)kcbNb*X@ zV(o~J%vH++P5zldrl|&W!P;rJnosB&CZ`ZT6G0#ZLh<{BAKJ#GzaCasOu=^%?dJlUThS7;mSG;TTKd{Kq{JYpsLmbl6mv{fr12{-N2_U= ztF$mU5*UVB`XJLXM$<&1WBEAF!NIZB7AX1{dRHTTObv4FbevZM+>MnVQ1&H9Dm-w6 zFhuLkEG-jjy29_4_VkCTwL14ZoA6HCed!K-=T)4)k@TI(NAaD7@HCjR03V8K#Ri83 zgZhMgYrR*VyBqx{-az^XKeRXMnN+RTdT zj>jWvVCRyLhO^d_6Xp8`v{?=CgXuGrIY# zp14sYZW6ySd8p8CMdalyGcDrST?DDSlm13$mYtEt1UtbTXF2&Z6N5VY*fJ64H5eB1 z<^illB8+~Cj-9jf^3dbaNF9^;20WK(ZMo6l=al6!A+WoXr_RUFj`^5hJ;UoFGr zZZIbyj@cvR1n`?KN_DJ#5Y&6{JP5A_77NzCL*VvIGrEm8+r!g%D~{!LT42opI1W-2 z#Gb+z!73jd?VO9$SZhZQW1ye)fS!0nuYyuLjyWMj_k`*V=CX*c))MNq7J8gyV656e zfx0Q;144$CzKf~AllAm>^x(U?F**{)m_5bc+U{q(z*$y=hu-C8hMA59(SPK|9v(T} z2}kruYW6@ZD~vUNvJw6dR7VteC1N>|`1gV?HgK3?=dqKF&dxwzb9nq_`7UCl=)t2& zrG{RwmXr%UI5Tstbm*W^?z*;a{}1}|<^$T_k2n&5s{tMzws|n3bp`YR(DwO3>wS9k z=N;Fa_~-od;O2cKk{D81jb+BB9*Hszs`4S^}TbQ|sDiQ`n00_?|k66zcORK0f-J|Pq2?6*mtLN!@+?bBlm2@*^q#=Ob zHUlr}q%9UjJW-eeLP-k*!dMoMJ6&v#6`AY{mv0dN5XtFOm@Bzg)OD=?L|0N3pD`Qm z!u-R%PDk%LrM>I)3z^1|{^T?6dk%ly`yxHPhJ8UB-kJp&^)yp0t)zN1=~f}pBI>S z3E28h!Cs-~Ulieu}?+XJ`q|jQcj9YU}gZC+*F?bsw@nmkH|uXB#IPjg7OY7;Eo?a#l~`s zF3K8_LMg9G4u>*JzUL5>8P-mYs5ozN>um%>38fmTk?pE-!7d2l{yVw2U8ZE-*7DSc z-1{(qr2BVs>kfIJzAEZ?>ff5)-^i`I8t>nV(u#+;@d69i3Zr`tby>tr{zE?d;1;Vw zSjz&}-XyJlCoCY+3c*$BuWi_fSm6$_T#S9gah4WIWiah$nUQ8JJ2i{tMsrwxB##w_ z3s{R_bJi-}lC_Q}0&Sf(tbMp0>lp69isMC7N<rF^HHTnHX z?`*hE_r2>#%f`~=4f6fOzle`fgHqq6Tp?|U-*n<3ppQ0&sb|{XtLyUt@%M_aZ9xuN}m~kaD zSVQ0=JdoZgvjxmDC|F`^NrLSYWo<1yjlUvRB08}y_(fONLv%0cmFU@{ccQdhtL>`j zt`79%`Ty@h`dz?>02-%b_9P~MY?z_=_*gP1>WkRpnJ8viHt&?h0o3kM z?ZC5FJi2_HQ8Bem#|iNIqi{v5*cM$9`zWmcf;eeJ?3>IR46Stm#ODDBql;iyTm(fZ zTkin(Fq)CfgExY~dm|)d3x-A4gMw!maR>3GZJ2%`BvdGF6Pi^&6IUG0r!-@dLf_&wo+wvGdPB zsq5L5aCCtiynmZ4?~_a$R{#R?@pht+JBG~lLL@U~4(ocV{O!lL6$jF^)*FSEw>hE} z0X#k>3xi!hT%+w^y@9-#wENth?>w6P(|8`?EN7lm3ESHaGM0>rE@NnW5a}w`8S7_s z6+PtmwF5@c0@KuxFy*$_&HpCk%hPi{cskdnKL$Szz`^-{7T0KN@ra_YKhs0hxp7U7 zCjSV^sd9fb4^NpnZ!Q*d?AGN-l3+tqn_F3hg`bY{VS07{Y18oZ!zMz^LD+#rCV`gd z4gerFu%JXR(+Z6>LcdN}YlLXnKP~L1go#jB7YXx4A%^&HyibXO#KzO?2XyhU&NPfo zus^`-eInpPd{@MCj)<@_=qKcL_}VXg$l?Zh-Z0$JU6iv1tcTI%1wkP#h+s9Fb_bAgjR5K zc^!GKEmiq_6M6G;_rRNmR`_^CYXxw(L;R1AQ1#w8pdtUQ+a7$WO~+$vs;5?tr6#c1 zbhJ|x^T5aI-2BYIx4o}>`FY^~$NbP3b_UiT)26Ge)Vulk1>dZ7kD2^_v;8GMp~el8Ml96{=sIBzkp|bB_ttyrz4Y#S55242P4BFC(XpzFUIZ6u z?r&?gfthJ*Jpl;~F1b3i8}x_L-oZp3*5HBThFtXy{u&6;ZRw8g)8V3_hxA}7#tMG8 z0h42fE2o#0DZK6nzG_{p%-)7r@qi5gumAWI>F)q$y~1mYPKrLfIIRr`_1@w7@BY8i z{am8^|K=rg5&EwZM*01v-5Ti-AlGlxv8pp-<;6>ZBmwWtDvGuSTfwurPv6Q!7H@7u zp_AwdE3F8FZBPnDOGH3rRWHkcZ?C=XLi$TU{HexznTm7`;1U3ByWRETKX>Eg$cst4 zjsHjWQX|`Q;VSW?zEZGNVv5{9rcQdSgyr8B#QkAa@Cb_Vv*25FKZ|xJM8|B5JndKn zRwZ@lQ8~n4Ec~gkosW3tZGi7S7y4>hB0LYXM&SxaM3_djjV#0%$vU zT#-}-G8t*LLLUDc(msm>`w58KSq=pm98iwbE*hAXJ6Q1s1$7A4ASc>~lgaj0vsJKD zs3$I*UnT+y9ehF0%$upPN2M)wCnM|;)|v; zAQAHTDt4kA4du`{XselcM|WP*yM&xz4hmj^;SXnsZk**e+st&P=gdM`P7=jb$^2j%9p>Rkze+FCZb_kVQmq>E8jOr{{~2ZR+aC(zbk&(rQ_k}2R6FvYy3C# za5y=N_~n12o5lLJho2)lii}_&>`hc)ws9cTyU_9;6;a}=NA;XbymDk$SZ6(Et+QC2 z^*CQ=g;0gP^ePyMWs2tTG|WjoH?1tSZ`zpP$k2l<*f-QGSZby4ekr!z1(EQB(d@J$ zJ&G7meM`7qT?8i6{jAu+%(I$f{qZ<=M&|L8M)c$COK_wVObz&dM+_qt>Bw`#J>WW$ zi$CA72s+HOM)f+jv<{3tcq7jRPeN~zYtL?P+Gp7dZNpf>vVUdSdVC=U$Y+KWEBb?? z^~2|Wi7rNxmFPCIO3x+R&%VJCyl-f57!US>u||vn(P3gX$2`|4v-?_OoRPk}S>PCd zuRy7uqUSmJK^qMa;l0gls|e;U_+9O2MUR_6XRF-y@(0o>&w(ZZ9zAYB`dPqd0NRGW zr|9sL4u_{l?NVhIdK6@k#A*(e>#3jc=#dw0vNl`yTWpi{Ad`phaG=IGD|IPLn~TNP zQd85?Sn#;e9ZX-r!oHM#aXaNV7B8i0L7PR9%bK{M6?Ce4PXA zY0UGazRXhh!U!So3f5`s%yZLJ^<@dxf_n1fFZ509H^H8yx|Q*K@M+Ysq;UUqyEQ72 zx^mQF9pU&nmfaq!cdTOBuRw=~rcd^i`yA9I`tmsIIO=`X<17fG4MKMEY@nH-7hy|4n@bn9tEHX(cg%+Z0swC#;uu;ncSip~!-zXT zQK0c9LVME0ZWD$5kwZ|^Rs&Fr!b{Vt2(UqrBT}3inCGKGto&;_fHfUFZ_APP-U$kC ztMF}1v=Cj?s?L@ z`AWDPi4u1}jxccQ&%|Vd1ZX4V9~7w=xcj(ePQhFx0c3hJh}cJn4~%lt^DQtfmBn++ zESw<{9KRE*4dF-A0e-oZV>T8hE(ER`ZaMO17xyOK2on#W5lSPxt^gAaX&7jkh|Lhe zUsUetn1;XfhCf=^hW-px4B1amBy$CZor2f7_2KDkaYQX)jslNSO#+bL2v%04+=o#@ zp#*XFfZA0r zLBKxz1w=X=ju7cZy&}*K)sl~A2#It}_g;eX(@RoBL&AFzK+18=d=z3#R9X~J zfO;#G8@^vrQ2%L#O#aV$t;*NM95jOn- zU#GHi0mll7)c`IfC4mdPTJ|X1LU%*CkU z{2i|~c)tSe0AtC&J<{)}Rz2(i*Tj%r%uy{gw2p)FFWZD-UBdV{<_c54ta4*kp(n1b zQ~h^2`sc~AXNKH^^sRt90bc*z=`GcN?>>Tk-a&RO4f5&1{kPJoqW^xXzwE!So}&8i zMeCWiJpGXVoA|QiOD@MFK4-eUGulrRUMpEJJkh}9sf%jl%D|WGguziI2SgMhsP0q1 z$-vyv_|jY~QBPvxdJ1o$`V_iO*FXyr?1m1Y>6edIDnP9r1?xA(pD}-8F($Hy3?fAh zfc{jYq6|{ArL1~vf!GIvPRfrq$UClN0;Ev001;B7N4J8`JKuZO?RDMksx#S$=vPfU zU)lK8ui?t&eygFMbwAc{?tT^hDvVg&UN!}UOT8?Wi1H#!g*P6hAfrm4x;9seMSqT$ z36ERTOX=l8_L9)iQ$O#0O?@+2$^nBWW`@R2KcfkGVizQ{(6fYfdLjMMiv%KQA`4P- zQp`<$NsH*IN>r?EWvq{;zR)DO!}hy)nADdNqB=eq56p=whtpH5->b*LSjl{hMts6Tj5gS zOXBVSOtgSZNv+#$bEra8CH1_LoMcvI24n>*diLnLQPyaDR{i$>RMvHAMwUX^3$+l}RzhI9>JF2I#{ z_%ftv`^)l-M6uey@Ovhfe2YiAgjpmrY@u8;wdyPo4zZ^YrZ>Q-5OM}f9FT4&4&6=~ z%i~Zg)w%iEjqiDQ{cWev{)zK%-8>*0hsJ+B7_TZHBQe`sZ-%u?5V}il_3rzN-)Ve* zm+87T3g^cG-0iRHO}!thV^mYETzeSjoB#Xo45*ko z3!f>_*2>TLwfrAcz2(2#I3IKp(x(Bc0G_^eHPW=*;U35Rg}qI&=a+SOe2%F+qh>*+ zb_f+KY|K%2d3t0v>b-L~j4Wf(jny#ciuA;Sqy55=D@nW(y|ViIxC5gVI9qf=tOI8C zKn0NzS1T0I2Wz7|)`uDzG~|rIN0cr=Wbmp{(|&Zz|6XJHZ$PmDF2Jzx?YDbfBF2mGgO`b2@0wz&N*8W|DW8)qQaGP=3#;I`G1N<^$1MJif^YW z{SB)rx_JeH0W*gqqc|pn@NRA2rpwDF;x$L1?c_b@Fj*bLM~yx6e|Azp!s9D2*eNW zXM{}wW1}dn)+tJA6p#tme7IQ&T9vCA&1Pb&{Yll!X5c$tUV*hR()$2k0=#@n6a0IV(7wTB67OPRBr`6|1m zrb*1jFbEKH!t)j8qVx1KNd?>#cW^la?ov`M7kB7Q@FGiMPtLWYwE4d%{QUyFc=jdPdqKZ| zjsOpT3y{7Na6N#wjjq3iw|bri&b$GY2yIR#n`N5_hbvh>1mrgN zacu{1m}2|R?UIbYWh{IdQy2^(mfcczL>w-N)A|xbaKjL+L=hYz?R)@~7+9JZ-of94 z-ugl_Hbf5#qP8>yV?798EMJzY?pL?{f7pnpfk+dc%5n7YvOY+(7|}rQr3QKxD++0E6NV7WJQ+c2Sr|4KvT;W1n11t-5wwCeV5kh12#pv* zlXOv3#*5fbKv7rTs{uvL4v(Vz-xYq!K5FdeE<}1IU^T$Q&+ACj_O*NbD|^yGNA4d+ zp?9DdP1Gy?38~2r1YJZvx`9ZW& zn5~~7d7B{?=<{J1fWau15MQ95g{#b1==T~Wnm3EO7@S4zu?*bJ2cLW$ad}khOT#e& zTj{bt6rN^$+=!>;NE4o}$I-*nKBVRLi8}rjo+cg*o?MP4QBeg{ObiFLOLT>V;7UY7 z@yZ`t!4LpOEN1#520S1h??spzM0+#D3Z`F%xhK+g0~a{EobE8=3g$MqQV3U#=%7&6 zlL;k*5fBVaFbO3Y5+(Q*ZIl14@KTHNdiK1xBK;uXQGkaRW1qrDwtM_5d)}(Q#!CYi zl6YwWylkSFEcNDfT)WA|PTUj^FyleyyC(@t8Ecq+9pUFDreE*j2djbH0{pnNg0tIX zcw1w=*Z+-ef*(mPqb0eVoP--97sN_N|Mnk+mo+G_hnL5Yejcz3;Nhj&rwT7!+~Z&2 zrS`A!;&B~`ma~AC2@)$;a!g<$Vi_HKacRdp6A|0L83RrSj+Xg9vL^l5MXYd%Zd^p+gdj#?DaS(u0oZUULEs1GJizq_x$zH7 zRbVL7C=%=;_bBS!u4hTzTRS!%KAtg8q`IEgxOh~_1>kgkV<2YUhwwu90#HNDagmy- zBm>JK^S)4~-v~65Fe0{=Q;R{-OPmqXW*f1N$a!?v70c;gv`hg@&`(b|C z9@rkhb6ji_yCXR)2eb};32gg-?JSAyfL5nZ2G%rf@)wQm{CuRB0agI;Y8kg-_&whrB3>)>o=ngD3a;Zg?%Gg(Zg&uVYtogRPr1?f#8j2!{q zcc0qkX!@6j`VIZrcU?6D+KBvg?r03R`oJd$G%vrGZ%mfQa=g>q&PRF~UZn1 z?|b?T?|3Bp1zLf&q-)nsJ-vt#Pz>|||4M^m;Ayj3JE@wQ4o8^5ba0)l|Btrs0IZ_O z|KFLNt*__3Brp9XlovusLI_n#s8XdWC@P606lsy7c7O%!7CyDyM;5Ae45pxYEvx`Zl6e zP7*%b zq&Js)+P$hy-2eO}!{2iA34ppVe)I@$ke}Ie*lm*$e-xAMk1tKjLQFm#D&Q z`}yMhGZDedGBA%~fWD~z+fdd(mfif+Vf# zRibStUNeMI%~efbaio=(5jGVDwsZ9-3Cu(dV{^z^uu*v$7h3+2!1uyYq8AWNt#>N8 z!mbeYR`PXX+}sF!HQ+XY+YTQB{}ON*z>nYiF1KC1R6KWb{b|l@NW`94_OMAqKz?8J zx}1aRdi4NIO1`YWsVYN?vDKDG$ruZ+&$ZLZ7!6wt-tlcldf^P)9Ge1L=sKnc zOM5H;^1#-};vyDlPAN7z_WmHFegRE()cIhkRp$E$^5o7~?G9jW02Be7;~2X-C)bXP z=GJhA8~qVWUmd51iDAM-4N)x<9Dfsv4K0D+gGza;_xQ88?_o*sTJc~0R=n3wlvqHP z_aMsVmM-I9P-z3m2e|Fz8yHY7#kKprKN-E5#MB^5omz7hn-@gMH!@`jMr{uDLj=3Y z9}6A-3KB@c6Wq1o*Bndzw^ir6QxTHodJTDY%k@3*qX718qFnQUUjkU`_-{A~z04;4 zZ{}R6QDJ`1t2JKt9EE|;m-Qna<#Tu;`Hn!8CI-=y0*st&I1=Lf87aiCbF)GcD`~{q zKubc#b!2TEtf-Z{aTt2VAHyFU8wD>$Pv%0Ke|1c9JV$8-eZ~}>xgu4TzX4?f6GR-} z0{?&nF7` z1~>nu`^W2n$MCbv*@-d!yW`hNq9pE9iI|ha{%=dkcQE-M&$0YV^aHmBq)e53&K0z9 z6gtIk62E8-b5m#@^8FxL16*DpfxB`4+t97cgOC#TS{O1zoKi-o~pE&uqo+x+!mv!a#Tq*4;`(UE}5?G?|Ci-d9(JlKl zuY==b{FgjEem)lCDagBSn20$!?BwgE_1?o%Pn0Q9PB%ZT<+`=F6Fr|OiThL{=H#&d z%ThKiqx@#c7T|qW_Gg((UP|}+cp?AgiJx;1ybm0MeU)@?qj<+$dfg_rI3!sE{-iTT0Bju$phpQqaLt?mY;s2jH4?X|` zIf8ory2uf|kwkKFg$NIVE_V53>Awb*JU|@a9Hi#IqL=3^f?5zOd7Kp!kjc+5WpAoq zHX)={+~!L(n>)~WjEp@|=swNgBcDDtbYtB2xYvpBSnT-E%2ee2meYxQ{eRhPi8?}? z$)mh;g+SK+mRR3x2mTkpE`W28G5=LNO`Bg`4HF&oE#6Exb!%dE1mX%jABJc#w{DIf z^7@oA!aO_{h_ z2yhNk`(Kr30XMyf_PSF=fpA)z&I^>NR9WrK8|m1%#K3Xyxedmz6L)!R%b1giJNcpD zrr%m9Q*zT6#*$EJ`^zds)omjoWsFmadx3ur_!{8yZ!Y&igp8u@9lHsxU5gX&t!B)s znpOn2n)6|46)fy}@~+gZpU5@qU(?^DO1E6deD()};|yaE^TQqohUY}q4}7;%w9%52 zUEWRRuLf!NaPoHt@J9j916=-TrW#WE07e4%(b7Jc^V7y)m9ww;d-=~V;Kk@rG_D%9 zZfNQ@vRB(m$To5#5kX|DD9Z-@&n*5N8=;(=3ynm-2`70T%bTs!l#H;Nj?SNx4Go6Q z_!~;kg#*0^_WQZHXg{nx*kLWAL95g2W4fKx2Ws?28^mJ22ex|cc%XBErfC^E_qD_K z@%E|^At=PJPlYyb2}15#7n^(NxM0i>ww94>;?d9H49N{Tth$a6W!~SjLWFBkKSP~b zcpmthfIR@Woj(MA2rwZk`TvLix$`1V@@(9a& zl+9Eg75S%h_yu_vPT_WJc{|+huzyF;^H7v|yth6?uvIiuCR*FOLLjD=C`bJpnv#QS z7&K|D?<=qLD*e6wWAg9l=p^zfeFyB&M=LMGt9M5(qRNl`PC9jXXN8DSlt+R83P^?F zZMVLr0iO@J1;CGbXB^i!@>$MLl`;M51X|(jFEq&qSd}xUL0Nfj)q)w7vtW#B`rOKe zi-cTOExve138|%N;yhoRzn4++VyuYT*E9;-GyEsB#5waQYH+9ih4uoAzsMek-BkqJ z=?kpjMfM0$igIZ%kBe^W`l9UQjCAcXRck{_btu>9&@qDOdAhL}`{QwvJ5WA0y?4B4 zu7)_j30|kE&n_(JkgHuqRE-mEyaf-rY)`_z<4&I0R?Z8rRwu#2qlbM>)GF);d)RBj zz|t2QhSK8%W|+5Ay#Su_+zK05A+VqkQpyFUC>=Nsm;`X^|5D)B1L^_%Sk>LxXDe;K z&*r>BdG^@;%H8T#SBVR~Xg{$%OEPBQ$y_-`jtGI=5jLN?7L|TNJyN+ms z%`reG(w>m(P=15gQ6ZJ_IaIfJua`%|;0l45W~fmgZ4tGJdtw)*EZ_Pg=eC7r`7qMj_ii#tNTO@3ciP;qrBpMQ4h z+co7Cls$(AQZlF~JtIwrdh!w7TIQ7wTT-NlJtv1m zK~zW|aURIMMeo!%!lW3sP||F#eI~ntq?&q=<{Mn0CkC$_$}9$X5`;>{aGC#u$eSzo znQn)azJNag-2Q!+C!}lw>;&**-#*!{b)PkFUvU$s*Q{j%ZBicIzr}j(oSpnZuKrdRNx#?%DwdHF0qHGd%AsY*&o{aAIpJb$Dn9Z?-G z&)=)Tr3_x4?X?uc*0=#CP}1}5QCdO#4w|_N+IOB?sJ03_umQY1XEn`Ug>4cOadj~6 z4h*vDIV)&Z9W_lJ`x`0rYRTTBBp6ZNOvyTC?t`_L*m}RV1^M-FrD@w}Xe+h0(X@0m zjcc`qRWn^5rIm%#wdC$cVz9=<*I&@up;#W_R>M)81PsrLJh{~LD{GnZgahvo-D8CnP_GkL6~$u z4RlWsmXk)q3>(3IWtpn^`uNj)A;0DOkp#6sXW4UV$5sfW)|;4b?*{$^;6;GjAO8*f z2Y}|2{qgK~WIx>fZtMMVcPsO)u&sIQbkjiAz=ty|sE&P}&;~O2c~A>G^)2{T;gfZ# zvKul-anN}Adb;UL4yAZN>%}nC^1h?-|Ik7UhM?8nyej#;0b`k>D$C#$n3kv>eVK4} zzlGv!b15%q#dulX8kEE3C)@)39YEZlDDPi^e+c*$z>l&YWjP12=HHk4C*?e{*s)*J zWc)3fyI@gG%{-W*uWB;xB;CO+EB*}*2}m7CH4!Wp=NfT-Pug7EgrS88YUKm+R_<~L zpzYKT$zho+hNT>ea)?Io6AV{(SnefaTxN=~*i+yUfp-;nK;U(%@{3wbJAkD2K{#oO zw-XS<=;rbv^ zbI|p)t!m~LI7m<>u5E!lf~ISa444Q8j9(0xy#ymB6C}LzlwI(-s4P?4U}_6trPBt= zwgJe@K>Pzbqv14sGsa^VxvltkAV1}kD|oh)U@)=nXa~FyPy%rC)%$9%(hJvLeI)bc z$_F&b*Hq#3W8AW-2??ER42n72n=oET0GRXmHZK9`AJ5J9h%#4EPhk zO;=f~D=*+W5|HD>-M>F3-7bR$kH5K80H?g{yPdky2iM2T$F}3;qqJ)`k&}Cn6EAYIohVmh0f`l`M^Q$xIS^Ok zN5hl~5!ND&puiM`+r{00>&*b?IL1zIlXlN9sF`0qZRTQOdbj`J{)3uY;oYU%$_~)g z`<-+SHc2ORg-=Nhhm;I}+rE6A7ejnYJANlluhGixF0A`@veAlCUQr>kJ_+w`S+464 z_dhFF_2L?s1)aH26qV~H?n2STQQ@Sw8}F`lUj7|$H4;)xfLp#a;Ozh}ryh44wNjtb z!UVo8cginFY&m4~cIp47%G<0M$|UfS73P!(C#8iD9u)F8<#7#Vr_v3l~}teBI|P`tC&siYae7XvN>xaFw_z8SE}e}Z;xUY^EI_g!PB zv(f3``t27qY9*WaY z;`YO6D%MPZP5?K*V}PFx*w^!<^V`@7Z&tT*)6nRIJii~P$||jSe*c`vZ;z(=eI}9L z^-g{prpx@khP;$J_4^%gHHvQ`K$US?Z{TA9Qvv+=M3MQv`tTC@M2qt$$I{W2V4dQt zb?U%TiZk2EFF7>JFXEB>yYh&ns7K4M%O4M@Wd~L3fNCF9_Y-Ba2Hpi+(`#rFvYeHz zWY!91-^RLvE74vJY7xT^%ibPIQNxiutZG5xz8@%ST~WrcY%EH;(^0gq5wJ@vJoZOu zkV4Cs;MGd)%nDJ0-6&tVQ0Qu@z2+2mxxNP#qpkuEYE~T5 z*9E25Y8J8Wt`OR7qXr+*AmY6loBUyxjDF(O7qm2z=y256l@9NG1RD&Umw{SGGu;Y$ z^SBeT60D0$p&tn!!DF2gHq5RN*nZT9`~BT*QRO#W(~QJ8U;BivRAhNh{Jo#LaK^;> zl`zCTAMUETZU0^RY4Epm8d8YOk?9P@J6onR>wlAu@K`4j+rSg5b<){}cbcMPit;A# zcL9HIl8*YGNBIY?Pe{KsEzh`Wh>?R*k9El51mmg7Mu5_pc_pE^-$^$=6aCFecV2&w zaw@Jz0^IrXf_HUgEUwF1_-$*_zhLIKNj*wK&b#z$FmLdaGMW`&3!wsf3>m>X`UXpy}LyuBuWvRC(}`~677})yZ}%HaLd&V_+Y>>b}JJ56LYIC8aQw6 zf`yfH7fzTtr<(IWQJ*ElpxPTOHc+Pv;SME>j&ZG3bV7#?Kxqg`~O6dl8*W;MqrZRpg{|F<*m?#8ycb&3ALr4bv`O zS`0ORu^6k7^%2a*n$t-?A9xQyAApxBy{MDDgB z4=$f&!}pr<0J9MK;cNmC91oMXT9-e6ARz4^e40bKxY`OAxf zN0u9wJPa1rMOWhm zE?*$ay%=e@^XBwRsd6)}Zvi+5ZMk3Aq#Y%7A~+udE*>@rrR||?Hh6+C{pr4-)=B4c zyt8F_?cD!ec_n%9Z1Yksl;s+OcegB84dVX0a)}f~;dZwuSh){{Y~}s*s#d zT)vL;Q>CVRoj|=;%KJ^)U*vn*%;_D9;1zgQHP0^JcN%ci7N zwQ$3UX~}vX6#8!cDd>fJ&ZXnPrNrOA!Ts0tlmoC?mWsWz@9>CnDVtCG^X0>P1PtF| zEIWXUqvFB}5!SSk?HvP2cO&q%fUN+hyn3g$A!P~RRscVqboO-t1@~(SC z-fy+s182{h%Qx0)?l+)KdyvlapftnoX7)X--nyG*-@~-KS=v2VrakTHPLuv-+xu)U zR^~RW*u^j_bOdla%I>)%c(s1$PWfneQ5Tuw+ApsBz`M{c-Sc|*iJyct{23uNq(aw) zBpnOy+u85h+BJYYE6PO;D8iTe36og^mIs@-d5sB7QF7aZUkm6BaL3t%Z*}E#T%QBrM`433mrFM|KWz;59=k3`jGrbm zZnF_qRXwc|>Yz6>%EY>)$lwV#U^0*DG6<~1gLva zma`SUd7?ax`zU8r&*!bkbA6EPqX)pF+^zpcm8X%@V%nFs)6?{@&*SgygRNeo`F$QU znCHqTeB?-&454$#WHwD^xuI6(w-$MEzw1AqPL;pl`VD~FK2A}T_YudBK`rdPx%b_$ z>_qdRQ=SC_xkg^=Q=m}pf(f^2)$^<2scZqtIaXN{(0oyUdu$QtI+o%ZG~vsf1Y=iJ z2rNH8@%2?->g)M>Wu;oxsjzm$h3{DT;M)|_Y{Lr;&lQSm|%1N^A;dVc4pa)sx3Xiidex)b)rH$>n&@$cl#tyz` z^X4_90f$~+Pb zFwf6H?MN_>VzZ8Koc|a^EYy|}dpL-h0$Z9=kDA(Z}rN@2QofQqQfS z{B@x?iFDQmRct~!!I4%p)G4Sz7{s)+De4f74z6qmi(S_e)-A~w(&$y>%Rv0yK-}oV zC1tp3<&tFiEx8EtMD%T>-c72I=1A> zv03QDW?@fEc|L3w-a(S`Ve=^x5C6zg;>mNB`lS`3f9^(qjtNXrtU}E9fI$Ga-+yzi zPg#rW%>aJ1wI$v3=n**|IzLs$ICPZ~KZXy4`4IwlGpEg*3d(O>^#zNn7c4wM_lLO0 z`%<>!Vj_BwnO3}eOFK5v@_Oz;eo>WZ7m|-(Fh3hr5Pm}b| zlFVdu7d^=zLJYknDSls4++%;56#YC2{xrfSNlM|K7BYNs+e}W1!iO+6llwr<>9TN! zA-)#V=_}Y`Vv}hNok=g!&gawdD+E2c5;9?(F|#3Ie$-WnK?Y}N!r9Gk{Tet8 z3FkuC0G=7Z#g3x$c|nI!JyjnDamy(61~hvB`a#GCi!;p2MSVC8l?}4#D5s;M_*FNA zHbQQb2D{(fHBT}@UBKS?2+JCv6Zr%W)bAvWQmmyz}gUPCW<*Um(Xv&YLcLC4MCt;umcdo#80m#IiQCUYl6T zW>&C?MK&{g6Qi41U=s^&W<8aU!^I$$o}%w0W$jDqNy)E45Q(*+m}Y+$jQl5P{}iO( z1Ou-o1$QL{h-PK*CSHi$-2QYdAGBwKt}WvO`zVFbZ7(o53RnbwuP9^&f~2P`NyDlN zi4-TsQx5PVKo@}9{--Mer9ZCgo|oM_81*M!qP4q1{zcMb_k9RXyqDgWQ^Af5dkX-W)_^> zjRtr^c2aUEsEsF~PLVbko$%GDx9<=<6+YXe{nAtzA>uw}H2lbL~Dt7tp7$AFmCEWis+dL9932D(j;TWp&rjE5Ov=S2B$n$TKr3YWq(6L#0L|c$mw8yS10DtfwfzO` z5-Ue-_LmorqeE6xz;1n%VeFjY2_T;|0LJRkI24!g{V^WM*Av-cdr`LCGi1tS5CTYjg^=UU{! z&F53VcLDwiaP#^0S-SEOu1}OtLA~`~v~UKOy=H6pThZ==e7h@X1&)78sa zkmqn2!DJ#U5BaLNOXjPjOCn#RfKLES0=W5_H5vN}xc=&uCHNKWFx@Z1CgW7BKjwBQ z8aWSEP5M_=!4hP+yiY$<*`rTV{sW=$GR_(73`zh&gOlzCywj}@H{DnMXX$bqybG&E z!dGM8RHUZF>Sg(YWeNWCr2d*xjO#7{w|w(pjN%kr*B_rR=#~$w<3ZR`nhk5!DB6X} zN+dRp7j3jGny8<8C*A8?Nq5u#I9;v<(wO$0Zn`|d*xj<6M_NfQv+Mt~oMJPhF~QAE z60CR9n}K(?Y`+_ur04dVmh1Co{RSIQb3k3pT_}Y8z^0o&f8Km$Kb?ev-6PAjyGc5* zfu*-0cT4a&tAWo3)BxQ6 z(qodXEX4H*_oG^-$G1`DE#kXUC8gjc@6ksopU_c!OeDJ;MNcw( z#78{DZ(v2t=g`wY1bV?kG7)mfCq1|^++BnX=k{`y^7~}|KS$o&?`g0HXdXa2fSdoG zz()ZV|6R^k(FaJ(IP7PjT~=nIqv2qi+r3kyu!p9M#*$}~6vLcovv zD?||Mndo0bf!6>Q0o;7727WhSl_Rfw{q7Ui+c7dUG_{U=OO_KDe@+r-xOHvB5ku<; zV!X&Mgqc1@eWt)anQeDqU0F}&quDI%$yVVvG&?KTE9>@4N4Du~HUmy=Z*&y4>K~Bh zIf%Tv{lM;pJ`KnMxaHx69*^r2)GPc9a8*Iqc`x*xpX9wZ6<{Hs{~*drSQZOJ4by`5 zv(7B=%(%-xjV2i?>X+}&_`KYJ0DM`bPSZr9K18MCRJ)-9=TrGUEX}%;=Do`)7rWv(NC(-o1u8tly^2C{$Q(4 z=LV4Kw0qccjnrw&i3pyRS0ly6L~)k*Z<@HMRYmX|6~TOQu3oMMP)U#KH!))wn|2d3 zm$567l)io2uUKd)vmc%DttOWuvj2<`YPGQRqS#PMEBo1ubDnRjC&hT?nr+rUjFigV>m0*WR()i8dz6U(z#8 z4}qT&W!-dd%3^BWNvy6>-0e0>jlu+K+pJ(L9a>BfdybAUG@qH`&&98n!E{+px1@gB zy`)zIfnwzp7*)Q4XhY01GB3A@>GHs9!M5j<0t~iwQoHD9!-_n^KzN+eA@nKL=toMu zD^=}Y&D=-nlrR|LVE-Fr8m7N(It!^;tVrDiryJ^E-sa_xRfwiH`y}?G`U9^3%m%pq z=%c>SqXnGOPxhl8(;Plwb#s29co|N!U3>BgIRL1jHqP(ht+wn(Egib&?T6<1hdSA> z-)@VbJ|u#d)DN+Jxkul^?q-ATVM{ZWdyG;{Q%8xJLrJ@KkfHC8S(wo`)3cfm+Cr_f zqTZ;-1O!K>l^I@sVbT|xg9m__&959Y+Mhzj7);ED%urZV$>+Kx5I42Go)}5 zoFsvJVvdLR@w5AtjK*XbfelS=Wlp_Ztpzday%j4r-OjBe_Hq)h!^GVU#_M3pg8B3w z-slu zFP#gfhXtmG3bDSn)W^=k)(^}JkfJhcik5w zi-an=kj^vu*`;7Bru{}T&w%dUIrMUhhrU9WC7US0zx9oJG6{ zN5l3C5x$0g+e2XBlvhyYOI#lSxbym{mZc0u{3p)7NGt1-an)1j&952?(z$xRpe!Bw za*w`P`NmuE$QnI9fbpxsyv7 z)DK%Bklu@Bd^37i(n|A@Bbm3+^9K$b-*j$vml~j^q_XS4d2?!JS1)X_`H>ixpj$-I zu=bi&UDevFAkA}3MPTmV7J5tF8Y0_5ADA-GMDT-*{7?qliHQ7@3_h2^RvG*YkI-$Q zkEnR$%Vv*!EF<5S!G|*VRtAsBSFe+S^RoZQd(Q73lvn?fS-M>pDI8=X*dTwmL#B7x z){qBN%jqjv>PqHa!St0ZvVse0UP)H7=o+@3t!91JuovlSRiaycKNlN|v>Pja|uBy4eXnjqXOtG&y2qQH zhn=NA(42co@EWsZiRrt+3@$OF^`vT((NQmC{(FqrdbsQe(z8v~Xum{na~tv5^MhoQ z7Wh&P9fax^GvL|zd)5Dg3S}c5ZjVVARx^w{HOykP-*)+$XN}nNMk-!&4u8#S5baDi z!MQP`&s+Wm%kroZe+gt;Y2m$QN4=Q&Z!=?en2pKSo7PX3|4Q5HuZ`1xCHB=IncC9R zUX{;2XU1MgJo|&0(KY`CvlD;IlVrJf;(s^POdVXj zq*N`N7r_EltTs#aBIaLe<}OdXW0mP=?QX?8P&IXC?B>LsI&*+E`8xTV-;CH5iML;C z`nBvUWV%O;*w3OmC!5TO|HsJx8TEsl9x`IzJMpFwKV;;8C*%KZ#J*0%e~t#XX#Uso zU7wh-e>J9`|FrJg#;c``wZvmw=b<-Kdle|k{I|?Pp1^Bn?DfWn+wV248?3==EYKd& zZ;i1_t?n1(e<4%cZ^Zs-WVkJbn!$DqAZ*bN@5m>18nHJMU1En(^16(F(THs~qWI;x zsO|U`BS?GkuK9@Jbj`<&;A2MnZANN1OxxcA!_>j+%zV-1LQBlhDpGZq(TUgV8Y6bA zQ)awpiJrESgi>`lkKRUT&UMW@>eXcWa+&(IM(hUXS7^~Cdi0N`55t4zT$FK*S?2ca z(8B3Qby4F#=&_%4-otp`$j9@~=ur=8IQHX&0xu zO=RH>(xU!qy7WMR?5(#M7tE?1FoV38h`cUroA}^+0$%=~>nr z;ccW(SvkTDB&%zivO!RjxRRXu3~7(o4SkT%JIJuEBXRpiGOBD05<)&sKfV1KBFii3 z_|B8=6*=uVIrrRa$T@e>3Ou`%(6&?WpwoaNU&XULbl_|}_Ba_<*$zE^9Bculs$K?r z1W@-Z)2;-GYpf>rVPXaC_EsuP4Gi}7@xb*oxr6$HXilhUlc=vMsX7!(+Cg05P+L@U%GGdvVNQRZ|&ssxbGjd|FO(bVv-ubz)yp1Gx zdfORoVr{pQHk(NMx$Sb>ZzS!y(%?cg^ZHw(FE0GrGrm{E2iQ z&}&A|Sg%c_XG!lFr^I@1Bd1)__b}-b?AyN2nDUNbEBR1>P`|1{(+9=|p-^f44wE5` zg&jP7_>5t(;V{e*9o%mk$ux8V7m`~d-wMzIV7-w!h4hb&^sf8ga$UUO*k(h&CA@s+`L%#)`>=N3BZ!t`Q za_$T@nXMseFDK8nKU4^8Ez0N8^G^YP2k>`*OV9rX+#VTH?sM!}yY_#Y(a)6&M^;xZ zSjfxBRTp?D7@y6|fWFw&Q<*`$vV8Gyp$-*}p`D zQKvn{MUzwgiQ2KUiVJ?8K6l|vE~Q#Wev%BeJi$uCnR8q^t!Bm=mb02!YglMCD_+Ca zg67Rz0|~u{QDX019;4oqtgEJRm2Le3S03I2R+wh~W>rF9G2}}+fT*lP7T68E?Duo6 zlp*+xl%ZWw@(WpK$mh-A;(47g&T=5e7GlNfTEgxG;KzAT^%fFHe`eO`CJ*ZFB8}H;lSgAiXcJemASxH)I*wjN@JBwGt6Z}hh z8oGPCX5GC+ba#9;rqk7gf_JkOTg1@4BXlXS|=BAcAfcIxBcapY#3){E#XcgnufK{8ni9nr3Lw%(A^+kL?XkrdZ<0Y=*iFG4k9j~JxBp7BG9^uk=MB|C`5uE zakN5|dJ@X!&R-V*zY=gAz%Bne;C}*)d}E1tzE!@tEWeN@L0dJD7i72HE>z0*aN9Vy z`FY{4<_5h^HwQw9CJ)ZwMAyXFPeyE*K0?q@!A`6t3{>K2tUvFHxT+NCDq|DlF%5Ve zPzZ4IISBYfz|iB%8@Nn}X|v%(68Wqp8-)T~0Zb3<(SIcHy^{<7CB?{HHkjUN0mExa zp%p%aE&*8(!$%@RejuiEIcd~nEtUCeK-%Rpr^->_dE<~jfIDAo1-=XL5r7|8JM!ck zocYxGX=Cu(@#j%d&+=P%9QKG1a+(ib3#5AL>DBsOl+@EgasB{yc5rdAIM2O{`cd@n zRW-ua>B>%jG0QA(-%lT|4pWC~!*W?^TW?0rTL#+!|LmP$Uyo6LQX8oB=Fv<&+Qw`P zOGh4R)snRYOVa|~2UcMxty6ckEQD`u7Myr^V4lVEx>2~(Ff+BN@2H_&Yi4@eSUFhT zXqHBEEiK>$bC2(q`Qwm-;?gNMQq=Xk{Irb_nAk!ZFfk$or=9_As$#M5D+SAaBQfxc zS~y6-)(k|4)~PV$mkzG@V^nD{WV=+1m+cZGigG#d<$!vCJ8!KkF_dR<{ThHDHO~If z@#d`>XWshd`14ki_CYV(O+F)Ua*?un?HqJ zrfE0$tvWwUrw}0${}^H8@Ku9Ql%h-pdBUribdr6jX_g&30*09m5S;`vLWi>O`FiUW;#;j&@)-ZiF^Q~cr zKon%IVYc#IpqK_W`7<{A^*`&;2KYtnXp#^7Df|8HsQ($i`Lf@7$?t#Juf3zEgW*Iw z>f|Ha5WFF^8E?oVctau18+zB)-?x4HY`)oO%=W1jY=Ayc?ewKjtp7jub^O#PSLWc6 z{N`mexyn0LV`&xMDYgd;7_iN=&ec_GvZiMz(=#l5sN4O`CwMZueI5Vmi)uc4CPqfV z4xc_2d#!jrcO0G{jps{~qux|)e6SaTU*F6jnEeMr@*b)Y)Sq19E!Hsl0%kHz%Ja4{ zPh|rjMt7P{(=)vpnkP9w7}tEPtx3~@5J+s3To&x2jqpS3=U?7J&bau8x8q0N6eK?0<_sN3B$Xw9Sf@Yop z?T?FSfATlNdSPel9IZ20^>hir8raj>zll(|YkY)xa+79Z;kl8}#Wa`u{^^S;Rmavu zXRtN8n5JQwetgl=t?X`=kDU4?Dp3xGcWXaKn5e#oSdaw*_p06%sQm*e5{zcn8h;wDbd zI{QdfkIVb5j1T8?pHr`X&+bx5y}DlptEjk|BhFLAd7e1;6z9lYuET}tO<#{CnlCdJNy60E z_X~fqUc&qz`{#V>PjmWP`*;1tN$C(_O$iKzK1Y#}^fx?(j$OGbJ78V~Vl@_)fr20Jps>&k89Q0&WKI zqitK+j)xnXxA#F;kL|GB2l?dq^E}_om<_?}xz*F>Eu2}&)$*Foii_q#hydDpjV30g z6cEkXMvqAACo_ckiE44~-sJo=5f_ExJW8A+TqJjo{uw$+U>ig4z>6`3*VmFD$-; zJxkOJA+BYzcH(k~vOhE&uAK8IJXu+)*S3>Pe5qlDG5Aw3uq@=cDP-1#GBM|M*89Sz z7{vCE2CZxH*#_Ml>cMQ?e{U#$e<&X6VfFB7mhDfuN4xMYEmp6Y>#@3weIJZ}73|GF zOuTDsqhKTq!*}{8(5Re-Cu7(W&{ z5Axp668%Cgh7H7a*iIWF8Ym{)H99##pAP|k0pK!#`+a*3_&)$Y0QlkBX*+B-Z`a*U zyB<6~T_xLd%)A8)8^3Z1n-Ac83dym}hP7f*&pk1VmeR50Dy-FXsGq^yT?hWVXF&?lS!z1Fji_UJy;QW3 z^;vVaq@#PGk!}Y5FyM87J5Gwu2`N(m*8%uZGeXwmhQBwj&uvb9u5;|#KI-^&Z)IMT z^$Icb(`%}rslaJc^))#)zAUH4kBFEFpGUxZ`O&6R;eDEz3a?g`Ly(V1M~5-(L9~n| zeH}_a7%~oo%tPqMr_eFDW9#IBU`M^feZkK62E&;At{_{2BWX{)XO{L+$oeFt!4w?z z6x_%kV*V?Gv1@|vH@V-{L8mh>;q~1aD-!vQ>q%dth$w3DC3F-~&SE1V7`>F3bLeCk zW!^%xfh^6_#v5d9VeS=+u1ly!ZFe&qK#=XtZzEkni;S zbLID|0S!|Eyb|yjz-^}?Q$or(zy$z)EOhLMEX-@(PU0p`m)er9YdHRV*2s3c9_v() z-Z#k=>UtTRx0SP^-jEmV#03_zyt&rMc__M?h1al&D_HJI=3Bw&N_OrF=2^)!v59FQ zMg*y*ZnbtSw0?j6RYS zel`i}ZnW2rNm`pQ-+ZhK`<9344m0|iZP7kAht*7TU08b}EOsWJ3>!~`>DhVvlC&$r zDOZQl>i&`(?R`6X2%-dWjh_Dzb0hUjN&rXJU@xWQbk8DoPUOV~fEFLe4rMPmNz z416$PIKZ9%9`0f&3vpcw;KzQ)E~mRr7m-a)S0A4b1uf+TLQ8o$fjUGMU??CL%F286 zr>SynBo_q&B|?}PF}?Rw>n4aR*;)+B3E3X-9YCTMrwKbRR}ywAX~za|dh9IJ2CUod z?Qe|1Qg=Z~)!w*r42up8ixyCcB8=Y^DX06*6LYl#S- zbo7wLMU&HJCx3g7KklaTdOr;$#I%bAr)#a`bp3I;7+v6W;amTJ9All7Pajm_S?InYjAuRup(Uef zKZku+M!cLF((<$dL;F|Q`X&sQ^Qh|x_|)RV48@rcw#q>IaP_Vzq~SJzC&Z_SR|%_z zf7755w*LT&M+5XsBLI0qFd(t32&xu>l^R$o+MNVaqHw2eorQ?$xY<=y7Ea}*vOQu| zA>~P2cR+s}2z)%C3gEU!E%2p)wE%ux-$%B?@%R@FPJi5R{Qk&S`%SD&;CtPm{*yef zk_NTc^AJ|vqc1~m1pD=3QQ_T`*OC{|j7K8u@kkCGr<&^{*2V|~%t*RxX7GMT)D`@U zvq0M?t6rhD!N|oZayOCh`Yy7(X02lw54$^zo1C$)3e>gySX?HMXPx5#go9my2RSiBua2}7d1#z*qPz}t* zEC?}Lv!@yg!$k`%WQDoTifKKAC>^@P0xV+x;*_A9EawsAKZbn11c*+9O&35Jz-`yM zFKlHDuFnSWW9{3{JaZg=`sYqLA3c7%jvhFE46I1iK!2W3^A#%yOwi^};nVv>(VpW) zd$v>RQu@*8&k^=pB!%_Rw67!9;Rv5sLY(1w8=pf$B*3Z6iQv4AM-#TgcgHE$ZseEC zEqB8FaR_*1dPwOGaLY9h_$7dw0sLtD-igaqHd4+XJC9#3=%;f(3ZUEPLg@k}t0(`I zdQKCB{?NJNEb;E#TKgXTG%=~Jm$OHaAlJKynWT%HVwC$*y3y$JWOHS53boUHpg3%O z#!JcZhUB8>lMA0t?($S}{L$nhO}ir5x)y?o7FhGCso+NPY4W$z!CuQvzICk7@o=$o!#1ny&Ly^3inwvCxvnYN8Be4Ul+Lr5Y`9s5 z7`Pqw!Fozk9;L~?$jERo5)PY@tf#q7|DU|xU250YRcnhkm0EXuBhRUYf977PX?HX4 z&Iq|9*?%QjMb`LoR`_yg?zQ|kS9m)jUqSOBq7T&Cs@>J_H6(r|>8twHOD>E3(Y#Ds zOCUgHgp7&POK0?*9-0>ROgyvOS$)q8oDt5ojfjy(xSg0ZlBT7QDDiufcKXyWeHx2f zeeHs9-$*>CT-DmfJNDfOiCptRf@zLR-@d~w#00?u;*bR8&Lycf8fsq!dI|79HNVGzc0e2{K38?fPJx@gzf+l z%?H-=g!QAdAZHA=QhWL<;s0GI+{G_S?jOxf9UFxMhNH z2p#A-#XgIzAnIBAw>0j_J1f_dcMhNuFq==?hww)7B!SiaZw<)!dd;qeWyh?Dx6m`+ zk5xoq6-mkol}DoCdsHa=ze<9!fC+Q@3!|l}eWRh&^V7Sf^-T|?eM`V@(=y{3eX|R) z`o`ipgY)9K=U|>3>dgNbx17#86VqtMZ0JS+t^m06WVbocp8(tp;K!qmU5ug6HXq;n z+pQ4y_d4rv<~-MAY`XhiQ{@;S4D6@M#3@% zzjvLC{DZg{v)uMzc>ddfbmCSbM$A3~vX=Q8ct3Nou(<&`CJ?d?dl{^Tp72X=AI4?JgMpDCuQa2?{mk;a$N3k;|N zQ@@I{*9%+5Tu42LWmga`y-Un%h+gJI1$9V+T}M1CNv2t?l@Bzr=!a{XPdxgk9<7bf zpYnKQ1$iVg_tA*?RK)BA!cPr`(jap0MQYl#*i1AD@7B-pzPCIHy)Z~RWA9Ha{uZ-q zu~a0q(im=sNq3xk($1dYb}GHlbDBNU8g9Qrak_{V`-a;CfGsCFT)yN3>ue3NGc3IP z-v)X-Je$PTebB8T`T7c?%>^}x+Jb)qXa1j?umdm#_!hvI0H=Rwd*{J^P7QQ!0Q^`u zS@w&1ClrxQP6v4-Wp{DxCJn5Fa~QC2&}kajBLOive<>&Lsp8@R zdGWpsE)a2#$!XoWHBZFJr{!73JR*NG_IWJ)--Y&+maPARuc>}LUBP;1TR*Uf9Jp{yge-Y=)#t#CQgaSOyl zOz#swa~@SOVLzs_N7XTG5;O`5;cE?BlKHTjszpMbNY@`s?Pkm8txB#645G6(TiOd& zce5u17X4aPJCB*UPiEjFtGC_9hFn#WnXAz%1JAx|X&+ftp{mrXrJl%g4|wm!Xy2(` zGhJ;L4vhW+23ImwKm#>Ko31sKSNU#CHkebZxHw`q*z#X-LBj2X%%k6{m{gH2t>@~7 zL5?_vSs_1Hfms&YGITKCo_ULqEOsMR?<24zlTW9?jMzE!QN~3={zmECwB01-el=|! zjg8Gk=Y5vYRPqFYhUYH?J2ux-v$t^{fg#`yHnZLZXGXi4JAC+pdXqg5av5FdRYXV~ zwR1_Xk*36^%Kp6{{Wj+GPkKQ}2?L@4XFs345HFLq#N@$0;&kk5L4JM@cTyMUpFE>e@ z*;Sl##2EruJW5_PJ^z&+=SI_q8t`jgo#3eClAk^3WS4@n?XG;Ll|pSP0~5Ys&$jFo zos!#W?mdvIC}08VX-D(nXH4&;kJbZbz&k*1TZ)YUlVyOEifJ*-+pq_e4D)s+FeQS& z(zbeAeIy`2WBMrgQ;HcPUW@&c25*CLpD{O4^B!tKi73XWS3Y+yC!`c)NIG4|2Sgi; zjAd{F0z#zPoydo=dx=`0pF(!2te)^4i2I55pm3SkvOK)4DrP(Fk9Kw2|9#;927C{2 z+uySgydgjv06%_@e-*q+((TRqaqI2R`*jo3ZF~uLAy#nX0O2Wo?z~HWf_o^nMlKy| z+b~!cx>1iW(=!qpy+g5wun<)ODrzhpM$h8QIEYfl#o~jPbB-)$9rEv%bHF0#Y6GTT zm?-D0#n3$k^to7;bMcq595bfKb%rQBPPN_@0v&o;E9;Ne%ULyV$fDV^8+&l`-oBt3 zCe!BQOEh?~)Xg5X0EQ*!RL;l9nBCf=!YbGx*k3&UgEAY(dne7S_q;*x@{oGZ+o}k@ zute}H5y2gX2u>Gg7}($s%8NVY#XT~}5qECZMDTYRcK`vc_k1R={nUI%p@Z4L9k&N}R5@zpGA4O;-CI`tF=n?K|7F)@5XmZ}&4 zQ4H0U=qz3F-0KXBpjVj|)OQ@%jF+klVd)T>%YzJea zA$W+1DXXCN7>4UROAlf)HI1Mhq$!6%D1RG>vG%OM-GVezEHgbF3ekA9EZ8BOKIlC| z`@7-4(de+uh`(=S?KO07F*Y%Sx=ze`f6DFt7`>EQb~|eMGpHR4Q@yv@!FbIdr|Mt* z`F1b~cS+Q?EOU=P@~S`fnm=>9zueBMc$LuUHn~NQt<$5o>%nDEuZ`04udupaX~nOw z0>7A{bK;N$)k!dzDt3-cI}JK2ASjbQBrx!VHRe6gced{33ayr%2PdPkK#)OcLJOnRW^`!>NbgO-bDS z!0-P@;_jz@@v7ux%v2^zr<1t%xYQ_1Y4EQifA;4-=Qm&SYZ>V*PD@cOFrC)djG9w4 z8uOvfX$dDMJWft;YnhQh`TY+kl3MQf~KL#{4_97gO@jp}NXP74az;8^oH~M9){ggj>jz>Y-)!~linohqto6ZnFEdT)B$;Ux%ZN`OGxMbWtX(OH2NvYc#{;?bh^ZNn;Iw) zh#intb)2B_>m9T>oT@D_Q;d3Q7g(PWZ^RSxXZn_^SOZ3b!N5wA)HM`O3WQ!|kzjbY z;on7ke*XY)vps(A0MFMnWfe)zO)-<7RLxzGT{8z5yGS&idM>`2?fmUY+!HeMjhL7( z%deI5aP$v}d3ZkXs{rc(?)N6+64*imoB`lRnX~@;Wrv)PMP!pxG+(Y0PU3q5b8?mE z%?53OnX#D}(WVO2aOqP6m(NWtWaPmXqWPvyU`~#{seAc{$sm0po6-%;00Yb75Fi3b z1qA7LL|Fo*+%UGUb+7R@tj8Hqy8zo3>DW)@=gHzc+zVHV#P^_Kf-=b103Tt%QfcZ& zBjZ8C*kGs+7|;>kXsG-Nl~5|X^tRyc`}L8j1u zaY`BcLw_X<+U(PKFPlbtdmf;ExFw>`I91{qPn5dc482POUZtP?svZ4630C;eTmnLiJP zL>G(~Ur_N`Z2rA${c9{gxZoQvnp0intOf;-b&qrl`VtfD)jv1oxTc7xw`9~5adDE-~u(u@Vu1MVJE$#$_Lck;u(^H(gi1Q%jWjmMp zSf&}alR&!%z2Hk@MHZ*tzu5XtJM^m+ueD=u*wGy@oMu7f#ME^a;}{Yt2J1b!4fWn^ zdk)#AnQW(ei)dH;b=Eu53_Vpd3@py`O^9M(=c1k39u7(^$PTno+nQb#6TXqF=9%!U zs9F9e?D*4m%;SH^j&B2_C>8v2JNh5nZmZqlF;?2%v%OjfMpZN)I2kl`xgB4L4;dG- z34CG2zp`S1@cUN$A6Cpt;}Z^NA3w}TM=3_h^?c^cb)YR+XFUO_WG1xKq456~if2K_ zm9j4hJ%{vE4>0|80$Hqk;ei$VE8vrm5Q-7QEWDQJ#nfZGrGnF3VM$g6Z8H&;41-70^It)@`{l16yR+DKkjqn*+#aI zXRCASf6RYZ|IN&0H*W+e-Z#73jIQQRWv|vgq+~zcB!fb6A+3z|`w;Tx@9MX3VziUi zQ-71e2ju5qtg{j|rrk(jUBT4LSw9FwF7-O|tNbKxF>E zz&P#z5-SWj8~)dk^8rIflykV%Br_C%B~l}x!uKhff?&G^I)9K(BMe%@e3k|#xQ+rt z3LYM(h4&Nfai0af3vB$r-h+*Aj1T0Tk9K5{KLyN(14c;mhq)7s=L5Dk`nSd|LWoOlLWegxseiE+$yyAZh;1i6XEn>CcX+ z`#3fjR#nE)gAChQtcWX5JPvw7QD8VULOQXY>YI#d(1b&El#m#i}JJC%v>z^=ZAE`2!Bg5$Dc$%>+GlG)vYr4S#r!fF1LB#M=SN|H+hJ6 zusUR~J-F=R%tP!83S4|v;t;#L3Y4Sej=ZY648wWAU0jwCd*)Z^=>c7?eXi!V*i zzsB3+T5tRsZ|G64k!*)8-obfueL}E*^AP`XO#{5di9CqS_2)E#4@sHyQMn8Vq1TJe z(a~@V5XRy#gtgG0C|zJoQST;xuqFRM`mr%+?Ui6Z3Z>sH?HqHu5lu!)5ZCBKCqv0s zRrlJgEvPOSmL+dcy@wXpLmD=o_dnqDk82Y1=b6B30E+E^+H7#U6ud0s=Ap8L>B$LozGKU-P7r=daCMqo_gx3=dt@e{R#O_*I|9# z)+=6P+wY|G^RY>9u_@k_`3^>qJcvO1$#VT$ZW7v6r1xNNJlDc3bt|7@D;K74F#m6Z zl|BQe`*4un#_WUGw$V|vEMLpRB4soRUvZFHKw9d#ycsbum2`V>*@4RobO`PRLmJ{W zYF;y{#X<%CnA(GOQM>BB)Sk3j?MmB(T&F-r3Ay?NR4d><1EHtWOH8iP;c`Szke7$} z4C@zy1vG5)}o zmBi~D;Eb>D^J6XYTL7;D9K3#t{C@$8y{>O`m*Xx4ug>mGmTdW6y32vwX3}gWd+6V&m+}wBjl&C+nwG<;PjwBP>1Q` zaN2|*(C{-2-ru;|ria%j4t>j;i~ZGr{s4ab`h_fS+ppwVzAc&dI^KDoShg=J zODdcBM&0wFPhCK;SjbOpL>m5+>9CaNhlo5OK~-(zF(T(U_hCPOeeh z9g#gw8>ku~PcJN*7SFk&sjQh-#qVRd>Ro zlRrSJA-A>T*lk!R+p7`n;OI+#N51{MpmH(5X|H|A{|s>5Als|IZLj3)2T!(?%x1EYpJPe5vy0yA&#@5imja!{#Oq zMcf*Kod#w0bf^lGpdw6yiZBTht%`PiO1A5Cw3pMa|3dy{z&3zGFCOivD<9zcNIicF zy+C}smMiaKJ;!c0E;RMI;_}O{I(kNfaaSk?%0+UZtRc!)Setohh%s>fG~Rg!W71l{ z>sPbErf)YU%R3kO4*;1rCHrBUo3UmESPbCD^!}1QH`)FeW$(j&C|mC9^_i5Hj<-Ix zl0m2!hSdtk4I(fBtO%Rb=iJ*>(xhgJ^cx~hr6SE0=^ZX{S|Wd$E-uEVq?d~P)gtw5 zhZW%-{TJp5cvowlg~S_&Q@MG>6YlLn1P7f7RTR@2K2tP8*ju9; z=`e|l>sike?-Rro_L&BY{1jS9eh9@6gp8jTI6L~^cqdLBhS;sCVt*P6B}uw5Szh$k$=*}GTaNhv#zI!c{Fi}=LW|*a}}4` zzTtwxXlUE=F){IAN%<3kc*q5^*bNdy8$9MAJ>epL0R2tHFViqv5d-Zc`ZWV) z)XhZAHQ`_e{($BXbvA`OdKd9@@{jVZCfFump5xQk`GItIftS>igDHcMYL?sog_`(E1x5W(P3-08x7EZh zHS1m$TLMp%Ag3M;3KBK3jXx`;%Ng8d6z>vwpt}h`2p)|eg(W2K61NdH9)cwtC_c_0 zkDbcnV)TbTs)>Pb)_?@}0`)CII~Spyi_p$lm?O016xeB+x>sd;#}+2r`)uT=0j>o& z?cI$0kAUbR+1^|2c;rh?lyuE`pL1?|yz8wteLk!NKfE3(wBgvrr*3kuQOR0$tVl>8c?gSWv_2DDcr~<1e!9!{)cg=Z2uBWx6qa-~z;pe6vJ4kobhHSnbUEVaqbDMLdi%xo&nD17pGrs&BB)Z?eQ2%))1+)HoRWQNft65swV^+$Jw)QUv*h8VI4E zJl-s~)KYkP%kkZ+dL#>N=P?QyLb|c8Mwz_NO$5Csg73o0h(=!46R+tVRNZ9-24a?% zM$W(p3xE?}@{ZmatC;WT)(7yDn3dBDoKR0})Du|&zq2ll?af%&e5huB-qy>1*+6o~7Ito-027%~c)nJVbBR zK@1b%lZzQlUh7n-=!ZewYzIHQib1u}4e@@um@5fcM%Wqfnntj|luOQMmsxS|UZT$< z8q8RrA0B9)X5CJ(Nd(_`O~*?&s;o2UgrzPgAfAJb#l#iIO8YgSpo6`)5l>=hQH0?#L{U-RqDl;H1V}8vgnh~rT7JY65TTaYNS(X~{2)W4; zX)tVMT@UAsu0$5>L+`T|k`-2brDZ%Q`UqNq>lb!#Iq7C@PXy&Zx26lzf7|r7AA_;n zc=B(`{zKH!`rqJpieAaZpi#UD=TL0l{gE#z4b)-k$u<4u4t|^W$@Wr~B-`sG z2yogf_n)x;2V4f=$GT@_J8fOpl72KiF6r-rKS_W2JfszE4Ayd2nPM$>D-owmkzOY+ z&P}m`XjD=U2&jQsJ&%Uf7E1hpmS?(+F3<+H ziDG4n!ZkP@X=WG+s5nQ$_ye{X#A{wi(Pn<1K6Dp1&3Knh7UMla$3JdVt}_yk8`e`s z4F5Tt%i__n+R4)^9DtU&B0bBU@4_O0C(ifE!JPtE`J1jRPSp7jo&>@l!w7!Eut#tM z!<>LS`hgPZJhqEMl7c1)hMwnumkz|5#JNz}X>r3(J874@PB4a&4Ryz{VRdmbKt z-1ER9Rk#y)2P;opPFbueo80AaBR2xVemWLEhZzG|h+8T1i6-{Cd*WPJ;JqK4T8gk* zA*R-K*xw1aAP-PBnd=7mQS+^==T_7qF7CjV9{H@Lh=&Dm`mw$YHe-Mh0DkOn$$s4D zS6OfSUk{&_*!zE4)LZK1xr*wt+S89%juJvcPa!Sr!cKCC4qK==)rvGtq~{(Is;5_p zCknq=oxD#lIL_*;rNNZZljc3`4OaRl8z3l6iL&1f*HwsKmyU3OVeHlmBT_?@X18HG zRj!;j-&MW4vGdo?cYysUW_D2ea_PGa_OxZ#r{Jb#=~}FlUi1PBKgTLq`U@;=GaRu~ zEegNpYD9ENm9Ja*MCX+2R$^re>sIm}U$-*FuX5c=tYwLXN|m^^;663&2emuINB`~a z(mULF?(}f@zeeJBSOlSoxPbFDUxVt5ex8FhC%z690vXFhmycn}SAcUnz;Z(N5W2#q zYY=}Rk)BBRsj$MxCAU$alABm!ojD2tH3tqipJ4EF-d9i4z9vE$e;PJ44FF#%kxkg^ zN7*kL(cW>pRX;`kYruB^M~~8Rwof^T>&rIFe$a~FbKA%dp|1N~F;PUpE*osy{n2o* z6A+kbw?0BC%)xF~*lv``;6)Ag_iOG<>aG8Q{5-&7fYUGUMgA$k^8kKSzu2-IcF_-? zs*j#Wj2<^m#I!ye@yiC)P8>gtI~g0goFHaue25=~yS$#BJA<}SD)Z=|&siaMK9qmS zV43Zx>q8J0_+C;d7Pt<6BrQ^_73FeFxZ$>gv=QHK_(|fk;;tk(4zPr-8d z4*(1X@T0-jXCFwH<4xQ|5??R*Me{M_O&(YEh*jPNs$|u-P{E`hQRhPmwOe08>S<&K zjnvZ#GpM(osxv67r-2!?dp)euXHcV_nlq?cPwDG4v5f|2P)|M8Qt~}#V9jq6B6r7N zB;1~oa&wvL{enh@7v4(3die{g_jy%)L1oXY0p!6}UFnvdt``_5u2;1u)E+ZcW0s0d zD(V~+`diFlgTEoD2^O0Zi8s~Ye|Y{^sy&GnXP4@KOwBpOml!#DgSR8`I(nb2KKz8R zdKL4w9s&%#k`E&|;6fA*HKG^N-YzeeUKkwxcV{#C`b`hK6tN2!g@hJV;mF)fU_g7A z2BWbYuA&o1^Ut#Vn$RwB`<~hmR4hOmz!?uW-s)0{aed44TB*ZhbvbK4p2VJrDrnMJRmAJ=Cl79R(M_qSGm+((sEc=Mt2`Ddz#? zwY!5#KEUZ`=OaHAFcZL!17FS)=W$(H_Ok}NpRwDeywZZ6A6btpCUZw2PWi8dXfkO^ z`S?kf!RDiE(v<8hi1yKHw#B=-iBz_miZgyTJ&b>x26z5fI z=Zo{P@jS5=!)Y$LkGOc^v}#Gicns=`r?-s3;5^c?%Ms6Dn}PdA8DseaO$0}l(Uad- z#l;yZx9*i8l&==IZkE3flNU^!+KTkL!@tl4?jPl=Z%KVXegG+czezqU!!nN*|Nn66 z|DPvrT`SVBTLKR`vQHU64*U_9qB)yI@ zaR>RzIy&(X8CFNv;GyoY)=K%g^bjenqx}z&bL*&Eq660D=AihnCUM6^1Zeu(s~qZyEM z#H;l>I^qyP+F2Cn4Dn?5L!_*Z{sx}jp^hH$Z_N3(v&Fx46#sVdA##2lbsZvR9fde0 z>wy$VxE_MmBY0EqI+`tBi-O^od8)WTRnDk`OPNEYizpP3g(3re@+~D!kq3!iTm5#X zQ&Id^4=0~iM+b`UoFu;dFba?_(2Y-rMUnAnuR2V%50O5iMg!}pCQySEWvM(w=F&Ql zh^Me;&2bg}nR#NTO3tnT{Z!w6$={r>RmTEPrx;Of|T8Xn>fmJP-i(Bb9 zlNDNFwO5aSg^7uaP0CWUqwd(9T2+}F|N6Viep9XARUO;5qV=qF7Rxf=U64lK);i!D zZST+wN^W7LiNxFN4X^s#g{6K?jrI1P#xBwq>b%;EHR~2FxL89?xsSe_=$EYY8fcr z*Xsz)b@t;t^}xQ%rN%zt-&w6^oTamj_XwM!Gaw4?$lf94yP+9x`#p1g$IM^U^v9_8 zQOfQ?z_YY~rAOb=-lF-RxX3J0x=+_1Gz!NB^fO*?!DAZmcz~#xh+}0`ooj8y z2Jq;2df5TU94P(`29Q*GqYG=CL^r;I`}CehHENL&b7h=kGHkvnyIBvRE@^Hr?JCyTr8wkG||GF7qyO#b)!*rZ3U0+jVP+Zetol>;Y=tPvLSL2wSg}J_Ve3OwV>* zYmzvDx&rSCH|ZJMb=Jgj@`kQA>1S`}sg09*7jW{5p7FA1%$>URwr=f&(J1$Gl2xRi z;dx*D(5zV>YH7`w?q;a5q0tq3>3xW~8>M+axGJhcjj*TYX!yS`srnVYP^6h^>={=+ z`3wgJB;2)3m`=CG-4k{2t%F=bWw7>m2!; z%b)z+d5)ir7qHt@O#bu8K>8MUV)!3!920>QxBzNIdL`yZw{Y{AG%)sORD{VIgbS`7 z{)JDY(vX=+NkS_7dgBh?VL6j~NbHM?5H6nL#_dop(p88@ApYTcjl|)&P5!)yYm-Lf z!T><`TuJBJldjJw8$g#(dJl2svXS~a;x6#+CN$(L@a`dOF}217uIGL=gT>w}jKSe0 zByBmRFTc5yN&>KJ^>%? zdd7*R-MgPu*5fzYt3%J?-rC9OeF{nL$r-)b$?o3z$!7yP_Q8WEU)&D?B1nh6u6}0U z+JU75P922h)WQ0&LNa>DBXn35v4&(1FFUjQS-5@nJoVhNbNXBmCmqjQOD-rT-t%HL zDA8zZj5WG;T%K^@JsZ$*0zOomaS5Kh)Vj>N^u&qCO|mAZ zO&U0*^lv9!SwTWmAEwhVNNDL*;Vbd*)E?PY#Fw#>{G%<2U+uoe3!idG-^f*vfVhDrWJLAlkk~GUCtOG&$VBbl*gTU-(cKxMiDG^E6|w zW?rYMvo(8ewm`#t>}f+z&Tiuu3$>KV+0CeUV_eod{zy_jJ{9@r0C%iT)_d{Vpt1?@ z3xFT(?RCJdpR}BZ#0N_G__YbG$j3+4`-;QzG0okteyn|~lKtx6Mf#d5PHjcHii%TI zq&E?9+8|FK%F}XrS}jjwQyxDjB`;qf(@*g=E@$$f zjaN7V5uThPB(91>q_B>@1c3;mkd!&M;Z1{utO~KxJVeeC`BUm>)gc10sK616Ae=$O z!<#w@InyiNjTCqAqQj!w5FuZUhX4i957OMUI_f(_LXH>=!OsxIJ5@+r_#`B*PD0}P zC;q`y{^co$iX&sT2NKs$rasprB(81ny$pNA`JR$|w;uhs2{-ov$W8CNi<;f(Qf_+G zli+gGPgo8j-?~e`fetPoW=7r{vGx*MNZ8-$6~=W$Tj=Ey%q)*J(__to1!fvJY3AkF zjax8DNwKX=HqVp7Jm0PPgPl9hX+GhZ$rr^ z9@3$#LenF&JnBe|W!>pDQIpC~J?tZ1g7jw7^`U8fVA9dn|OCRQBM6#F zw|a2Zn{!Xj5#b6(=$l_x88-M8=>O1)^`!)=$R8-WP9N81sy(mjSlmwNo$=FSRQru! zM>sbBajkVjDl04UmXNr+%wOc|9ZHZ6mOHI9JS_r}9kpUsbV_DfMtL?|zvR3`m;kk5Y92J@!oP`ROge5o>p%}nbGq}&MDsh;Ohg}Kx=^7%joIu zV?wJQIz=-On+PnS1a74&+rlllD;kYzW|wHUEY1q#Q7-9X^gpN^0%6zSF1GPMiuy$5UJIg$CbYzHoz=^M&t?$n_q{7oPBX*L&3`yllNUfIR05 zbKNcY!Y;#j$1ryo>RX1*7e2twD|N0{@`Zi;;zJ{aFYLjpbFWIf{*Nd5!g%Cw1H1`v z>VMWc=%)a40sL5O>p#~$-I6c#I!W?{y3bqThksQ6RQ}LR?~)r{^2LUiXi9pPlc&wt z4f~-`NZkcu+^&vdc73LxlEs1mgRFxLgK%@Jhw3_tX*X_RCW{F=rm8VcH+KNpgQ&&Q zR`~MM)I~lIMvuF~=%~U6l#8Z4?knWf@gVlNkncRj`#tbn2I?66%~Mq84e7yKyoI-U zbx5ex=;IC3n9sxMnGa;+5nn}_@q{mSa>mD=qP-rq4DV#xa$h^IG0#`|nO92E#s~1s zVUOTqx`IJyE`il0{5Wxk3u3t5s6ZCgO>li4q!mJ$^gW!0{>F0G_WIHSCll^?v8ck`B>rw!fqk&Et4}T%J zW>3u%cN*`L=N9@eC=!QU)OYlh%dYA(dfMm~U3j-XNm=dB!R|p7YC5bRoPa@&lhRdw ze>&dn*uDP(kAU~!`aXbt&|SxhJJQN`lU--GZYdl6*!3cgxc$Ar6Uq8Ia$XhgIsN*$ z__rI}~etoIqleq;d5C+7N@}BhZ(R`N~dE6^U^A4Z)EuXr>$KLV%w;!Au#=b2vv(YEF=NHl9r4xdZaO0pkEpy;mdu z0^k(@Kc;Pw?Y!g}c@{U3tUXcEvr)Dm_ZI!2WxbP40dwi62@IkO02zSm$RxgQGv*M< z6H2bCI(m*!8$p!8h=kAs?V4Zt9&uwjU_@%q``e_cYyJ9*{)~LgaPLAeZ;R*{X^4_c zF^z)r9~TP-=`;SKm~W+Dd)kk&^?=`63NC3Z^4kkR?XWC|Ra$sT!EnM#&_e7S#f~`m zRBt0@W*Hk`>J1k&Cg!-2axA|X}PCGU)pCHADd>gseWkM zed*u4``qf6eQ7_x_%gLGeJ=VE$3#s}iHDIJlKp8e@{a*J{40rvj!(nCD4+(w4`=hskU^C=nD=|*f&zom?X~+t_Zxx?w7{)A13#X~t>sE1@vDFGyg|4?C z?uLH%vEO{w?>_pj-)Qn1ruLh!u+Fc($FFp+w%N)V=hka>`u%e)E}+iyv$=k29z4Jq zZb+7dK8d%Rd;HpZoP)dl+T#|6Q-f9chAnh*VN?jDw$jMe7FH+ry;MQfxo2$k`{Y_| zjP#)EOmMU{e0L?5bRJ|ZN1dU<9ZEJfsjh`bVM-B+0*^z=jEiB}j$EjX^0p&g+-@+n zG)S2>Rz;|o1gtjRVuUhpi^&5vI6!AY1BT^`6-Kc))$(NLkc%_?ZSY$zi(9$T>|$bO zXht!CZKC9cfsR@bQaAAB_p$JhGQ#s4*h;pXkp}iP6{i6ry;V)QSS2soid#{Uo|BT_fPdt({+*Ej zz+u37E_CMjnAbxVY%7*ZLSi{1aEM^31Y=mQqnM2&g=PS2PZx+M<$}(!pDY*Z0l8Ge z&166l`Z^+~6gI;ka1dLr z;rpj_zoo-$1{3p97LAQY=n>60OB)=V9VnU;;7W00c~ENzYHlx-^ub)N%x(wDJwE)Nvl6NvFQZ3t@rl5hG>zUg1V zz=k0E7k|^!LH%FBAscvVzv)ZuWti^v(Pk3;7D@-YW)kBw zLMPBB)sySb@#O~Q5!#bab$1e03?eiJQb8BU)}`(#kgePLHD4%@OWOGYe&*Bsx^D=e zEC6EUyC77AM%2Qg2!4m>Sr&%zQ+NoVEP~&|h=c#}1{zjX77{6q zHj}mNLqeJnltP>q;KawtejQ;!@@NJkCc=>sGTUK4quxx;L(A#YlKCD4MQt3rd(mNBGUS~->Z3PaTdBnHK$6g33IRFPw zlaOBwSYpSEZDs!{_LC)dCJQ{J&>(CYtLNVD77)KkcgTxuaUpkZj+!q}w_I1A3g^Lb zc>;D^>1I19kRq7;s6lQ3qwCoSIc2IA_gf#)^xiC@M^qS^!5C~b_4H6r^dTITzrX^v ztP{K>PTE@DA{!m$Febc>VgY3c{^-Dcf_){*Etdz2xj$TZUs&av z&EHOI=hYh1N;zKmz)vS9a5o1Xxrn%q!mCL(qLtyd#e`*n9%J#g5P|vH!Q5CzIY9j` zl=#{YJUI5iowmR~0bmor!PgBh1(mgcCICN}?cc@N*>YYI@3&C=-rR7M+;SK(sV$yD zZyhV1%1%dG@gU&C2dW785EbcCCQfZrZatIwyL>ADIauoN*82&WqS`TVff&(2*up@T z!P)^6>j;HH)W{N|238r`*J0C*P>DQ&FVa~0o@QFubC9zM^Fb_)K6K3y) ztvzT<1G@F#z3G)P_mXr-5Qk_ zk?(qfclg0OEN=wToBOiAm%!3|hYI~!-1`uvIX=9kA6x@g>upIFc!Mu; zb%0ts;T{do!inhN@dAsMaaio?(Ti+j%aK3@Gx+}_X8{^n!P z&-kA5D_Uyhzd~dJTlex#I%jClh9YmJNrv`)y7#+u_4{=8U3vg{&d@f8S}?SgA!9|z zYzV2#LpDQuC}5gs$7IZ;J zyI1aL_dLpisIA@mFaqt~wut^wq#dU)8=(kcFnRZB6q>!jtq~A{(4us^^opTEq z=)V_%qVI)>_HLw|*W41ZHbkK4TN}aX;~kzW`u-yoePZZ8OS!nObMI}AuCE=zb}uXg zhG2yegnLLw>t_nBA0BMDT;ltil=186$K$Uh$L}BM$Ja(vbl$*se4>2c1pKeV&mDdD zEx2bN$JTeJtfL?Hyqj#5XQNPd_p-key&^eI9J;eBrQCl+cebHb{setdnkUJ6^?yBC zuPczB3%D8J(2oa@=f^SW$5`Rd;h6MeG3E(^fcS6@fr#wZzeP0HXfC`C=sKLw>j_Cs zutF-r)bkEe05hahYI$PWNq3UKPZ4Ea@nbpU=i>j8_O zZ%IcM*y{nmwzi+BMZJ?9mGib*&ebMe%iFI;sQa-PcUYBb93rY9Bal`yl(jKVC_?{@ z8fogkqoFlXP7qc_*uxQPRRrD`|C6r%l&=3L+SaRm8nqlA!ifNAQyOpVe-d^kH!H8f z)*zLHuyPQ>4g-suY`S&U7Gh+Es3em36*hjFlKr9&@}mHw0aQLngKq?tLcmsgzR=0` zr=KDRwwO=ao~~N-0PgDQeso``u3sYJbRSL}B~QAQov3xT$*=*C8^(er_kEl{%7e+d39#JqY`P3=-sO&py##&| zB<1~G6BBlWeriwHA0zZpMs5K9v9{U(zx58Do3E01t^*Dodbk>S zb=zgXYx*DhjZ{3R_PG7zR<}U0TqH;n`TUeSxhZ-1jJ$KfQTMyZHd^adHf7||YQz-> zrVRuwM?3JgKm=27Czr=`0n-WWk`H0VZ{SxE;L(*H@SwH&pdF#}XBp^^ep(DO78lwJ zn1rgNtpV?fECVAKyRq@VF6aimMmbbkp{N=|5DumpgRo0E9lS6@3wc7B>A68q$Q`o$ zT1dmWtvjK?D^?Dayat@t0T3nw&$&Zd`ssN??6%^8SD)wJNe=L&oguwFhbP&pN zG-Eg1ZHvO)$~{MS!LNC8u8NqPujtrk1Xm=ijgTREo5t&8drp2UIc^+!x)k>ux^mp~ z)Y)&_8a++X(;ylJoCvlUR{zdCOjzH};DmK!B=SZ~64nD5-XAj50~zdxi~#bSu)Y*& zL0IpJ7Z~0uNX01-Rbi zU8zPG$@+{O(hl~WTsX5l+#hg0)@@A9!i?Vw0WBMk#%D`>_pM9zw?^RfEx;~-Lq9@0 zgGvI>+WhJfF~1tXv2&8Z&O55|FH|?fi6scwe{%Js6`(@`de?yW?ABj9;}BQJe~JU@;p zFSW8hT2z+W4FV6Pic4H%3JZ~GF;zazg2=>7&@LCu|J+7``Qd@t@5Y}Nc9 zfPny~p09Y^$_2O{^~dw#saH&%JZZ|bseMoB)4wm@x;m3@T{S?5KhPf`%6+VW70^7` z$hf>AA9h!8No6?PRN{mYF<0Wd0pGx;RdL*$t}Acj`dxs1(D)yJf6CO+5UEcdKc%+z z^4f{h%Es4Dtet{=fM*^V@8YdrCmdNm+tt}AM@|NyRc)qwRAKa0Dbl}-w2zZ|P=T2gAp;&l z=)A;E_9jtIET?Ta1BD+~&1Hm&)piWgF0=sit@feU-|K@aNTXe`3>;~bC^*T}*VUJ! zpBqvx(CGO?^@*R0e1kL@_&K+AwXo0EdN(8+#m|uDIw3vlFQsRN9&u9EWgtq zTWjzI^k+7oBFxo9k&Y1QfcfA{&H5xOGoU@f+^ZRTgmqfY+-UjdHRT#?JH-mB>sN0O zu^WBbUCezuV@p`4TbcVHkLUQEmwUJ{jci0H$%cg03w}+*kfkW3r&H80hfGe^U7jq* zNy8J|8O;v97Zq_dgvNdlokVlg2?$r-iNX{4c0xOHmn4(0@n3@<0meFEY7x^=9{X4* z@w)(ctFUjaM1D131Hi#=jcO_HA$b`2k&d8rQD__;J~{^Tc`F=khErQWB%+sQt^A zkL!G9|C61nG?JC-bA&XK%I82CoAsgFN$)1|DICJ0LS>W`=2F=3piaznZp#`ChM)Li z*}^2om7*tMVeUrMkDSV`QH5LIq0pt~t9Y5xYq6|f?1SVuI1%}C02cu82tVj;$ln9l zM`XY6qsw#3{BHuk^t&B@J48a~&O4mY71&g$`v{s~}x$}@_{YxDpQ8^rLb;jCW zHe~TEy&6%1P40fz4AWBD4pTy|2#cY!rR&`=31oB(8`BO| zwRY?{5#8`G?$9iS&GtPIwL$ zd(D_qgEaip1$wZByv-_ivZ}Y4wUaH>1Y7u06(`&)*~uOx%1TSA}o2Gy@pn*fi8~PR)nk>qO8s5o%&xTHpCrStma5@bNn#7B+Du9sNoPyD2V$P7 zK^|3&3r&I$K*Z<>(?xC?@sLCFhX#6pqp1 zPYbj5Pbs9a1Z)L3<0~*3espks z*=|WMTJc-bzPOeC^~b^klujeLsDCX_s;}iE?QyE?RP)fl-H68+06Ut5){Rr~#|gaA ziQ%F&uaxEOzc)Ev>XH8h5d1hfUfxCC*azMZ;KwvOUi|Q;mV9x4ce##SGx+$%ON-z# zDVk9QE&;UneDgK3Kr0eyhm^ELsGjRn;>z_o~uU10Tf;tUa3!jb^UY1AATm<4Y;5iUR4Tjg9 z-F}}N@!j8di?QVjc*kMh!JYYxp}DjlMpqj0CiEw6Z!g4}b!Vo-8ym(RNq4cdI!b$M z?O;FR#!qL@XO#Nq-vP!_EiP);h&E9aDUv}pd`$|5a~`i;sJ+!bnP z)dzmh&furfKw5A9`G&Q!J_}IJ33hp&M*cNk_D_@bxgGfxfad`G$bL$WpT*l+j-y`Q zh2r-;t?EZEo;;y;%9&SOJgwGoH#&VXyjl$!J*F1k@nBE*g(i#xUxU@4PuZ(an@fx5 z(Hn0h0Vx~S-Erq*a3OWstYU#mt?t7F<{YP^#neD3_0 zBb@i%XTX^#WP6w~UJDF2kf{gpWi3@tGJnx%xSHkjy0KDJEe9S)Phvi5hu{&OOH{OaAW^waGBZ$44;uJN!O3_Gd0*<)+0&qT~&EEd6G zGZ%g@hvc{x3;uF4j`GEyl=U6?d2(D{i~It>-2kV)Pb2>l;4J_@hTC%dAJuo+@zu9> z`m`yd$I7ZcFHQF9aPp$Adr`~6yIA?1EdMU%RsO;2dOuO!mzXsB(tL;)a8=84rg~*{ zxA5vhtAg3>-@&)@;q+VC%VV^c!{842hNombdwr3d&;AYhdcY!pQ_owk)|Gp4{Rn^` zU;PjNUTcr9o)b~g;ZsImbkW6QQyTXrS>uFk+~PY~(Ov8&S#48UfO&xXTm+mx?n# z+26jm5BUQC?aL(IbC53rbOrEZu|5CTba0;iUoB6Yjt}q7ik+N4!r#+&bvaCRXg+uM zv{&zbC#$@R%}_-jGnG}Si}xgp7WBLXE*xvHLRhoj%xi2Q4SHvmpMypQ~M0Pm}^eolOc|IrS$)4A#m%>eIq zgQ4@9axr(0igmYxa>1<~U(4C6KSz~k(S6VqpbID&SLHs&9kqurPwWVzQO|1RKrfHOXdzJ|UKPz&J4tag%*?JH`@&%}>No;q4z;;>?&KTPrB zd@TQ6S0I{iW#`TvyOoU;C-lPoa4!EXCHv_}krs<|HWjCiBK^m=*mSj5ziJKZzm^Ti z9TRuwhT;d=J1nt_^#pY&2hS*fLhb)JR2#j{A5-JLFm%x-f{;cfp%|YPnML3?8Vr&u zU4@8KF^5#L88u@!&;kjU4cL<10XFAfctoK(ozbidc;NIaGHj|zdR4us%J}gslC{O*+_*#z0v;8 zWV|zOzw~zGmjhM;oN=D}iLR{1^)dbP9r?YXS0J9MB(~u?G6i$Hz4`*8Y=NF98wwC0 zqZsBP9eAc@v#jqye8ZtPx%=V&4bTbTlwBeSV)ON_*fJd2T^(jx5KS6A-d> zoFMwqCh{4yf{0NY-!9nm!>ZB*=dXqAL`-^41-U%aozDqk8SMva**4e#GLm1$Uy|ip zfNwkPu|K3MPvZJ%fPK&d>t%UbwZoe(#(e87Vkkdx*|3-Wor8F1+9pX^} zobOCHR=Xelon%Yw)$^3!X`b_*?4< zI`4DNyISLGN0%$LW#G9MOP{2U=5crTd-ajIW8*`WSx^Mz-%ircAA68sipC0fYn%?d zg&I!rPodL^n1(fMmF-jgeNt{1fc!at3jj|4xe57y0%8Lso$qwCesHp_q}@+$*MyOQ z7kVpYm;Q>TRKt@g+}n1+N(Dle(sqbxY=t8*isM(^X*ptn^FfauV|vpuzBs>nfU5|> zr-rlsbO;FT)mV(gbGloMzq(gtxdT67Po|B$laW6Ia2CM9|4iiP16Bg~F|A6L|G*yE z4)(tuK7DmG{wGhlsAENHY|E*WFS~de{2dM+Jrx}IuspY)Y+<6_5zd5n>Ay2&Or9}- z!O0ct#z@~%n%QqW)SZz5?FVWDJOjHR0)a_FgJBQT8xt8>>yKa!$ZI`{UIeRT$a|{^ z?SkmrVQy-&mC%0VK`y@Y4J?yD8^ebSzD?poIglJ@xjE43;5r@Pv||_C>kBx>IN9k`ze&V*{fd`bar0Quz{gZqiHmgKP@gAWzjfpu`_`t!rU!&z9t;=~U}-F8{; z*pIM{x65@F^5X%Q0i1p?8~KHR6##xr=p*~VI<vg6=h^J(k&h<|aDcrK-h zVWr^sN|zKbe?MzRRAsWC9m2_jMxM#ufjLp&iml`(sx;84-ZoeZPj_Q2+zrRBY#r^E znOv~)r{pnDcUFD=k$yP4jm*T(E2#|m91iL^MsDQ3)${4ih(E9QVSNzqaxLY$-kA(i zZIKQ6Kz_c3GAPZvZTuWe@~e}P9|kxZfJgX2uR{K2!1T@Y#En+xhpDrwqO!B6Kre*> z+(_=HVnypE!k4sm=?j?h0YrqZuqlg}L3)v%5AWyPmwyj;6|Lr5=NzhckS|5%W8>G8 zTvLPEodushP}7rJ2=Dm(cwd&k8D*;zHxQc!dGlxJ4*^d3zaQ;V61X1si7bCB^e46S zWoKS-If`CKF6CPEb>vz<%DtI#PLIBN7F4s?%|i>3T674P;D@rIq?T6M+9CyUG-SD^ z3G$H|2XjBd`4z@^DW%-ddUWw!p&#$tL7l`YFkMU6=r_MWlr_e6?|^NMDc_ zJ;g=0HG(AFswq#Pn8ilW#R6~@K*#9Qf)$WN#&~+Uv$5c9qwoit>)u1y213rn-qpt( z2D4ArYdXq2(yrI-8-2<$T;B(9=*q7jyOk$!e;w}eW3kQu9C_CM*TbjwN7LDfllqF; zy^y$5=l8qxwy(4JHddt^P+{@#8;$>t%{^et+}NQ?zOv3Pr}Ar39?C_&1W*BR+PfR_ z!vT|zC|^oHtiAN|qse`=;spY}d`h_w-S8m{5Qvw*V%w&aT6)3+j;j`Ud?|jm$Z32O zm(uUVBPe3yClZeh_;%dJ!ehuk3wROWloZ<5GC=9%jxjI;fc`e+j_nk6j(v&ihqFfQBiA3DzRJG^S%trM`&vUA;QEjJuL(VZg ztnIlTnth?Rm$`3Z*3DRRV0wt#{TluJJ$3)hBOGZK(TfmnIsNY}i_ZpNubfb^+|8{w$&ViF1<*)42fy)e zWPhkoLP{_DX3t0b%DK3%1vqqT4DMZl>;D7rBk-(jhfzn^yEXM#Abzhn+ODM!Z>N-Y zj^uN55Y5;i%cSw_(J?!S?;YU zkHbekM*bJTApjMnP~d@qHTDd%@lw;7O(c?yTuIL8+#n90gPvPri+GBLN zMjEvDi8x)j9DAttagqE+HWhM;o5YN+(D-%*-}s#@ z=K_@BDOo1ve&jQ0NO=_C;CH7QQho-EWfH%G#>;Wgw4vpAXtw>V4t?RM{kbjc(4;-- z->#7+ZK_CLCE|3gNI#Sp&&iX0C4U+z9=K4X53AyIwMgg4haaQjYKFXOl&8>k$P)YX zz1|;4{{w{mK;j38v4ZKH6T8{jD_HhQR{H}9A0X}(%veh`IKcjaXrEC0I{saT^cf;f z3*~7YP9?k91>)W!x|t5xLW?)k%qhy=F7*yoJ6a|7X-@n!APiHd8NB@$-lu z2s~|vl!w?GqeQ(ECEu@r}y#2{OvW( zZ)Ij?B8(*UXJ%#a|A?oZ39HqNSZ2042$zO=8uVEm^RsBy%gjf+Z(;03&U=l!HT`s0 z2AsV@%f3g`{llWZ%`ChHws@K`R9ma6kEoiOXkz2Hv&0^DVU;%oSI=R?Z4~Ce$P!!F z2y!CMjchD-Kj9an-Q%p}33kDHmRQTq@txzY`H2jeNsWyByJ#ksTrI!eL*Gsgsfl0J z5e%Ume^wJesUvdydd6DXc?~u2 zH4kwAAyFt(o^aeOysDBCx)qjvoDcp&*aH-sYUvw<-b)cj0Al1V)bfO|dhma=Al(z1 z$qi$+5q_mPri1j9L1#I& zw(lt^zw*h(YO$9=80tSRviW@WkqsmiA?M(C_pqu+RG_J3KT zPAh<~b&D7@{Git5360Ly5;toFT0y!9@yunN|Jc?XC(${Cc-Y_6LaaD=*lZ!yZXvK@ z7{R*07komSYFAE|`053dX-7|6i~Ll;bbv#5>ycjom~8WjR^&6xj}drl>a@`lr*SET z&ymY6LL{b+Vxo16sBqv~OWCHsLX>rSJNR3Go=}KrmqK>T!WQ zhlZ)^@aCf{ahJr?1bj1Yf3yMl7XU8-96aqqem@60etWMb*{@RiP5a{GFAq@HjQNeQ z1t@GS^`N|8tIB~uN5ucK0^Mo5z-%BBTjvsa2|W=`@L3`68BKQ2n7{_nzp2kU^Xs@< z)~nh`;`g~qy!aLM4-G7XMp_$h?Fb5ILmFQmm~ zPkz+di^lSINiqD0^Mo8OJU0K562_qO!3qqpdrAG(5Bo zwlnk^;)0BO6})xi_|s`G3$v|>*o!E1q2lwV#Mc7;eY-sCkbebm0HDfyT1PXa3<1;v z_~FFEU8hKSMLwn|`)&DZ)zN+^M~^!)UYJub& z>gm@+81r;}ofd7QC(eM5rJm;S_*tE*XhbJB6Y@Rv|E6ZmVxAkAwg?mDsBsw#kI#Bv zE!m}ZeMc>NQ_W@AaNr3nWKWVCSiLYC-_VerXIuhIo+B_>{*H>^V%c5|CVT;{ z&6oHer33STF?&>#{ld{v4vBuan3L965)b=PKL-yv-jLD(FahA;VIA^s1HJ(8W3j!T z^pDn;2K{&x|N38es08uZPZr9xjU2wVv0EQrPxD0QT2BNz5GM;g%@Wwiok1-Gb4$&O zXGEX-09b-`d)L4sEaG}TqT1)cQX8W?u+;BIYGN(#N2>iqEeJBZ)ZA~W)mghw^$5)6 zenG8&)4&(h+(&ah;n?H!3>wF_e50pyk=D&VCuA>NPQm3o>|6nP@Ct)}8V)`hME_e4 zUdXM8m<4W_tMa%w9-tpNcvdl;4%rx#?J(V!YzL=5-iv!qJN()Hxa`ljLzVJ5?_zH{ zUF;odesJtA_8v!)eWb~H^ztX`F%|ilfChk5j~&Q=2XI@*H?F?=^Y!Rd){*}~gV#Vu z3%d0kjB$vTUVuIn)Qtyo9>-_Za@*9K}f(lAEXR5`lPS1N5f zg`M7>fR^(NuR14DH&9Z6#TOD6B+3Gb%8`9zI5M{o%= zEFQ?a5)|sJ!)hX>E+X+b2l#Qu#>65^t7@L=fWyejb9GivpG&X}~h!F~0N+$3O#wkW<)ESk?hg0&E9LF=T6N8M! z4rBj%ni0uMH#btxb5z?xV|rYU(Ct)fqER_EU#40_y&6*=dvv}?wXIYIWy^h@@X{-?5+5qw4S=w|{h!J-fTsSsj-U(J9H#S}c^8G`W{6{Awq zRVX_=kXZS8YNgX6%`w>?v0$=2MxLxGr{ekyfYYxhU1cig<9Zx`9~*5y(2gAIw8sLw zJx)AYKl{JvB-#Ug_=I{|GJ|4~0xJj^nI#!8Q{lmZ)41qx0!X~AimS>+sEj{!!04j7uW(++^c3lky$b?I>F8F`M zq`~Bq>V)9wd;Iuc{QtzC{xtvZR8}Q=_PGwvU-Kt<{wmOwPL#|hlIg=L?hf#+m?SCp zQ$#Q`?x)%V4%5GzS~JPvCeK|;tz|R-e`L4OoZES$o@+DkHOIri<9QN~)#*t*I`m;Y z?m2ioUiwgB?{^vcSLj2P@(`yAR!Klm1>Rl_cKLRtl+PUrDH(t+0H=J8d~pfx@#BBV zyEp!ocIFg}%e)mr=Ka8yd8-_m_rOsy?+a*I|3PBQyuT517Gi|w{ISei&)cu+u*`d) zrOeCg)m$Lk>vPo8>Gw{1MWac49B+FKwcBglUumySu=YcHm6uc;Zm*iQvV3z;7N>kp zdp(&_zCYVud;Us$bynWw4e+_s0AF*}=rJ4MJJ5Do0;n0_Q+;jTfpQSIQ z|M?5_r3?DqUP&aLwu!|1MB9hmyEc(PvA-wAcSQT1*fWDKiM1hxNPI%9Pf6etVm6bU zk3=1dWc?~KlJ#@?(S^9@)bDuv(PX()QtDKSiC^4Tp_DEhYM{+}%tVb+6Sq}%#r{bPdkK@JjS{u&| ze+AFg%8f7!$hf-Xn!|XGm&@`!l~TShk^e6smXj0@w{_6Z5m;Nfap!YDWu~&sg#bd))2zP;d}Fmxquk+iMGI@yK{-PlqQAanZ%k!0@(ET zj~sY>!co&%)~|g$Sw9E=BXQ5E-|^yqtvw&!^H=Z>v*{Oj13XGf9C)Uf)emTK}AGGJcx*jf`W*OiW(I!WLMT>7q8|2t*Y+K zBm@lX@ALm`&*x27SM~8;)vN2hSFf%BxO8kp_+7w3fH>U#WAC@~<+b})>-tF^a+F=~ zpUX)E9S~dYqIc1qlN~N*>_cPnEUM3@1832s*)&Jak_%_SI8k;fv668m&s6g9 zREpP3uwBey+SjW{>|o7$cna`>FhIm%wADfzD*5PZIq z6zy{Oyvo5Zr9~7!w>>!>aV~x?pEq^>Jo=>gTnI{TXJ}6f94>EVbvSN&($;NHYD!f; z*T&G{=5r`7N=H-kIpp-4DJPZB?X`sr`Fw;F!DXWCI7@pf{HlP9+pljxcq`zu82sFP zj?1rK509mnTYFOZ?25i?Nud-~eCdcPid|8~Z%2tLR-;%vVW(J1+!^i?S4>w9&L6LEx4T~d9sC_#fyZc&~y$D!Eg*vK>A9wwCX;J zp0a`{J#P6fMx0B}iI(s0Ct1FtrQIls%++>ztiW-LOe`h9QQJ?^QBWAA<2-~X0Hy(4 zI_|yG)~?0%4FGYtwh~N`Q7%i=K)OWR0Mf8Z@&TH6 zKkT8<&_V^CuEUUEiCEQN0y_FDIt~Fpm%p-GhP9S}ZUC2#)nfzN5L}-F5Qi)O-Qnn) zWF-Eo9XduGZNJ%scDDnls3t4$6IAq;vw>#vKSf)`TGG@TXx>uV>`w7-3Fk=@up%WW zcAM-Ca+a+n4~z!zY<~TF;^nho38+|na`1qAj`OWX?+2dv7RD5K_N>`7YZl$2 zRDG6I*hJ(Y1j_j0w@o<|2l$yg8%Zh|BqP@wVB}I8SNBoE^2{J3*NCgzXvCk}3|EVYxcrSon&-B`tq;h{Z%L$M zVGpD}DK9@Fk>0r@arwHue1$~L&AkLSe@X&)_Hh!KmVY%;|0z@NA(2@mcL@o@UjP;F zzK&oCozNg&3&idaQPx$F2T0!21kbFIw~UWGM{>7`>lftpnUOb1E`-O7d?2qol8@vY z+Tsmg$Tt*4ek8eY3C+mQ;yOMxGKc2gL^6$p^{@j`O2`zm8*B@Pw&xuzrSE={>T{{} zZ)fsdENr6mB3_{GPLs;4VSRZ&zqWEAEAi%GUz5hPStLxwY1jV|EC`N*-1Ii+c z=?{Zfu=_*|H~G!iD1T4fUkKhttx4x+srO4eLY<5BH)!Y?7-hki-Fp2|wq2+N#Zrs^ z1fZtBS!m&fEnyGGU}OyR_0oz&%xp!ZNG!vY_jdPAVOk<0FK8_CO84PIdqw8l_Lx{6Onb>omDPt35(ZzK&o2?o0T$z%ity64Ud!Y z$vW(&lAK6+k)V&oa^8OtR)j(w1z1O=$(@fqB?1=!9(4mS67pgD5+8e}UO?FBHTmYF zA3sjanRxR{;#+3{fQnhegq_!L%q_xt#K`6Fx=iNN#TPP#!}0k(RlI!Quc7w~DmMZyjofxb~4wxgF!XAk>v~hbhRZ zzEYc7JtKYcdWZTUwtwXG+<~Dp5E>jAmOC_lI6@=H*|_r@-+8$6{K$p57lg`e8b3BN zA(yr-&lvnc`tvD{8O`f@;(xk z(&Ez)?j=w;mG?;QSKK%2RNhN|Z$N&3gnI~7y7F$xLy8B>{!w5#EPp7%-GE9@-eC_B z-S4RUbMU>6yd9Fy%Re`QIDtwlUSf^QAB&{cyckIn^2bLICzA4cfi*e*QY01fJS0uc zpAtcwNXp?6>x%r#k<^@LBWY&-l@Y{=q;#HPRgwH_k(SBRkT#p-&ms{-i_}D(WX&h} z^9Yi$Vi3s-Nd8SEf_RaPU1qGsB!7`e#+C|5UPAKkAQ8liWOyR9?j`y6h-8#gpqk{b zAQ8ldkq9S*)@oAtkVr%x;Mqq=A^avGJ~XbGp23A_5dML1He7z<$UWFOoiU4n_s}%4AT$OojRzgh_qAOO-`Y#5bBcT zFKH{9->tZPPLIO&%~}>_!Sy{W&COj8-x62)eMXvVw|Io|D&4VL{DqoD(f;m7$`326 zMc8etPG}J$SJEks^$(8aB0Nf7uTz_2AGX$LQbf~p2{T2TavFrH$@)n6K+VCXi_NS; z@6~p$5BI+z4H;nqmZf7$9BBep&x}zkt5oGyi}G;i#jgA%zFV~1PE`JK#;;9~*Izz; z0@g0|nlu&eCuh`IzpvsDzg+A{%*I*gA6NWxA*dHe^%aVa)xg=M!tkXtMGadjiBgdMv&YOhjsq;x-;j)90fSvn4$I2}sKb31m3 zHs5z?+0Cu($EW2jMN9V8ijLYCI$Zg8@YE z)6pAOJNQ)r7niS}MEGSuZ47?Gjv=8(SStpI!);fOCI9aJTO;-J&+t<@E$rvdmv#*Q zcI+4~aP8+Cv172>)0W?I$9{f4w?5{_wPUCi#WDnXBMZ2oCe&!p5w}MF%B>&y zal3+l3Dzl_t>{<>oLxR}`+?75=xAy`knOCGzyDwuf zQ9Yx0y8L9gcNoXj@Gce9!CvG>MMn*AcIo&Y z;Uj=-X5DV^o3XkFD~A?~%n~bypw)F`KCyBSXZ(FF97{A@IVAW;tQ>-ubGdS8-Jh%+ zs+zCLV_4s4dARlJTEv}fy}J5j_^Ok3KxE=eO{_2vBA2QbDEveHqWHV@q9@{9{7^K?xh5hiaqF!uv>%}I+dh+=7;%QYcYHm?KOdqMEDbcaYhusWkbW-v%|vLm^16o5B59z zg2+Z6e`O<-+-1+2M)cZ$(!N9TvW|V}eVmbX?30qmrQ~I3jIP-ubcr2PO*87W%O|NB{-4pT)+tai>`~!AK~3ni z(W^q~UZ+(@M+)^T#9D~ot&;0j=OPL^c_jfu_~W`{^)#aDbn*y0FRxSCpIEZOe?1Hs zE$vrtAFRM7@0ddMx|Nf6O`&?-#<5B6M z_rxOxf3*G+iw(Xg=83mVzPa8L%MHGw{u8qeJ}c&l=S}`-y(i|2u~a>*{>AyvnC?q| z3ed)_Gx^GTPyC1DjrE@R1b(Dqo>*$~dG((7it}%Iy(iw_{Pmb8t~dG5W^C^6=KS4y zPYl~(@Y@J~mP3&&RNyt7|LAeYghEfaSEj$jT}61BRLA$lW6*9s#hsz#Zh5(rD~+;W zDJAhJ?s>s^G|`m-$2=;&SpQLH4SV#_Se0f!JbDk*;^M2$qquy{`D*l0L_USD{x^^M zP2|M4a9wB!&FCWiX)%`;n$h>I#so`4CUe&(p)KPlc8-CYDf_| z5z1QWxIL?PAC_l7LkJ7{7_4TiXtTit%7Y3Y8&a0+dk9@q0W(Rxm`ou z5jr(eT6kKh7ec)w{c>sh{uzDtklxV7DcxaT-V2HcY@g@Cz6VIu~m}a3Q4Va zOC&)l8bO>$YQgiYF8Q62l+SaK)Gfbj1aTrMi)UNC@_QmFhc`n~pZwkt#EGO-o@Skq zKL|<&#Z$6SI5KH^{xpP5k%aYH z$yXF#o~2uGf`o5DXNWLM!v>msK$g_ZWdQX$t(?%&;U~3nRqNEaaN~&Rxb+K!e**kv zWOQ8E|E#dK1h5q#4p(lx;JNzxxH3l$c6jA2^1gcZj>jDrV&OuqUafzs(^~xj-=`3- zd}wr+)BQnGZ0wa`h8g~BS}7MX%*GILD~2AJ_1BF|8tCIbYLMJOK+rI`!P6CD%u(xw z@6~5a$PdD+c`MrN9Z%th9`8#sVm)VXn%?EQwGsK=jdlFkld}L@E?)9Ay7mOOVZWdA z``ixU5soe1-Zkr-L1eAtd<~*vd#ip+Ilt3=;9kz3;IS?yvhcbU4WGD$^PAl#7IX92 zn9l8P&hHSn^D)asTP>DjJgk#T=tZOv*C$PHg~uq*6WG7>O8p@M*VSu}*MeQb28c$c zJA@!%eGnFKcuj=7%=S{tUm3~?&L)Aa1P*JZFBQKDPuS9v3S#2H;Lt-S!zo3U7rtr* zgTwVA!d5&7@5BEJTrN6lOE_K>YRh=58MM$!>%39%_tLYY{O#&R-a(u@FFR4a$n%ZO zFUzM&b}ygMcV=bzRIzWwjK=h7LTZhf<|aQ>9!E?^dC(Q@iV}b`3`HB|1JRr)m>_vrVO$?sjBK!-W`T0>iv-jHC5Wtm)6UU$F zG0F}!vW^csmYgL`8jfxqqz0q2)L?vMC=EuNS@hdj4MqoP^Dms8U;?gu98DA*)rWx= zVsiX~A0Dz2(E-I7S&geSmRjT`aT6R_8t(MtC{IVc>kWg4LE35u-`e8R<8TR1P23*BE+__Vn&ijQb_FUIw9fXgqU-Te;NWX$o~-Ca(* zFmh-k^{vVHDt)19c~5C_%lj4z1=6O60;1(z>Pvmb-v%x30w15_t801xWAQ&Mr{%rg z$Di@Xw!HJjt+M5vFUsq$%xa|NEp}Soye750Wv{FJU5I>h%lj3CcLKfyxcU3o6MjuE z3u}IWINbGFdmF6JsXQBfT5rq*Kw)f|x|WtLQeH zzhu{W72RN4Puk`Nn?GqgzJoT|!B2xx@l=f++-!$xZ0i*}bF1yNPuiP`-sxkb^t$7~ zHHdTRZR$91dKYK^gvR>S6QWm}8;Ab&yFk&wfUA2z`BXXhW{->F>z2<2h;#8h(enBI zzbzkOgR@+f&(BWz?6Wys*1BF_x7pTfwuv%(%`Km|?BI_v<+H~QzGH{>*wzPj=3cja zYImyg35}1^>z28)52y%53-tgURVtpllpu~s#h zwvZZ80JVlqZ%^&a$?e^KM_X#4<)C|v5?dP zYF~xAP^iH+2Qk`-UuTn-fr=rABgc zLfPAd8bAssvv{P=(KF(N3=$&$WFeAF#X=Y%>laE3#EGPMo{%hrlCBy+C@l~tgqm1+ zlboHOl@;U(Qm82wIU!CY`M8~2ke{FB;~|+O7CRwMBzd@*4828`hubo#U4C1HO_3x- zp+bj&6^t?oIt(cc6>5NNu{=lk?{x+LWuL3|c-^JZ_Sp3k{x#y<{-ddW!go3S$HvCy zUrlb0wSA!$23i#1*Y!T%n_n*@RvMr%czt0 z1@ChVu&4ZI!m$s4g$kQ zlWijS%Y<|&OZSPDMAA9B$=Wzu<9i6Pz ziG^NGz;N=WkTaZ|8?ufBk2{>49Rlt0$UdO(|0V|iU*ER1@U*bj8qg5`8+Y6CIagnH z%ykypQF4o8;;)uhd}zRl7yMbkn&v-8c@z31A%~jxxdh%DvhEBeHRe!=sqF}w-R%-s zc2Lo`4Yau9%0mc~>G1aqaOt}a;rjv40>t6gm(ktp*Oy&A)Oa~#*a^`myqM|mN~4oq z`WAhsR0cv0nlYPREQee$^V+KhTgNihYAr>g39Tu4E*{NEcva})XTmuf!=YuNkdfr* z=0@BY;x~jGm56im|3vG@f)nB|#bG~) zTJns`MC(J=n$U4;$z#Z*vVSW4&59`g?GWw`=ntsFAK~$UO9A3I)_f~_qvD^cqvs{Z zsUKoeFD6&dVR9veO$*fIs!00>>IEiO?}qdCgmZ+0P+`nn;|w_-CD`!|;-m#)qE#f( z(n^0&^y~$mZhdIBHBKu4XfvYp^u+aGz*!<5hbuq5|ImE(BIo~*Qy&(e7(LoSaPow= z!-c!UA%V7*{iDKbI&g6DT7vMS0Dol^uayYD5BLv29IkvXMf`UVr6Z;oG~L(=H8tEkpH z(EBBtK1v>q7jubwLl?df&fS3>4asI1u`V=aXIRc9+>IRiEeY{ELe5-bd5G@}$4(7a zh4?+8`s4Ny*N6CZZp#3(v=_tt@%X4rj_C_R{MJzD#!%?yP}dtp>maP4?ri#idY~HS z{y<leZqK%8_2FvZUCg{WMY!<6@HLNz z3s;Bp1pAAYc)nv_^)_KI5j{=Q9wpcX;~Ff@X#@O9e^qoX1io(laP?6yBhIDEZAY7` zkJ@*1JBoGY(ILi&%CVJ~H>&m5{*wgV&C9NfzZb*SGvVXvqBey=Y1QwFj!{=e>2SyC z^AP9K(bRGJA!nRkb##B+Bs#QTK?V6JQH;>Hgj3&7$l04<`0FP z+V)tOzn`GbA<;qS2Gpgn9HKuhNR&tIe-xdiGoy66}Bt(D4hx-oJ#k(*Z6W_aVF$@Bu&^?)dMg z27V*kbXEQL{y*WjW6^Rv0g}udpe0fjvP{swR%00#2-!q3uBAHtaVBJ zixjuR5Z1rA^$FPZg&w8;Pg3|(NjV!t-ifiq0paDgZc8cQyhd2Lwy(36s)4Ug%$&{v z{Eb**#Mj7FJEv&pC5^Y1DF6PSgJy<+qgxKLPj! z;Fi<4tHatIfQVHS z5ANu79rIt$oYv?M-9MAhb@v4Ox9&fT$#2)6g)d>zsjLyZvB9TXww(A1mre5Gj)sn$ zx}regzxYzji4*Ci zbX8ym_r_J0D(6wxM9W#sH|OE{L4aG%ZoAmj`R0yB+m$~p z=XNO9!g(}%KDFjGn06vcI~%jk{kqfQe5+fBWHBA(#@3y6{$23DI%;f1XZGL1+9)UP zJcK6zCIMXggqa9m59sdLoo>ibqZCB0y>TM_aR9fH35b9Uf@(FV>Io1 zgpUB&wNboN5Y7es~l9aQx%2Jh(=7r}wIyAyFOc|>ie>-`iXp1sDqRGW{@O#8Al zGzDE)1+_Q^daBXcNLBk%&PHP=(-dK9nH^GiEyOq7{Co=GJ%A4YF5k4O3Ts^f7XZXz zeyH;Eo4xAnkfaTB<}Is_p0|`=I;CI5v?+tf&OpA7C?8)@Ug=ViwNAgAKCY8>`X)o3 zoU1K}6h!UwYgO>X8m8^odZM?4{c?g1X_Jw{GQEbCP0M`#kUk(rGao9*GG{vk_}o}Vz%b)_;8b7WeV!&DEbtk1L#~zFT+*_iwT|V zeTzW5uIkJuF z?B-X<$*-95oe<@@{l*2_VwyvXs3ojn`eU-ZblZ+p_zNtNivr!O_8k?EIDv$QviPY8XVupST=+AD~!Jv>rn zX_G%8JD`rh&&&i)_#L);tRg*VkNtX&`x3g-vq<3ngt#yCI1;y##JwBHQp~@KkU~0( zk_6ha9)xKtG`be~i>SFhFzY`bGFLK>C~B(Ho%#XXs~iH?Ap9a=3qa79q-pe3gm(je zcj{}cvoB*zxfZmnoIIm#o54MM7na};4NA5CAzh)9YTa0&Th)5HW<8*53?TcMZ$xd) z*0iT68>F{zzFn57@F~46TJEC}o(#AgAnGgd;iYez+TUx|&p%!FR zIp67W{r|ytdS6~~Mft!!vG3Qi6dgLNuDGszRn=!Vzbf&lRR4L@L(eruklYBv-SoMvj;b%Yh` z5#bfFlpskf%~5m=0DkWLeyg8qmu@n(sQ__^`rGFjm5**a9g}?pEnz8oQE$1|`We&4 zUn-v|bKYD1fAik6rcPFGu5#X+eM2kq1mQ`tn304< zf;u89pEm*@mtO?l4ePm!cSC&Z<+p=Zw05I@JMT}qF|1`fpX`kA>3|^sw_XfK_(FjD z{?#95i5fNYCD7VOnb)1D-( zm);!TuWs(t4}2@+;8Xo3*Pg@mW`K*&?Af-~(++5Ezh9rfN(2TaL&~pgbGV~ZjU|-!Vm|)Dty4hLy{XO5pZcIw6G%dro_9oPqfR1*Vxy;T!5f+JX&p6zC6 zzQXH9e6!Svt3i1G0l)S&z{N`>9l-VVn@5#{J;E9&9p54D5q@jHo{8s?MpXx(Y`(em(umPeJVgLfm@7vJWB)})Qm+S3M& zc*%PPd6(rMA)Pf=Qh}uyGdc_F=u^cIt%TxQ$F(|Y9eQR8{GGTX*!aRU0-6ut=Ceo& z;M#3p-TtN?A68D9Qa&mmoPQJWcn^9RY9@izq(2$n*!Yy(lGndGT%n$1H zkUHI2?`pfcTCB@EPm}-f_$cN-rSktfOolA&E8}spjGafl#H5TE%Nbk7nEWTpSrB1t zNiINN4l{q5#6=`Fc394w5RwqWf6~#n#mqNi?0_-E7@rjRp>N7s~={E-q$S`!;79x*( z$btY{6yQq&>VUo?z^wq)Kg?hcrx|P0*yCx&`ZWEc zjM$g#&0tTY(G6+(XBqsf4E@Ut{!IpZDvfST)4$E&|H#n)p1}hTktNOfBc9ZUJ?3f; zf1#zF#dGX%M;f^%nc7v@xy(zSC9L#1l45x)_%((RxEeddUzs(NUsGgQx)Gs`gk4KW2K~h#sdSHpV!`zNNu7m%tA!oDDUB)x?iA7a_O zraers`mqfOu~flfjOhkv0p@2m@dZQ4L2P2cU|nYcyZ}9z6i$viK*HWQ9)|eAIj<#< z!lsI1)+-a5$1k7>Ik47Ci1#K4tGy}lrzhnl{!Efuz^QgZqMekLXeD(|G?M~=IDidE za>C-8CyFgc?BI$Ece z<>f|lwI3FhclfYr!NA-Ls=enqJ7VALMbQy;nxjrFW!zNxfBH@;Sa{kpHn3ii zTgzFV>=f>1>@Fr-{8HIfcwZS2l=I|A?-bVCh0g2#xy zQ1F@ebDB_KCmYp1^aQj!JC&jzSm`T$(8nI|c~<+J#{Xd-UF&li|3`gvy-$C_M~j~# z@n4qPNV-@Dy>S>kEGJ;)97_+G?ArXUE=j=`mD4d9m7g< ztX%tci@j;lU6y&9)pLo(?yx*9(CdC^TRZK@C%(wXzTtaqLsWn-aBBWJtc>Y1usFib%f0)bR=0q^mulm-i0j`fiv|K_BMqVn>$3>N&@dZ9kIzX zpHDEiA$Z>;+16VuP_IsT{duTUEX0GQXzV>`>?s<1ipG8scFlL_A$RQFW%vBs%qBK8A>zXbdP;I`lN zuCSH~aODB+xv_T75sVMJ6UvNXJs`6?V7yQCi&!`9emW+p$BTNm*Xc!X(7+3NTw=Ov zXS&phxI<`jgm}BR=pQ@MLxc<$IA!-%c$NVNcicMhTwUv(YH0lc;t+DNW6tv%Lv-ZK zq0_}IdxoZ!Iq%;V^ZpC|W@{4xlL790B7d|`JA`|VDaUN=J441!DW9PF0PT9hA|y#{ zJt&XTK2Cdhca*N_2+sn{1vvT7Zb$fDz*ff&VVI*IabBbC>ZSUl*w2?tD4#LDV$!(s znD2{uG={li9z6#qpLWpLOtq36DC_@*u12WXM7zJr(Y*C!gXk$_5N$40a^k4!tMCmi zi^@YfAbdJt48ZM2uR(YoUy&kphVxW*#$(Ir1UJ^fp(J-4wD$=gx7#*Pq z1gZB6Qlaxcm7WhJXgfV3%4Q&KhF8-x;_5MK`zg8)gC2MMkhMIl4F!w@xaD*M!Ycu1 ze5&YWpR2PZ9)H=-H&RaH$M>FCjs^ednZ!A^xU$yK59s5RtfQ-lJY~uM+f|&pNRv^m z9v2%M8?Sh@ajj{Z9mFu7hq~mY(QBv;cL?eFeS~N0ffjCSc8Xqwh`4lriSAb|*`dAhQyg23PAKgMGn542jFzH3+8V*4 zWrGx6)%fNpC)2ke{0-pm0JogB-i!73fB_#UzRNg&fpfPyl{({!`gqEDX3vU?XJ7;| zRmj%MC%{&;n!j(X;G~-Wq07@}M4ld2SC8UK5b#`_wwJV{t;5X{{C_c1;D)b|8pHjA z+E&es=rS`Uv%hx$@1uv!xE#zyk}bc{j)rXR8H~YsiajdR-|SA3oMXUd zhGq=rq)&JFHtB43(A(;maT(oAVs`bQ`v_shc{cG-BVf_2Y%lAIu-U?27Q&WwpOYCT zH7j^NkK?q$%dgT$`{MNk*kIB$s43eQBULx?Ql8_3aZUmstn({$u4CUfeoA5@4Tk)G z(QhF9Z=S*SK;*hG5q*M@L^Rv+6l*}jbS1->aV%nbks#eKI0D`20hzZT5 ztcYGq&*yWPSp}}5>|U6d#AVp=K~aoa_DEHZGgpMQE>6k3hH#ISVJ)yK+RpTN5bF~j z3TuY};u!XwD$g^@)OgT&RMSQ(`9bKGTV+(eauxCc$P0SH^~S|hhflk5hE_j$^0cYt zy{@V(ZxHWD79chf2E!JHeHPheb&&rzQ+cZ4^0dm3r_<#>6hz{_r&uF#DrtpE>Kl9e zYwCs0!%wOkC&%7#MBTAlmyyHe|6S_7Ju2x3b=syPuaI|KBL5ZA^Hsu9oG_Adp1sqF zRIlBtPB*AGwUQ72r0#e~o!(X_^~Rdmi^pRxK2{gEsFU*%JcJLgP;p)5%V)~}Q{?}} z^1r+MFWm{3CI^js^_A$N9NEh%##So>y@nUDmDi< z43;;*4rUcQeer$X{SZ6!Nh3Qi#AS;ewevKFgYcqOw} zu?{O4KP~@r)^;T;S;eNVWJ#-7mzAtv^y>l{B(&%RE=o^kLT5 zy6*rUNilP2FPi7`(JYu}q@BAoPSLdWl{KP zR+MsEiN{>ZQ55@Wob6jLh);Ofqd(%IQYuA}4L@`3SmzK{P|KK!%x_*n+>B z7r-3<5l{NcJl@<+O1ZOX@3|N6=_cRfD{$Up$@io_niovAOF}f4awA*MYHxQ-h5Rs` z#GS(5A%1Mu=Qk206L%~WmV*AY5RKcVdtcXO>@L0kPS^u>$_xh(Z}9hY@4I?u+;8vG z`+uY->mB3LF6>PGH5_%Mw~AcPQ$+OSJ*Zk z5sE{ZcY@5-<4=JYvhxQ-|BwW2(`Vkr@)xswUh=wnAr>7bh!>0VxaKq;(j<{t;Wvnk z;}g7ClW;m>0^W3ixiLl3Qj+Np#Ni4ogzDKbE@eE#tC%DTYC|%v`(a`OS>{hmrg52O z?!|}=HaBxm*E?p$gI2%MPu|xlE?4JENq(9iQOOiyE4|~@^4p%J#Kxj>6&t`u66Nbf z-Ui_tH0@rJ{w(YgWzSoSsiSxuol7u_`m1v>O9&hP5avUR35G~3a!3jtEu=Xj9zAXx z9f-8Y-Df)@VlX1^RS`bOZo}9V4g;)o7>jmwUW$!B)u-lAnPSs#IXM$;Vk#{mZD}#t zMCj`($w#kn?!DiAXqy{&#tlGCEjS@a|LMj&M8GFPes`@r2k0e0Ox!V5jUo0C_AJpM z)w>ZDlYc`@td3Yi=*6@RJzcka=ZE{!aq-tAJwm+mh&|Fb-so#yl6pU~6@<7xdx!3M z%+uu-g52%;BR35p*IlY(uAd8bP7n3<>5Yb26%<88%5_AP?}j7$~(Qq8SUUx2s$%Q=L4i$Lf6?w_k>fH zdwL|Cx|NsqIIYIp!E7JuVs=jG7jzI4<4}HVq2=uml=q``G(?b&` z>S-T&0{c7#eq(V`J*nOuN$r;=Ia0m5lIXoj`aMasI!UQ==*lGeV3Pho5;Y9?+c4N> zqu29>`Ha!a^zO(&$K{Bqe{Akc<*=1a!KROKYBFeGUV&EVR*recIMq~4fYs0Aki$_u z16$B$vphYQt%XxzO}km>RN6syU0=@T@n<-4w$6~V(HJ?~^&}jKD@h2(w@>iKQ^?q8 zf+^2oh}n9jn8}0cnhm>AK3206myLT)pI$z7f}`tPi22v8lq{q_sMFQ*VxGFVN0(RMs;eb92^j*0 z#~44Gl8sslmQxx`-zxT3Io#!n4KeU_LID{apvJf z#g)Ea68V1;eSajHRSs8Lvy%ASB;TAQp48e~{H+H=;%_`{zV`I~#>2n%a8|P4!@uzK zmjCoa54g&NW`xdxmV^$z-LT*BpR>`^W~pKC^ylsJvz`978$50Nj?86_P{d+x`~q44 zqK{;s68fr6FQ<9lp{$QS0)QJ^Q-a3X)LR-^{IBpy%#AQ00RT@LrsZK~oM$G0n?8?e zhUB@-D)F|ldc&5!JwTkPcMZk(=p28WkPYQ9qiy3()oSJ|KCK1+xpFO`7yAR(C67n> zw5fWrb&ede^x(~kpJVj&B@Go~2Msi6b;_?*Otq87N`~oG_mwPh6(o|92{8-9Tq|GO zV?z0LDE-frU#ks$iC45@>pjK-)0ky;pO5V-FyOuuU0R*;Yq4-AGSBq>njURgRyKE( zUo{Iu1JS&g>&aN5d-0r;n`= z63kli3Uq;_mOQ3TGe3na_8-QrOv}ijeX%frhg!xZm_=5C7u_-K7raU|TtZ&?gR?%g zA01AoLRg-Rl~qD)tB%@R6y6Jg(@ZCB4Z^Pj-U7IIe~s{ufc+g6-jlyo?M>r&_nlcj z9!OVV%efs&6E#hfeu=b*4fR(--?Y$3{7cwr-pbhM4X#nCyuwuvsK4LOEFXRQuY1NlaQS{{8fzb*?@}y zuKfQVgdYaH0uV>{0V@CYo9gU5T6fyz*pW5Pf4#~to-|d~nm1H+SzE92oFg-LxceVO z4lm(fu>Ae3SpIj@whFQ^$VKkfuF0<~o{>MXI2~#LQQsam zU<85|x*r`)uf`&&3Q^}E3dP0~10>+SB^(O!Vk}tiR!7isl}}wZMD6X)L3j#aCcw?7 z#R#tkYygPEwYSUOuj*sn` zNUqW;q~}u8=-~0&t#t20B>#TWg?Yu?M39YnVt{r7=@Zpxs)rEc`q%}cGOwZ_caX%W z`W{7R_ET!S{va&#J0aX3a0bBPM`Jv~GXcMUujtzGL*w+h^s=W^dOdy^4mJ>rvA_xI z$iE3Duj|MII6)<6Y}U2&N+1l#guWz(P}J5+YP{Pb-C1vlT)?r~Y$e!YyOM9o(>>e&`1*`$INQm*tv;2ACQ29g_V6 z8Izu+XBh^~Vp&0K3yCv)`ZA~l^sG#jYR1h&+R_ z0iYD%d&xS_IwVG zDCqhdYXv=6ykOdXu+F;xIa4T6DqW@M+z9;L^8N_n?*NAYZaex1!rqNxt&gK`{pp=Q z#UE>kCshc=5STO~dGuk2NB=>zH!vHsXwd1iMMZntVaN+vJ6PzY2vl*D-ly=Z!nX@> zU5tQx3IFDTf*5m5x7hNz50j z#^pXv=sJ=kDQlxD-;nLh+`)bzvQS!S`#ha!%4>%ChLOZ9qdgtY^1Klbf7bBU7#w;e zVW$W(7ZH#Qd2T9v#^G`Zgmphax=UIC-@`p4qK=??t||D1kT`z*M!Tw3GHTzE<4f}+1j(LYGi|EQwBK>O9xo~C?hnBN#l zI!x2h=d`D7SR@X5LT@cxqHyBUe;INjD2jX#h=(Z4OGIId<3UCLDA47OKW8C)Ctx|i z<f>0n5_slzE4cQy(F3b1r4nS4Q(d$n|Y8*qU*er zb*2j-s-i227q^Qed$ppk>^arWB%plfA-n>x4&avWB^wQGH?BVch-2w)#n;Cw-|L<7 z-S+3@JAU%C8RdO?)#bxFa=j|rQIfS=siGY!i}pSgt#1b_(6*VKY08rx^Jx!+6tobt z*|U870gvwyk7#pbvEEGBWiWb^#X1zl3KfE{u2{<+R&)=0KHBbFhVYGmeE^4Ue!zy` zr59jR0T2gs>^~=4tpcgE>5Q{}`C1fEwPkFlhR#e7AMyVv!vz>)f z^S@&0+gWf2mW;tYT*jkSMhwD1Z6Ek2>ot%0 z2Bys9J8n~UgybLeq<_hS2RLM4KO)LJ%>5sD%zYjYrUWLsX6pz?g{d;&(}%KubN{cL z|HA1HoNf1b-x8VpPwxML`~S&#E$7dAyw7{A&tzD%D3>q@n_EB@oS=6$QbpfiikEs= zi01Qj7K8`M1drb`y=T~lugLbp_aq$oSzfPY`r*WYLpA_z2Y&~((lx4_4kQ2F{-N26 zm=goe1i0mN2f`}>j{(GS(D944r&axNZdHNOKOb&voLj$~u-a?vWT&vQs_All1trzA z(+bG@4;gP#Sr`S|nP&&wU zW#%>VcldbqSd0$}OI*I5{sMuuNGTvNO$-zsr`jHGCz=FfVZ&e;>J?)rrrz#=aGua@ zAcpo<#h640bZ#G(RpPzqbda;v^*HkT+Xvg;nBOne1 zjF%Kaxgf{Ns|i%uEy*C3>4{^f8p*osuls=&lA(FlGufl??Lsk2(xZ0a>s0=Ac`0hY zaTdbmfLQ>y{`7d;(AMJmS%5gU>Z;yU9>b5z04M(%^lu_})VNV(ooM4$s>ZETY~$8> zJ4@WbT5e}y*;Ivgumo+Lg+^+b*Sr_3LN*gAbZ2jg%F}uyd>&v7z^Mmp0m3T*_7{pz zDjoTP%mn<^ z!SJ%a^eQ7K-ov?F8W#!AB5{8aj4!JFcNA7TDd@*tKV;MBI69NQOX-uM1*qMi=sN`5 z-Em3y<*=3p$O5?IlJhqhT0X8PJL_2+v-dBVFk`ASxSA%7vc)hO%zBlV3N5)l8sqat3yW2RKHNTK*^KgALz&Ysi$F(<${jQSC;d6=FLSNlFzG~+? zd+|=U{XdBC?*OtjN@pvCPX#Q_QGB~0)X4Zt%w_uY0$c7P57V7ub@-5-vd_Is*Piy} z&~6x51>i>~k*1>-i66=<)Rra3Ay)n%+Z97(Q9)M|(v=P-BgDX}3-t0q+>i<*ze~i- z9dk65KkI-~7bi2eAp8|Tek_#L~8~)$Apfu!Y6F#0s`xlk9)_T2p?9 zFL9~w49K}X-&<*i;K#~9Kh_0Wm15S(SVa=o63)J}5)N7(+xoY~^P4XeANK;yPlfo6kcWcraKk57U3m;WdIkiHxd2-aJ^H%68^66bMac(^O$%I z174_hmE|*0*-&_YsDe^@p?jnX=#cRW)1J0Fn?BnL@YEoMz}7^_?>FG$!??yT!p#lK zwEUd*AqI9%;~vPtM0u2>*SDrmHcn{JsK`ZXymxQmO&Zv*Gn=C@_>~<&GnYyi%0XJTV9N9z1VQl#72}Bv_35#wZdyH?;49Quo&K9 z8Q4N4&ugi1$68`J0#jitY||M3m(d6mrvZ&>`#;Yk(XRjHQHKN9bSa z^@MiucD08=&!ZHLYxm z!w0WN*SkK{jB4lM`a*zn9OdVtk$j&q5iYQ>SJA);lgme9t*g^59x`&Y-G1!&Ygn9U z)z0^}#e6UB4fI)n0RVSAvKrwHfL8(HNcc$c-?98#XV6CHU1B^k0vouEohh&qom>Ok zQN_AbV$$8-9OAB9Vf|7?uDDjr!_~Cx4loRuq^lh8+j1s`Fh+Tpp@Csiw4eMK#(Zj< zpW6Y-Yyui4~7I%S&2o8&Gay~cs>Q@U4{p#VldPUrJZQR z^HZUT?1~AMZ5l8?X55eIf)GiEMUD{axu9y`0CNd1Xe8!}U%EelF z^l4>F$QV*KW{hasM#_xpxSe%}MAnrFC<#Cy$4ubpIdt!ve!9(XS!vii#f!aB>_o`v zQ~%*h7wYTR{NAm8>sufH%Ev`72-iMYaC4YOQgs7dhM18s8t5p3fI{SnnxuoS>Q_~M ztOlJTKf;)&y@l{Tz~2E*ei&z;?a?yd!oFz$ab$n3>h-bYq)Qz+Qo^z4Y0-RfTOQ;~ z6+}p)@}4VY)nzhg7Ad)4EA7T$iO7yXz+?X6r$6}(GsRFDlBqJn$G`J?fAWKGNP@_S z2M7#cg{e!bF2zY@$c9C(h*Z*1`x`PRkjScXfM}0l=fe`(o_5x42>ap%JBzXDdFU!SV+}w&!Wf>8 zr$cEg9cj?FZc}&;*cs(pIJ4HS0c-@geA{(bSQ`u|2Z+P<>p5}YP4e3PTj%u0laJm0 zj<1v3I>cbKa|o`izo+C79r?W+mcEX;a`Fy_ju;c?(`6S72^gk_39{r8Wpfa6|IsKY zIaf37`Cw}!U}P{F!El|#r<+)Q4rkRye340mdK(B@!g?O{J{w5h67aqhFrNqCTQ{P+ zX7eaqhT4P&GAa4lPN3|1Sby^G?J)NCO1Ivz>F0B6e997#@dF@3xWXl*$Gv3D736KGB`D!1+)^7NA zDpKV$vOt|1;j5#{WBBEDYiQtFt`<(^XtjQrufWR6L&hBVXbH8Znb`q5#d`)TGO@gc zr-}ZAqa%Uf4kot-gB43>T1=BYkm6yN4&9@hCV+Z$1Ew$(i{<$+A|!jJ+(Mp10;EI! zB~%c8(g*W)5g&%U1A?yv4{Mi0GtZ!{pge=W20a!3Ftq`TccZI|tX_GepxyYsKoYDtd~r_c&xslp_K01lq)280&@x;S!+ZO*($l%Y6w9eX|( ztyf}fQ(hq_b-$}g9sb22@)4@N5!aehtB7YYn|BmN!Shg=Y_uK3ivgTqRTy>4;oUiK zD@vSpp!Qvbzxl4hzZ07ERtOIQj0d=U_cOxz@4=rZKpd-2SM9(6$M33)#9y{EU#@Y+ z8#^3-5RH__G*DBoC_q?^{wvw6lNx<3lcx>3JPp~5UgnT-Ke#Ye>Lt*cZ^T>*1LQ&4 z=W(Z*ZOeuLy#5o?WTW8P*9qsUC4DMY~9njdd zqFV?}2%a4t;%jf8O|#<@!sq$0qCAdt2(=9+ifVMfP^vP#WVO!L!Bq>^Lh1h&RcDY} z4y7Nda@~vaa^)W>@54p|(0*@Sx$;jxK)HVy)@}lbqu^6juFsEA^B(to*`-QeIci@c z_Ni(dDfqN8srFO|BAo4J;aSTaaYU*i|6B3@czYB0D2nEPc)I7<+1(tQ-P{M+B!nd7 zdDn9z? z|66@zH!&Q~^Z35Qr)GL4OLuj3b$4}jRkg#K(A~u2eJ&nfS>kb{S3H)P;_x)jBrAAUr$=83JoFti)Ou@C(6+`0sP^m%NQuXR+pAj+f$%*1Pyq zNG$YGF80)r=iB%T7#5r8=^L$Q@OR{f9c5&VG_(cV@aU|!;gXDv!R|co$prW51S}D@ z$3sro5#RUi`1qFi9-Ms<4~gQ-_?nO7Q+CBCIx#%0BKdr1r|(I?UfaD1L%;*ESA=q} z30WkDKazl7Kbp|z!Gx4|Swdq7v!IDT=PV7uQB`z_ZV>a(C482}9`psA!QAT(UFu`6 z(tms{q4>pwl%>#_Q}6K0-m%OldWP%|9rTRg)uDv>A$w&)YEy!HM}mECpbKYpVEoAg z$Y0?J*rvK`0zKU9+?2dR9?b2USH`_}2J;>W=5h9V07{O{f&T7k+~d~Rg(iO^kPggm z3%bmk>C3Y^m-7DkzOK%hadTPs?ZHq>5Jxp$jJjKwBm{Zie+Q_ZZ{gQ=Op>pc+aJ~# z3B0erXMA{j(EU1SBZLkhzVF9@^aldLNJKhSXB_==1>~!z+ zWc2?#8Bfg~*lCD$G#`~c&T>7$F3Fy=eWw;wS~ZCwc0}SOEdNT@x!ylMa4{ z2kvL_ESQ>5986)GS^SK+gjm~6g_vb~3KG*oqYy`EE>F!%>71UKww$M@GvcsJ$jO}o zStB|jpNzM8rkin8a(dD;ES!)%Je-ovax-(bvD}Po@F+%Rsf+(gj#Klt%lT(8Cd?<0 zei^`aw2o8GSfuL#mjUQX>>|ghK_&93$0_{`-z@jxdhc8AnLeYT?wIMbXVp!TTLg3j ziilGPiC{O3HH1ock@+19U^E#jx^Us^7B*qAFK%tL6=R2tXt#7d-#oa8(NY>{GvAYsHpC|Co z66_a3aqB|v=Lt5_p6emEKN9jj9`a!y59&2xkHO*RRRkCcQ8N`{W(lNBG&1|Pk7F6)Oj6n1&cUjsM7zdsWP1eOkLah1 zksb=T7NFO0!#}_p4loZuS3|07hfm@=ZjWd8y^gD&E%Xnd`YN^o9vv_~N?A#!e-BA> zjX&YVMg#iQ7$!l|`#@Rj@?mVoGENeHg7}gu#^l*TVRopvqd{QQ0doa3+UVD_Uf{w?IcCDG2Z?+;?-vk0dDA+u|yFT;*wx8itD%Za@$*eL}~ zOU?c*dG-y*umU%{9R8XL<1qwoZBP`S!#jJDvGI^?&VY2%+k?&HY?kKBBas9?F{N*5vmxLd3V^-~ra(T=XgABFwihADXm$sMaMN$c2g@B9=SZi_8lanrxAImbl+!ibtimrz`}2 z!I06O266C(`-I!gjopx%R?dbyF%LFWl zgJ!Cm>QD4y|IJLbQ}uZoFEfxh2Qu0K^e#G1<7+aBf^7T?vqte_?W5h1{22QT`uXh) zKlp}W$^Q|?u9iKLUKK47yWr7CPX$Z|=yl62NZ$)MQQ13f`M>#sZXM7=ITc{ZBDZv4 z&~?4E3s@tqq~qwMh275Yrm-!#$ibG5V<*6&l2^Ds$FaS15*OGy$BBpK%^U{TtAy6g z#+_;{2H|rU-@^DHk}m0L`B~OuFL11opN!Cl&xfb&mpSZTakVcunM5cwXQnXYrKH>X0>0} zK7L!Eqj)(|PF41CVw68u4DxqltfxW#C2q`1$%#7>tJp5EG)ltAIR;Vvpam0q8iFo+ zRr`J<&&}A24lokwNr0&Ug`cwq=}!SYpOXCFxUp?NFjV{zRYk*6`?DRizrvTc8nYYC zrPiOEHJVSE;*t0#r-ji?uJJ(1$zXMxcXOU&4g$hpxs#87$uYL?$SgZ~8r=Gesgs+1 zNnh{ACYwoE&_IAoXkFUKRM4It0u#^@=3@@QhMa#1Yz#bg;+cr~CYBFj%bDIXdYfab z3I|-|Q}mDDa3Y4->lAhSx1?9q$B}bJPC@!CzWq42Yle ztBjSiP(Bh?3W~gB8(*gML1&0{;?WtfVCvzATzagWdbq zE7S?6A8Zg`CUpt#;1Nfi5?1D^<#0@~!7^RV5Xom@O_ zm2dyY#M=@)s4wh}F_xv)@t)41R=neOcH!yZ`1xVhL_{%om-R@2mh?6rm&)neJIvEP z%jfh#w8ZpS5TB&^*P*+mk^XsReao=-`6ENL)z>+KyIskxK|Ie)qUp57m2^n`EaIor z8|hJiu>j2%7a~0$@QFI_XFq%`9CRX#y@)?2tuA)S=82Pr7H}>4IkmENlwZhb=N`km z*D1A&#SjkzlGZVv82eeZ;@C(V_W#B}Ph)(QU*f+Fxao2B(P!h0@A3XKK;u8+<5=TQ zyf-NR+Rykd9gHxFGGj?v4oAd0jG7l%eZ=4yj*;aJ`8+ZXj7R!Rz*zuY-W5pS2GHYx z-p}fQFU}NBU}WL2NfN4hq3HV0m_}0?@h`U4d^lvSgz8I#Kp4JcQhlWgSs6_{9=aA- z?;=hXej%@xSc#{x3%U~ZEd%MnfDr(Nr}aM4fiKYSK9c-h|6E)CZW^{v`!0dEWN7Ch z=R1e(##s{IIefQszIfSeiN|&F+X6gjcE2fLJe}5^_py>3x3?cmyp}m>zSH={B-ik+b=$gcxme>b1eZ@+5sx)?0C7(e7~$~5U??2KC{5@;DA5lA{X#nN*PHWR8zKeiMSkdi% zq#pr10nqq%4tR{`@V=pW`VM4VRx#(%<8MkC)!72uZMuQ=fN_(RgRW6iB zMwKcz2kCBrUI1P0V5I*6&~p8L#;*y=>2VeNRxWf8S&0#IxAPh|?#jr4W{ONImP5OX zu=k=x$@Ah=DD=C?rN}TiTpY)z(O(UtDpS&B4a(O2<)28Cob?Hw3O}pIH<+UUi&gyf zk|$-qQB}hE2KyYh1inHFZQ|cZqxJjQ9X#H6JfkmU1)E>PV$$LcM=&88NoWI)b|iCR z?O+y-D6wXo7*T8zKM~KFMig5PO^Ch2vF{h3%rlcv8yc%0paTu#pftIpu=n0>(4(~4mbo;!FZ=3Oc5O!y+hNH(D zKY$?)A%^H~g(X8Ys`665jjWFv|r&edziKi zceCN*ej_?MjsK5Y#-dEoX@YiEJU^N`4a~$~uoq*SNpKwNS_P11VGn7Ca9QTWJ2LZb$?T~I>>4qs1*z-OfH#V-MARX)8AiZ~2%%p^Xtdivt@?H`($@lR1ZX~5W+fQ+;C=Cq`Qlgmki#TCZB|HN zs43qu1lnBzP9gz$4aOIsbO=!D0?U5FX)rC2XxU-3be8z+Mp@$&KEdzhIG2m3#-|(7 z{Q))K90WdM<^pO;f6>TiiGAir5Lh72SPfhT2wWZ&YYk#g^nS5ZIhZ#y99MvOs;Y~` zYaYtfQs_?$x z#e<;3Uo<&e*hDcuUW)lq=tH>iHE@8vG@52E^ZE^AAi_?81W1XisZipz24#lvWg+YY z-bDIyz^?$+FP%60c#OOsvDXKntNCMz-=gp2Q+&j&My(HjSN(F)069)|$mgTSpN#>k zk*}9_0^LO`#{DV$37epu#U|G}sidtmF7H$gNz&_J>)#iDX_k9=R`=w?5i{-30-w+N zDbu?*6X#soX=M8_)K##$=;f0og{nfMBW1(qpDcqQMe~E zznp;dWWf0VJs#e5h0j=m_qzdf%~R(J=>94`M%@DQ_CxQ9)ou_53=m{ zv-mdh3_k;McSwfXt$-cO>9Ci9dXO{_@+y(_-Hm$a`E-6utl|GDXv725#pkS+{M1pr z@Ya*Q8s=0t%)!XxHGb{{%W=BGD*s00MdNT4_;?-C+W_waG#~$obm(Wq=m5}loAM{y z`$1bi7JtO8v!bWl@xG62e?iywTh(n{)T7DV#J-Sj`~3y4#7kazmm9x#>V=h4h-X;d zcT&2(my!&7k#06zXJLBF6S~uQN~0Mz|FU2rlqqBw+b~Xn3V9Gei55^-GWcClOohf$ zN$)ABr>6I%NY4jc1JHbaI?g(Kv%l{^@{6pevRDt*_Q#>AyPa{yz1dkXB_oNpMU}S= zdAo{uuRUxVKjHn?sPbl<<1_rf9MG>T*DojrbP!ytsxMNwxNw}i8tvVa$6$uPm- zzBCe^u-pzPdAhbt)^`r_*8Tcnq+bAh2+;j0v=@3dKqY{#9m>9S*S#IL|A}hfuif}X z__azG9sF(TEP*&oftnZuj~ReQCX9hjS!?;$S?*dZw9e|i){0+eHLSHV)>+P4%U)+4 zWqcIQamr)juMPXQW!wGGgTBcQ?auCLTc2jf@6L{K#+#m-!x>w%oquNAbHjtE(TOKM zmMgFTKT|G?dq5g%usV^}MDWciAexPceIO1@}8W_^$z3OF91`z87Horm`;0dzH~ zcm&J$;qTd7^~+Tq_e;V@^hz~F%IFFY$aPVP^wgGtAubS`fJMf#95_EN3|rTQlWC>F zvYafheR0@(d6=B}L~w{p$+|Wb8i)Xiy%Znf0{l?Lhv+8hX#7jku?Zcl57H+B#shRc zZ$|nNz$*Z{wkFB(dcE=k6CZI4D?h3cs-DaCUC-(nXPkkPg*)IvK>=+bgKeIR<^Ikg zJIQZOaq1MDdxo(7Jb^s1?l+M} ztX3>Ny=*2b+*8u8>NhEej0F8oM0yrr4nWgyHPV{_9|7o^v}e9}TJ8L2YZd)!75$#w zH~nVLm^N*~c$|C6L84WrAkN&@oiJKKe`y(WbNj&lAh9h$ihUKFr&_)*;Qk1*>|&Ll zv)HZ7`!@6L09kA}J=va0+SDWB2%i{DoY`{CU2i4w2B_XZj8xcAV0+gvUZ+6ynlzVU zET^lnm!wbc-y{9>{DL@REZ)Zf^myJ4cCZWa{Q&Dx>K~(Zy#bdOna+;B6 zl_=RTK0^9eK-s@r+rd5y>FWS@1L$&9oPo#p5vONY$NmC@$6ut_-E5;=vA!vlQu*Hr zJqDRI!Nv&=7LW_eg&kIwu$I8KJggzfGMhaAW zIM#v#@l3@Y?ljqkgZVUmDKmQ^?*8+PyxO)gSWlB_UO9|ovY@DdyRSJ8Do}W9!cxUH zPce+zKC(UCKO*aWy&sx~Z@N7Xaz8YRj>dw#EV%R^$dH93-sa5isbs0Av%{!JHUf0X> zE$_{FsZDv_JMz4D<#p%mMf}=YpHux@PVDnJ_H#M>>6|f7<=9W>WL}@=U7Q#DRF1nQ z#|GoTMg(I84w7@@U=$ZJG25hh`90WYaVH;bKTB%S?nD-_VYN%!+tZj8AVW&R^24k7{77M%m z+bvk0z(?0C_N98^!XE1l_-v1trNcPJMKpS}t5H=g`M;ipTIHLgE{2^E-WLFLKilwX zoUs`1;r;7pG+%>Drx@>b&Vn8cg6I%<8Icf-t67!Tg1leh0aqtjjb0g@WV;vaCdlDrXJy9H;8F4e4EgZ=%YH zcRa@Lct3z1hsxQ97UvmgaYz<FqJKt}bC7zX)^eiujaHyUy-#G+4wdCq zS)tZ-jNS)25#N*?WdEo8Kn+R`>Oe}=S}S865NW&b137${#Ah4ICO?uoAn+^F$#%#n z1!%c!I@0F?t_9FF=|wqzEK`0y;$zfpp2DZGW4TO5YKi82p54O#;5#{M;Tz;*q<9&$ zlar=vl2}%~0#yapQUMV*z;Ep6GMWc}%lG|}9}k1AL`3v=>DU_hF1aF-W~^1_9OZQb zuw_f*hw@tIsC&9(?9IoKI6vj5|D5laTL&LezyO#fe9I6TV<^P6kr-*sb71t5M7~m! zusDAOQOq$Irh{?SRdtl4+b+ zL3AZJ|3SQfb*PN4gg^8$UPkN0xE5?p5T2i(R~rcR^4XNDkR>6wBgXGibua zEGG8H&WNm%p`Vs#8 zIYdB6h0>(j5n|+w*R6t$)0yA20J{IKMf!cf*8sYD|2SVf=cza<;w9?Vdw=~`SvZaoj{B{hcMBpci`|h=u{%N= zf!Bdqz!YE>EEo`P18WW}D8V0cL3x^BBDtUhD}kXZjgJa*cr7g^Hx(pqEAYNk;J#OI z6lcFtYyF;|^;3TE=Y0F8eEviJY2WADKjde;Uf|tW;6r+MzHPS#Kio#{8-rPfkg{nh zS&deL1bK$Fgcg#+`SpzaL9c_~7Q|tfjS>sVpjb$rrWTUq(q3sapCIWwCr0+;I@>UA zLHY&2{{i&;e0VJ80l-`UT_yjN{kW-GKBGu0lDDb(dBOhX=e80Yj7cw%R-{jGVNAM5 zK0asSZP1@$qMjlq>KDCjCh9*5kLAJJ3w=$6Z71sZsEK-%HxQ_&1$3`u%+;g%#JXe{ zkZZbez1$Oh30R^Hv=iY5V*8JPnGzi9a6mT}Q@v__E6lj15OexXh3QKQF{w~T`N)RV z7CF(?vc9BGKbvGeG0T5Xv-~hbZmu@ZhG*{uWP#evljVDzd5dGx{bI_Wf+_z+oU9c! z<=2jv?XV5>)%xcjk^UXP;v)0?9Y45+7wX#&#c0!luVu ztTBl8IE=RFNLP0=O{_rxd0~fOyN_-)1>RKS_yCD{xK84|0XUIfg{~O`{l;f_{|cb- zKJ)(^V=lgbeK7J}t0WG0c4j=?H47&&VHlvRS(R5Ckn7mBDBwJ#mjPM;t^GV9WE=-L z13=d@L$>#_-zD9|$Ee#9wZGA@zkaUveV`tFL+VAJl8*sGS2{`D&lPvL4v<{B03)jm z2a1tZx=g~=312#i5%+GXiIIaU#3qPJ7;-Vd4mZ|!?E{|4urfX7Lq+k=7TM1fxz88D zadBq>hS-*Z!gmYeKPW(01RUzjzQH$lcY)vaI(1f6e4XFkSrGd>ol^Nlf!%^%#l+eV z6?s<`*>4xvTMKNnpKjl;$RA@XM+ZJ$&W|IleWP)MHQ{IDU6$KR!! zTOp>yPVjHJ8_qkz1A8t_CjK z=Mzgl#mA`IDm4x-*UVQ&a01Q~1jH?#=ITwyd8Bi8a)Meesn(D{Jz zJuqChSq}8qsW?M4%gT+le4cm{hmP6r7kNJ_!tMv8(^faJpUGb+7j!GfX(i|so0*l$ z2P4dV3XAu^K9_sQI)`G~@R@$o=Sji%oeHr< zY)?~npAKn>^jpIuGxUI1bTUu3dt(Ry7tqysy6W%1QP<;hq<;bY4p8;5G86H4z#flm z&sBrlwrfqtek8J9<6Cz;IRd#h@;h++6VJZ`Zg{($E!=1nl#ctGY$x(SZxt&M2AYd1TqkRSIMAq17jefK%=8ksQuIVb_rY+u1e$5| z#D2!pCB1okOxh#G?&HPTmLDopujSCGcEc;ej`s7Kz3c%tCmSoA(WPGJz9n>Zq<1`$Nc$U1id%d_C8Y{1{bWlP}8 z&H4#)7)|w(j%z?4;)_}kZadO{06M2c=y*QTw*i^~bS+cyisyD~%irRUxYa88{P9=h z-#g?2;*ZfncYxSPVo~^tsaJbg8ZMLzz*EGh0dfI2p9%Sq;+uhpD>0Ga@j5<2?SoDA zEV5HsREl7iywK-+ro{Jr3B_UYSw1%tr=&V@ehh7a`%1hIlw@1o@GG02V4duZOY#M* zn@hY+B{l-yc;Uli^WmN(d-w|_8EZ=1Crh#(D8WEL{R;;UCc)4gGzVGZNcED06aD14 z0oid1G(;gR>Wj@}+z;W0npqI;vv`>m^rVvLfEAdJ-N0~E^Gc4}>o}GWwrN>15*18k zI!0sWsG0ahE8{o9h=m5)$s+FD5u#mgLQ7-|iy;K;j4`&A_Tursl=%K7Tj6SGJltz| z=|Y@-X*2$L*X%td_KzirKa@bqLD1l?aIXv@auwc=0F}~lKuW1qgz=6J0FZ)E?QBV( zDQTEnQSX1D-(QCG-GB`MO`mS*A)`Ow7yw<%lz;d7&K>h>j4AiC_T=t|U#n+Kn|;Rg zSz@F38z}<)E*}fz;~Xjej2E<^7&qweH%nH1v6V+b862%rI-18{UFus@D){t(Z7nEM{o zzyG{z#{5#7nOBr1&MO7O(HQYF{sYa{KavrJZG$xqUez7!XsA2erF{+QY3} zXo@i51F{hD@n(jtmQ=UEGu+Mf_@DuRw!tQT6@lxCu8nhKd+Y_BsXda=0BISJIOT5fNqM!Blg zy{0tVy1=&nUE*ylrC=CwZ(uvn-ONY4ffK&DZ+L)>5q8ep6GKVTv&K%^a zjg+*#(``}Z9KhZwx|}f+$4@7U8Gn?e8nr6_ZOBW@S@{Vt?W4Rq{a5ukYWB2g<0n9V z+wCM9^UIS(MH*E(Rmih_J*GvK^B=VLpz#gkC%_n#>LS{HX?d#Aq{`WXJhj~W8`5?* z_J9DYf7_=Z-3Yh_K-UgsS6-p~SH(x%0@aH|a#ctAyP1&gC1rO?SG}lEM^0*O6Py1A#|GqwNXiKC3c``w8X@of_?7<{*VbfoUts7 z&HrR9dW6GBFF3qa^LoBZ1Tg*zK`(fWIS_VwM~ZqiH%dM>!g4&MkLgH{1l$YI^ctRn zbvmFCKv#IUoOkB6i+|Ok;v4Kz_7?5>tCUBH10g=OnAbBn;J28IO6+RG9z9iZ6!>`@}f${XXYY3uk^5h(F^$ zz;LmbmcWosGqzTg^Wcx=zP;rI6eEPQH_HQC%M0Hq4{j-U-zeu>%Q4^GU}LDf(PrDr z6F(>qo3U?~$G%q{`))bkP;SThZm@H2w0&=v+v~bwU|njn56crjD^GV)9IxMlptS)g zW}pm&lZkC6$7FbY)>}CAl3(b3h5cDx@UQZMkIQ2}DtA9Dw>OqUNod9VSe~(`-2JiK zzOBM*Iz-G%M<*;;qmo>_yqi? zff%7HppGqs?ExKVk?Is;OD54%3LSYY3>4xWc%8WvIFR8i#`~O@0)GK46QC2vfdMpi z+AIz2-E_I+lSi@6(*0N4=eoJA>q)*Z``o47l;!OeGM{?f7z}owz?aaiTgIrkP!4V)@a`7Gt9ck1j6=z51t?ac{RXg&HT` zLEH@YC?~^-gOa8b?_*PZXYQdGwcO5%?ddJ__K>}92>RGh99AfWP|cFo1^Vzo)>8$FaNo#!q;+I?H~xM4h*?uk+-# zsc~u0KI;#|CehuXm3>m0aU)t7+5C?b_hI5*30ub|mL<~4zWXfEXLgvzzq<9}f!uT+ zpO27w_jM~+(XBfc3zjb{hTPt|I^H{3#$BvyC|Ut;|UB-unc&syO@=Yu*!D}^5~5W)&qV) z+RhIdJ^;v0mvh|ApplICy(<35zWh<;&oK{lq+gaM^KwO4HUevoq0?v8%|xJXNOz6! zH@XO`jX2>3^*#c9^cVmIRu-Q~in^n*m^+#h#?_JzjliWnzW4nP_+}muUmA)BPM(Y? zkad%c1<}Y+om&)Msa<6IEWo!iq$&PnA3QZ3eY3pAV7xcKG@t&Pu~qq>h?fp-RUP4V z_SA+cG--ltH4TWv8#gLoEbCDw$mQeRNl#aWh)zABLTI{1(rX^dbXES3Y_p9!@qQ0L zU3`x!r$fJtQ>V?CP*yg1_VkI}FsJy8>$*`(h^txUzYBS3dFY=faU-@MWE}dDt$)UK zzWUi_p2NQJS>S_#vt~^_bvn^mmG!mL%Kt5@oN>rkUl$-f53m5B=*yQNeK$bcSu9g} z`GY90JB}KuABy9GJTp8v^!jw836VcCKva(xLscx}{1}W{C}r1 zFjD_z?2%vkE}fqjZ@hz96mu?&i5J<%b6Go(u&PzTC`tpR3<(X8+cbdB(d z_K)g=Z|dUh`Jx?uZO%24ai1A<4L4n255S8*iTga zBXMqtZp%>pDd-~p88OCPF}*j3<2o+k45@afNqa{@aJF4CAQqcZkM~CX>#9l8#7j?jDm#gV`-##q(#q6a3ppS z@Tn6u(3w_Wvy_5yl`oR9#u#m3pfFw z>2WU7w*vG&(Qc*B?|>ho$^$VF68Jh93%KhGCISOz$xy(T*~YV}a96*P_O4v_6*5H5 zFawZPT|^0whXzTEJ%7@^FalXpaFSTWw~M8p0dmPe@C#<`Mpz?zBGqJxq}wjws4uq+ z<0c>rpzBeDbU(mBv};s7dWc57vrVJ!V8)Kr!JKRpS{wCTAQ;uC(_kSe8Wr-0!fzhR z)|aO9{ZaV+2XsD@;^(w-`{uO$QnT?^Ngua7Qog275x)Hw^ocINyKzZ+6fH0h-zLkO z6II^LNZ${5II6s6q_+YzU$wj{=eZ8}O2x0xqY6yg9+Po{l4WG2AGz^#Mjb*#mtY(2 z956{Tsak0UyOPZ4sa=S9Y}(+$v{h|apdngrmv~lHMD$>%AblF(T!5ArZbNz{U=4t- zC2vSPHQwT5^zBMHPq(WFsaN0$H2!Q|iGz6>0; zxhf-ucZTbO&-NqA26nCP$cSH>p~jITuqo07ipg}juD;MJ!DR2&TRoyA^8;7#>bw1Jy0M`OEezzmN3UC1WNqcLoJA}PzEBTt(WfHvZgwuy{3Wbk);7IEbE)L?PjGGib^H7$)mLt6q@EAbj zGwnB@@f_X{Krg9>r2Sk?t5D6bQPhH`8p}(1aO~@wkUuN2$0Wp1BYd}{N8uq6dK``P ziGW&w9_OYbeGZ@zKv#RYOXIGei5-t~5&T;Bn_CgB%@RU84HPxT;?gXhINwUX0;)YV zOwe{Z$Z<>_dMuq6z_qVn(EJ0ce>-Qh7F1gr$;{2xPl0)8{?B{_cRe%mJ7 z!mh0uW7?z=XuoIDvE!%F?)n(3$jnmJG8&i5c3Fiyb-f0z^BX55JH{yhU2gfUt}znd z_gSwKdY&{DzMwNFkDpi2jZ**<06PCVr0W4a*YBs@BDu@VkyrRZiGNMsza9TJ z{N1R^S%Y%^&U)xVT2%h2her5dBaAziruvOL0h&$}hi@6)4~`FL0#`?c2+E_qS4es_ zMwN3X(hmb31?X~4Lmax*ct5yuBHO_iZ5H2QXe{Rt~!m9M|%YEZ^uC zsptPbPGI^_^=o`s=06VkYkpoI%Z<~Yc8%!(x;h$1Po072I{)` zCnG%-P_lo2-=VW+OdH=Yb;k4&b!XL0Bbj<8ogQl#RVyW(o1@B^a=d9&r#Z%v08Qt8 zj)!fCICjpcdMag>(Wvqb9~Qy?)+XDyKLzKW12q1$FSru#2g{!b1t_i-m49PY{+%&) zcQl?Kith)PfAqoJvYe(>l0NQ$$aqnTbbr8+0Fv*(hju8@V~oQ4u>iUn+qH-I7LA^*iZ{6Bc^?F>^YXi#r+v}w#MHJPj%BeU! zQm_5V561q^O^9wSB|)^P{1^T0`A1a(^{9G6(rYiu(fz7hHO2-&KY-@D#ZSf~X2bsb zRk^H(Fi?|1tCEu2%d(}D&%hW^Q$5NxzI0AD?gC&5Dk82nDSYOkERD~jNYj4dQ&ITP ze&MqRz^ANLn(i2Z`kEQ8F#()9(8{AFBwgXvk{<4$$ojV&>0W@o08Njt3Vp_5c;D~7 zPh>}JW?3XHYhTqz>~)Ma{^cEuqxcY|Szyqp%3qGWwLao4q~8U+576UtRaw9o4)_$` z==xp7d(rZ*_!xETw~z5z3`8_87nRM7#2l0;)(#Zm%>8fqYvOo)t$# z@NC*-8V0BmnA~Yd5l}22=!eX4qO+ut`5g92!k00)8;AB+{baO7j`V;!K6*9 z-pf%R-9P^K`A94?2hKbd^XwYM|AQlTasi|>0AT>ND=uDybU(n{c5xx6%{a9Thdn~v zu9$|Z=9T-5AE3K1=E9ZfauE)J)Db1ZNGym5H6mnr2|rIP`jBz>DOvt1W*24e7WcAtMbymyW-=bsu&aHH!axKP}gBglA#b5i?FbwQeTF$GV15vm0Wi(@7gD>Le)8U{$IA7vC1cbIquu^~|4&3&{1WdmVrpk#pdfEvDIl^fquu074 zjf;h+j&PcwqbgAcUQY&ah`PzGp5}0;oQLQzcyT=|@$Ee{5-(#6(vtvl0jfRhZo?3# z3h)eou4RRCU*qut`4k^<+j9IOk({#jYVmB_4wGlhJY#&rkh<}cNF2i{&kLOI=#=NT z0^h+&`3L7lw#lsCY&QEhnem%V-zL-CY_{-CX1C2I_Mu;6bTSmq_2m^!@$AD`7ULP3 zG6Lr~$Hu&XGw$qATreRMoBmcV;^-yE%mVwR!oMyX2ZRK1;Ff)gc@jGf=fK*5K*$ce zId)nJ{#b$|w&LBVtxK<}Z$KFWqx+sEExb*tKS@8Joa9u)A%yvvIs1 z{jW@3h#-9__Nn+=ZCqNKZNp;BHS5Wfg6RJpogF$6Y;zX$ash;EIu71@ixJHlTbP~r zMFw~RuB@Wvp1H@l^tv1Z!xWlI1VK4<*9K`Wb2uA zn};vA`NNFj@?vk5{(0aln8IQ}-Iy3=dVSCgxA3@bv7jv0kFQ8R7&2Ur3*9ZlI0@P`txSY``OxWxR9`@sqG*e1)} zY?W@ZLYuAPO;+k=E4<0#o2@Hs(OvNp+-${(bhYt3JjV;2a29V!(Ku0c%&frQXb#B0Gs6F#*1o6rHzvAOF&QBhotMqdtBooygv%isHvHWTSQKmkCPNBW*(yf4`( z$EW?2hiR|A7LwKvIB{XFc^+-*QE`~saD_L?`prRJVe!QZ0}iP*`Zie(CGN26-9 ztXB>4)a_P>^mM>1fTm;oD95-6@B42zQSM|+PK|6XyC@z-$mQDk8uj#pZ!Mi z$+R!jv(5ZlGk*HiiKyNpb|YH|QQ!yXNtnW*e!a0n)q^N{7&>Jt4^yWTSM3&A&sE4< z)AuE$-vDd{=<*l!bBq?e*M5A!@`=7pa15m_#2=h3%vcWuMgoCKLf{ze(xu|uMTJ1s zo3i|hn!ncnNPN@f{|EI4o!^SCvaIM^vYck*8Nin&z*eMp0=@(2ahJXB7@ng;Mi4-k z)`#y)9-5@&A-BVE_sD7EPo1S@q3lt_>%)XB1c?Z;P>%7DS;<42xU~iIXYq^NyAI~_ z!YBJYD-}~xfJT*J5PSw=Aj=_qnpHjK0Y9n-N!fQG{Q%%;fUXDG*>1)AP5@m8s>d8v zkCG1S0lBWVDw81Ab%MdI7V9cSRq~A=Oo(;wBZdKnelO2@n|t3vWfGv4Lf98XPKOkS zBuR0Vs7$`V3hV!W%61tyTFP;E;rkg#&jHK>==wAvy#lZXK-UsAKI{Es@iFSQK-DMQ zVSNyib@Eg?ai`{7Q5$NQp`u}qkh7lAX!YWuuW;Nf8m9b1jv1Wlg4Q4*711GuyCDK5 zhj7+kJhjqLRO|>;EG&_z+A8Z+G3KxG_Yi#3^=dDFYklOt_^nCRYfy*vLOZFdT?7BS z(60#EgVqpY_IKR-t*8S86d6$)p=hYl)c+x85ukxXSd;Gh2CKA{H$8 z2!Tbg#Es0#gMcYmq8cnQ9TI2;@N51*N$=ESB7SChNEZVt0D7F8GT1Tt;$5%9v>t-w z4<&Wb>~%K01b%RChL?a%lb4V_MqB-f5#?3;jx28;@+wr{^t^d}RCzRS-m#DJ)NFZ| zD1xSDF+UEYc+W<7o6LU~{;$5iNBVbqKQ>aI9)n^H3-5Y=UO&b9OKi`F>W_gzZ5(XQ ze{e1|ZebUab96e(mbo^ma_W(XUguqb^tFJy0g4~&HAt@qya}MIe&>Ag)ayO*5_Kz7 z`iU8{%E|5!Ygl-N%)k;>Jg{ck!snT9Kt$f-4Bu%rA+B%YPAjFwx{VpNgOtex=`p@qvJ~%E?pD|(B0pPtS zKwW?FWBCi7$4oza=Jwy>A^8*~`i>v;H3&||i-mFGCin3;qnx@{F&xp676*)RaF#36e)SIM6S z!F>=`UIjGKOZ`L=Uq}%IQeY>62vo%wAZblj&QHKkm1|lFf`bo5Si}kZb!HE79g1ON zRJ||hG-X_bP8TA517I6KxBFqo!!>u~fbc3nVq&8X z!HbWfw_W5-jtj(weM1lyr`ir;DUM^#c@m;>_z>RV!MmM}`eZ9786phfqZR1_@#aG=ft+o_LoH?783Muu+W0dHQ<-#5Z zHZ^M?HzhbHQvaG@9gbnI@gs@X5|pX=e+AM{0@eaFztH;bO}uXd(6xT8ELV@S;$zfp zhnhDuUW#Aprk`~d)-P362$k6b=U(h_#$IKH7MR7x%MkICdCuKtXt|j~_>F}sh#t^D z6l2`{SmIfALPXv@2I+GFR|7PjZy}v=BG&%^x)!MOTlT1NEeb`E+}$yrvl=E%h3hP0 z2guXWewCpEJ~TxjfwfFL9>asG(CoR=teS7mV^^B-^UdYV_!44t8je)QSrO>CKHtfF z6hAL9H-r;Y&rJ3uh1gU~+^Ke#40dkS*f^V+TgFnn-v!X);n)FQ;{m+4KliX* zdxMTMpw-;tOfw#~vM^v_)!n=GutVhEqRQJFRbJdl@FfD|0d#qzhd4$zydSu{I(R99 z?XP6u`{BanBo^f(=J#qV2dB;*VvRtoCukQ>LVUM=l#th#vfhm-L-&Iwq+bHO2GDrK zoeckMK+iwti~l#t+jbsMe15hN*ap?jnmBW6eZvgGNVC$_l84@xkEbH66bCe4^BL=b zW&)TeKZ<#v z?+g&UlMO3#;vwRlRhoCk;6u9LBcYKMyz?f*L5PUdYrV$!MP5O&+V3R2R)H2_MZzse zzXR9~(Db@;5d3rSzDC70)Zl`8F#-opxuD1e#vfoD@)yb^<{5_o@Z<^C zqROv0B{I)dBYhNL3_zEEM`NsU3f|lA$G0ng^lbF@P*qD49bSl=JhQ=Q_i_BpQ_pJsHmPpX?D`|7V^qaa3UA0W6#kGWDlC_0D9n@xD#&vc zMv9l4n0R=1A{NgcXJP0ItKds(D>HtuyYuACI431>wskZ&18zb(+_k;FWBqPSgFhB3 zh)Q1SzQ~~L@qWj{dwHqzfNtiR}Rf(J>n{2qfEb(mn~c)pRtVeS$W3hU|k3Py&W29nY;2EFJ%@TUfdPFqd6 zF9KEbs|+FsDMSy!8O{7iZv{Id?kDEIn8%He?Hg#oXPm@+T+$;fPy+w{)8l6aoCK8p zg$KC?VW<4NgPN^09YhYqs2ToE^1~Xmv*w4w%WdPIcz*|=`EV!FUjo|O6-Ao%h&fXm z+Q`y-oH53;o-9Pv1K*RMTz?Gb$ge1}TMYSCl|ODmr2J_}Ujuj^p!u=e#E>xxa2kNF zLCJF5n%hp^+pXli#&uVV-$n2f^4@VcdJAJ&3)^e%q{D*BcQW3>x{9=n^f;J89bsC( z-t4p3oV3ui7nzT->rHGsj1NvQ7nv6`@oltVxg2pXHa^1!Z^#Kc9?SEa-RBQG(@Au) z12`pzA^D?Ye~AFuz6>9)O1IPObWe(%YNv5usTKIxW|uN|wdo`~j`OBze(i8R#wij1 zB|9a-L{x!f%z-JkXGxPn;7E6smMzSs?V z>-of)1RpQJp#a@3mmz%@U^RfQ-fF+?aiymeAERzHe_4NtcF`LZP$2Hc`U+78-ooMu zk5bYm?r~PKC#;Iqmi>ffuC|tPLH6<|EdOe&#CX`HlTOn>0^133$4%ijx;uZ@Q}Kz% z{@7!F;>qy@%vg&Hd7?Xx3G+Ly$M%l!1YhtJJnboe(c}NXlS_yF5lsh!iu1^k3C?k# zRQJ{xs75HSdyM<8;7iK+GtboNtXL zVx^#AiM&5y#jLjCj2(iKekX}&X(jN`#csu1*S^#>=enItKa2a(Q}C^)Fq# zo|tdxR3M_+QJ@(GEt!fT3mhnpOc~6N;1`;HX9&nWmQ)DX9FnvmZ;29&KPAwikxqfG zDpS&{c5*~-aSqZq0`3E7dVPU(+NmL9nYp*S1vk1yH@F3h+#-Es)@0CYD8~6DERm&SSpW-Yl8U>EXOM;Y zI_k;r$QXd_OX|$mGK4hu_H`jB)^7H7X9uGPQ0JAERzf`{W16`HCS{vFG?oif{R_ ze2fq;H7mu$Fjq1}moKd1FA;Wj+g})BFap&#LNx>)_y>3(1T}H?_in+LZkI3I;!oTz zdhFIb&uG%ZZ|SvRpY*~yMbK-$uYH&3hyr(e3vTmvxz$^IqgPPCh?Dr%oEEX8 zU54}%fTsZ(-+rgV_6RToK$qo6e(bECQB5n7D^z^F2*1Kz9XkIpvnSJiJnU49%7i+) zRRS&S+QCmIPn|Zc^%rMMoj!Cr1@dg-kH`bvpRg%bbTHjnjFM0X0eoW$%v@!@8V7;biVj`wx3^XX@8!uFqTxa z5PygCZZ$|;gzjWhBuP51{9y*W2>uMSJG+&nXzPnr(`Yz{E`V(mvk>vg>WMb=S=B0UapK0vo)GtwIY z9{}iDmLc15P>OttkGNH+acB2m_^yLSI{ci5IxMwtBFjDUD3(puN;;9Hhqw<|DVnGm zr0KcZdI^v6)s`zHh_O#t?rJOH39HL$#H?KGFX!owpPz|Pkb|u>p71GRI;HYZi;FcA zuL6waeb@TR*ZbTTedc;!IIZXXK6jnZW!}#bt}th-tJ>gOI#CBCzmi#}K|B9GC%5~4 zBy%-$oG1Gdx5|mq=2+OUB*2>(f2c~9^c!+Uq#vAw^mM>1fRewhW~4s@B!(ot_U>v+ zx9~S|yw-fLmsQmWe21k~v$NZL!C}qL?bZtjWV6SC%fvQ+cdSbN|RHyF%;ciUxlU|EInvvZu$DTAj3y$MOQ3^7_%{u_{2m@>e% z6X|aOdjXnX>3r&h8JJrDbhX#ph>uaX)OPbE%vC2HfenV)h&?!sq>TD{=yFm4A?Pyq zI<;5QHrNRU&fO?Mxa+tD%mOVCW6guurHQ0R?`d6(j%Yx2Yq1lIrVL4!M&P66@wG^Q z05Iz#cn?H+GT?jwU7x79x!XRTAB8REN!8DW9Nl)EG-Ae#(`&{fd}Tdu~$*NP2LBafI9SBold%9#dkFgkAr0zym@hbDY#(b|b`wP>z+e~nh?8Gn3pP=JGG`|#3UYUfb!d;ZR==ki2$2F3Jk4U}naRetI6s5U z?Dv@Md)Z2v?t93NX|}MrLVfpSs~U0ak?6tup^XurA3-(Y9)xyQ^{bi{sozmA!CwjQ zrvUW0Z(ad^F1%|yF>dJLmWuHtW$ zq5VC6MA~eCEet@@xf1DWz$gG++O8z5&c_uWaa*PEnb+|;Rp=fX}p5HyjF95m--}Z5W zJETXe_apS7D&OWPeE(}YjF~-gV%@A+giDpmH}&lO?fIci`;9qAUbN+^Wk3keokt_IDFjn~Eh^tF$Tuv$Al!8ad^Ths;|4+!kdj2{OQxJLLvwH!$-jl?MAqva z=D#P(tY{gls}O^x{R>O z-@V}P!C$DXq6(-|`AfJP{`%wMc5#n8oL5A?QNj^0 zZ&dm3LjIaR%trVU0)haNZ;9{7zBC!{+OApKS+&Ns(FL|n573d+9EZKCQtVa1+Rebx z$%b)(l`YIpjjFDaUbV_t#b|bxXvuoT5(B&>e`d56UjK4@d_pmRo6Jfg3K7^$A>Z zaP9TU0n3%bLzBvP3i2JN+T$XmJBsr#AK!PYedvADNeB--)W~LDLIlfSZAv8HXyG9(f1?#rcmjf_FrEiPWQ1!M$xrg)~jOf-&~FoPFhs{i;(}p&~4ji z2cnx4P8usD-n$MWU*|HMHFpsCMhPb)5u*(30J*T6ET;x}90>n|E~mYaf^wQwIcxsT za@q?kD5ti&tWWsz1CMW&_|1XGHzBlOyr~ofYf=|p@x6>#ABrB!&r6f6 zE9Xksy#e|GNFRz0Y9EXAR6sp|ENe#sdgoTbM@7bnFHK z8^%;9+VL0s7h$0y{sUC2;^#oPQr3S1>XoYS|F7a>=&DG+8lzd|8=fDL|Mw?{w3i!O zv(sA4GSX)XC8oeK+*{(^7?uCcNZ$px2cYN4s5oRTKyAt+;nZt0IU>t5$0*{6F`N3@Xy!|C^lm?!9l_ zd+s{-+%vWVWPiZVKJ7%@)1&mpu|1(%oO`oKJ{BHe<_L>70tu)Jccvk8K+G*Dhm=o94H$578 z5dT&(_8w_3!*>rx3$cYA9BO$CPI2c`T_QE#sQ?jL=maA&jExnMh}6nKKcy2 zWD|O;`yv&s>hR44`72gZEFLaq!)GnO6L>b2`xJCF`8nHO`%PIj0+((?RF7-nqqn%ZIA{>dpKkh+O@o9_*=Xg*s;z#0ii6Dm#9ABqmz+IAIVZnN;VL~ zgw7=&x{IVB_yK*$k4VK7^H}rvP8s+k)mBh?KFvG6Q(-#gvg#nwmQ#8z%^TP0rqSl8 zPVf3R(GC#$ACfn!Q(=fz@0(5XeH18;H?dO<9-w@F;Sg(ZipEZs7cM7-iAHU&eRU0_z39-*=$f)~Zu3dv(+#v}UF(iz`=pygP!yQWy9|Ow^Ui{QU z;t$FP+FxnWcUAk2_BY5U#{bG2e@FV#e-IoX^feOSk-|3?+H2EOS9uVI|QXddH zJL^m-O7SM+7KXyh2-C2~pU0&W%;^9hJ6m@yXd33CJUA5A_2~egwx0T&E2P5EOlsu$ ziXdb=Sqj=(X2`u%=GOw$pBF0hF1eo}WnnEs!+Ei`3(=i43~r3 zk0Lt-4Ub6HF?rr=bF?(g$;)f|qW9XWd@V!s^9tK86qu#nGeuf4Ey^ox`y#h0eC1N_ zhs4WRc}`hjjn$>PT1wK9M3q*ZQxz(wWh1I-)v+~rrYcr5rHik|KuE9gu`;5U>E(dR zsZNOcD)dTUMZDUo(#s+7DgO+Cb|;L!-ax-&iZg%gyrP|M`x@bY0S*8>`eFS)bR}>l z?9)8F(th-p>4%OpFc|A#e$`b&Rc#D3Xn#^~AdpclfSoUR5%rbOLMcr+4`S^&VV(M0 zCm+l34Uhi)3&O7f-UfK{SeL7y4-Pm1Ab;!La`Lltr*pOwI~>-!>#@VnV>9M0Ib@n? zr-EiQH#geK@-1{NJxE9keb_nmup_>M=&?VkTLjD-74V=MJi(eyrWy2i)3Y%CL0#lc zYP|(YA^%lsL4o@jSVu#PeUe&_Qtbnh-=I;A`KO&=pY>_RbTQ30v5V;WXR1C<)hO^H z*@dtM*iCAS4S>YBk5-_s&3F3Rhm1c=s89Bl0ay(}d%;J>*4T=dlaCiWD zWjx^{Gq}n^E^ac@9DIKPJbHAmel=uQ0C@lp-*XT?53m{_e@DXiwj<&jZjL1Fm2AQ2 zY-8~foNP35);_BK4MNsH<1T8wPqnvj zhyTp{X8*$RfcW2t_J*_q>7Z^lNV!)f`XuR<1I+my%)QL`NT~DSt^ylzXOcS3ZN#Zy zn*ebv8;Z<+42LjQhB^Y$8cuTXmAa-KU%e0>4j2va@byQ8*8}bZ$lqF5J}vb6N;`h& zq1u%vJ$(CI4An|ohUzBAJJWWEQU%cJR&r%0>v|EfRuOVBsM6+DB#W8QE3<&IN?M~? z)Vzx1uOj}7$$0x1Uqw{8%OXkB`9RIh9Z{n?+B~Z%zkxve*1l2dIT3mWDByN8=nv#-gP^%9w7wQ=;+c%prkaAo4RFcVHhr?T0l$ER9?`6{A~n8m;rS;>N5&V zwGbkSBFogITDxwCeVSmAM@jx8#Q!iEZy)0@ekawiCH58pd;Lhhlt>%g*0@y1Hsn)` zL+W`XRi%EjgTb@O0onnB5)&=69Q+Nxu6^Csfbf}s7JxVIUXJj3fTy?P$qRLEFJqfM z%kI)N*EyIjT1k<8{Jnk5UXLF3v-%!Y3JbUo<)4GmDW9LI+1B~wDD$u@TlXx14hnAG z)##27__nvM6xTdJf2gQ~(B6RS*k+|widOdVz=g1#E4BQ8otBZX3sHaA6m&@SokM`KcZ zumG#UVgkl)iO(ulb!a@tw5agan6O@&1&7@+645NbJN7{+TdFo1w7(q0(6C#%Gx%Yx zxl*OGooQE5<~n%Zk1~3A=zb&SSHN!pUO&DW;l}~50OZfJx7ypaS7#@7I2?bNe(d3C z{){tDZp6mEq!JE?%SDY#rBVaLN2jl$WG(%dBSdlyV$Kop39)>OtddxSzmd z)ERK|dO&@RD*I>w6%Rn^2UG%&(^6f(8&jpJYDfn{nRP!ATS-Wl^dmGUHBfNz`-niQ z8n>Au;%>BRn^vj73Zy5nmV@?I0G3C-gRzIvujv>Tled+EAUO#*PXoOIxI-n$WM!d) zx5>bVN24r4_z!^P0Ixr8+^#A&BJPcY-nnzRp~y9K;k;R}mVovXt(yTF)DIf;fO-im zEJF}KX!=ZUr~n-;h8aFH5Dv&5(Ul%==SFFD^P}I?-Y<#}?h5D$@al0E!lwhy1<0Rg zzu_#`j)a}q;c(Uw>Jcmx$VaS|O1&e-$_n~7Q8vI#i}<@+L19{{`#whn6@Kqj1q||j zgAqNs7h?H@e%`Svj)FlZSaOmJvo(Z|g^r?K0aHkYjr=TJqE2y@)@BF)KcXDXZhn(D zN0nm%g8^Q7-$yw27U<;y@^~XXN223<6Sjd0Flbt|7|ck$=*BCcYKO@&!b}+c3p@axCffBjDujrL0#T^@_4}c)6Ti1Z zig^ze4^p*{=nHA}zW#oqzYuyDbc3PBfmu0^J}9;Fs-ci_B1V>KEHS6J=N_aBJsRr3 z9BD;JyHl!8$r~c%cap}pYiSeV_Yis;r*p{)%BRpmsV}uw(6QtJhAJyYen|$SZC}ZBciragTXpU)PA!aB3=dimy@nFg9RwOo0UM}& zHlfXQK!tUhD|rD(ao`n{Cg%aDJ?c6%pph%6Ia92G;vp-TE%HH2DauB|p+Ya)Lfz?Z z|K8BvzfVW_V!&E}*S=mx_!Ge20rKb36^H9j8;;PvW;#<0g-yLL?RK=rKKjm)#kqqi z&!RZFY7ERcsneOQ8-HSGCKw3AG+WnOS>as}{71vs#Ki63zQ#8&aSH>FV?@vwM8Ra0 zqY;&1o~WXyA%d>fhfXIG(Ax&m5?OXwlRl0V+8r)wcen<-U}re^m~(48K2{=p6W}(0 zhmU_EY~2Q1Q2_aS_PLezS%1Yj+mQ~3ZAZWdR=bUh7D$;ln%qJ@1B(#+!ONY~aPSL( zmCu;6pBJ>_#0>h>kC@Ywj0LzAyJiN-wJ}cp%9*I1Qev@(6kt>s>Cha znGU{|0S~&Hvl#(43b`j*s_bAp=Xa=V8j_68r zRPraDvb#hUn7iRtUa_8wR`iKdYUtf9=3@_V_8`~SbJoho7v zY+?bwjWbJ-VAQ;eGw_?^cW~#EaSYbCx#6I=kr(-VL3g<}4MxZ+P!H2F@kw%cB#64z z((H~86>=;_AolK@P(Du;8E@%9oFuo(6B03aVcM*6=Ri!_GkysFey-gN^ z7MDuC)1l-G&)J16g+Lz9Wli>3K<8CG2CWB-I?{ou(q8Xm$); zXoy~<*Q&n}Lq&bHYN$p+FZQ3r#z4ieICYZIU$0Q53FRoJ`_P+k7GPHBHCmXUUN6xKsnm}4hKm>81? zc}}2h)8x;b{fn3C)+6u-y^xp3S)vGveNYdFF-#vU^Po}Eu}PMm$jSTIvp^J-a-kmG zDMr!iljNKAFq&b+7^(Nwi5eUEk+{=*Ola<#*EX?KJ*~kwb$ZnfRkdmj?~3K4tg`Lrv%b zp7xhSd09#%N9F6aBB|>-oI2=TMXk{FAPZ&C_k^a0Lcj~&YHv;Iuqj}GLGQb<_U}Y$^c@&#OSdV=Bt~vtNEe<5YMnKws^~juvdfG5R2#g+}V5@KlwbR^_u%zA8PRWd>I3BlV0wU(X1_7z?Rz z?Vw{m!sJH+1Th#6=#|pD>3Ml_SI>-V(hz_jIEc{LC0dR*L|vx&>EMCUQEVjMY=mUU zm?+&}KsA)5STYn~ev#55*h5x}vRJkS^=A_syaNEb<_GCnzMJ-bk6I;`NN5Sm8UU8A zg)OsTI0yAyUmwgIa---;|1k`p`EVR)UXiB%oJ)cx=C< za&M6A!@(qj$+`&6ISR1WNHp*gISNx<^yb+tg%;Z%mM*Hn{#Z%$uogFa8(n-otkpbls(&L3 z6#GMgY=H0&MOQD>E41FBqTu5qG9cWlx&!O3tDSK=w7H!R)gR&C044&war!}oKLLCT zkiX$?JM`HXt~{Fiy0+7Y-n`3ry7II7cdEQwff_pliH)IR<|>?P5SLb)4~ zVfo!vI#q5us6nH$3&T36D`MpD8fM;qXg+%$O~ry$JY^uv&(-~uuhyAG{|HW~PUt^1 zEi#a1sZ5BA>=ff`&8+mpwwXW6w%Z_SxGO1IKopgqM$-zSzOi_+>%0kzlS))O$eULP zuOS1;$Aph@ECoL*EpkoZVT=z^)`T_8vEYAR=gxmy+VQ#$;Z1ZRF zJiow)TMzu~+J)&nDYer*o$%^TU=pwYwlV4~L6_Q#gOx(L7A9|UG1RM+y{@Mze@j0N zD<7bkrhLI19v&c-b#xRyu`4_ zIxO1L0YBtDVD{}oLnK3nyPy~b{GLZ_1P8G^lnAZ@J0*p14|KLtAU_LOv=RXm-vN9gYX3e!cpu>J0FRE& z-x^hV0XDdH_;^u z^2>>RdV<=g8=cs4VaHx{61O_1ea-{ly0uZptsvqY{djW%7UaDpt219Ot}(ix{zoH) zrUIN6(c&_;f*8|;zd8$#i$;u$;ofRI=tt^O!D`CQ{%R9HUd8f0pqDnvoAdF8STK}9 zgY14jVK39rOBCrKP(-yK`%EAi#E6ty+E3m*0x#^ww-B}7)T|jYfN9alR+HFz5A$rb zFZnwRBpEdRxyk-!=7Ni5>1ttm2F>M(`)LSoxR(fFndBnu4)fK%(Q5oTDl_*a!J9Aj zg<;uRd}>Vu>p^HC6Hnc0KXo&n3WSQnxapt;`2ZUK8ZQow3vKu5K7FT&l?>>Ky-wO@ zJLMx&Y_(PrBS1?=80QoJYXnZOXh|Q0jmL>dVP@}d0uLpBkSQyu7!al<1Ld916Orgf zD?TQ{{a}g4e<1#^ft)I!qWW8+BD2dxd=Sm-A?$0Senl9#a_4kulK9TSEnQ^Y0J$S1 zzWNCkIxf~|Vu@ASO(sl03C<41tHD^wqBS=Vqm`IoQ=RoE7xTKrPqny@+zGaRh8J)H zLy9yG$xAI5I((NVa=XxKKIxxSEYwMGfGO>|+jPQI5CiWUn65E{`@x~LK(yr2+X==J z3^Xz&0LJG$u-oFa2WIele0Cs-N!Shaz)I;YmrHd5$PKz1d@9W8>>3WcndWR-V+~ga zNXJlT@So*c=|bXvnT2XCh_3t|%&bVy@Fz5|hFGVu*J)x>5*d5J#J3n0W?#r{pW9Ii zwvi%Q4$@6$Dn>Xs1;bg8Cn8G2R;Rx&LqD!_yX*A`zW~??@W!pg{jgOIn0Lth+j-n- zpAYQ$cD%=gTuTPn!`Dh@6dPnm9(N*TcBJhnmoZ;*WpzOM9jqpnX}|>ljLWE^k{J+w zBCrI?;yk}72dwlExQwcySBbnD&SD{Oo?*cfLMr9{a#7*!*u2c0`4d*9ej@a*cmc|} z>t3ha%_viEx7=4D{3hT%fLHD-x54H=Uc2G(< zEVYjxg9=0BJ!gyl0w>nXPP?OHq>tRYKcK$jQ$7e36j$}l2Pci5sKZJwo1)GN%`|?i zhB3c&>8i5+sklK+Ux zR5!t6&6k_odfLpwb?nlbQLM_S9y0wRx1@p16a!$ZF#yXec)pAlszoMz!qE`!DF>sq zgv2qDh>~zG^#a05YOOwE6S3x-MHVr70nc*U`I4foSEKds;m#8vf|GUvYTmgr-KimL zlEO+O3=)l3-zRLL`YmDCFtte9TdUjd)K}dD?R?qO5nchf7~suIvbhX;N+m{kCTlEDw6iv11(ASTpQAWFB(L^&|4@wZp6j;ok&M z3-H>Zym>O>J0Ek(b2vF6kIuEvw$VLayN5v5xEPQRkp1gXs=OA$B1t!~K#CbEHt}O5 z%Lf@`U)VBu$X5`g(TtYK#zNr%%2j;3;bAAAt;mDN7vF~Pn}GKK9rO7!;vQf8m-7iH zi!%5MO>A7WqzRhSgQVn$bY~Xo8P$Tl*SHxAh4R7HcL+Pw|uibUK|k0=V5Fw<{5mb%IG8z#9`R}5@rK2 zD=gqFK+oc*M+La81r&ZOh^B=sUCtV^r^4Gnm`(#xd?r?(6S49<)#;ZH61qsvN)YMv z9FXmL%)#?c6Nj|hh!jw%U&*B{p+{1?Cz0QvLww~ycBl+Q&;+o}H%_^XRgpV8c@ z8nDK=)ZWgz-9DDeW!yx2{q~+vZjKb7aaUePvaTnY>qtH%VR?W@zY1ylL*$zf78oCd z^cYrL--hH8L+lM{+|a~Ln8?Ahxss@96N4g5@nO%~%;&{aE#m!xJR+qDe8gwQU&T7# zzSCce#~IjPmJzk&2DZfKpQPExV&JqK#>wHGAxiTBL993$9mKvz_>TmFpR`IoG7a0^ z87OR5!hj`Fvqia@6mjSSL&P{m2019&@uY+I@<-a+_c(+b0jC1I_Ot=v7XkmL_C3Do zlsQX4lQ&A&7j*{-ws+5!hxvfw0dhY23s?3=;lbaMasATPVN1(yf%tjp5{dNa1`z-w=>?SOnf;2=Q$<{Tnl;$X4;IEr6lM>_YjL;8I^%q76o zap!xME|}9OU7>9u|8cf?Han+fc85I1K6bN@j{RFF{DNZ=x7xn2)_G#I9hunS3ET_O zMCYbc?ewJ6zkl*8cSj3gY+qHYVV9q6C2($xLC^=bCIy!Z?h3%Yp-EpwzQq%Fll9~Y zl2~aLUu3FgRVxv95j;X^ch-F~Vd&P({}*{yWr}KW6I3~t0lm~fk zRk6hgZiIImh<4ou<{i7op!do1{e}?>s9lP>%w}`NgNE+{gVU-NhW;O2uhqK1=BZk9 zK*xO-7<84q{}RJsT~^A_8mV!I^A$#LSeLWAvmVcoF5298i0^%3$w^Z7pnVjrq$hYI z*Gc*VOps}GHG_L05a;AA0`|vR#!r`RkNZQ+piVsq)`FpcK1 zmYlOj3M@^qk4x=i$PriiH+FsF=uAG7PFIf)udtO*WT|03U6wj73p@&T9H=e0ZD+gX z6}SIC-mZ6g0>bkErvbctuR!=R0RL>I{oMaFze&2)&b!;-DQtTWd{mnDUQX=OCY&Ud zvxX^;Bj-V^Dw6847$Ph9?Ih^d**{#7w<`#RNzO*62WMV&^1Tarnq%ikQS>K3=KyK} zUcM(HJQq-${eQr>B+zEr83)35k>MZ7eWkDHM{@u1Y3w=TRBIpAkL+3UZK3>CA|8_V zQT-To7mPfF53FfQtn>UkSH?>Vrc(o914?yl1|+U9`&L8ZlCw6C8= z@;=#3{1oBu0Y3q}^5s2=Jtsg<*YCoS+LI$GJ#5j!rBZ;U6@1F|QV8)fCM6FKsBds( zc>rv7N^XB-RI6w;34^@cC%*2^C0=Kz+7!S_;><%Gk8XJ39CS!h>K z!&^@NTah=fem+Au{Zv#b0C?l;O9+1l$a~ruUx&Zr)YC)9Ip;QfxPe{2NM9e)UrvDc zRR|M0HQ2dlEp;v(?^uC_o3ZKNNcUp@V)p5NZl699_UUP2pMGN>kF}3g-`HYoH$;>V z5?z6kUZhI|qFC5y!m5WSX<5=-5bhQS=P_oATE8W(A^D5^Xc0-Yg#o4G3WjTjq37w~ zMAXud;PCE^btfHz4OuJyX2Se)`_W@>B>t{`U0D9cx@+wq-qWblx~tvvm?3iEFvVAk zMN|Y7Y+knR_XqH~5rPf~_EB#lY(R2Ap-J>8e1;dHx}>Pw78N!17y&6#i}p7ZOQyf5 zb|5Ml4EA979CjNh?6iU-eJq_Q{-8gFX%j@-5c{+LP2Ql_E6j7)XzsRBW+U zqbWH7K0(fvS$LS;3Sy-QT)yJymNuuqXEVbzOsk!mgahUrI zU-JO9g{b|6Z-I%0^;rLc2pzQ0kECcj+G@INU(bokOIWU z3YayF<%q`;HY@w_gl_4jA`3g2mcBg!cH1KzQmnru#3M<4s_1HgD{MEK3#39*HJZsf z7)!_SmZFa_H&Bn})E#uz7%+aNd{hYXQ71d1)$enD8I#oNY`&4Q=Lo-?(oyi8^C;ni zC>WWFayD&%(P3<7d>g=|d6JHP;by--$9t{>7C>(^89ebU8&Osi$_I@71mjbQ?c=>!p*OZ$P@A<3iZD({I z<;sXstVK?h>yH!8k!#n_h_WvWiV$=npD1P4PvC#VfZ^h1$Cpk%_Tw8~K1{Ud44?$y ziIwCCRbgVXgJHsf|evwec!de$FcJ<)Z$;`;+2&IAXUM(q;u= zJ$-ssSWe)O0j{LB$flFg%vbLCfIN8l+=lR*fcF4iKJ#9PDwTl$C7)7+v~gB@Zr`SM zK6g2pKHiQPPNugB<&o?Hp0CHCEuxkObA16-<+Kl4Zx9jeAq9~+fIA%8tz(hk5i#Lb5ppm;eevWy?)InV~2zgm?v-#dMf0>ZfAU z)Xv4O{Z76@FaCGFYLWK;lP?GFPVUwnJ|oWe|L%N$ZHMpQgYaX3r#pN7*Am*{}! zzT4P01Y2+$$u~CD^$k~^2LAf`1hz4;ou=y1pwG0*V1^sqQ9Z{?_si4*XotzvLVvXg z>LOkpz}>pY&Mv?v9Sm-BxTo1o$XL5MDy`o;<QA6^{VlLtn%57vke$v# z`y!l7<;8S?ng}or8>DdEASVwgh?s2{QNn^*b4RJi?y3weZ+laCv zH-OApT&hr=26v{|gm%96Z|D2vJKOnhtK+IN5b=6|`=f{QzdPfp?Yj%`wTNq z?`vc0CgD*mJ^?F%8HJ_1_OKA zSe!UkwJ~42`AEImj^|p0>j3otFCWtoJ`M2e?PBJf#+h?ZIDTkv%#!1FN@aiofI3$8 zf@`UAM{WW2nOP9o%AvaIWApV0Q>Cc5T$IBDW(3SC=`PbIz8j_9wXG5<(X;Dkr#^Nd z58gcaHo~6+z65yr{5QhE*T9QDgfG>(UKTViTjH?CljF`t4(9=~VAm0%)l}JmTw?L6 z4rPDfhIC{la|Z{=5y&86Uj}mvNc*1j6PY_)b$*!xPW~q2+g^P>daF>DAifOX{^+mk z!;M(5cuBiXT#I(J(u|#Fju$q#@88wo{rgvF%6|~oUTLc_UmIS*B-vBK*=v?sHd8m;z zAb^6vll;pNlaV@p&_js(!4_pyi^I1e)e?cI3W?%iK+i!nUFz&9)e$FuSL54Wdue@1 zDBEOSI^=Kpa-Z_#|Byci)uY??zgo1h%5rEu5($alvrT#O;GZYdc1kpUDy>aLfZ{#?LWcA~sM>he2;OW_!|MT7X( zIw%VHIWJW~jAO+GcZX>a^F`x{j0#m1=mKM#*vEpJ8iGT~Q^HUOLnly8jhj8NQxMgo z;b_XQMp?8ZI?+$8B*b#@YUNhO-zBb^s#3=tyw|?nUjBs$uLfKW@cPFy2)_<^A0U5+ z;=xYraJc?3{QeI3sh0+HCN<516Q9nb&WOd67A~6Q@^x#ELX)Zlly#-G#{y18xE_$( zRrZ(%n6efsFjCF57*HV<|9~2u^XF*ci!|~A?fEk0FVVuCH2ey!eU>JCxt<5j>!qA0 z#_-#jBpnsyF1VPY*!kxv_&}B{sIV5+p@=DtLBUi9Pm|wi$CEeiE=8J$r~h@_4Y}h; z`Y_|JgC|#x{a$!x#%TK{Gv8tMXbXOlo1ZW6?Tm}}e<5tX3!m%&FFy+rz5s9qK>q5! zcE&aRW9RHX+IFh_MftIlPe&_mGk9Zj)4jQz|al)OYS?D+!5*UWs5p7uN~dXDmEDSL+cvA?nv{&^_Uhun(& zxq`j2PUM^m69^ILoMq7o*e)$_>g*}Pk7K`4Po$=#7so<28UiUa4ta~yAkE7`Zu+qR z*RX9kgbu`u8g!@rQt!2oe_atC0Qfz?!`I&s{x=};zJsqJh0eSf+uoV~wrl@d8)x_a z;yf^G>HKCn6F~X5mF-s7Gt$b6*CQJ(+HQzvME3IBJv_FTr*_i@8&kr@+Oc2I#U3_$ z*y-h8(A?cL?+Y4IUgTh`MrPAI`rCN16eR;WO)lb*3wiDZJhn*iUFoYGczn?AD0C(S&`ihkZ0}C3&mC^nwWE4=nE|X7>Zh72N@4G6+>C z;7KnJ76R;k0`0y$ayQ|10;Zkd6m6zD#whdoCE>Q4VkHSD-XsNIy``j&?U|49W7%R@ zaWrAJSDIGJ6S-=haAu#3}1z_T|WeU9)CfXD~!^>Hi0_W;C)PJR45)UAgvJJ*Mj zd#PO?cYgRw`XjPFWRE&zpjq0ZyRvx6BIwxtuI4vO7cOZ$e&N6ggGY>jq~v~R7Js9X z{pz;(H*iGPqFrQt$+wby{7wB!z5!=Mll%C`MEMSGl`^OcW<`9Kb(HUExG4-qphVas z1oI{a1iM)>lCqM$Q`-q|&{#AQv!Xp>PY}yU;HqlrgD+rE?z@M4#~09q%6g@HFJ==V zIwmyd$coa%OISlT)=KhigVRG6Gd3Vyx0nrvAEGbGEu=+Q2YK>Ip6``% zV?)D&QzAQv-;}I?X{c=r!)zXyn6O1*sY#mFUL$4%K3|ixz`BtJB7O_fOzGgAxD~r0 z(F;f-9e;?X(#e04>~z-e(&?Q0NFJfLkbEnH8w+4a0l$Wn(=J+3-yR+C2*L*d?62+f zaRb8h0Q+1&#NWNX(ysr`Gf8>ncw5J(qQ-V-CI>NcrsNz3+78t+R3Q)dL&D#^QY7Qc zbDB0vl=7r^KOuW5+e3BxIAJf<_t2`nG`)x3 zLif^F`5roNFZJz#n0fnMXYQru9(oh5`}fdYnlw8v)K-^$-ZXY8fHJ#?b|-dk}UAMU%ChV08Y_Sj1Uc6d8pGu6I)J?@&nhvwOz zS!_Qz-OF&@9@=ETaLQgZ*)Mr|Ic&`ArM>M}RNL`e5J!gd&!zt5^c?%RU@xuML;d!v zmg908B82_kLG}@-LEey`ak8}bM`W>(txs`6>Hg}(E@O6;FA;HS`yx98OjWD~*cc^lz=$R7EaH36z2evI;42}EpTctwu) ziru&|?`ce{S-dM#ed`Hyh>p9J6kJP;2E1UBci|3Mv=&OHA|XpYd>ayqmQ&nwl6TLa3aKBMWo+g@=V*+wG1FcxPJgMWhP4`RbAtef9< zyytxKEP;^K!z6z*@oyocP#L$8h#ek>Y`sOpDLkHq8-S}#gl#0iRiRyiYavWZ#Fa$9 zf(*Krz`6l81oc~Nj6Fm{W)4I}#Ggo@8Fg?9O3~g3*2)Gx-z(_dvL2zXa~cxtYOx#2 zwX*6?_wKn7_Y}5La;EnlJAwb1z%8et-9=wR=SqaFS0R_M4iu|BE#4wG=CS7Xqe1(i zQQM2o1cd`@2Hojk1hzn32r6PRIu(&?3AWy3>NpUdC*izLZu^~t@qZeK%&ABm3z&-V z1o-jn82?ayWdgjUPsIivHVgCUby6x|jTFS4k5~gVVdQsVHz-U`fUw$I6de)8jYG0M zNoU~lG+Zvl#^O{czMYQnWdz@9Kwt`V%o`9M=Eg_bc_>6iWnK#DI(PQ?BjtnHTB2S- z*nBciKbwq!d^)TRen-f5qD~O=^s~rNaXC@Pz&`7P1cqMubn6D9juJN#b+SCzq4$ZH z8~C$S_#RGAq($+kNNQMo0F+Xv#8#8!rGzX_uOs24_AyB}@uQ5H*P}y zjO?q4eojFRxs0gO#PtL^vXJ(hEH0H_MOq{O57bD(oyRbdOZt#yDu{SeBSPw_G$SM# zuLCAv=<$QtNSpF5NbcuZukrwdB*`$pFJKQi=(E#kUeE-wkqff`G%xB;;E3S`1amrT zY+9zTC&=7!v>-Q{kx>ZzWC}m zV_0wOu!srJ9z}`z)YZv5M-B)M!BPGcf5yz6M~cFckaeVrjQe586;k& zL0T25HzGcR%#!!YVAnKDXVNf$k%2;K;8?_}(=3l6#xiC0k!1=BLitWg3@K)TTv0o} z%m9ZzY1!RQzsYs-dcrWFL)q;cL4G?%k}^LohzSaCw4e&Dg7n-snHQ= zbcJhbMl2q;sL6KFzLq}V*nj$WYz>8JsC(ENtdy5)bKW`&UB$#mbxiz9riUQNqhav9p=~<90i$o;PPub) zS6kW|q~$-tq^bkCwJr5hIZnR^RZY;?7^>}wYv49WSDGq$Os3dt)~DsOpRla$)O?7F z?X(`F`292+)4)DPo{_iN^T6n5Y5sF&CCeGw#vtJljK*(wetpNb_rm1$fI&}9tE9dc^-q&c^U?MY$8-)_|AvCvLC~_ z9M;413Td*dH|Ca6P+9&BtZhtedpgthw48|KTW)f7ZPrSM8Ib-PDG%exVwfYHrY?Xz zZZQtz2cHf557^{cNz^m7ER|5m*LkY1gTc8w?b> zgAdEZGC7&zOnxSvDJpPxrgw~T#+`S*Z12}UAWZjRUj*RsarxV-Qh>O}zx-u;Lhb9u z(KD7oI|U2J=Cfc;h%c1>es3jU#uR{|!Fi4;AcvcP`gY0;2O;LGD3ka=oLWXZ`I&=n zW!!P%B7`pmTnX^<6WgdNHzV%t&m1W~$c&W2Tr6|6mMpZ8ovlRq0xY%x5H%^1*r=ov zC@g6^AFUhXs<0RZ2AFC0(j%}wr{W>xgYfjkiXQc&N_F_Nx!_#ZMf_b`@X}KYeLg}xQd^B zsA2cTZgMv)gV*vEnyud2U?2bCXuq~Pda{3Y6l5QD5}#7-Te{lEi=DKGog1CB3j0c) z)RR3=JD9KJb*D*ba{_9uB9!7G7Rc7KU_+ahWBoS}WYDxAj8<5=kVGto46Uk@5Sxp& zUm0$OrWg%ANsK2*>P3>HCSJvA5%L4?!Mf8)SW$^?Iad(qAi>0Xf(J~~%!X7khNMQ|f^m>wNC7F%brsr6fT|;tj#!9q+o(!&* zP2lI=>BKK3$(R+v?vBOB#~}UQ5Qjl^21^-MFbGE3ffo`jw1?U`xVQ(V2?T zcnEH<#d-h(5U^p`k0&r(53+CcBdrpYynLt+J=s0~n~)0dLc_qjydZBn8P)}Y7^^ip z0P!eZ<~-!br!)9Km`>{Ou@J7Wb%jsCPy7?1Gbn^mp`8lUuO_}Xi8V6t4xy)oFCekA zV4Na8BDE|zJar8PyL(nneI681N5aHDq$Q)Tkp!$9&d&!9_Gs9)PN66%xtjQ#VHLj< z;ytiAkzv?k0(93Rj?({>MHW%Tu0Ota{aVGW;@TCCzZ(AvEQ9eli62I2=enAU&h-XS4 z6=a9ls6L93ZH0DS7p3=Ln1P>xdG1uT3~ItHAxsj#e|skp4DskE%5sl2#sc4cwJ z?CRpG*)_#oW<#@YcK70Lr}QlDG5hG^qh|Lm?zK|A4vPqhf3IdMylP%-Zgi3VG)VJQp-V=w zytV~<8h+>GSO2a(zkM%={FgtoDM*_II5eM!&L17}V&{IB`~Eo{-ao2JRZaj*2FU&Z zeAwNh0r4Zfzs(YGTag_k)t&B?V`qo&{D1ku>Rce#8y3E?%z6K~@7w3$9T!KGd5A9s zbgb9Cd;QAUNRvO0zB*jJPCiV%I+9Wq5YbwIy8KSnzEz7+ikE7@BNOlw|Ac>6KrWzTzIr0w7a)H}%GbOj=4&x%FMsV~>_9gwX5a7BKHsW=@6<8h zs)Y*gdmcnH93isgMY(~MxnnNMEmV|kg!fUDzVJO7kkq@(N5dHoUN<3M-af}u2)_dO zE1+Zke?a^{0Qoyo{#%Zae@8UTUwb`40s7emnEkD4ey8Sqt7cGuYjZWV*p(7Xug^7G zb92_`W)$TP!u!c0%*Ku?NP4B=_fG!j{LpUC@N$H21l$3T`NvN@f$)2PPXO|Fr2Mbz zl>eI9iy9jTH7$`o6g|Dbn)1q~16Ekf z$M$wM0^x~(sQ|kJqaQzo@ZSMfyK+OF<(84>ligBO)GTUT+$b$KHe3w)py3vFlFau` zDA9rd@N-Mm)p7`XnAC4ZA<_W~4a7=B4Y*<0>LR9gtPZat(2dZ-G9*BkXja734EV?3 zLt%{uoM@`~N0^X2hp|E22TQNg7MmJYvs?^3Caq#gamQ{rgN}r$EO`#v76u_M0wT%Y z_1z}Tt&t%d3qs(d?raAi4gYGFzxxZq?*Tpqc=dkb&yYC)+y{`q)|Xb==R^Hm>|DIo zPXDfJM>+ub8t-o0Em?HNpcymgE?IN?6vxI8PDb}QBVoQ^n_-ALFC zB+){}3~>WF3Y59dVG(UDVSi9n^Bg}d9-|k8A?Gix%D_UMW#oy${^9f}K99`JqrM0W z7ta%OSx>eJ#F&^r>R7#-_?m=r)%QuoJOPU_QLdenljW!7DNbvl#6sCY#<4bfBd$V3A$Mxhe_2k69x zndAmcf$t;yOE4NBff-v4gj)8y)C6RV_Yi#lAt$e59}!=YkWW}iA6!sS z<^zvi1k=<=n2=I~*DvfSOo6)=lNfYZS+e9gp zNm542NPb217OLMv<TbOTp!q^051n=r8y+5zfayh$WNYQ^cf`KdyxVrh9&hfjS# zL_aaZ{Z4#_@R`NouxYCSK~nRN9dm_SlTwDk$y4YU5rCK)c5%cNhukH^Rc&`wiLw+- z?c%P}I)Al7EcU)S7Hxw)No!xBV?xXh# zejj02DS+3H$>ws6{Dsih1PtS1?I8Q@V5|+tV94kV9a5HBZsB`i}EYXXCa zUmv9v!G_!rGlw2UdKzhSlu-n0^o1aq!UhYNv?$0zVbaSl?@k29`ir1MAp(#rfv8k~ zr2IW19F8hVr36)&MvB9wxGx$R70!bxM-j0MDXtzfkc)`GaS^nz;=0i8VzE>-*NtFv zz)5X%&*!-3d6+o;;BffCPKrmYa4h7LJDIcjyPPGGbWV?N|IS2^R8a-iJ~_!$Uq z0Ne-g#;4kY@OuK72avzBo^bdEvx=Ovo!H^9uai7mhwz- z@{u!^fT`0qj&-==a<-+%kZZW@G2~)mpAI7aYIkduf?F#4$@`lsOjL{9K$A!2+%Ty zK1;d`EiW2GwU~vgeR6~}xB|PPLXG5z5DZt*va~_VKIGw{q+b~g9AKe?EE&yS#mlrZ z+45wYzC-SE-K60V!qPJj8(edocDn>Et(l)foAAxW7pkxeEmEtfj={4DCv;}3m=@E3r60B>G)sxbCnbVnPe=g*iqr)fc> z>v0ictdl?(MU<7=0%Zp}A52%E=UE(@zr8-i+U$F%%Y#YHUj`3dREDY@v~ZrFpGcrJM&s!3XhR zC_Zuy*-wteJ4Z}I3^I+JH{~=#2v~--w&C3=nOh&| z38UO1W7ckAFNlK_?k&G?PU=Cl?v>hSma+?t1;*J_P}`tcz_EfA2B@b28DSVXyAakg zQwxeJSP870_Ec&3s2~mM{%XCbvbTtW9KA-j--+!f~SJ|()3!x(1* zoNYY^8c_yjbg-VLJVyAlL_8ypC2(sAgAI@ijY6EKr>X}+J=E?%dQ|k(`-xiE=_PtW zrV!OZcmgrMeMiVYgbfqJ*;p|IvvZ&7zS5*3PlzaDZz;rmj~VJZ7)S&)x{%%7zS19pz&I3Rhx4H&|+fps~Qmt$xG(GC7ngCx4^u66Re4SDJ9Hl0@x z{s^!K;Ps~iJf<8aV#@IV`8)4>C%@}{aL(?dZKqA0*ApGu-Qp#U;Opmap||@nv2LNm zHiHOUsjc*ZlKBbU3`1rD6OM*rfp!Kbk_N_tpB5CuwR+v2`5>#u0}O-)4Z}L&0F0xR zWwct21QXJHM;+>K!c_b2--;=sSU)-?rm4^(#`FGS7$q(lS}M9SlcltzGzG?wVn=sj zhN%a%0McWkA3K^M&BE3;g(bG*dx%m!2GhQiM2(K`nmq1@xoC@-LQ9rmMPO=4y;4*N zycIS|%4jjBc;o?41VJ9+H+Z@F97%jioIL20@uuT(Z=NJrV+A_QeX2$NQW;a>cuz!h z8p=W53=_V2(m_3lYLcV*-v}f<2cZgKHIDv3V480j21@BR**n#>T=houa2wN)JF~DmF zyAV!jF{LX&{zg6L%-g)iIlGUxogO-DJD4$RR{Ol$HY&}SH*eugd*N^kw-*UdsrJcP zKQzh(!%A(fPx%?mq;i}#+EDAvFd9rggwS(fz$ir$KC?!TLn>Da;Ls7YM`30(kEr0= zM`)%i>yN>yFO>O?_Vp3kZ;_TNv(qAhpqZ9~k(Dq9%GxuA7;1B($0XGw~JGkrmxm+ygIKm|KcMN8xhl!xi zN{Q5TGcL-~XhH#DLEI-G>q6G>4uUEAL4gV^Le0Wq6Rii|F@uRv$Eddym0O9bk0Fjt zOPc`wlJVcuErgy&XKomOfcHOjWbn1Hx z>QQ%V>v@FV1iT0E>N}vvloCJ}fczbakFc|IeM>&V*aeN_VAXxJGvQ+aUN;GBgR{Xl zSgHL$l(i}h@cjT4isX;HkedI(eu_r(_jC3QH^1ZTTa2wm`#AfO2kkVm4|_KDsFneq zs<8qOg6Xeg4*{dvNiHj)5|);#X;&291e5@R0I84KvdJm;CX}beE|H@AfNcRv+4yn$${l^Hg-eIyO_o z?!a8A-J(_#CyQxXx#$z>Z{E*yF5_w)cB?)XvClEkQ!ZxOZ<&QICB4IZEc8#%iE1=t zJ_q7cmNo$PNzjc2H?hvadVg>k=#IEe5A?+}=xcEE)TBYKK6e@VAP6&*B}dfzlR7E* zeTS53dP-yoqu@Mv|FLho1OkS_>h=of?+L8o#SgGNTPa;+lEux;zeoiDic$)y*C(2Gmaqk~xg`|Yi% zB-{W>u1`!_O0|3PcyK9O2_L{7XB-C6qVz1<8+5r9g2A+l2{HvvNZ_|tKXa;-8W;u& z{WlnedW)0c6Z$gU%rWwPF{_VHx8NdxYo?kY0kaEeAk!eeYHVsIgR?*bT?K z`#;*Z*AHGmct7BKfY%RB^2L<%0j&V}OS|p=ywYDD@B53x_JjImjWd@@2Qr{9ALKTD zxslkXa~)^u+Yy5i`0)<4d84JQ)8PlPdzUgGG!o5VpbmiS!$4vE9UeYOSs9v?MFV%h z6b9NN)+>X}C6GFkZ7z?NJ)mXT6<9+9k89!yE#NGawrS!4EwM!_z?PnXzsG7?<~eVB zm|#or5{`LP+7FR?g>u;xMdiKV&Zk=lrh(JczSsgr{nXR({#F`z5gbEBd4?c2!`N4h zXORF0+fNO^5XDhg;wQ8>nX+q}gYS$#rethPq7D!~5ikef;ro7sp8&i9kiS+}F5saz zSGrF*r~U4@v%ixbOwB@PtnH)+S<~cdK3>dhoyQ9MSZW_jAi$%n(*8rgrRI0^Mxu-_ zpt&>y6BJ=s6V{>DgCTTG_OKMRA;DY=&{D>u{W1_!&cgy$hisshW0q#Zw67SPPuNf~ zDZdhW@o`wigAWWSy-D(Og%q066ew5n6WHl$iF9{^9n5Uf6G|%QLoQhq!rXo##^-TL zX1jx@@<99gt{=i<022Tn{cJ8alv5BtbUt#&QWpxu4#kuyfcXIV zyZ2vCJ%xsK?jNnL-qI?!e{68=3%U8V4PQ!)=n0KW+Sn1D5^H8JT`;pdI!`P6p4*B| zR|W0UOJqF@p+qYa%0qM!NFEV zjX^U!Pk^WM8Dxy<7C(U_U|eryARt1Zz@YFc#Z|{3a187UrqPgxLn&?|AdYUH1CcEg zyEa%1@Oqq&fn~llmV|YfKqGvNeGIUpVTmCG0W1|+@q-Jf!oLq}_+yBu2QMv&t3n-y z5H=g&8wEDX!M{^s(a+TT;(QFO`=v3A)Ct^y-36foKL~}iO$4jq1gXZ~B)jp+!!u6* znG_)EYNcis7^-)XOId$bJ{Ep7Hl7x>a-w~{ps<_I5S5bZ>B zTPuL2u5>mA3YBGHJRS^=rjHp24a1~{#S;w81q$IZN7ukIwr4wosUE5Osnjh5L5Ag8 z0@C1vF&9n%%gmk%|G{}ou1)2y^*N_Jy(8`A8Hw;zKm))l&)W$99pH_ZcRt;zJnncf zcwzGyps99KLYw2Y;!rkOsA2+ z$vo}LM;P8>VDkj_v%vO&E-(hiwqu-@nS2iT9%_gsgLSF7Cgy6iG+8zg94>-jo(hOA zhHa;zl1Nw~gM z`(eLq52I$099rfz&1|$ccvne2hVyH2-+MLQQ|qSPjqv?|?EnwI^3tP-A7;Gm_K%Q@dpr5d z0-JEIx6bK6=l^DDr|@9D)Vs^uL~Wz}J6~*C0I$HF+9XyJov%W1a>)$b;(hAS z>(0|-8c#pm4UJL-{BRegaSn8nR(=tDvyoPQvVH8c9t)0D+G~dL4)4zMO-TP1(_#jb z=`{5s4Zmc^^1zylybPvFt$8JBq2yBGTPJ+~fyba!2tvo7Q?Z&JMZ>E4XA!zvdg{AI z_^ub^c5q6}I|N90SaVL(*=pt@Fu3z6ycEOQ70giXqv@^G|9~)i!&FU{?{jbgY{9a( zHIfbAKu(7_1#18Z-Bi%!*n=TP4_~QuO5h$!`^fe1c*(^J^GgXNuV6UU2N_%Vv+=3c zZvr7d3;BX1(k1)_@%3cjHyITXQzVEFgH`4Yr~Pb0yYSl2%LxArfWTc_d)@NekTMwY zCtdl?PhI<--gvrjesj}2ukt~X>>|%O_B)TZ4R;=8AM@>Fyu&g55s}o&Q*bBEC#oka+SamhJtFkK!D!N#J_j}Hn$xV>r{(tY>&ogso?#wyQdCqhCQ{X}nRut{N z*j4gk-qkJ`Z5J@2a10i~oCzD5nCm8^t-|7mk(^@F@hSLJIpaCYS&Znh1r0ry(~&Ov7%XJ5XVU&lE6#LhO!S~Ep^7b0m5PQ3dNhfA-c z2zyd7mj<}?J00N?fE)jzKdq5T{vv+WWJgDzHHwVR90jB1=0@0^!>p567VOqmS zoe9)bC;o$otHOz_U0O^V0+<4D+m&w-&P>PJDL@=cd#iTkUp>^-`Ly}6we@=a48=EY zRh>U=sk_G|1a%x@sV(=!ZoSyDx()ld*q6GR$xB;#4&6?D$j3K^ULeH5jlV;K5pMN% zV@yaQ^nFgfVZ`u_!IMZxF8%x+=7L0|A2@uE@^Ja{_Xs}(_#?pOPm+N( zPQb=gRZhi@9fDu*=PV&pf*I`>Y7YAoCboh}*L$>gF+3Up)4NHK?aZR%X&dA|l}`6z zg99T7!%9Wd21~wwhTb}Bj|mWk01ZY78Q4~WlminYwIqpUrbC%ou(_bd`IwJFKmszX zG2wtEhr*#+cPNm_6|kicsL6+G6ghf02WtmnmU<6?y2o|`B0dNUFm^;N9*Co%Nzq{& z^61jxIfUN={0MOQcVs5!Hh_x&;&8`{CzCJxSL^k^L_LD!keG?xOfUu8Mf!L_qn@lC z9|VW6jQIdv05+h)x#~O>tdXIug;YB{H&F5exGwug)O-5~H4jntHTC^S*$+?)v1!|{ zDEpGy-&6LTFsLWhZ|rZxJLo%1$z=9_DT9X_Y{hxiv{bGffsljX2QLZAeqJ&jfN74V zK`Oq92*+4Gu@o=}^8g0jLG>VP zBs-=>0pi&Gv1<38{Fl1Qhb>NrThkK`%7+LcEK&P~AL!sQ3XTLlrwFu+MgIwDTvWFpaj+#lVv%C}O+3YXe`~cc_ z#$UPl9-}jTe$RM1+t|U)y^PK?zT@T^?&U$h2kuxX?GTOfs0nEto`ngH@B$1)Y69mL zXhZUNH8xPV7;IZuRKOA%+`ANbl3+cF_KEqK8N^(|m6!UP966&d6MiwB!p<@l=w&oR z%nv4_xKQ{2(FG(qS3d#vTKrR09_vA8w}0D?@ZSJ00NnBzGsmNSi2E-9;;=qc<#63e z%ENb^Y7d-tV8oI-*kO`=yW1MOx59ACi-PO9ExlmZ)07q?u72Jn~VXUWd5p{G6`2pKTKk{t26VIhkJGhyVH!&st zipPWjn2vM=&1aoxUphpT$QA-6O8B?Q*5dmV-ur-$+rIqxGS{F)q?rKcIN5w9(Qb@f zGJoun=`~9i0-2z8r&yyAN0k%*V8j)dPc-e`Y^v4Zeol+{-SLIH@BfSTTScyPYSx;$ z+M{}|h_2p=e_MTI&(D*auMQ3|TN!I#m!pJfONW z<_baMSBUl^h-;%4qr~z>y*7+l$Iz}x5@YeVvpXBsV24=Ib#QJX-!l-t2yiLD#p~Nw z4Q(CnPcnaUWMI_bt)xut$)=3%k3m*XKsvw}C2)gvka}#;VC5gZRtoRMGrUkL!8nHZ z6Bn)OKMJ2+NXsqf9s#O-hx;D@&OzPr-7m^P?s>R)%$T;IT9`0&)-TtZhIWgA&3NS7 zNoQ(aqCVFnd@bNMfLjlGuJCF1;eH!H9H;BA;;rlRadeqI4Vt4h=g)y3jOgf&k}xKS z()j@82Wj8{O+Scr&CM`0l6E$q5;!~k98=RKK)VB%&giHESmQ)c98C`XT7IJ3G7&BU zv;(;1mh-8hb;SMgcsKKHD|*dQ>=d0k7ZR-|1X?lhAlf}ZXpR^%oDcmHT*OiTg({~8 z#O=0+Pb2&i;2nTVzvy2K?HKMoZ4~{E=N}bY;@t6+^b-?RwFEnL4ltZ4F`SKVzAHp0 z`f3juxrQ7KVJ!vnBrkFMEgsbiUg7*z*fpkR1bgd#td1cMcMzUK3Ph%6qh;(0;`WtD z)bCgMngo2~4qA&4UI|zOaOrwD4+Vw$(gB9VeGkz-M0LnP zWrevjK0sCnAcacWPm+4-mkOUIq;-6K;03?7J~((BR~^1bRj7C3pN+WOauoGo74B~a zxaH{9g9n^6kLRaE398WH*8PD~4;(xa)nKhz`5L!g=YiLF`s>`(eg>;0Z6elfB=EM8 zdeFp>6AWL8)w}@Yn_xGkIi0T*U9`ePIb|SR0w@Ex<@D)g9<4L(kH`0f<>atGj0;^U zH6abzoMLHz1t!bCLI0&)ff^v1DXq~-zX5T(bbAWn7XiBgF5Qf8EbS=nt)gGY|ASA+ z|8Ds;v;V@y&|+OsD_Yb#uzwZUzdz>nxrUJ5fxCOLLwm1oZ-bOn`}=f$KSVYu#7baf z4eXB$JW6;Qk|%Oh2llT5`}bFrZ91UnItlp3)g!GA;i~|v04`k@?13!++@Ey5E87*d z>zt?nXMyoE00rj>%%3J&BM9rY;^><^5V!EqXcP_z1mpLBsueC;!$E~l6Vf`K|MlWu z%l|GO$5nt2(H}TRqm#~H#Oao&+waV7kZaN7D|IA6E$%%g};)*+IKC)NW zcH;iU|2clesB1A|f^TFM+t-RtR!IVXH~vCAbB@#Wn=bxtJNvyGyB2ripN+WUPGn0n zskRFD*S3iNwBsYk_$@fVh2EdPj<=>cyucyn&I!G(7HM_8JJWpGTEo93x&Q2BF zDW=fZI;(}hfzNL|>H_84JYW?6MwLe+@Qh0g;p7fsk)MA6PL`kJ+bQSM=F7rU=Eqq) zyHy~WG%i^psXlFT@EP4Uu}(e<;R^wm09<@lB76hjcK~sm4xbgL#77cvWitUMfPnW1 z+;S)6H4_l44RVD6_6_nC1t)-l4c{vIY3&kngn0;80(t>ld?z409WW0djoyymXz_+V>qG?|v{FA`HeRKQD zmlT+q2lsA%ZaFSLqn-Txf^`%5aB7;H9hl{R3-G4yD4a7DyP}+}SCQ!N> zj$}lea|?l4P=QqZzY3pyNNbW4IWL2J6(Akp_D7o$z8|pkoonS=8waTCFX*$VxtzhA z2iZ#h0Y(n8_t*jS>ub$5VeL^*c`Vfmg}t#HPt@mw23nrKyjRW zP59%4oo)2+t|ai0Bi7<3^DcB+6kp+JJf`T?wLBqrIS%2OfLefCZ`L4u6QBVgj{OdQ z`S!Oi$El8-!2_qSH-et@-}Hd+Hx)TR!82x;*Ld0M{5h&UX`{xh@|3Oih#F(T2a~VZ z>xZuqXY+hHQ=8{!(QmOkE}nLwWdtAAli3o#!@zU23~8Fnj~Nxs<48R0ZQR^l0T=gaQZ zsUJe0rL01ugHULLwDn#rfnlW*%iB#(yt5I9+g@Fc@b!QV0Jr{SZ8fxQxF7d7m2bB{ z{zd&;IBh1@g&N3TlrNL4+hvOcxy?_!ur^}FuqhyF8@3FvJc>ap&+$r)#p{T4CDFtk zen47oIm9YsS{pzKz{TfUgl`tl9e*XQ?Vz++M}dH_?CXUo7C6@W$dLZCdOi#uTx-4z zE1|w}?ybz}Q0}Y7dSf8Mg2^O3VudX_8M=3E+Cd_)m(?3yvAS7R!>PBg74cT+eNtiD z!`sd<(jaQm$?$SG`6%JNQy}#cO47}wT$VQsvPv0IZplktde!nXZYHe|^?NRjqh2H8IXWTOoV@Huw7}1d8@`dg> zAeui2miZtfM=ys?MlCCXL-zpWtO$P;#)5PNwW+qkXDQNh@wpM<`v4CCTzsBJ_+`L; zr(ZZJKL~t=sAUJR{9nNGFr!r{&t73mYpr=B)qVt07h;LC5-53Hk&myPTCk4rWz8K} zy;tFv+A%TC>4@-9z$k!=-!z2h0|x&ZdbwRFsJ2Q`EnQNrjZigMYaXQ9M!%riB~aO! z#UM5=c11)*ecc%fY$5y(LZyu(zryb^r0w>ve@FNL;1IyYk9Wd+2Qcrs)6^$HKZRd{ zirZUK(XXhu9$vtJ)iNRQ$@JB32wO1r_4~h2JEkeLQ}b;Mwu`{mW_RPFcQc zTA}cJ@pvReOQmU5L6z<2^eT0Knz*lh$iVmnGAcR+89I`(ACR z6-o)wQnjo0HP=1?K2fMKpv4JUo#7h`%jOU^66OkZ2C#~QMRqoPqd7$iRfqz-uEwmW z7USp`_HSae5sOMV8pA3dw*bd_`9#yUA^bRC2f)q8iwN%qEKXD7l#|xi0ZSK7lQp&p zW+gwOA@Ccz#XAG#U`6cZOE%jq*bM29=XVm?M=oi{qY96B7wC66c=SeiFkl40#p91p z`?U$UpY*|L_)Ou^ToW2#Vn^sGt~GC_+BRVGq3oSr7ZS&U5>+vkegooO?WF%0!Y=_{ z2e|2P>58>gKu1UKBi8XG<2J0=D>^jN&tf|$X+(dxLsq|clC{TxKnO-{AQOVEUjqMiT6&)84`t`tdD+<3^K?kcgM;5Z)>f|ySgy#qDkRUZc?S5kr(zw!ZbgZ+3@MxMrZxQ(Bn=TJoIeH0uJ*hF<8C# zq_R|aV)Ld*H#(vT`6INBMaKZIS8wR_BG?-w6ZqAPErpr}LTA!jsrfRo=RjU&8F;iI zN721&w?zLq2;myQwE%bg*QPt_H=r*-96O(BUH(pyHDBW8*U7NcpJNer(X<(h(UW7X zjEk*@-s03loF0>FXKk(!O=A;z2XehIg!nGs$VAXg_CC#hPw|YWdE_0Q^)6qg-5P|9 zux0af+fId+od=37Net3_RX_Rl+laB5B>n8qumgSzx_#jkmY!qrJR=1@!aN?DpTpv2 zAl35lAT%H|yRkUM6F_&CLH(6*Q>rJUxb)&{1BeTr?izx=+k=#+Z3=RT?a=@F({#Qf z;DZF62`}QhzjzWnAr|OSUmCbr2pfOsSYdsZ(C-P_RpFyITd+990^$UILg9N2`^_0} zijYN?Y4LnT|64#;w;g)|;pYG^16;m;8{v-tk2&_vPg?(5wquDF{zG6QwgA^qZB3}0 zzCm31hR=u+VSvVXIqfMVqt#K>M&VV_BhjvpLs+!07vSpRwGrXF0dBu|>h^Up%wW#K zPE?|m{i+ZIxlc*Hx*x>n!c^_k)ZMipmI>#i4433&-8Hi zD2Uoyj)R(t6dv_R<9IyoI{_ZoIr8?W#6$IyE48d3bI6{YxPRh?_7l20xAGb z{&|0dM*|w2@%X--iViM6O#N!T4E|z#F8B?LAQ&R9r(YreqKUr+8_l6|$kHv;Iq_Pl zzM^Qzor|DS$oA;WxN9JoU(@OEa~qW(!z~yuwJ1k2o~BimDLg_|2|Qdr>x^eEoleJR zac6zwWc8qRWxPLf0tS;*s47?ZG$3ua++4nTp#?stG-JOf5J!A%KE6FQfn@%>Y(u1)&idw5dIh7-wAx^h5cZ6 z0k9%RjRU#k_x%^;GZyAO7t4w4RxT%Fk3qB@>fdL`b8j^>J7Q^%N6UG-)yC>b^Q}Od zH$BOdZX0PV9pfx&q?t6;@AeIpwXRs5*4RnWZ69co>eM$@4cS*f6hPE7 znv1YF+Wc9y^T)04{8IG?7Eq(1blBP(j+W#)b0vY?W(GRyOju)w0F@GhRSpu{!G6Ki z&I*t9NT*n(sojV0Gl1s-?zpn^K&*)Y!h;k(Jzr4excWcp>U`RK*|dAD3_Io9LhfBE zC;`iyCib}g5hG3Pzs2&hnSX@w*LCKPmD>0G3`qC+c*^xG2vYu*8R>yTG#LwcJ~XP( zv7rND?}{5xxw#z6xEwNLX~v9K$Avci^;p);R>Ndx@BKpVR= zmOq5`hZ+bD7QiBo{@(7c$VSx#o0hwC;50KVNlT!2Tn&Y=0;Cg8=WJ9eFDSXF6>BhUT zR@U%zz>RF#P@I}q&ddStM-he8n9~Sa4#C`{^2(7f}J}kA_&mnB4MfP$N7A#