From d640091c7683a32b27e08d760f4e6e2fc30b16bb Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Jan 2025 21:43:52 +0000 Subject: [PATCH 1/2] Sync and remove all non-FOSS parts --- .../workflows/alert-on-failed-automerge.yml | 49 - .github/workflows/automerge.yml | 35 - .github/workflows/benchmark.yml | 172 - .github/workflows/browserslist-update.yml | 40 - .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 | 419 - .github/workflows/ci-e2e.yml | 315 - .github/workflows/ci-frontend.yml | 163 - .github/workflows/ci-hobby.yml | 50 - .github/workflows/ci-hog.yml | 289 - .github/workflows/ci-plugin-server.yml | 293 - .github/workflows/codeql.yml | 105 - .github/workflows/codespaces.yml | 92 - .github/workflows/container-images-cd.yml | 250 - .github/workflows/container-images-ci.yml | 75 - .github/workflows/copy-clickhouse-udfs.yml | 20 - .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 | 89 - .github/workflows/pr-cleanup.yml | 70 - .github/workflows/pr-deploy.yml | 109 - .github/workflows/replay-capture.yml | 35 - .github/workflows/report-pr-age.yml | 30 - .github/workflows/rust-docker-build.yml | 181 - .github/workflows/rust.yml | 255 - .github/workflows/stale.yaml | 28 - .github/workflows/storybook-chromatic.yml | 286 - .github/workflows/storybook-deploy.yml | 63 - .../workflows/vector-docker-build-deploy.yml | 106 - LICENSE | 8 +- ee/LICENSE | 36 - ee/__init__.py | 0 ee/api/__init__.py | 0 ee/api/authentication.py | 225 - ee/api/billing.py | 365 - ee/api/conversation.py | 69 - ee/api/dashboard_collaborator.py | 116 - ee/api/ee_event_definition.py | 138 - 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 | 138 - ee/api/integration.py | 37 - ee/api/license.py | 87 - ee/api/rbac/access_control.py | 194 - ee/api/rbac/organization_resource_access.py | 47 - ee/api/rbac/role.py | 124 - ee/api/rbac/test/test_access_control.py | 598 -- ee/api/sentry_stats.py | 112 - ee/api/subscription.py | 126 - ee/api/test/__init__.py | 0 .../__snapshots__/test_instance_settings.ambr | 7 - .../test_organization_resource_access.ambr | 259 - 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 | 102 - ee/api/test/test_authentication.py | 772 -- ee/api/test/test_billing.py | 974 -- ee/api/test/test_capture.py | 309 - ee/api/test/test_conversation.py | 157 - 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 | 232 - 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 | 293 - .../test/test_organization_resource_access.py | 193 - ee/api/test/test_project.py | 180 - 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 | 666 -- 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 | 705 -- ee/benchmarks/helpers.py | 79 - ee/benchmarks/measure.sh | 164 - ee/billing/billing_manager.py | 426 - ee/billing/billing_types.py | 159 - ee/billing/quota_limiting.py | 453 - ee/billing/test/test_billing_manager.py | 127 - ee/billing/test/test_quota_limiting.py | 787 -- 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 | 213 - ee/clickhouse/materialized_columns/columns.py | 489 - .../materialized_columns/test/__init__.py | 0 .../materialized_columns/test/test_analyze.py | 57 - .../materialized_columns/test/test_columns.py | 419 - .../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 | 318 - ee/clickhouse/models/test/test_cohort.py | 1449 --- .../models/test/test_dead_letter_queue.py | 114 - ee/clickhouse/models/test/test_filters.py | 1469 --- 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 | 71 - ee/clickhouse/queries/experiments/__init__.py | 15 - .../experiments/funnel_experiment_result.py | 193 - .../secondary_experiment_result.py | 84 - .../test_funnel_experiment_result.py | 561 - .../test_trend_experiment_result.py | 240 - .../queries/experiments/test_utils.py | 160 - .../experiments/trend_experiment_result.py | 362 - ee/clickhouse/queries/experiments/utils.py | 68 - ee/clickhouse/queries/funnels/__init__.py | 0 .../queries/funnels/funnel_correlation.py | 971 -- .../funnels/funnel_correlation_persons.py | 211 - .../queries/funnels/test/__init__.py | 0 .../test/__snapshots__/test_funnel.ambr | 3540 ------- .../test_funnel_correlation.ambr | 4902 --------- .../test_funnel_correlations_persons.ambr | 785 -- .../queries/funnels/test/breakdown_cases.py | 420 - .../queries/funnels/test/test_funnel.py | 209 - .../funnels/test/test_funnel_correlation.py | 2088 ---- .../test/test_funnel_correlations_persons.py | 651 -- ee/clickhouse/queries/groups_join_query.py | 92 - ee/clickhouse/queries/related_actors_query.py | 126 - ee/clickhouse/queries/retention/__init__.py | 1 - 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_person_distinct_id_query.ambr | 13 - .../test/__snapshots__/test_person_query.ambr | 369 - .../queries/test/test_breakdown_props.py | 554 - .../queries/test/test_cohort_query.py | 3273 ------ .../queries/test/test_column_optimizer.py | 260 - .../queries/test/test_event_query.py | 748 -- .../queries/test/test_experiments.py | 235 - .../queries/test/test_groups_join_query.py | 48 - ee/clickhouse/queries/test/test_lifecycle.py | 298 - .../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_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/experiment_holdouts.py | 110 - .../views/experiment_saved_metrics.py | 85 - ee/clickhouse/views/experiments.py | 630 -- ee/clickhouse/views/groups.py | 210 - ee/clickhouse/views/insights.py | 53 - ee/clickhouse/views/person.py | 65 - ee/clickhouse/views/test/__init__.py | 0 ...ickhouse_experiment_secondary_results.ambr | 186 - .../test_clickhouse_experiments.ambr | 1531 --- .../__snapshots__/test_clickhouse_groups.ambr | 90 - .../test_clickhouse_retention.ambr | 493 - .../test_clickhouse_stickiness.ambr | 807 -- .../__snapshots__/test_clickhouse_trends.ambr | 1065 -- ee/clickhouse/views/test/funnel/__init__.py | 0 .../__snapshots__/test_clickhouse_funnel.ambr | 503 - .../test_clickhouse_funnel_person.ambr | 117 - .../test_clickhouse_funnel_unordered.ambr | 172 - .../test/funnel/test_clickhouse_funnel.py | 272 - .../test_clickhouse_funnel_correlation.py | 704 -- .../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 | 96 - ...clickhouse_experiment_secondary_results.py | 1317 --- .../views/test/test_clickhouse_experiments.py | 4926 --------- .../views/test/test_clickhouse_groups.py | 655 -- .../views/test/test_clickhouse_stickiness.py | 212 - .../views/test/test_clickhouse_trends.py | 1380 --- .../views/test/test_experiment_holdouts.py | 145 - .../test/test_experiment_saved_metrics.py | 240 - ee/conftest.py | 2 - ee/frontend/exports.ts | 12 - .../__mocks__/encoded-snapshot-data.ts | 6 - .../increment-with-child-duplication.json | 217 - .../__snapshots__/parsing.test.ts.snap | 339 - .../__snapshots__/transform.test.ts.snap | 9005 ----------------- ee/frontend/mobile-replay/index.ts | 82 - ee/frontend/mobile-replay/mobile.types.ts | 406 - ee/frontend/mobile-replay/parsing.test.ts | 20 - .../schema/mobile/rr-mobile-schema.json | 1349 --- .../schema/web/rr-web-schema.json | 968 -- 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/hogai/__init__.py | 0 ee/hogai/assistant.py | 314 - ee/hogai/django_checkpoint/__init__.py | 0 ee/hogai/django_checkpoint/checkpointer.py | 312 - .../test/test_checkpointer.py | 425 - ee/hogai/eval/__init__.py | 0 ee/hogai/eval/conftest.py | 134 - .../eval/tests/test_eval_funnel_generator.py | 46 - .../eval/tests/test_eval_funnel_planner.py | 224 - ee/hogai/eval/tests/test_eval_memory.py | 178 - .../tests/test_eval_retention_generator.py | 76 - .../eval/tests/test_eval_retention_planner.py | 118 - ee/hogai/eval/tests/test_eval_router.py | 80 - .../eval/tests/test_eval_trends_generator.py | 65 - .../eval/tests/test_eval_trends_planner.py | 196 - ee/hogai/funnels/__init__.py | 0 ee/hogai/funnels/nodes.py | 50 - ee/hogai/funnels/prompts.py | 155 - ee/hogai/funnels/test/__init__.py | 0 ee/hogai/funnels/test/test_nodes.py | 39 - ee/hogai/funnels/toolkit.py | 73 - ee/hogai/graph.py | 302 - ee/hogai/memory/__init__.py | 0 ee/hogai/memory/nodes.py | 377 - ee/hogai/memory/parsers.py | 24 - ee/hogai/memory/prompts.py | 164 - ee/hogai/memory/test/__init__.py | 0 ee/hogai/memory/test/test_nodes.py | 836 -- ee/hogai/memory/test/test_parsers.py | 22 - ee/hogai/retention/__init__.py | 0 ee/hogai/retention/nodes.py | 50 - ee/hogai/retention/prompts.py | 88 - ee/hogai/retention/test/test_nodes.py | 50 - ee/hogai/retention/toolkit.py | 57 - ee/hogai/router/__init__.py | 0 ee/hogai/router/nodes.py | 64 - ee/hogai/router/prompts.py | 55 - ee/hogai/router/test/__init__.py | 0 ee/hogai/router/test/test_nodes.py | 64 - ee/hogai/schema_generator/__init__.py | 0 ee/hogai/schema_generator/nodes.py | 255 - ee/hogai/schema_generator/parsers.py | 28 - ee/hogai/schema_generator/prompts.py | 38 - ee/hogai/schema_generator/test/__init__.py | 0 ee/hogai/schema_generator/test/test_nodes.py | 425 - ee/hogai/schema_generator/utils.py | 9 - ee/hogai/summarizer/__init__.py | 0 ee/hogai/summarizer/nodes.py | 114 - ee/hogai/summarizer/prompts.py | 31 - ee/hogai/summarizer/test/__init__.py | 0 ee/hogai/summarizer/test/test_nodes.py | 181 - ee/hogai/taxonomy_agent/__init__.py | 0 ee/hogai/taxonomy_agent/nodes.py | 308 - ee/hogai/taxonomy_agent/parsers.py | 70 - ee/hogai/taxonomy_agent/prompts.py | 140 - ee/hogai/taxonomy_agent/test/__init__.py | 0 ee/hogai/taxonomy_agent/test/test_nodes.py | 301 - ee/hogai/taxonomy_agent/test/test_parsers.py | 78 - ee/hogai/taxonomy_agent/test/test_toolkit.py | 273 - ee/hogai/taxonomy_agent/toolkit.py | 437 - ee/hogai/test/__init__.py | 0 ee/hogai/test/test_assistant.py | 692 -- ee/hogai/test/test_utils.py | 74 - ee/hogai/trends/__init__.py | 0 ee/hogai/trends/nodes.py | 50 - ee/hogai/trends/prompts.py | 193 - ee/hogai/trends/test/__init__.py | 0 ee/hogai/trends/test/test_nodes.py | 44 - ee/hogai/trends/test/test_prompt.py | 21 - ee/hogai/trends/toolkit.py | 74 - ee/hogai/utils/__init__.py | 0 ee/hogai/utils/asgi.py | 34 - ee/hogai/utils/helpers.py | 79 - ee/hogai/utils/markdown.py | 111 - ee/hogai/utils/nodes.py | 32 - ee/hogai/utils/state.py | 70 - ee/hogai/utils/test/test_assistant_node.py | 31 - ee/hogai/utils/types.py | 74 - ee/management/commands/materialize_columns.py | 111 - .../commands/update_materialized_column.py | 31 - 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/0017_accesscontrol_and_more.py | 75 - ...rsation_conversationcheckpoint_and_more.py | 147 - ...intblob_unique_checkpoint_blob_and_more.py | 38 - ee/migrations/0020_corememory.py | 45 - ee/migrations/__init__.py | 0 ee/migrations/max_migration.txt | 1 - ee/models/__init__.py | 34 - ee/models/assistant.py | 145 - ee/models/dashboard_privilege.py | 30 - ee/models/event_definition.py | 35 - ee/models/explicit_team_membership.py | 47 - ee/models/feature_flag_role_access.py | 21 - ee/models/hook.py | 47 - ee/models/license.py | 119 - ee/models/property_definition.py | 31 - ee/models/rbac/access_control.py | 53 - .../rbac/organization_resource_access.py | 41 - ee/models/rbac/role.py | 61 - ee/models/test/__init__.py | 0 ee/models/test/test_assistant.py | 95 - 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/utils.py | 179 - ee/session_recordings/persistence_tasks.py | 42 - ee/session_recordings/queries/__init__.py | 0 .../queries/test/__init__.py | 0 ...est_session_recording_list_from_query.ambr | 1649 --- .../test_session_recording_list_from_query.py | 347 - .../session_recording_extensions.py | 97 - .../session_recording_playlist.py | 261 - .../session_summary/summarize_session.py | 144 - .../test/test_summarize_session.py | 116 - ee/session_recordings/test/__init__.py | 0 .../test/test_session_recording_extensions.py | 134 - .../test/test_session_recording_playlist.py | 351 - ee/settings.py | 75 - ee/surveys/summaries/summarize_surveys.py | 137 - ee/tasks/__init__.py | 19 - ee/tasks/auto_rollback_feature_flag.py | 85 - ee/tasks/materialized_columns.py | 60 - 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 | 541 - 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 | 113 - 385 files changed, 2 insertions(+), 101042 deletions(-) delete mode 100644 .github/workflows/alert-on-failed-automerge.yml delete mode 100644 .github/workflows/automerge.yml delete mode 100644 .github/workflows/benchmark.yml delete mode 100644 .github/workflows/browserslist-update.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/copy-clickhouse-udfs.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/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.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/conversation.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/rbac/access_control.py delete mode 100644 ee/api/rbac/organization_resource_access.py delete mode 100644 ee/api/rbac/role.py delete mode 100644 ee/api/rbac/test/test_access_control.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_conversation.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_project.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_funnel_experiment_result.py delete mode 100644 ee/clickhouse/queries/experiments/test_trend_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/related_actors_query.py delete mode 100644 ee/clickhouse/queries/retention/__init__.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_person_distinct_id_query.ambr delete mode 100644 ee/clickhouse/queries/test/__snapshots__/test_person_query.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_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_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/experiment_holdouts.py delete mode 100644 ee/clickhouse/views/experiment_saved_metrics.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_stickiness.py delete mode 100644 ee/clickhouse/views/test/test_clickhouse_trends.py delete mode 100644 ee/clickhouse/views/test/test_experiment_holdouts.py delete mode 100644 ee/clickhouse/views/test/test_experiment_saved_metrics.py delete mode 100644 ee/conftest.py delete mode 100644 ee/frontend/exports.ts delete mode 100644 ee/frontend/mobile-replay/__mocks__/encoded-snapshot-data.ts delete mode 100644 ee/frontend/mobile-replay/__mocks__/increment-with-child-duplication.json delete mode 100644 ee/frontend/mobile-replay/__snapshots__/parsing.test.ts.snap 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/parsing.test.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/hogai/__init__.py delete mode 100644 ee/hogai/assistant.py delete mode 100644 ee/hogai/django_checkpoint/__init__.py delete mode 100644 ee/hogai/django_checkpoint/checkpointer.py delete mode 100644 ee/hogai/django_checkpoint/test/test_checkpointer.py delete mode 100644 ee/hogai/eval/__init__.py delete mode 100644 ee/hogai/eval/conftest.py delete mode 100644 ee/hogai/eval/tests/test_eval_funnel_generator.py delete mode 100644 ee/hogai/eval/tests/test_eval_funnel_planner.py delete mode 100644 ee/hogai/eval/tests/test_eval_memory.py delete mode 100644 ee/hogai/eval/tests/test_eval_retention_generator.py delete mode 100644 ee/hogai/eval/tests/test_eval_retention_planner.py delete mode 100644 ee/hogai/eval/tests/test_eval_router.py delete mode 100644 ee/hogai/eval/tests/test_eval_trends_generator.py delete mode 100644 ee/hogai/eval/tests/test_eval_trends_planner.py delete mode 100644 ee/hogai/funnels/__init__.py delete mode 100644 ee/hogai/funnels/nodes.py delete mode 100644 ee/hogai/funnels/prompts.py delete mode 100644 ee/hogai/funnels/test/__init__.py delete mode 100644 ee/hogai/funnels/test/test_nodes.py delete mode 100644 ee/hogai/funnels/toolkit.py delete mode 100644 ee/hogai/graph.py delete mode 100644 ee/hogai/memory/__init__.py delete mode 100644 ee/hogai/memory/nodes.py delete mode 100644 ee/hogai/memory/parsers.py delete mode 100644 ee/hogai/memory/prompts.py delete mode 100644 ee/hogai/memory/test/__init__.py delete mode 100644 ee/hogai/memory/test/test_nodes.py delete mode 100644 ee/hogai/memory/test/test_parsers.py delete mode 100644 ee/hogai/retention/__init__.py delete mode 100644 ee/hogai/retention/nodes.py delete mode 100644 ee/hogai/retention/prompts.py delete mode 100644 ee/hogai/retention/test/test_nodes.py delete mode 100644 ee/hogai/retention/toolkit.py delete mode 100644 ee/hogai/router/__init__.py delete mode 100644 ee/hogai/router/nodes.py delete mode 100644 ee/hogai/router/prompts.py delete mode 100644 ee/hogai/router/test/__init__.py delete mode 100644 ee/hogai/router/test/test_nodes.py delete mode 100644 ee/hogai/schema_generator/__init__.py delete mode 100644 ee/hogai/schema_generator/nodes.py delete mode 100644 ee/hogai/schema_generator/parsers.py delete mode 100644 ee/hogai/schema_generator/prompts.py delete mode 100644 ee/hogai/schema_generator/test/__init__.py delete mode 100644 ee/hogai/schema_generator/test/test_nodes.py delete mode 100644 ee/hogai/schema_generator/utils.py delete mode 100644 ee/hogai/summarizer/__init__.py delete mode 100644 ee/hogai/summarizer/nodes.py delete mode 100644 ee/hogai/summarizer/prompts.py delete mode 100644 ee/hogai/summarizer/test/__init__.py delete mode 100644 ee/hogai/summarizer/test/test_nodes.py delete mode 100644 ee/hogai/taxonomy_agent/__init__.py delete mode 100644 ee/hogai/taxonomy_agent/nodes.py delete mode 100644 ee/hogai/taxonomy_agent/parsers.py delete mode 100644 ee/hogai/taxonomy_agent/prompts.py delete mode 100644 ee/hogai/taxonomy_agent/test/__init__.py delete mode 100644 ee/hogai/taxonomy_agent/test/test_nodes.py delete mode 100644 ee/hogai/taxonomy_agent/test/test_parsers.py delete mode 100644 ee/hogai/taxonomy_agent/test/test_toolkit.py delete mode 100644 ee/hogai/taxonomy_agent/toolkit.py delete mode 100644 ee/hogai/test/__init__.py delete mode 100644 ee/hogai/test/test_assistant.py delete mode 100644 ee/hogai/test/test_utils.py delete mode 100644 ee/hogai/trends/__init__.py delete mode 100644 ee/hogai/trends/nodes.py delete mode 100644 ee/hogai/trends/prompts.py delete mode 100644 ee/hogai/trends/test/__init__.py delete mode 100644 ee/hogai/trends/test/test_nodes.py delete mode 100644 ee/hogai/trends/test/test_prompt.py delete mode 100644 ee/hogai/trends/toolkit.py delete mode 100644 ee/hogai/utils/__init__.py delete mode 100644 ee/hogai/utils/asgi.py delete mode 100644 ee/hogai/utils/helpers.py delete mode 100644 ee/hogai/utils/markdown.py delete mode 100644 ee/hogai/utils/nodes.py delete mode 100644 ee/hogai/utils/state.py delete mode 100644 ee/hogai/utils/test/test_assistant_node.py delete mode 100644 ee/hogai/utils/types.py delete mode 100644 ee/management/commands/materialize_columns.py delete mode 100644 ee/management/commands/update_materialized_column.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/0017_accesscontrol_and_more.py delete mode 100644 ee/migrations/0018_conversation_conversationcheckpoint_and_more.py delete mode 100644 ee/migrations/0019_remove_conversationcheckpointblob_unique_checkpoint_blob_and_more.py delete mode 100644 ee/migrations/0020_corememory.py delete mode 100644 ee/migrations/__init__.py delete mode 100644 ee/migrations/max_migration.txt delete mode 100644 ee/models/__init__.py delete mode 100644 ee/models/assistant.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/property_definition.py delete mode 100644 ee/models/rbac/access_control.py delete mode 100644 ee/models/rbac/organization_resource_access.py delete mode 100644 ee/models/rbac/role.py delete mode 100644 ee/models/test/__init__.py delete mode 100644 ee/models/test/test_assistant.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/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_query.ambr delete mode 100644 ee/session_recordings/queries/test/test_session_recording_list_from_query.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/summarize_session.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/surveys/summaries/summarize_surveys.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/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/alert-on-failed-automerge.yml b/.github/workflows/alert-on-failed-automerge.yml deleted file mode 100644 index 31582b50d1..0000000000 --- a/.github/workflows/alert-on-failed-automerge.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Check Suite Failure Notification - -on: - # whenever a workflow suite completes trigger this - check_suite: - types: - - completed - -jobs: - notify_on_failure: - if: ${{ github.event.check_suite.conclusion == 'failure' }} - runs-on: ubuntu-24.04 - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Check if the PR has "automerge" label - id: automerge_check - run: | - pr_number=$(jq -r '.check_suite.pull_requests[0].number' <<< "${{ toJson(github.event) }}") - if [ -z "$pr_number" ]; then - echo "No PR associated with this check suite." - echo "::set-output name=result::false" - exit 0 - fi - - labels=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ - https://api.github.com/repos/${{ github.repository }}/issues/$pr_number/labels) - - echo "Labels: $labels" - if echo "$labels" | grep -q "automerge"; then - echo "::set-output name=result::true" - else - echo "::set-output name=result::false" - fi - - - name: Send Slack notification if the PR has "automerge" label - if: ${{ steps.automerge_check.outputs.result == 'true' }} - uses: rtCamp/action-slack-notify@v2 - env: - SLACK_CHANNEL: subscriptions-slack-testing - SLACK_COLOR: ${{ job.status }} # or a specific color like 'good' or '#ff00ff' - SLACK_ICON: https://github.com/posthog.png?size=48 - SLACK_USERNAME: Max Hedgehog - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} - with: - message: "PR #${{ github.event.check_suite.pull_requests[0].number }} failed a check suite and is labeled 'automerge'. Please investigate!" - status: failure diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml deleted file mode 100644 index 4b2a41b913..0000000000 --- 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-24.04 - 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 2ff87672fd..0000000000 --- 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@v4 - 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/browserslist-update.yml b/.github/workflows/browserslist-update.yml deleted file mode 100644 index ff27f8d674..0000000000 --- a/.github/workflows/browserslist-update.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Update Browserslist database - -on: - schedule: - - cron: '0 12 * * MON' - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -jobs: - update-browserslist-database: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Configure git - run: | - git config --global user.email "action@github.com" - git config --global user.name "Browserslist Update Action" - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 18.12.1 - - - name: Update Browserslist database and create PR if applies - uses: c2corg/browserslist-update-action@v2 - with: - github_token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} # This token has permission to open PRs - commit_message: 'build: update Browserslist db' - title: 'build: update Browserslist db' - labels: 'dependencies, automerge' diff --git a/.github/workflows/build-and-deploy-prod.yml b/.github/workflows/build-and-deploy-prod.yml deleted file mode 100644 index 9d5c7004ab..0000000000 --- a/.github/workflows/build-and-deploy-prod.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Notify Sentry - -# -# 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@v4 - - 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 8feffe960e..0000000000 --- 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@v4 - 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@v4 - 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 eb1c36329c..0000000000 --- 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-24.04 - 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:24.8.7.41' - 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:24.8.7.41' }} - 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 22882cafb7..0000000000 --- a/.github/workflows/ci-backend.yml +++ /dev/null @@ -1,419 +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-24.04 - 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-24.04 - - 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-24.04 - - 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 - # increase for tmate testing - 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-24.04 - - strategy: - fail-fast: false - matrix: - python-version: ['3.11.9'] - clickhouse-server-image: ['clickhouse/clickhouse-server:24.8.7.41'] - 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:24.8.7.41' - python-version: '3.11.9' - concurrency: 3 - group: 1 - - segment: 'Temporal' - person-on-events: false - clickhouse-server-image: 'clickhouse/clickhouse-server:24.8.7.41' - python-version: '3.11.9' - concurrency: 3 - group: 2 - - segment: 'Temporal' - person-on-events: false - clickhouse-server-image: 'clickhouse/clickhouse-server:24.8.7.41' - python-version: '3.11.9' - concurrency: 3 - group: 3 - - 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@v4 - 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:24.8.7.41'] - if: needs.changes.outputs.backend == 'true' - runs-on: ubuntu-24.04 - 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-24.04 - 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 8717352036..0000000000 --- a/.github/workflows/ci-e2e.yml +++ /dev/null @@ -1,315 +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-24.04 - 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-24.04 - 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-24.04 - 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-24.04 - 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 - ENCRYPTION_SALT_KEYS=00beef0000beef0000beef0000beef00 - 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 - spec: ${{ matrix.chunk }} - install: false - # We were seeing suprising crashes in headless mode - # See https://github.com/cypress-io/cypress/issues/28893#issuecomment-1956480875 - headed: true - 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@v4 - with: - name: screenshots - path: cypress/screenshots - if: ${{ failure() }} - - - name: Archive test downloads - uses: actions/upload-artifact@v4 - with: - name: downloads - path: cypress/downloads - if: ${{ failure() }} - - - name: Archive test videos - uses: actions/upload-artifact@v4 - with: - name: videos - path: cypress/videos - if: ${{ failure() }} - - - name: Archive accessibility violations - if: needs.changes.outputs.shouldTriggerCypress == 'true' - uses: actions/upload-artifact@v4 - 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@v4 - with: - name: logs-${{ strategy.job-index }} - path: /tmp/logs - if: ${{ failure() }} - - calculate-running-time: - name: Calculate running time - runs-on: ubuntu-24.04 - 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 f59c7e8eef..0000000000 --- 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-24.04 - 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-24.04 - 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-24.04 - 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 73d29cbdad..0000000000 --- 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-24.04 - # 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 ea51f70721..0000000000 --- a/.github/workflows/ci-hog.yml +++ /dev/null @@ -1,289 +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-24.04 - 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-24.04 - if: needs.changes.outputs.hog == 'true' - - 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' - 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: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 18 - - - name: Check if ANTLR definitions are up to date - 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 August 2024 (version 4.13.2), - # as apt-get is quite out of date. The same version must be set in hogql_parser/pyproject.toml - ANTLR_VERSION: '4.13.2' - - - name: Check if STL bytecode is up to date - run: | - python -m hogvm.stl.compile - git diff --exit-code - - - name: Run HogVM Python tests - run: | - pytest hogvm - - - name: Run HogVM TypeScript tests - run: | - cd hogvm/typescript - pnpm install --frozen-lockfile - pnpm run test - - - name: Run Hog tests - run: | - cd hogvm/typescript - pnpm run build - cd ../ - ./test.sh && git diff --exit-code - - check-package-version: - name: Check HogVM TypeScript package version and detect an update - needs: hog-tests - if: needs.hog-tests.result == 'success' && needs.changes.outputs.hog == 'true' - runs-on: ubuntu-24.04 - outputs: - committed-version: ${{ steps.check-package-version.outputs.committed-version }} - published-version: ${{ steps.check-package-version.outputs.published-version }} - is-new-version: ${{ steps.check-package-version.outputs.is-new-version }} - steps: - - name: Checkout the repository - uses: actions/checkout@v2 - - name: Check package version and detect an update - id: check-package-version - uses: PostHog/check-package-version@v2 - with: - path: hogvm/typescript - - release-hogvm: - name: Release new HogVM TypeScript version - runs-on: ubuntu-24.04 - needs: check-package-version - if: needs.changes.outputs.hog == 'true' && needs.check-package-version.outputs.is-new-version == 'true' - env: - COMMITTED_VERSION: ${{ needs.check-package-version.outputs.committed-version }} - PUBLISHED_VERSION: ${{ needs.check-package-version.outputs.published-version }} - steps: - - name: Checkout the repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - 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 }} - - 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: Install pnpm - uses: pnpm/action-setup@v4 - - name: Set up Node 18 - uses: actions/setup-node@v4 - with: - node-version: 18 - registry-url: https://registry.npmjs.org - - name: Install package.json dependencies - run: cd hogvm/typescript && pnpm install - - name: Publish the package in the npm registry - run: cd hogvm/typescript && npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Sleep 60 seconds to allow npm to update the package - run: sleep 60 - - update-versions: - name: Update versions in package.json - runs-on: ubuntu-24.04 - needs: release-hogvm - if: always() # This ensures the job runs regardless of the result of release-hogvm - steps: - - name: Checkout the repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.ref }} - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - name: Set up Node 18 - uses: actions/setup-node@v4 - with: - node-version: 18 - registry-url: https://registry.npmjs.org - - - name: Check for version mismatches - id: check-mismatch - run: | - # Extract committed version - HOGVM_VERSION=$(jq -r '.version' hogvm/typescript/package.json) - - # Compare dependencies in package.json - MAIN_VERSION=$(jq -r '.dependencies."@posthog/hogvm"' package.json | tr -d '^') - PLUGIN_VERSION=$(jq -r '.dependencies."@posthog/hogvm"' plugin-server/package.json | tr -d '^') - - echo "HOGVM_VERSION=$HOGVM_VERSION" - echo "MAIN_VERSION=$MAIN_VERSION" - echo "PLUGIN_VERSION=$PLUGIN_VERSION" - - # Set output if mismatches exist - if [[ "$HOGVM_VERSION" != "$MAIN_VERSION" || "$HOGVM_VERSION" != "$PLUGIN_VERSION" ]]; then - echo "mismatch=true" >> "$GITHUB_ENV" - else - echo "mismatch=false" >> "$GITHUB_ENV" - fi - - - name: Update package.json versions - if: env.mismatch == 'true' - run: | - VERSION=$(jq ".version" hogvm/typescript/package.json -r) - - retry_pnpm_install() { - local retries=0 - local max_retries=20 # 10 minutes total - local delay=30 - - while [[ $retries -lt $max_retries ]]; do - echo "Attempting pnpm install (retry $((retries+1))/$max_retries)..." - pnpm install --no-frozen-lockfile && break - echo "Install failed. Retrying in $delay seconds..." - sleep $delay - retries=$((retries + 1)) - done - - if [[ $retries -eq $max_retries ]]; then - echo "pnpm install failed after $max_retries attempts." - exit 1 - fi - } - - # Update main package.json - mv package.json package.old.json - jq --indent 4 '.dependencies."@posthog/hogvm" = "^'$VERSION'"' package.old.json > package.json - rm package.old.json - retry_pnpm_install - - # Update plugin-server/package.json - cd plugin-server - mv package.json package.old.json - jq --indent 4 '.dependencies."@posthog/hogvm" = "^'$VERSION'"' package.old.json > package.json - rm package.old.json - retry_pnpm_install - - - name: Commit updated package.json files - if: env.mismatch == 'true' - uses: EndBug/add-and-commit@v9 - with: - add: '["package.json", "pnpm-lock.yaml", "plugin-server/package.json", "plugin-server/pnpm-lock.yaml", "hogvm/typescript/package.json"]' - message: 'Update @posthog/hogvm version in package.json' - default_author: github_actions - github_token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} diff --git a/.github/workflows/ci-plugin-server.yml b/.github/workflows/ci-plugin-server.yml deleted file mode 100644 index 1c8ba97095..0000000000 --- a/.github/workflows/ci-plugin-server.yml +++ /dev/null @@ -1,293 +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-24.04 - 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-24.04 - 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-24.04 - - 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 - # NOTE: We need this check on every step so that it still runs if skipped as we need it to suceed for the CI - 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 rust - if: needs.changes.outputs.plugin-server == 'true' - uses: dtolnay/rust-toolchain@1.82 - - - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - rust/target - key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }} - - - name: Install sqlx-cli - if: needs.changes.outputs.plugin-server == 'true' - working-directory: rust - run: cargo install sqlx-cli@0.7.3 --locked --no-default-features --features native-tls,postgres - - - 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 - runs-on: ubuntu-24.04 - - 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 - ENCRYPTION_SALT_KEYS: '00beef0000beef0000beef0000beef00' - - 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 install --frozen-lockfile - pnpm build - - - 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: - DEBUG: 'true' - SECRET_KEY: 'abcdef' # unsafe - for testing only - run: | - ./manage.py migrate - ./manage.py migrate_clickhouse - - - name: Run functional tests - if: needs.changes.outputs.plugin-server == 'true' - run: | - cd plugin-server - ./bin/ci_functional_tests.sh - - - name: Upload coverage report - uses: actions/upload-artifact@v4 - if: needs.changes.outputs.plugin-server == 'true' - 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 56f1ed3bf0..0000000000 --- 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-24.04' - 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 06f796951d..0000000000 --- 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-24.04 - - # 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 c5cacd1dec..0000000000 --- a/.github/workflows/container-images-cd.yml +++ /dev/null @@ -1,250 +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-24.04 - 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@v4 - with: - fetch-depth: 2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - 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@v3 - with: - username: ${{ secrets.DOCKERHUB_USER }} - 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 }}, - "timestamp": "${{ github.event.head_commit.timestamp }}" - } - - - 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) }}, - "timestamp": "${{ github.event.head_commit.timestamp }}" - } - - - 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$|^requirements.txt$' && echo true) || echo false)" >> $GITHUB_OUTPUT - - - name: Trigger Batch Exports Sync 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 }}, - "timestamp": "${{ github.event.head_commit.timestamp }}" - } - - - 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-batch-exports", - "commit": ${{ toJson(github.event.head_commit) }}, - "repository": ${{ toJson(github.repository) }}, - "labels": ${{ steps.labels.outputs.labels }}, - "timestamp": "${{ github.event.head_commit.timestamp }}" - } - - - 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$|^requirements.txt$' && 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 }}, - "timestamp": "${{ github.event.head_commit.timestamp }}" - } - - - 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$|^requirements.txt$' && 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 }}, - "timestamp": "${{ github.event.head_commit.timestamp }}" - } - - - name: Trigger Data Warehouse V2 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-v2", - "commit": ${{ toJson(github.event.head_commit) }}, - "repository": ${{ toJson(github.repository) }}, - "labels": ${{ steps.labels.outputs.labels }}, - "timestamp": "${{ github.event.head_commit.timestamp }}" - } diff --git a/.github/workflows/container-images-ci.yml b/.github/workflows/container-images-ci.yml deleted file mode 100644 index 7b434a7cb5..0000000000 --- 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-24.04 - 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-24.04 - 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/copy-clickhouse-udfs.yml b/.github/workflows/copy-clickhouse-udfs.yml deleted file mode 100644 index b55d66bc30..0000000000 --- a/.github/workflows/copy-clickhouse-udfs.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Trigger UDFs Workflow - -on: - push: - branches: - - master - paths: - - 'posthog/user_scripts/**' - -jobs: - trigger_udfs_workflow: - runs-on: ubuntu-24.04 - steps: - - name: Trigger UDFs Workflow - uses: benc-uk/workflow-dispatch@v1 - with: - workflow: .github/workflows/clickhouse-udfs.yml - repo: posthog/posthog-cloud-infra - token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - ref: refs/heads/main diff --git a/.github/workflows/foss-sync.yml b/.github/workflows/foss-sync.yml deleted file mode 100644 index b8edfe6321..0000000000 --- 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-24.04 - 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 4fc7344a97..0000000000 --- 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-24.04 - - 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 7db972a9bc..0000000000 --- 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 e69de29bb2..0000000000 diff --git a/ee/clickhouse/materialized_columns/analyze.py b/ee/clickhouse/materialized_columns/analyze.py deleted file mode 100644 index fd1d1d09cb..0000000000 --- a/ee/clickhouse/materialized_columns/analyze.py +++ /dev/null @@ -1,213 +0,0 @@ -from collections import defaultdict -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, - MaterializedColumn, - 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 -from posthog.settings import CLICKHOUSE_CLUSTER - -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({cluster}, 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 "", - cluster=CLICKHOUSE_CLUSTER, - ), - ) - - return [("events", table_column, property_name) for (table_column, property_name) in raw_queries] - - -def materialize_properties_task( - properties_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, - is_nullable: bool = False, -) -> None: - """ - Creates materialized columns for event and person properties based off of slow queries - """ - - if properties_to_materialize is None: - properties_to_materialize = _analyze(time_to_analyze_hours, min_query_time, team_id_to_analyze) - - properties_by_table: dict[TableWithProperties, list[tuple[TableColumn, PropertyName]]] = defaultdict(list) - for table, table_column, property_name in properties_to_materialize: - properties_by_table[table].append((table_column, property_name)) - - result: list[Suggestion] = [] - for table, properties in properties_by_table.items(): - existing_materialized_properties = get_materialized_columns(table).keys() - for table_column, property_name in properties: - if (property_name, table_column) not in existing_materialized_properties: - result.append((table, table_column, property_name)) - - if len(result) > 0: - logger.info(f"Calculated columns that could be materialized. count={len(result)}") - else: - logger.info("Found no columns to materialize.") - - materialized_columns: dict[TableWithProperties, list[MaterializedColumn]] = defaultdict(list) - for table, table_column, property_name in result[:maximum]: - logger.info(f"Materializing column. table={table}, property_name={property_name}") - if not dry_run: - materialized_columns[table].append( - materialize(table, property_name, table_column=table_column, is_nullable=is_nullable) - ) - - if backfill_period_days > 0 and not dry_run: - logger.info(f"Starting backfill for new materialized columns. period_days={backfill_period_days}") - for table, columns in materialized_columns.items(): - backfill_materialized_columns(table, columns, 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 ab051fee55..0000000000 --- a/ee/clickhouse/materialized_columns/columns.py +++ /dev/null @@ -1,489 +0,0 @@ -from __future__ import annotations - -import logging -import re -from collections.abc import Callable, Iterable, Iterator -from copy import copy -from dataclasses import dataclass, replace -from datetime import timedelta -from typing import Any, Literal, TypeVar, cast - -from clickhouse_driver import Client -from django.utils.timezone import now - -from posthog.cache_utils import cache_for -from posthog.clickhouse.client.connection import default_client -from posthog.clickhouse.cluster import ClickhouseCluster, ConnectionInfo, FuturesMap, HostInfo -from posthog.clickhouse.kafka_engine import trim_quotes_expr -from posthog.clickhouse.materialized_columns import ColumnName, TablesWithMaterializedColumns -from posthog.client import sync_execute -from posthog.models.event.sql import EVENTS_DATA_TABLE -from posthog.models.person.sql import PERSONS_TABLE -from posthog.models.property import PropertyName, TableColumn, TableWithProperties -from posthog.models.utils import generate_random_short_suffix -from posthog.settings import CLICKHOUSE_DATABASE, CLICKHOUSE_PER_TEAM_SETTINGS, TEST - - -logger = logging.getLogger(__name__) - -T = TypeVar("T") - -DEFAULT_TABLE_COLUMN: Literal["properties"] = "properties" - -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", -} - - -@dataclass -class MaterializedColumn: - name: ColumnName - details: MaterializedColumnDetails - is_nullable: bool - - @property - def type(self) -> str: - if self.is_nullable: - return "Nullable(String)" - else: - return "String" - - def get_expression_and_parameters(self) -> tuple[str, dict[str, Any]]: - if self.is_nullable: - return ( - f"JSONExtract({self.details.table_column}, %(property_name)s, %(property_type)s)", - {"property_name": self.details.property_name, "property_type": self.type}, - ) - else: - return ( - trim_quotes_expr(f"JSONExtractRaw({self.details.table_column}, %(property)s)"), - {"property": self.details.property_name}, - ) - - @staticmethod - def get_all(table: TablesWithMaterializedColumns) -> Iterator[MaterializedColumn]: - rows = sync_execute( - """ - SELECT name, comment, type like 'Nullable(%%)' as is_nullable - 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}, - ) - - for name, comment, is_nullable in rows: - yield MaterializedColumn(name, MaterializedColumnDetails.from_column_comment(comment), is_nullable) - - @staticmethod - def get(table: TablesWithMaterializedColumns, column_name: ColumnName) -> MaterializedColumn: - # TODO: It would be more efficient to push the filter here down into the `get_all` query, but that would require - # more a sophisticated method of constructing queries than we have right now, and this data set should be small - # enough that this doesn't really matter (at least as of writing.) - columns = [column for column in MaterializedColumn.get_all(table) if column.name == column_name] - match columns: - case []: - raise ValueError("column does not exist") - case [column]: - return column - case _: - # this should never happen (column names are unique within a table) and suggests an error in the query - raise ValueError(f"got {len(columns)} columns, expected 0 or 1") - - -@dataclass(frozen=True) -class MaterializedColumnDetails: - table_column: TableColumn - property_name: PropertyName - is_disabled: bool - - COMMENT_PREFIX = "column_materializer" - COMMENT_SEPARATOR = "::" - COMMENT_DISABLED_MARKER = "disabled" - - def as_column_comment(self) -> str: - bits = [self.COMMENT_PREFIX, self.table_column, self.property_name] - if self.is_disabled: - bits.append(self.COMMENT_DISABLED_MARKER) - return self.COMMENT_SEPARATOR.join(bits) - - @classmethod - def from_column_comment(cls, comment: str) -> MaterializedColumnDetails: - match comment.split(cls.COMMENT_SEPARATOR, 3): - # Old style comments have the format "column_materializer::property", dealing with the default table column. - case [cls.COMMENT_PREFIX, property_name]: - return MaterializedColumnDetails(DEFAULT_TABLE_COLUMN, property_name, is_disabled=False) - # Otherwise, it's "column_materializer::table_column::property" for columns that are active. - case [cls.COMMENT_PREFIX, table_column, property_name]: - return MaterializedColumnDetails(cast(TableColumn, table_column), property_name, is_disabled=False) - # Columns that are marked as disabled have an extra trailer indicating their status. - case [cls.COMMENT_PREFIX, table_column, property_name, cls.COMMENT_DISABLED_MARKER]: - return MaterializedColumnDetails(cast(TableColumn, table_column), property_name, is_disabled=True) - case _: - raise ValueError(f"unexpected comment format: {comment!r}") - - -def get_materialized_columns( - table: TablesWithMaterializedColumns, -) -> dict[tuple[PropertyName, TableColumn], MaterializedColumn]: - return { - (column.details.property_name, column.details.table_column): column - for column in MaterializedColumn.get_all(table) - } - - -@cache_for(timedelta(minutes=15)) -def get_enabled_materialized_columns( - table: TablesWithMaterializedColumns, -) -> dict[tuple[PropertyName, TableColumn], MaterializedColumn]: - return {k: column for k, column in get_materialized_columns(table).items() if not column.details.is_disabled} - - -def get_cluster() -> ClickhouseCluster: - extra_hosts = [] - for host_config in map(copy, CLICKHOUSE_PER_TEAM_SETTINGS.values()): - extra_hosts.append(ConnectionInfo(host_config.pop("host"))) - assert len(host_config) == 0, f"unexpected values: {host_config!r}" - return ClickhouseCluster(default_client(), extra_hosts=extra_hosts) - - -@dataclass -class TableInfo: - data_table: str - - @property - def read_table(self) -> str: - return self.data_table - - def map_data_nodes(self, cluster: ClickhouseCluster, fn: Callable[[Client], T]) -> FuturesMap[HostInfo, T]: - return cluster.map_all_hosts(fn) - - -@dataclass -class ShardedTableInfo(TableInfo): - dist_table: str - - @property - def read_table(self) -> str: - return self.dist_table - - def map_data_nodes(self, cluster: ClickhouseCluster, fn: Callable[[Client], T]) -> FuturesMap[HostInfo, T]: - return cluster.map_one_host_per_shard(fn) - - -tables: dict[str, TableInfo | ShardedTableInfo] = { - PERSONS_TABLE: TableInfo(PERSONS_TABLE), - "events": ShardedTableInfo(EVENTS_DATA_TABLE(), "events"), -} - - -def get_minmax_index_name(column: str) -> str: - return f"minmax_{column}" - - -@dataclass -class CreateColumnOnDataNodesTask: - table: str - column: MaterializedColumn - create_minmax_index: bool - add_column_comment: bool - - def execute(self, client: Client) -> None: - expression, parameters = self.column.get_expression_and_parameters() - actions = [ - f"ADD COLUMN IF NOT EXISTS {self.column.name} {self.column.type} MATERIALIZED {expression}", - ] - - if self.add_column_comment: - actions.append(f"COMMENT COLUMN {self.column.name} %(comment)s") - parameters["comment"] = self.column.details.as_column_comment() - - if self.create_minmax_index: - index_name = get_minmax_index_name(self.column.name) - actions.append(f"ADD INDEX IF NOT EXISTS {index_name} {self.column.name} TYPE minmax GRANULARITY 1") - - client.execute( - f"ALTER TABLE {self.table} " + ", ".join(actions), - parameters, - settings={"alter_sync": 2 if TEST else 1}, - ) - - -@dataclass -class CreateColumnOnQueryNodesTask: - table: str - column: MaterializedColumn - - def execute(self, client: Client) -> None: - client.execute( - f""" - ALTER TABLE {self.table} - ADD COLUMN IF NOT EXISTS {self.column.name} {self.column.type}, - COMMENT COLUMN {self.column.name} %(comment)s - """, - {"comment": self.column.details.as_column_comment()}, - settings={"alter_sync": 2 if TEST else 1}, - ) - - -def materialize( - table: TableWithProperties, - property: PropertyName, - column_name: ColumnName | None = None, - table_column: TableColumn = DEFAULT_TABLE_COLUMN, - create_minmax_index=not TEST, - is_nullable: bool = False, -) -> MaterializedColumn: - if existing_column := get_materialized_columns(table).get((property, table_column)): - if TEST: - return existing_column - - 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") - - cluster = get_cluster() - table_info = tables[table] - - column = MaterializedColumn( - name=column_name or _materialized_column_name(table, property, table_column), - details=MaterializedColumnDetails( - table_column=table_column, - property_name=property, - is_disabled=False, - ), - is_nullable=is_nullable, - ) - - table_info.map_data_nodes( - cluster, - CreateColumnOnDataNodesTask( - table_info.data_table, - column, - create_minmax_index, - add_column_comment=table_info.read_table == table_info.data_table, - ).execute, - ).result() - - if isinstance(table_info, ShardedTableInfo): - cluster.map_all_hosts( - CreateColumnOnQueryNodesTask( - table_info.dist_table, - column, - ).execute - ).result() - - return column - - -@dataclass -class UpdateColumnCommentTask: - table: str - columns: list[MaterializedColumn] - - def execute(self, client: Client) -> None: - actions = [] - parameters = {} - for i, column in enumerate(self.columns): - parameter_name = f"comment_{i}" - actions.append(f"COMMENT COLUMN {column.name} %({parameter_name})s") - parameters[parameter_name] = column.details.as_column_comment() - - client.execute( - f"ALTER TABLE {self.table} " + ", ".join(actions), - parameters, - settings={"alter_sync": 2 if TEST else 1}, - ) - - -def update_column_is_disabled( - table: TablesWithMaterializedColumns, column_names: Iterable[str], is_disabled: bool -) -> None: - cluster = get_cluster() - table_info = tables[table] - - columns = [MaterializedColumn.get(table, column_name) for column_name in column_names] - - cluster.map_all_hosts( - UpdateColumnCommentTask( - table_info.read_table, - [replace(column, details=replace(column.details, is_disabled=is_disabled)) for column in columns], - ).execute - ).result() - - -def check_index_exists(client: Client, table: str, index: str) -> bool: - [(count,)] = client.execute( - """ - SELECT count() - FROM system.data_skipping_indices - WHERE database = currentDatabase() AND table = %(table)s AND name = %(name)s - """, - {"table": table, "name": index}, - ) - assert 1 >= count >= 0 - return bool(count) - - -def check_column_exists(client: Client, table: str, column: str) -> bool: - [(count,)] = client.execute( - """ - SELECT count() - FROM system.columns - WHERE database = currentDatabase() AND table = %(table)s AND name = %(name)s - """, - {"table": table, "name": column}, - ) - assert 1 >= count >= 0 - return bool(count) - - -@dataclass -class DropColumnTask: - table: str - column_names: list[str] - try_drop_index: bool - - def execute(self, client: Client) -> None: - actions = [] - - for column_name in self.column_names: - if self.try_drop_index: - index_name = get_minmax_index_name(column_name) - drop_index_action = f"DROP INDEX IF EXISTS {index_name}" - if check_index_exists(client, self.table, index_name): - actions.append(drop_index_action) - else: - logger.info("Skipping %r, nothing to do...", drop_index_action) - - drop_column_action = f"DROP COLUMN IF EXISTS {column_name}" - if check_column_exists(client, self.table, column_name): - actions.append(drop_column_action) - else: - logger.info("Skipping %r, nothing to do...", drop_column_action) - - if actions: - client.execute( - f"ALTER TABLE {self.table} " + ", ".join(actions), - settings={"alter_sync": 2 if TEST else 1}, - ) - - -def drop_column(table: TablesWithMaterializedColumns, column_names: Iterable[str]) -> None: - cluster = get_cluster() - table_info = tables[table] - column_names = [*column_names] - - if isinstance(table_info, ShardedTableInfo): - cluster.map_all_hosts( - DropColumnTask( - table_info.dist_table, - column_names, - try_drop_index=False, # no indexes on distributed tables - ).execute - ).result() - - table_info.map_data_nodes( - cluster, - DropColumnTask( - table_info.data_table, - column_names, - try_drop_index=True, - ).execute, - ).result() - - -@dataclass -class BackfillColumnTask: - table: str - columns: list[MaterializedColumn] - backfill_period: timedelta | None - test_settings: dict[str, Any] | None - - def execute(self, client: Client) -> None: - # 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 column in self.columns: - expression, parameters = column.get_expression_and_parameters() - client.execute( - f""" - ALTER TABLE {self.table} - MODIFY COLUMN {column.name} {column.type} DEFAULT {expression} - """, - parameters, - settings=self.test_settings, - ) - - # Kick off mutations which will update clickhouse partitions in the background. This will return immediately - assignments = ", ".join(f"{column.name} = {column.name}" for column in self.columns) - - if self.backfill_period is not None: - where_clause = "timestamp > %(cutoff)s" - parameters = {"cutoff": (now() - self.backfill_period).strftime("%Y-%m-%d")} - else: - where_clause = "1 = 1" - parameters = {} - - client.execute( - f"ALTER TABLE {self.table} UPDATE {assignments} WHERE {where_clause}", - parameters, - settings=self.test_settings, - ) - - -def backfill_materialized_columns( - table: TableWithProperties, - columns: Iterable[MaterializedColumn], - 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. - """ - cluster = get_cluster() - table_info = tables[table] - - table_info.map_data_nodes( - cluster, - BackfillColumnTask( - table_info.data_table, - [*columns], - backfill_period if table == "events" else None, # XXX - test_settings, - ).execute, - ).result() - - -def _materialized_column_name( - table: TableWithProperties, - property: PropertyName, - table_column: TableColumn = DEFAULT_TABLE_COLUMN, -) -> ColumnName: - "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_column_names = {column.name for column in get_materialized_columns(table).values()} - suffix = "" - - while f"{prefix}{property_str}{suffix}" in existing_materialized_column_names: - suffix = "_" + generate_random_short_suffix() - - return f"{prefix}{property_str}{suffix}" diff --git a/ee/clickhouse/materialized_columns/test/__init__.py b/ee/clickhouse/materialized_columns/test/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 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 3b225ab670..0000000000 --- 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", is_nullable=False), - call("events", "materialize_me2", table_column="properties", is_nullable=False), - call("events", "materialize_person_prop", table_column="person_properties", is_nullable=False), - call("events", "materialize_me3", table_column="properties", is_nullable=False), - ] - ) 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 bf09121143..0000000000 --- a/ee/clickhouse/materialized_columns/test/test_columns.py +++ /dev/null @@ -1,419 +0,0 @@ -from datetime import timedelta -from time import sleep -from collections.abc import Iterable -from unittest import TestCase -from unittest.mock import patch - -from freezegun import freeze_time - -from ee.clickhouse.materialized_columns.columns import ( - MaterializedColumn, - MaterializedColumnDetails, - backfill_materialized_columns, - drop_column, - get_enabled_materialized_columns, - get_materialized_columns, - materialize, - update_column_is_disabled, -) -from ee.tasks.materialized_columns import mark_all_materialized -from posthog.clickhouse.materialized_columns import TablesWithMaterializedColumns -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.models.property import PropertyName, TableColumn -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 TestMaterializedColumnDetails(TestCase): - def test_column_comment_formats(self): - old_format_comment = "column_materializer::foo" - old_format_details = MaterializedColumnDetails.from_column_comment(old_format_comment) - assert old_format_details == MaterializedColumnDetails( - "properties", # the default - "foo", - is_disabled=False, - ) - # old comment format is implicitly upgraded to the newer format when serializing - assert old_format_details.as_column_comment() == "column_materializer::properties::foo" - - new_format_comment = "column_materializer::person_properties::bar" - new_format_details = MaterializedColumnDetails.from_column_comment(new_format_comment) - assert new_format_details == MaterializedColumnDetails( - "person_properties", - "bar", - is_disabled=False, - ) - assert new_format_details.as_column_comment() == new_format_comment - - new_format_disabled_comment = "column_materializer::person_properties::bar::disabled" - new_format_disabled_details = MaterializedColumnDetails.from_column_comment(new_format_disabled_comment) - assert new_format_disabled_details == MaterializedColumnDetails( - "person_properties", - "bar", - is_disabled=True, - ) - assert new_format_disabled_details.as_column_comment() == new_format_disabled_comment - - with self.assertRaises(ValueError): - MaterializedColumnDetails.from_column_comment("bad-prefix::property") - - with self.assertRaises(ValueError): - MaterializedColumnDetails.from_column_comment("bad-prefix::column::property") - - with self.assertRaises(ValueError): - MaterializedColumnDetails.from_column_comment("column_materializer::column::property::enabled") - - -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_enabled_materialized_columns("events", use_cache=True).keys() - ], - ["$foo", "$bar", *EVENTS_TABLE_DEFAULT_MATERIALIZED_COLUMNS], - ) - self.assertCountEqual( - get_enabled_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_enabled_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_enabled_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): - assert materialize("events", "$foO();--sqlinject", create_minmax_index=True).name == "mat_$foO_____sqlinject" - - mock_choice.return_value = "Y" - assert ( - materialize("events", "$foO();ääsqlinject", create_minmax_index=True).name == "mat_$foO_____sqlinject_YYYY" - ) - - mock_choice.return_value = "Z" - assert ( - materialize("events", "$foO_____sqlinject", create_minmax_index=True).name == "mat_$foO_____sqlinject_ZZZZ" - ) - - assert materialize("person", "SoMePrOp", create_minmax_index=True).name == "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}, - ) - - columns = [ - 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", - columns, - 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): - columns = [ - materialize("events", "myprop", create_minmax_index=True), - materialize("events", "myprop_nullable", create_minmax_index=True, is_nullable=True), - ] - - expr_nonnullable = "replaceRegexpAll(JSONExtractRaw(properties, 'myprop'), '^\"|\"$', '')" - expr_nullable = "JSONExtract(properties, 'myprop_nullable', 'Nullable(String)')" - self.assertEqual(("String", "MATERIALIZED", expr_nonnullable), self._get_column_types("mat_myprop")) - self.assertEqual( - ("Nullable(String)", "MATERIALIZED", expr_nullable), self._get_column_types("mat_myprop_nullable") - ) - - backfill_materialized_columns("events", columns, timedelta(days=50)) - self.assertEqual(("String", "DEFAULT", expr_nonnullable), self._get_column_types("mat_myprop")) - self.assertEqual(("Nullable(String)", "DEFAULT", expr_nullable), self._get_column_types("mat_myprop_nullable")) - - mark_all_materialized() - self.assertEqual(("String", "MATERIALIZED", expr_nonnullable), self._get_column_types("mat_myprop")) - self.assertEqual( - ("Nullable(String)", "MATERIALIZED", expr_nullable), self._get_column_types("mat_myprop_nullable") - ) - - 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 type, 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] - - def test_lifecycle(self): - table: TablesWithMaterializedColumns = "events" - property_names = ["foo", "bar"] - source_column: TableColumn = "properties" - - # create materialized columns - materialized_columns = {} - for property_name in property_names: - materialized_columns[property_name] = materialize( - table, property_name, table_column=source_column, create_minmax_index=True - ).name - - assert set(property_names) == materialized_columns.keys() - - # ensure they exist everywhere - for property_name, destination_column in materialized_columns.items(): - key = (property_name, source_column) - assert get_materialized_columns(table)[key].name == destination_column - assert MaterializedColumn.get(table, destination_column) == MaterializedColumn( - destination_column, - MaterializedColumnDetails(source_column, property_name, is_disabled=False), - is_nullable=False, - ) - - # disable them and ensure updates apply as needed - update_column_is_disabled(table, materialized_columns.values(), is_disabled=True) - for property_name, destination_column in materialized_columns.items(): - key = (property_name, source_column) - assert get_materialized_columns(table)[key].name == destination_column - assert MaterializedColumn.get(table, destination_column) == MaterializedColumn( - destination_column, - MaterializedColumnDetails(source_column, property_name, is_disabled=True), - is_nullable=False, - ) - - # re-enable them and ensure updates apply as needed - update_column_is_disabled(table, materialized_columns.values(), is_disabled=False) - for property_name, destination_column in materialized_columns.items(): - key = (property_name, source_column) - assert get_materialized_columns(table)[key].name == destination_column - assert MaterializedColumn.get(table, destination_column) == MaterializedColumn( - destination_column, - MaterializedColumnDetails(source_column, property_name, is_disabled=False), - is_nullable=False, - ) - - # drop them and ensure updates apply as needed - drop_column(table, materialized_columns.values()) - for property_name, destination_column in materialized_columns.items(): - key = (property_name, source_column) - assert key not in get_materialized_columns(table) - with self.assertRaises(ValueError): - MaterializedColumn.get(table, destination_column) - - def _get_latest_mutation_id(self, table: str) -> str: - [(mutation_id,)] = sync_execute( - """ - SELECT max(mutation_id) - FROM system.mutations - WHERE - database = currentDatabase() - AND table = %(table)s - """, - {"table": table}, - ) - return mutation_id - - def _get_mutations_since_id(self, table: str, id: str) -> Iterable[str]: - return [ - command - for (command,) in sync_execute( - """ - SELECT command - FROM system.mutations - WHERE - database = currentDatabase() - AND table = %(table)s - AND mutation_id > %(mutation_id)s - ORDER BY mutation_id - """, - {"table": table, "mutation_id": id}, - ) - ] - - def test_drop_optimized_no_index(self): - table: TablesWithMaterializedColumns = ( - "person" # little bit easier than events because no shard awareness needed - ) - property: PropertyName = "myprop" - source_column: TableColumn = "properties" - - destination_column = materialize(table, property, table_column=source_column, create_minmax_index=False) - - latest_mutation_id_before_drop = self._get_latest_mutation_id(table) - - drop_column(table, destination_column.name) - - mutations_ran = self._get_mutations_since_id(table, latest_mutation_id_before_drop) - assert not any("DROP INDEX" in mutation for mutation in mutations_ran) - - def test_drop_optimized_no_column(self): - table: TablesWithMaterializedColumns = ( - "person" # little bit easier than events because no shard awareness needed - ) - property: PropertyName = "myprop" - source_column: TableColumn = "properties" - - # create the materialized column - destination_column = materialize(table, property, table_column=source_column, create_minmax_index=False) - - sync_execute(f"ALTER TABLE {table} DROP COLUMN {destination_column.name}", settings={"alter_sync": 1}) - - latest_mutation_id_before_drop = self._get_latest_mutation_id(table) - - drop_column(table, destination_column.name) - - mutations_ran = self._get_mutations_since_id(table, latest_mutation_id_before_drop) - assert not any("DROP COLUMN" in mutation for mutation in mutations_ran) 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 3a55a0614f..0000000000 --- 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 e69de29bb2..0000000000 diff --git a/ee/clickhouse/models/__init__.py b/ee/clickhouse/models/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/clickhouse/models/group.py b/ee/clickhouse/models/group.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/clickhouse/models/test/__init__.py b/ee/clickhouse/models/test/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 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 24db8bb3f1..0000000000 --- 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, - 99999 as cohort_id, - 99999 as team_id, - 1 AS sign, - 0 AS version - FROM - (SELECT id - FROM person - WHERE team_id = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - AND cohort_id = 99999 - 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, - 99999 as cohort_id, - 99999 as team_id, - 1 AS sign, - 0 AS version - FROM - (SELECT id - FROM person - WHERE team_id = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - AND cohort_id = 99999 - 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, - 99999 as cohort_id, - 99999 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - AND cohort_id = 99999 - 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 = 99999 - 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 = 99999 - 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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, - 99999 as cohort_id, - 99999 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - AND cohort_id = 99999 - 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, - 99999 as cohort_id, - 99999 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - AND cohort_id = 99999 - 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 131ac57b3f..0000000000 --- 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 b9aaf44a4c..0000000000 --- a/ee/clickhouse/models/test/test_action.py +++ /dev/null @@ -1,318 +0,0 @@ -import dataclasses - -from posthog.client import sync_execute -from posthog.hogql.compiler.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, HOGQL_BYTECODE_VERSION - - -@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)).bytecode) - self.assertEqual( - action1.bytecode, - [ - _H, - HOGQL_BYTECODE_VERSION, - # event = 'insight viewed' - op.STRING, - "insight viewed", - op.STRING, - "event", - op.GET_GLOBAL, - 1, - op.EQ, - # toInt(properties.filters_count) > 10 - op.INTEGER, - 10, - op.STRING, - "filters_count", - op.STRING, - "properties", - op.GET_GLOBAL, - 2, - op.CALL_GLOBAL, - "toInt", - 1, - op.GT, - # 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 1600584169..0000000000 --- a/ee/clickhouse/models/test/test_cohort.py +++ /dev/null @@ -1,1449 +0,0 @@ -from datetime import datetime, timedelta -from typing import Optional - -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 -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.person_distinct_id_query import get_team_distinct_ids_query -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, -) -from posthog.models.person.sql import GET_LATEST_PERSON_SQL, GET_PERSON_IDS_BY_FILTER - - -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 - - -def get_person_ids_by_cohort_id( - team_id: int, - cohort_id: int, - limit: Optional[int] = None, - offset: Optional[int] = None, -): - from posthog.models.property.util import parse_prop_grouped_clauses - - filter = Filter(data={"properties": [{"key": "id", "value": cohort_id, "type": "cohort"}]}) - filter_query, filter_params = parse_prop_grouped_clauses( - team_id=team_id, - property_group=filter.property_groups, - table_name="pdi", - hogql_context=filter.hogql_context, - ) - - results = sync_execute( - GET_PERSON_IDS_BY_FILTER.format( - person_query=GET_LATEST_PERSON_SQL, - distinct_query=filter_query, - query="", - GET_TEAM_PERSON_DISTINCT_IDS=get_team_distinct_ids_query(team_id), - offset="OFFSET %(offset)s" if offset else "", - limit="ORDER BY _timestamp ASC LIMIT %(limit)s" if limit else "", - ), - {**filter_params, "team_id": team_id, "offset": offset, "limit": limit}, - ) - - return [str(row[0]) for row in results] - - -class TestCohort(ClickhouseTestMixin, BaseTest): - def _get_cohortpeople(self, cohort: Cohort, *, team_id: Optional[int] = None): - team_id = team_id or cohort.team_id - return sync_execute( - GET_COHORTPEOPLE_BY_COHORT_ID, - { - "team_id": team_id, - "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.pk, 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.pk, 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.pk, 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.pk, 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) - - def test_calculate_people_ch_in_multiteam_project(self): - # Create another team in the same project - team2 = Team.objects.create(organization=self.organization, project=self.team.project) - - # Create people in team 1 - _person1_team1 = _create_person( - team_id=self.team.pk, - distinct_ids=["person1"], - properties={"$some_prop": "else"}, - ) - person2_team1 = _create_person( - team_id=self.team.pk, - distinct_ids=["person2"], - properties={"$some_prop": "something"}, - ) - # Create people in team 2 with same property - person1_team2 = _create_person( - team_id=team2.pk, - distinct_ids=["person1_team2"], - properties={"$some_prop": "something"}, - ) - _person2_team2 = _create_person( - team_id=team2.pk, - distinct_ids=["person2_team2"], - properties={"$some_prop": "else"}, - ) - # Create cohort in team 2 (but same project as team 1) - shared_cohort = Cohort.objects.create( - team=team2, - groups=[{"properties": [{"key": "$some_prop", "value": "something", "type": "person"}]}], - name="shared cohort", - ) - # Calculate cohort - shared_cohort.calculate_people_ch(pending_version=0) - - # Verify shared_cohort is now calculated for both teams - results_team1 = self._get_cohortpeople(shared_cohort, team_id=self.team.pk) - results_team2 = self._get_cohortpeople(shared_cohort, team_id=team2.pk) - - self.assertCountEqual([r[0] for r in results_team1], [person2_team1.uuid]) - self.assertCountEqual([r[0] for r in results_team2], [person1_team2.uuid]) 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 220d7ada32..0000000000 --- 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 96cc887df4..0000000000 --- a/ee/clickhouse/models/test/test_filters.py +++ /dev/null @@ -1,1469 +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, - "negation": False, - "type": "precalculated-cohort", - } - ], - } - }, - ) - - self.assertEqual( - filter_with_groups.simplify(self.team).properties_to_dict(), - { - "properties": { - "type": "AND", - "values": [ - { - "key": "id", - "negation": False, - "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", "negation": False, "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", "negation": False, "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 fd1791438c..0000000000 --- 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 6194a6a6a9..0000000000 --- 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 e69de29bb2..0000000000 diff --git a/ee/clickhouse/queries/column_optimizer.py b/ee/clickhouse/queries/column_optimizer.py deleted file mode 100644 index df7873a7ce..0000000000 --- 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 0629c6757a..0000000000 --- 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 64f08da69d..0000000000 --- a/ee/clickhouse/queries/event_query.py +++ /dev/null @@ -1,71 +0,0 @@ -from typing import Optional, Union - -from ee.clickhouse.queries.column_optimizer import EnterpriseColumnOptimizer -from ee.clickhouse.queries.groups_join_query import GroupsJoinQuery -from posthog.clickhouse.materialized_columns import ColumnName -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.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, - 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 89f0035201..0000000000 --- a/ee/clickhouse/queries/experiments/__init__.py +++ /dev/null @@ -1,15 +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 - -# Trends only: If p-value is below this threshold, the results are considered significant -P_VALUE_SIGNIFICANCE_LEVEL = 0.05 - -CONTROL_VARIANT_KEY = "control" 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 f68816ed3b..0000000000 --- a/ee/clickhouse/queries/experiments/funnel_experiment_result.py +++ /dev/null @@ -1,193 +0,0 @@ -from dataclasses import asdict, dataclass -from datetime import datetime -import json -from typing import Optional -from zoneinfo import ZoneInfo - -from rest_framework.exceptions import ValidationError - -from posthog.constants import ExperimentNoResultsErrorKeys -from posthog.hogql_queries.experiments import CONTROL_VARIANT_KEY -from posthog.hogql_queries.experiments.funnels_statistics import ( - are_results_significant, - calculate_credible_intervals, - calculate_probabilities, -) -from posthog.models.experiment import ExperimentHoldout -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 -from posthog.schema import ExperimentSignificanceCode - -Probability = float - - -@dataclass(frozen=True) -class Variant: - key: str - success_count: int - failure_count: int - - -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, - holdout: Optional[ExperimentHoldout] = None, - funnel_class: type[ClickhouseFunnel] = ClickhouseFunnel, - ): - breakdown_key = f"$feature/{feature_flag.key}" - self.variants = [variant["key"] for variant in feature_flag.variants] - if holdout: - self.variants.append(f"holdout-{holdout.id}") - - # 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 = calculate_probabilities(control_variant, test_variants) - - mapping = { - variant.key: probability - for variant, probability in zip([control_variant, *test_variants], probabilities) - } - - significance_code, loss = 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 - - -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 bd485c4362..0000000000 --- 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_funnel_experiment_result.py b/ee/clickhouse/queries/experiments/test_funnel_experiment_result.py deleted file mode 100644 index 55fca255ed..0000000000 --- a/ee/clickhouse/queries/experiments/test_funnel_experiment_result.py +++ /dev/null @@ -1,561 +0,0 @@ -import unittest -from functools import lru_cache -from math import exp, lgamma, log, ceil - -from flaky import flaky - -from posthog.hogql_queries.experiments.funnels_statistics import ( - are_results_significant, - calculate_expected_loss, - calculate_probabilities, - calculate_credible_intervals as calculate_funnel_credible_intervals, -) -from posthog.schema import ExperimentSignificanceCode, ExperimentVariantFunnelsBaseStats - -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: ExperimentVariantFunnelsBaseStats, other_variants: list[ExperimentVariantFunnelsBaseStats] -) -> 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: float, A_failure: float, B_success: float, B_failure: float) -> Probability: - total: Probability = 0 - for i in range(ceil(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: float, - A_failure: float, - B_success: float, - B_failure: float, - C_success: float, - C_failure: float, -): - total: Probability = 0 - for i in range(ceil(A_success)): - for j in range(ceil(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: float, - A_failure: float, - B_success: float, - B_failure: float, - C_success: float, - C_failure: float, - D_success: float, - D_failure: float, -): - total: Probability = 0 - for i in range(ceil(A_success)): - for j in range(ceil(B_success)): - for k in range(ceil(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 = ExperimentVariantFunnelsBaseStats(key="A", success_count=100, failure_count=10) - variant_control = ExperimentVariantFunnelsBaseStats(key="B", success_count=100, failure_count=18) - - _, probability = calculate_probabilities(variant_control, [variant_test]) - self.assertAlmostEqual(probability, 0.918, places=2) - - significant, loss = 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 = ExperimentVariantFunnelsBaseStats(key="A", success_count=100, failure_count=10) - variant_control = ExperimentVariantFunnelsBaseStats(key="B", success_count=100, failure_count=18) - - _, probability = calculate_probabilities(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 = ExperimentVariantFunnelsBaseStats(key="A", success_count=100, failure_count=10) - variant_test_2 = ExperimentVariantFunnelsBaseStats(key="B", success_count=100, failure_count=3) - variant_control = ExperimentVariantFunnelsBaseStats(key="C", success_count=100, failure_count=18) - - probabilities = calculate_probabilities(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 = 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 = ExperimentVariantFunnelsBaseStats(key="A", success_count=120, failure_count=60) - variant_test_2 = ExperimentVariantFunnelsBaseStats(key="B", success_count=110, failure_count=52) - variant_control = ExperimentVariantFunnelsBaseStats(key="C", success_count=130, failure_count=65) - - probabilities = calculate_probabilities(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 = 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 = ExperimentVariantFunnelsBaseStats(key="A", success_count=286, failure_count=2014) - variant_control = ExperimentVariantFunnelsBaseStats(key="B", success_count=267, failure_count=2031) - - probabilities = calculate_probabilities(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 = 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 = ExperimentVariantFunnelsBaseStats(key="A", success_count=100, failure_count=10) - variant_test_2 = ExperimentVariantFunnelsBaseStats(key="B", success_count=100, failure_count=3) - variant_test_3 = ExperimentVariantFunnelsBaseStats(key="C", success_count=100, failure_count=30) - variant_control = ExperimentVariantFunnelsBaseStats(key="D", success_count=100, failure_count=18) - - probabilities = calculate_probabilities(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 = 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 = ExperimentVariantFunnelsBaseStats(key="A", success_count=120, failure_count=60) - variant_test_2 = ExperimentVariantFunnelsBaseStats(key="B", success_count=110, failure_count=52) - variant_test_3 = ExperimentVariantFunnelsBaseStats(key="C", success_count=100, failure_count=46) - variant_control = ExperimentVariantFunnelsBaseStats(key="D", success_count=130, failure_count=65) - - probabilities = calculate_probabilities(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 = 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 = ExperimentVariantFunnelsBaseStats(key="A", success_count=130, failure_count=60) - variant_test_2 = ExperimentVariantFunnelsBaseStats(key="B", success_count=135, failure_count=62) - variant_test_3 = ExperimentVariantFunnelsBaseStats(key="C", success_count=132, failure_count=60) - variant_control = ExperimentVariantFunnelsBaseStats(key="D", success_count=80, failure_count=65) - - probabilities = calculate_probabilities(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 = 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 = ExperimentVariantFunnelsBaseStats(key="A", success_count=100, failure_count=17) - variant_test_2 = ExperimentVariantFunnelsBaseStats(key="B", success_count=100, failure_count=16) - variant_test_3 = ExperimentVariantFunnelsBaseStats(key="C", success_count=100, failure_count=30) - variant_test_4 = ExperimentVariantFunnelsBaseStats(key="D", success_count=100, failure_count=31) - variant_test_5 = ExperimentVariantFunnelsBaseStats(key="E", success_count=100, failure_count=29) - variant_test_6 = ExperimentVariantFunnelsBaseStats(key="F", success_count=100, failure_count=32) - variant_test_7 = ExperimentVariantFunnelsBaseStats(key="G", success_count=100, failure_count=33) - variant_control = ExperimentVariantFunnelsBaseStats(key="H", success_count=100, failure_count=18) - - probabilities = calculate_probabilities( - 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 = 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 = ExperimentVariantFunnelsBaseStats(key="test", success_count=100, failure_count=18) - variant_control = ExperimentVariantFunnelsBaseStats(key="control", success_count=100, failure_count=10) - - probabilities = calculate_probabilities(variant_control, [variant_test]) - - self.assertAlmostEqual(probabilities[0], 0.918, places=2) - - significant, loss = 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 = ExperimentVariantFunnelsBaseStats(key="test_1", success_count=100, failure_count=20) - variant_test_2 = ExperimentVariantFunnelsBaseStats(key="test_2", success_count=100, failure_count=21) - variant_test_3 = ExperimentVariantFunnelsBaseStats(key="test_3", success_count=100, failure_count=22) - variant_test_4 = ExperimentVariantFunnelsBaseStats(key="test_4", success_count=100, failure_count=23) - variant_test_5 = ExperimentVariantFunnelsBaseStats(key="test_5", success_count=100, failure_count=24) - variant_test_6 = ExperimentVariantFunnelsBaseStats(key="test_6", success_count=100, failure_count=25) - variant_control = ExperimentVariantFunnelsBaseStats(key="control", success_count=100, failure_count=10) - - variants_test = [ - variant_test_1, - variant_test_2, - variant_test_3, - variant_test_4, - variant_test_5, - variant_test_6, - ] - - probabilities = calculate_probabilities(variant_control, variants_test) - - self.assertAlmostEqual(probabilities[0], 0.901, places=2) - - significant, loss = 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) diff --git a/ee/clickhouse/queries/experiments/test_trend_experiment_result.py b/ee/clickhouse/queries/experiments/test_trend_experiment_result.py deleted file mode 100644 index de983e6f14..0000000000 --- a/ee/clickhouse/queries/experiments/test_trend_experiment_result.py +++ /dev/null @@ -1,240 +0,0 @@ -import unittest -from functools import lru_cache -from math import exp, lgamma, log, ceil - -from flaky import flaky - -from posthog.hogql_queries.experiments.trends_statistics import ( - are_results_significant, - calculate_credible_intervals, - calculate_p_value, - calculate_probabilities, -) -from posthog.schema import ExperimentSignificanceCode, ExperimentVariantTrendsBaseStats - -Probability = float - - -@lru_cache(maxsize=100000) -def logbeta(x: float, y: float) -> 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#count_ab -def calculate_probability_of_winning_for_target_count_data( - target_variant: ExperimentVariantTrendsBaseStats, other_variants: list[ExperimentVariantTrendsBaseStats] -) -> 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: float, A_exposure: float, B_count: float, B_exposure: float -) -> Probability: - total: Probability = 0 - for i in range(ceil(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: float, - A_exposure: float, - B_count: float, - B_exposure: float, - C_count: float, - C_exposure: float, -) -> Probability: - total: Probability = 0 - - for i in range(ceil(B_count)): - for j in range(ceil(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 = ExperimentVariantTrendsBaseStats(key="A", count=20, exposure=1, absolute_exposure=200) - variant_test = ExperimentVariantTrendsBaseStats(key="B", count=30, exposure=1, absolute_exposure=200) - - probabilities = calculate_probabilities(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_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 = ExperimentVariantTrendsBaseStats(key="A", count=2, exposure=1, absolute_exposure=200) - variant_test = ExperimentVariantTrendsBaseStats(key="B", count=1, exposure=1, absolute_exposure=200) - - probabilities = calculate_probabilities(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_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 = ExperimentVariantTrendsBaseStats(key="A", count=20, exposure=1, absolute_exposure=200) - variant_test_1 = ExperimentVariantTrendsBaseStats(key="B", count=26, exposure=1, absolute_exposure=200) - variant_test_2 = ExperimentVariantTrendsBaseStats(key="C", count=19, exposure=1, absolute_exposure=200) - - probabilities = calculate_probabilities(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_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 = ExperimentVariantTrendsBaseStats(key="A", count=250, exposure=1, absolute_exposure=200) - variant_test_1 = ExperimentVariantTrendsBaseStats(key="B", count=180, exposure=1, absolute_exposure=200) - variant_test_2 = ExperimentVariantTrendsBaseStats(key="C", count=50, exposure=1, absolute_exposure=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 = 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 = ExperimentVariantTrendsBaseStats(key="B", count=100, exposure=1, absolute_exposure=200) - - significant, p_value = 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_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 = ExperimentVariantTrendsBaseStats(key="A", count=50, exposure=1.3, absolute_exposure=260) - variant_test_1 = ExperimentVariantTrendsBaseStats(key="B", count=30, exposure=1.8, absolute_exposure=360) - variant_test_2 = ExperimentVariantTrendsBaseStats(key="C", count=20, exposure=0.7, absolute_exposure=140) - - probabilities = calculate_probabilities(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 = 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_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 f01b00d0bb..0000000000 --- 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 cab803dff9..0000000000 --- a/ee/clickhouse/queries/experiments/trend_experiment_result.py +++ /dev/null @@ -1,362 +0,0 @@ -import json -from dataclasses import asdict, dataclass -from datetime import datetime -from typing import Optional -from zoneinfo import ZoneInfo - -from rest_framework.exceptions import ValidationError - -from ee.clickhouse.queries.experiments import ( - CONTROL_VARIANT_KEY, -) -from posthog.constants import ( - ACTIONS, - EVENTS, - TRENDS_CUMULATIVE, - TRENDS_LINEAR, - UNIQUE_USERS, - ExperimentNoResultsErrorKeys, -) -from posthog.hogql_queries.experiments.trends_statistics import ( - are_results_significant, - calculate_credible_intervals, - calculate_probabilities, -) -from posthog.models.experiment import ExperimentHoldout -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 -from posthog.schema import ExperimentSignificanceCode - -Probability = float - - -@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, - holdout: Optional[ExperimentHoldout] = None, - ): - breakdown_key = f"$feature/{feature_flag.key}" - self.variants = [variant["key"] for variant in feature_flag.variants] - if holdout: - self.variants.append(f"holdout-{holdout.id}") - - # 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(), - "exposure_filters": self.exposure_filter.to_dict(), - } - - try: - validate_event_variants(insight_results, self.variants) - - control_variant, test_variants = self.get_variants(insight_results, exposure_results) - - probabilities = calculate_probabilities(control_variant, test_variants) - - mapping = { - variant.key: probability - for variant, probability in zip([control_variant, *test_variants], probabilities) - } - - significance_code, p_value = 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 - - -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 5837a6aa9d..0000000000 --- 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 e69de29bb2..0000000000 diff --git a/ee/clickhouse/queries/funnels/funnel_correlation.py b/ee/clickhouse/queries/funnels/funnel_correlation.py deleted file mode 100644 index 0b909c84b3..0000000000 --- a/ee/clickhouse/queries/funnels/funnel_correlation.py +++ /dev/null @@ -1,971 +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_column_for_property -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 - 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 = get_materialized_column_for_property( - "events", "person_properties", property_name - ) - if possible_mat_col is not None and not possible_mat_col.is_nullable: - props_to_include.append(possible_mat_col.name) - 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, prop - -- 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 b02a8b8e9b..0000000000 --- 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 e69de29bb2..0000000000 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 10700d192c..0000000000 --- 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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, 99999) - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, 99999) - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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, 99999) - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, 99999) - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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, 99999) - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, 99999) - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 fcf2044085..0000000000 --- a/ee/clickhouse/queries/funnels/test/__snapshots__/test_funnel_correlation.ambr +++ /dev/null @@ -1,4902 +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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(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 = 99999 - 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 = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(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 = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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(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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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(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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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(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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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(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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(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 = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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(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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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(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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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(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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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(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 = 99999 - 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(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 = 99999 - 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, - prop - 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 = 99999 - 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(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 = 99999 - 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, - prop - 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 = 99999 - 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(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 = 99999 - 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 = 99999 - 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(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 = 99999 - 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 = 99999 - 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(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 = 99999 - 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 = 99999 - 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(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 = 99999 - 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 = 99999 - 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(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 = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - 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, 99999) - 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 = 99999 - 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(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 = 99999 - 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, 99999) - 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 = 99999 - 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(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 = 99999 - 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, 99999) - 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 = 99999 - 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(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 = 99999 - 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, 99999) - 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 = 99999 - 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(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 = 99999 - 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, 99999) - 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 = 99999 - 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(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 = 99999 - 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, 99999) - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - 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, 99999) - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - 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, 99999) - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - 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 = 99999 - 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(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 = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - 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(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 = 99999 - 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 = 99999 - 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(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 = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - 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(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 = 99999 - 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 = 99999 - 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(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 = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - 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(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 = 99999 - 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 = 99999 - 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(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 = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 = 99999 - 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(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 = 99999 - 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, 99999) - 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 = 99999 - 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(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 = 99999 - 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, 99999) - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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, 99999) - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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, 99999) - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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, 99999) - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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, 99999) - 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 = 99999 - 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(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 = 99999 - 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 200f16b611..0000000000 --- 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - e.uuid AS uuid, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(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 = 99999 - 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 = 99999 - 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 = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - e.uuid AS uuid, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(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 = 99999 - 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 = 99999 - 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 = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - e.uuid AS uuid, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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(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 = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - e.uuid AS uuid, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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(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 = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - e.uuid AS uuid, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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(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 = 99999 - 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 ce73579b84..0000000000 --- a/ee/clickhouse/queries/funnels/test/breakdown_cases.py +++ /dev/null @@ -1,420 +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, project_id=self.team.project_id, group_type="organization", group_type_index=0 - ) - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, 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 a48642198f..0000000000 --- a/ee/clickhouse/queries/funnels/test/test_funnel.py +++ /dev/null @@ -1,209 +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, project_id=self.team.project_id, group_type="organization", group_type_index=0 - ) - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, 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 bb470c2752..0000000000 --- a/ee/clickhouse/queries/funnels/test/test_funnel_correlation.py +++ /dev/null @@ -1,2088 +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, project_id=self.team.project_id, 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, project_id=self.team.project_id, 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, project_id=self.team.project_id, 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, project_id=self.team.project_id, 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 c6954e15ee..0000000000 --- a/ee/clickhouse/queries/funnels/test/test_funnel_correlations_persons.py +++ /dev/null @@ -1,651 +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", - }, - self.team.pk, - ) - - 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 5a48bc0d0e..0000000000 --- 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/related_actors_query.py b/ee/clickhouse/queries/related_actors_query.py deleted file mode 100644 index 99817998d7..0000000000 --- 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(project_id=self.team.project_id): - 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 dcdcf4349a..0000000000 --- a/ee/clickhouse/queries/retention/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .retention import * diff --git a/ee/clickhouse/queries/stickiness/__init__.py b/ee/clickhouse/queries/stickiness/__init__.py deleted file mode 100644 index 516cae3fe8..0000000000 --- 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 65f48c57e3..0000000000 --- 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 0405aa8674..0000000000 --- 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 db15ba05a9..0000000000 --- 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 e69de29bb2..0000000000 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 1dc13e551e..0000000000 --- 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON pdi.person_id = person.id - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON pdi.person_id = person.id - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 5494d3c98c..0000000000 --- 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - AND cohort_id = 99999 - 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 = 99999 - AND cohort_id = 99999 - AND version = 0 - ''' -# --- -# name: TestCohortQuery.test_precalculated_cohort_filter_with_extra_filters.2 - ''' - - SELECT id - FROM person - WHERE team_id = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - AND cohort_id = 99999 - ''' -# --- -# 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 = 99999 - 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 = 99999 - AND team_id = 99999)))) 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 = 99999 - AND cohort_id = 99999 - ''' -# --- -# 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - AND team_id = 99999)) - 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - AND team_id = 99999))) - 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 = 99999 - AND cohort_id = 99999 - ''' -# --- -# 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - AND team_id = 99999))) - 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 381db5c625..0000000000 --- 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 = 99999 - AND cohort_id = 99999 - AND version = NULL - ''' -# --- -# name: TestEventQuery.test_account_filters.1 - ''' - /* cohort_calculation: */ - SELECT count(DISTINCT person_id) - FROM cohortpeople - WHERE team_id = 99999 - AND cohort_id = 99999 - AND version = 0 - ''' -# --- -# name: TestEventQuery.test_account_filters.2 - ''' - SELECT e.timestamp as timestamp, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as person_id - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as person_id - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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 (if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) IN - (SELECT id - FROM person - WHERE team_id = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as person_id - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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 (if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) IN - (SELECT id - FROM person - WHERE team_id = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - AND group_type_index = 1 - GROUP BY group_key) groups_1 ON "$group_1" == groups_1.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as person_id - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as person_id - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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 (if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) IN - (SELECT person_id as id - FROM person_static_cohort - WHERE cohort_id = 99999 - AND team_id = 99999)) - ''' -# --- -# 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 = 99999 - 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 = 99999 - 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 85b77e6162..0000000000 --- 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 ff52a4b089..0000000000 --- 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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - 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 = 99999 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - 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 = 99999 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - 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 = 99999 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - 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 = 99999 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - 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 = 99999 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - 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 = 99999 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - 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 = 99999 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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_props, 'email'), ''), 'null'), '^"|"$', ''), '%test.com'), 0)) - GROUP BY if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - 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 = 99999 - GROUP BY id - HAVING max(is_deleted) = 0 SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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(pmat_email, ''), 'null'), '%test.com'), 0)) - GROUP BY if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.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_person_distinct_id_query.ambr b/ee/clickhouse/queries/test/__snapshots__/test_person_distinct_id_query.ambr deleted file mode 100644 index 112bddef4e..0000000000 --- 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 d281c880e5..0000000000 --- a/ee/clickhouse/queries/test/__snapshots__/test_person_query.ambr +++ /dev/null @@ -1,369 +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/test_breakdown_props.py b/ee/clickhouse/queries/test/test_breakdown_props.py deleted file mode 100644 index 7012586163..0000000000 --- a/ee/clickhouse/queries/test/test_breakdown_props.py +++ /dev/null @@ -1,554 +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, project_id=self.team.project_id, group_type="organization", group_type_index=0 - ) - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, 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 9d07d9378d..0000000000 --- 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 296f3d18b3..0000000000 --- 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 b37fba0bfa..0000000000 --- a/ee/clickhouse/queries/test/test_event_query.py +++ /dev/null @@ -1,748 +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, project_id=self.team.project_id, group_type="organization", group_type_index=0 - ) - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, 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 95aacc8ca0..0000000000 --- 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 1564cf8f50..0000000000 --- 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 f9fecbd0c5..0000000000 --- a/ee/clickhouse/queries/test/test_lifecycle.py +++ /dev/null @@ -1,298 +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, project_id=self.team.project_id, 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_person_distinct_id_query.py b/ee/clickhouse/queries/test/test_person_distinct_id_query.py deleted file mode 100644 index 52d75ffdd3..0000000000 --- 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 bd2a280d40..0000000000 --- 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 907c035b64..0000000000 --- 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_util.py b/ee/clickhouse/queries/test/test_util.py deleted file mode 100644 index 54124fde3e..0000000000 --- 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 e69de29bb2..0000000000 diff --git a/ee/clickhouse/test/test_error.py b/ee/clickhouse/test/test_error.py deleted file mode 100644 index 983e37b145..0000000000 --- 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 80a5b692c6..0000000000 --- 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 e69de29bb2..0000000000 diff --git a/ee/clickhouse/views/experiment_holdouts.py b/ee/clickhouse/views/experiment_holdouts.py deleted file mode 100644 index c7d8eff83c..0000000000 --- a/ee/clickhouse/views/experiment_holdouts.py +++ /dev/null @@ -1,110 +0,0 @@ -from typing import Any -from rest_framework import serializers, viewsets -from rest_framework.exceptions import ValidationError -from rest_framework.request import Request -from rest_framework.response import Response -from django.db import transaction - - -from posthog.api.feature_flag import FeatureFlagSerializer -from posthog.api.routing import TeamAndOrgViewSetMixin -from posthog.api.shared import UserBasicSerializer -from posthog.models.experiment import ExperimentHoldout - - -class ExperimentHoldoutSerializer(serializers.ModelSerializer): - created_by = UserBasicSerializer(read_only=True) - - class Meta: - model = ExperimentHoldout - fields = [ - "id", - "name", - "description", - "filters", - "created_by", - "created_at", - "updated_at", - ] - read_only_fields = [ - "id", - "created_by", - "created_at", - "updated_at", - ] - - def _get_filters_with_holdout_id(self, id: int, filters: list) -> list: - variant_key = f"holdout-{id}" - updated_filters = [] - for filter in filters: - updated_filters.append( - { - **filter, - "variant": variant_key, - } - ) - return updated_filters - - def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> ExperimentHoldout: - request = self.context["request"] - validated_data["created_by"] = request.user - validated_data["team_id"] = self.context["team_id"] - - if not validated_data.get("filters"): - raise ValidationError("Filters are required to create an holdout group") - - instance = super().create(validated_data) - instance.filters = self._get_filters_with_holdout_id(instance.id, instance.filters) - instance.save() - return instance - - def update(self, instance: ExperimentHoldout, validated_data): - filters = validated_data.get("filters") - if filters and instance.filters != filters: - # update flags on all experiments in this holdout group - new_filters = self._get_filters_with_holdout_id(instance.id, filters) - validated_data["filters"] = new_filters - with transaction.atomic(): - for experiment in instance.experiment_set.all(): - flag = experiment.feature_flag - existing_flag_serializer = FeatureFlagSerializer( - flag, - data={ - "filters": {**flag.filters, "holdout_groups": validated_data["filters"]}, - }, - partial=True, - context=self.context, - ) - existing_flag_serializer.is_valid(raise_exception=True) - existing_flag_serializer.save() - - return super().update(instance, validated_data) - - -class ExperimentHoldoutViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): - scope_object = "experiment" - queryset = ExperimentHoldout.objects.prefetch_related("created_by").all() - serializer_class = ExperimentHoldoutSerializer - ordering = "-created_at" - - def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: - instance = self.get_object() - - with transaction.atomic(): - for experiment in instance.experiment_set.all(): - flag = experiment.feature_flag - existing_flag_serializer = FeatureFlagSerializer( - flag, - data={ - "filters": { - **flag.filters, - "holdout_groups": None, - } - }, - partial=True, - context={"request": request, "team": self.team, "team_id": self.team_id}, - ) - existing_flag_serializer.is_valid(raise_exception=True) - existing_flag_serializer.save() - - return super().destroy(request, *args, **kwargs) diff --git a/ee/clickhouse/views/experiment_saved_metrics.py b/ee/clickhouse/views/experiment_saved_metrics.py deleted file mode 100644 index 911a34530c..0000000000 --- a/ee/clickhouse/views/experiment_saved_metrics.py +++ /dev/null @@ -1,85 +0,0 @@ -import pydantic -from rest_framework import serializers, viewsets -from rest_framework.exceptions import ValidationError - - -from posthog.api.routing import TeamAndOrgViewSetMixin -from posthog.api.shared import UserBasicSerializer -from posthog.models.experiment import ExperimentSavedMetric, ExperimentToSavedMetric -from posthog.schema import ExperimentFunnelsQuery, ExperimentTrendsQuery - - -class ExperimentToSavedMetricSerializer(serializers.ModelSerializer): - query = serializers.JSONField(source="saved_metric.query", read_only=True) - name = serializers.CharField(source="saved_metric.name", read_only=True) - - class Meta: - model = ExperimentToSavedMetric - fields = [ - "id", - "experiment", - "saved_metric", - "metadata", - "created_at", - "query", - "name", - ] - read_only_fields = [ - "id", - "created_at", - ] - - -class ExperimentSavedMetricSerializer(serializers.ModelSerializer): - created_by = UserBasicSerializer(read_only=True) - - class Meta: - model = ExperimentSavedMetric - fields = [ - "id", - "name", - "description", - "query", - "created_by", - "created_at", - "updated_at", - ] - read_only_fields = [ - "id", - "created_by", - "created_at", - "updated_at", - ] - - def validate_query(self, value): - if not value: - raise ValidationError("Query is required to create a saved metric") - - metric_query = value - - if metric_query.get("kind") not in ["ExperimentTrendsQuery", "ExperimentFunnelsQuery"]: - raise ValidationError("Metric query kind must be 'ExperimentTrendsQuery' or 'ExperimentFunnelsQuery'") - - # pydantic models are used to validate the query - try: - if metric_query["kind"] == "ExperimentTrendsQuery": - ExperimentTrendsQuery(**metric_query) - else: - ExperimentFunnelsQuery(**metric_query) - except pydantic.ValidationError as e: - raise ValidationError(str(e.errors())) from e - - return value - - def create(self, validated_data): - request = self.context["request"] - validated_data["created_by"] = request.user - validated_data["team_id"] = self.context["team_id"] - return super().create(validated_data) - - -class ExperimentSavedMetricViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): - scope_object = "experiment" - queryset = ExperimentSavedMetric.objects.prefetch_related("created_by").all() - serializer_class = ExperimentSavedMetricSerializer - ordering = "-created_at" diff --git a/ee/clickhouse/views/experiments.py b/ee/clickhouse/views/experiments.py deleted file mode 100644 index a0045f96f2..0000000000 --- a/ee/clickhouse/views/experiments.py +++ /dev/null @@ -1,630 +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.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 ee.clickhouse.views.experiment_holdouts import ExperimentHoldoutSerializer -from ee.clickhouse.views.experiment_saved_metrics import ExperimentToSavedMetricSerializer -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.api.utils import action -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, ExperimentHoldout, ExperimentSavedMetric -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, - holdout=experiment.holdout, - custom_exposure_filter=exposure_filter, - ).get_results() - else: - calculate_func = lambda: ClickhouseFunnelExperimentResult( - filter, - experiment.team, - experiment.feature_flag, - experiment.start_date, - experiment.end_date, - holdout=experiment.holdout, - ).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) - holdout = ExperimentHoldoutSerializer(read_only=True) - holdout_id = serializers.PrimaryKeyRelatedField( - queryset=ExperimentHoldout.objects.all(), source="holdout", required=False, allow_null=True - ) - saved_metrics = ExperimentToSavedMetricSerializer(many=True, source="experimenttosavedmetric_set", read_only=True) - saved_metrics_ids = serializers.ListField(child=serializers.JSONField(), required=False, allow_null=True) - - class Meta: - model = Experiment - fields = [ - "id", - "name", - "description", - "start_date", - "end_date", - "feature_flag_key", - "feature_flag", - "holdout", - "holdout_id", - "exposure_cohort", - "parameters", - "secondary_metrics", - "saved_metrics", - "saved_metrics_ids", - "filters", - "archived", - "created_by", - "created_at", - "updated_at", - "type", - "metrics", - "metrics_secondary", - "stats_config", - ] - read_only_fields = [ - "id", - "created_by", - "created_at", - "updated_at", - "feature_flag", - "exposure_cohort", - "holdout", - "saved_metrics", - ] - - def validate_saved_metrics_ids(self, value): - if value is None: - return value - - # check value is valid json list with id and optionally metadata param - if not isinstance(value, list): - raise ValidationError("Saved metrics must be a list") - - for saved_metric in value: - if not isinstance(saved_metric, dict): - raise ValidationError("Saved metric must be an object") - if "id" not in saved_metric: - raise ValidationError("Saved metric must have an id") - if "metadata" in saved_metric and not isinstance(saved_metric["metadata"], dict): - raise ValidationError("Metadata must be an object") - - # metadata is optional, but if it exists, should have type key - # TODO: extend with other metadata keys when known - if "metadata" in saved_metric and "type" not in saved_metric["metadata"]: - raise ValidationError("Metadata must have a type key") - - # check if all saved metrics exist - saved_metrics = ExperimentSavedMetric.objects.filter(id__in=[saved_metric["id"] for saved_metric in value]) - if saved_metrics.count() != len(value): - raise ValidationError("Saved metric does not exist") - - return value - - def validate_metrics(self, value): - # TODO 2024-11-15: commented code will be addressed when persistent metrics are implemented. - - return value - - def validate_parameters(self, value): - if not value: - return value - - variants = value.get("feature_flag_variants", []) - - if len(variants) >= 21: - raise ValidationError("Feature flag variants must be less than 21") - 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: - is_draft = "start_date" not in validated_data or validated_data["start_date"] is None - - # if not validated_data.get("filters") and not is_draft: - # raise ValidationError("Filters are required when creating a launched experiment") - - saved_metrics_data = validated_data.pop("saved_metrics_ids", []) - - 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") - - holdout_groups = None - if validated_data.get("holdout"): - holdout_groups = validated_data["holdout"].filters - - default_variants = [ - {"key": "control", "name": "Control Group", "rollout_percentage": 50}, - {"key": "test", "name": "Test Variant", "rollout_percentage": 50}, - ] - - feature_flag_filters = { - "groups": [{"properties": [], "rollout_percentage": 100}], - "multivariate": {"variants": variants or default_variants}, - "aggregation_group_type_index": aggregation_group_type_index, - "holdout_groups": holdout_groups, - } - - feature_flag_serializer = FeatureFlagSerializer( - data={ - "key": feature_flag_key, - "name": f'Feature Flag for Experiment {validated_data["name"]}', - "filters": feature_flag_filters, - "active": not is_draft, - "creation_context": "experiments", - }, - context=self.context, - ) - - feature_flag_serializer.is_valid(raise_exception=True) - feature_flag = feature_flag_serializer.save() - - if not validated_data.get("stats_config"): - validated_data["stats_config"] = {"version": 2} - - experiment = Experiment.objects.create( - team_id=self.context["team_id"], feature_flag=feature_flag, **validated_data - ) - - # if this is a web experiment, copy over the variant data to the experiment itself. - if validated_data.get("type", "") == "web": - web_variants = {} - ff_variants = variants or default_variants - - for variant in ff_variants: - web_variants[variant.get("key")] = { - "rollout_percentage": variant.get("rollout_percentage"), - } - - experiment.variants = web_variants - experiment.save() - - if saved_metrics_data: - for saved_metric_data in saved_metrics_data: - saved_metric_serializer = ExperimentToSavedMetricSerializer( - data={ - "experiment": experiment.id, - "saved_metric": saved_metric_data["id"], - "metadata": saved_metric_data.get("metadata"), - }, - context=self.context, - ) - saved_metric_serializer.is_valid(raise_exception=True) - saved_metric_serializer.save() - # TODO: Going the above route means we can still sometimes fail when validation fails? - # But this shouldn't really happen, if it does its a bug in our validation logic (validate_saved_metrics_ids) - return experiment - - def update(self, instance: Experiment, validated_data: dict, *args: Any, **kwargs: Any) -> Experiment: - # if ( - # not instance.filters.get("events") - # and not instance.filters.get("actions") - # and not instance.filters.get("data_warehouse") - # and validated_data.get("start_date") - # and not validated_data.get("filters") - # ): - # raise ValidationError("Filters are required when launching an experiment") - - update_saved_metrics = "saved_metrics_ids" in validated_data - saved_metrics_data = validated_data.pop("saved_metrics_ids", []) or [] - - # We replace all saved metrics on update to avoid issues with partial updates - if update_saved_metrics: - instance.experimenttosavedmetric_set.all().delete() - for saved_metric_data in saved_metrics_data: - saved_metric_serializer = ExperimentToSavedMetricSerializer( - data={ - "experiment": instance.id, - "saved_metric": saved_metric_data["id"], - "metadata": saved_metric_data.get("metadata"), - }, - context=self.context, - ) - saved_metric_serializer.is_valid(raise_exception=True) - saved_metric_serializer.save() - - 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", - "holdout", - "metrics", - "metrics_secondary", - "stats_config", - } - 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 or holdout 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") - if "holdout" in validated_data and validated_data["holdout"] != instance.holdout: - raise ValidationError("Can't update holdout on running 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 or holdout have changed, update the feature flag. - holdout_groups = instance.holdout.filters if instance.holdout else None - if "holdout" in validated_data: - holdout_groups = validated_data["holdout"].filters if validated_data["holdout"] else None - - 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}, - ] - - feature_flag_filters = { - "groups": feature_flag.filters.get("groups", []), - "multivariate": {"variants": variants or default_variants}, - "aggregation_group_type_index": aggregation_group_type_index, - "holdout_groups": holdout_groups, - } - - existing_flag_serializer = FeatureFlagSerializer( - feature_flag, - data={"filters": feature_flag_filters}, - partial=True, - context=self.context, - ) - existing_flag_serializer.is_valid(raise_exception=True) - existing_flag_serializer.save() - else: - # no parameters provided, just update the holdout if necessary - if "holdout" in validated_data: - existing_flag_serializer = FeatureFlagSerializer( - feature_flag, - data={"filters": {**feature_flag.filters, "holdout_groups": holdout_groups}}, - 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 EnterpriseExperimentsViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): - scope_object = "experiment" - serializer_class = ExperimentSerializer - queryset = Experiment.objects.prefetch_related( - "feature_flag", "created_by", "holdout", "experimenttosavedmetric_set", "saved_metrics" - ).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 be692dc597..0000000000 --- a/ee/clickhouse/views/groups.py +++ /dev/null @@ -1,210 +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 posthog.api.utils 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 GroupsTypesViewSet(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( - project_id=self.team.project_id, 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 GroupsViewSet(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) - - @action(methods=["GET"], detail=False) - def property_values(self, request: request.Request, **kw): - value_filter = request.GET.get("value") - - query = f""" - SELECT {trim_quotes_expr("tupleElement(keysAndValues, 2)")} as value, count(*) as count - 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 - {f"AND {trim_quotes_expr('tupleElement(keysAndValues, 2)')} ILIKE %(value_filter)s" if value_filter else ""} - GROUP BY value - ORDER BY count DESC, value ASC - LIMIT 20 - """ - - params = { - "team_id": self.team.pk, - "group_type_index": request.GET["group_type_index"], - "key": request.GET["key"], - } - - if value_filter: - params["value_filter"] = f"%{value_filter}%" - - rows = sync_execute(query, params) - - return response.Response([{"name": name, "count": count} for name, count in rows]) diff --git a/ee/clickhouse/views/insights.py b/ee/clickhouse/views/insights.py deleted file mode 100644 index 529bf53e77..0000000000 --- a/ee/clickhouse/views/insights.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import Any - -from posthog.api.utils 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.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 EnterpriseInsightsViewSet(InsightViewSet): - permission_classes = [CanEditInsight] - stickiness_query_class = ClickhouseStickiness - - # ****************************************** - # /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 750ce49809..0000000000 --- a/ee/clickhouse/views/person.py +++ /dev/null @@ -1,65 +0,0 @@ -from typing import Optional - -from rest_framework import request, response -from posthog.api.utils 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): - param_derived_from_user_current_team = "team_id" diff --git a/ee/clickhouse/views/test/__init__.py b/ee/clickhouse/views/test/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 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 5559eacedb..0000000000 --- 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(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 983cdf00b5..0000000000 --- a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiments.ambr +++ /dev/null @@ -1,1531 +0,0 @@ -# serializer version: 1 -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results - ''' - /* 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 = 99999 - 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.1 - ''' - /* 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(max_steps)) - GROUP BY prop - ''' -# --- -# 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(max_steps)) - GROUP BY prop - ''' -# --- -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results_and_events_out_of_time_range_timezones - ''' - /* 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 = 99999 - 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.1 - ''' - /* 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(max_steps)) - GROUP BY prop - ''' -# --- -# 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(max_steps)) - GROUP BY prop - ''' -# --- -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results_for_three_test_variants - ''' - /* 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 = 99999 - 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.1 - ''' - /* 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(max_steps)) - GROUP BY prop - ''' -# --- -# 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(max_steps)) - GROUP BY prop - ''' -# --- -# name: ClickhouseTestFunnelExperimentResults.test_experiment_flow_with_event_results_with_hogql_aggregation - ''' - /* 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 = 99999 - 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.1 - ''' - /* 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(max_steps)) - GROUP BY prop - ''' -# --- -# 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(max_steps)) - GROUP BY prop - ''' -# --- -# name: ClickhouseTestTrendExperimentResults.test_experiment_flow_with_event_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 = 99999 - 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.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 ['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 = 99999 - 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.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature_flag_response'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 99999 - 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.3 - ''' - /* 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 = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as pdi ON events.distinct_id = pdi.distinct_id - WHERE e.team_id = 99999 - 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.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 = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as pdi ON events.distinct_id = pdi.distinct_id - WHERE e.team_id = 99999 - 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 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 99999 - 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.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_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 = 99999 - 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.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature_flag_response'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 99999 - 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.3 - ''' - /* 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_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 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 99999 - 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.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 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 = 99999 - 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.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature_flag_response'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 99999 - 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.3 - ''' - /* 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 = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as pdi ON events.distinct_id = pdi.distinct_id - WHERE e.team_id = 99999 - 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_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 = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as pdi ON events.distinct_id = pdi.distinct_id - WHERE e.team_id = 99999 - 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 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature/a-b-test'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 99999 - 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.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 ['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 = 99999 - 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.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT replaceRegexpAll(JSONExtractRaw(properties, '$feature_flag_response'), '^"|"$', '') AS value, - count(*) as count - FROM events e - WHERE team_id = 99999 - 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.3 - ''' - /* 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 = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as pdi ON events.distinct_id = pdi.distinct_id - WHERE e.team_id = 99999 - 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: 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 = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as pdi ON events.distinct_id = pdi.distinct_id - WHERE e.team_id = 99999 - 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, - 99999 as cohort_id, - 99999 as team_id, - 1 AS sign, - 1 AS version - FROM - (SELECT id - FROM person - WHERE team_id = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - AND cohort_id = 99999 - 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, - 99999 as cohort_id, - 99999 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 = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) - WHERE person_id IN - (SELECT id - FROM person - WHERE team_id = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - AND cohort_id = 99999 - 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 b9bfce22b4..0000000000 --- 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 = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) pdi on e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 1 - GROUP BY group_key) groups ON $group_1 = groups.group_key - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups ON $group_0 = groups.group_key - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - AND group_type_index = 1 - GROUP BY group_key) groups ON $group_1 = groups.group_key - WHERE team_id = 99999 - 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 109942e765..0000000000 --- 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 = 99999 - 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 = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as target - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as target - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) AS pdi ON e.distinct_id = pdi.distinct_id - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as target - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - AND (NOT (replaceRegexpAll(JSONExtractRaw(properties, 'email'), '^"|"$', '') ILIKE '%posthog.com%')) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (NOT (replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'email'), '^"|"$', '') ILIKE '%posthog.com%')) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - AND (NOT (replaceRegexpAll(JSONExtractRaw(properties, 'email'), '^"|"$', '') ILIKE '%posthog.com%')) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (NOT (replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'email'), '^"|"$', '') ILIKE '%posthog.com%')) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as target - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - AND (NOT (replaceRegexpAll(JSONExtractRaw(properties, 'email'), '^"|"$', '') ILIKE '%posthog.com%')) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (NOT (replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'email'), '^"|"$', '') ILIKE '%posthog.com%')) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - AND (NOT (replaceRegexpAll(JSONExtractRaw(properties, 'email'), '^"|"$', '') ILIKE '%posthog.com%')) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (NOT (replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'email'), '^"|"$', '') ILIKE '%posthog.com%')) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as target - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - AND (NOT (replaceRegexpAll(JSONExtractRaw(properties, 'email'), '^"|"$', '') ILIKE '%posthog.com%')) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (NOT (replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'email'), '^"|"$', '') ILIKE '%posthog.com%')) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - AND (NOT (replaceRegexpAll(JSONExtractRaw(properties, 'email'), '^"|"$', '') ILIKE '%posthog.com%')) ) - GROUP BY id - HAVING max(is_deleted) = 0 - AND (NOT (replaceRegexpAll(JSONExtractRaw(argMax(person.properties, version), 'email'), '^"|"$', '') ILIKE '%posthog.com%')) SETTINGS optimize_aggregation_in_order = 1) person ON person.id = pdi.person_id - WHERE team_id = 99999 - 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 0d92ce9f57..0000000000 --- a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_stickiness.ambr +++ /dev/null @@ -1,807 +0,0 @@ -# serializer version: 1 -# name: TestClickhouseStickiness.test_aggregate_by_groups - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(num_actors) AS counts, - groupArray(num_intervals) AS intervals - FROM - (SELECT sum(num_actors) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT 0 AS num_actors, - plus(numbers.number, 1) AS num_intervals - FROM numbers(dateDiff('week', toStartOfWeek(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')), 0), plus(toStartOfWeek(assumeNotNull(parseDateTime64BestEffortOrNull('2020-02-15 23:59:59', 6, 'UTC')), 0), toIntervalWeek(1)))) AS numbers - UNION ALL SELECT count(DISTINCT aggregation_target) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT aggregation_target AS aggregation_target, - count() AS num_intervals - FROM - (SELECT e.`$group_0` AS aggregation_target, - toStartOfWeek(toTimeZone(e.timestamp, 'UTC'), 0) AS start_of_interval - FROM events AS e SAMPLE 1 - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfWeek(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')), 0)), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-02-15 23:59:59', 6, 'UTC'))), equals(e.event, 'watched movie'), ifNull(notEquals(nullIf(nullIf(e.`$group_0`, ''), 'null'), ''), 1), notEquals(e.`$group_0`, '')) - GROUP BY aggregation_target, - start_of_interval - HAVING ifNull(greater(count(), 0), 0)) - GROUP BY aggregation_target) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - LIMIT 100 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: 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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 groupArray(num_actors) AS counts, - groupArray(num_intervals) AS intervals - FROM - (SELECT sum(num_actors) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT 0 AS num_actors, - plus(numbers.number, 1) AS num_intervals - FROM numbers(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC'))), plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-08 23:59:59', 6, 'UTC'))), toIntervalDay(1)))) AS numbers - UNION ALL SELECT count(DISTINCT aggregation_target) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT aggregation_target AS aggregation_target, - count() AS num_intervals - FROM - (SELECT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id) AS aggregation_target, - toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS start_of_interval - FROM events AS e SAMPLE 1 - 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-08 23:59:59', 6, 'UTC'))), equals(e.event, 'watched movie')) - GROUP BY aggregation_target, - start_of_interval - HAVING ifNull(greater(count(), 0), 0)) - GROUP BY aggregation_target) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - LIMIT 100 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: TestClickhouseStickiness.test_compare.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(num_actors) AS counts, - groupArray(num_intervals) AS intervals - FROM - (SELECT sum(num_actors) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT 0 AS num_actors, - plus(numbers.number, 1) AS num_intervals - FROM numbers(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-24 00:00:00', 6, 'UTC'))), plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-31 23:59:59', 6, 'UTC'))), toIntervalDay(1)))) AS numbers - UNION ALL SELECT count(DISTINCT aggregation_target) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT aggregation_target AS aggregation_target, - count() AS num_intervals - FROM - (SELECT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id) AS aggregation_target, - toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS start_of_interval - FROM events AS e SAMPLE 1 - 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-24 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-31 23:59:59', 6, 'UTC'))), equals(e.event, 'watched movie')) - GROUP BY aggregation_target, - start_of_interval - HAVING ifNull(greater(count(), 0), 0)) - GROUP BY aggregation_target) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - LIMIT 100 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: TestClickhouseStickiness.test_filter_by_group_properties - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(num_actors) AS counts, - groupArray(num_intervals) AS intervals - FROM - (SELECT sum(num_actors) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT 0 AS num_actors, - plus(numbers.number, 1) AS num_intervals - FROM numbers(dateDiff('week', toStartOfWeek(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')), 0), plus(toStartOfWeek(assumeNotNull(parseDateTime64BestEffortOrNull('2020-02-15 23:59:59', 6, 'UTC')), 0), toIntervalWeek(1)))) AS numbers - UNION ALL SELECT count(DISTINCT aggregation_target) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT aggregation_target AS aggregation_target, - count() AS num_intervals - FROM - (SELECT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id) AS aggregation_target, - toStartOfWeek(toTimeZone(e.timestamp, 'UTC'), 0) AS start_of_interval - FROM events AS e SAMPLE 1 - 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - LEFT JOIN - (SELECT argMax(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(groups.group_properties, 'industry'), ''), 'null'), '^"|"$', ''), toTimeZone(groups._timestamp, 'UTC')) AS properties___industry, - groups.group_type_index AS index, - groups.group_key AS key - FROM groups - WHERE and(equals(groups.team_id, 99999), equals(index, 0)) - GROUP BY groups.group_type_index, - groups.group_key) AS e__group_0 ON equals(e.`$group_0`, e__group_0.key) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfWeek(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')), 0)), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-02-15 23:59:59', 6, 'UTC'))), equals(e.event, 'watched movie'), ifNull(equals(e__group_0.properties___industry, 'technology'), 0)) - GROUP BY aggregation_target, - start_of_interval - HAVING ifNull(greater(count(), 0), 0)) - GROUP BY aggregation_target) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - LIMIT 100 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: TestClickhouseStickiness.test_filter_by_group_properties.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT DISTINCT aggregation_target AS actor_id - FROM - (SELECT if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) AS aggregation_target, - countDistinct(toStartOfWeek(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'), 0)) as num_intervals - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) AS aggregation_target, - countDistinct(toStartOfWeek(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'), 0)) as num_intervals - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) AS aggregation_target, - countDistinct(toStartOfWeek(toTimeZone(toDateTime(timestamp, 'UTC'), 'UTC'), 0)) as num_intervals - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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 = 99999 - AND timestamp > '2015-01-01' - order by timestamp - limit 1 - ''' -# --- -# name: TestClickhouseStickiness.test_stickiness_all_time.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(num_actors) AS counts, - groupArray(num_intervals) AS intervals - FROM - (SELECT sum(num_actors) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT 0 AS num_actors, - plus(numbers.number, 1) AS num_intervals - FROM numbers(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 12:00:00', 6, 'UTC'))), plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-08 23:59:59', 6, 'UTC'))), toIntervalDay(1)))) AS numbers - UNION ALL SELECT count(DISTINCT aggregation_target) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT aggregation_target AS aggregation_target, - count() AS num_intervals - FROM - (SELECT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id) AS aggregation_target, - toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS start_of_interval - FROM events AS e SAMPLE 1 - 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 12:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-08 23:59:59', 6, 'UTC'))), equals(e.event, 'watched movie')) - GROUP BY aggregation_target, - start_of_interval - HAVING ifNull(greater(count(), 0), 0)) - GROUP BY aggregation_target) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - LIMIT 100 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: TestClickhouseStickiness.test_stickiness_all_time.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT timestamp - from events - WHERE team_id = 99999 - AND timestamp > '2015-01-01' - order by timestamp - limit 1 - ''' -# --- -# name: TestClickhouseStickiness.test_stickiness_all_time_with_sampling - ''' - /* user_id:0 request:_snapshot_ */ - SELECT timestamp - from events - WHERE team_id = 99999 - 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 groupArray(num_actors) AS counts, - groupArray(num_intervals) AS intervals - FROM - (SELECT sum(num_actors) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT 0 AS num_actors, - plus(numbers.number, 1) AS num_intervals - FROM numbers(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 12:00:00', 6, 'UTC'))), plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-08 23:59:59', 6, 'UTC'))), toIntervalDay(1)))) AS numbers - UNION ALL SELECT count(DISTINCT aggregation_target) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT aggregation_target AS aggregation_target, - count() AS num_intervals - FROM - (SELECT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id) AS aggregation_target, - toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS start_of_interval - FROM events AS e SAMPLE 1.0 - 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 12:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-08 23:59:59', 6, 'UTC'))), equals(e.event, 'watched movie')) - GROUP BY aggregation_target, - start_of_interval - HAVING ifNull(greater(count(), 0), 0)) - GROUP BY aggregation_target) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - LIMIT 100 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: TestClickhouseStickiness.test_stickiness_all_time_with_sampling.2 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT timestamp - from events - WHERE team_id = 99999 - AND timestamp > '2015-01-01' - order by timestamp - limit 1 - ''' -# --- -# name: TestClickhouseStickiness.test_stickiness_hours - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(num_actors) AS counts, - groupArray(num_intervals) AS intervals - FROM - (SELECT sum(num_actors) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT 0 AS num_actors, - plus(numbers.number, 1) AS num_intervals - FROM numbers(dateDiff('hour', toStartOfHour(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 12:00:00', 6, 'UTC'))), plus(toStartOfHour(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 20:00:00', 6, 'UTC'))), toIntervalHour(1)))) AS numbers - UNION ALL SELECT count(DISTINCT aggregation_target) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT aggregation_target AS aggregation_target, - count() AS num_intervals - FROM - (SELECT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id) AS aggregation_target, - toStartOfHour(toTimeZone(e.timestamp, 'UTC')) AS start_of_interval - FROM events AS e SAMPLE 1 - 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfHour(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 12:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 20:00:00', 6, 'UTC'))), equals(e.event, 'watched movie')) - GROUP BY aggregation_target, - start_of_interval - HAVING ifNull(greater(count(), 0), 0)) - GROUP BY aggregation_target) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - LIMIT 100 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: TestClickhouseStickiness.test_stickiness_people_endpoint - ''' - /* user_id:0 request:_snapshot_ */ - SELECT DISTINCT aggregation_target AS actor_id - FROM - (SELECT if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - AND distinct_id = 'person2' - ''' -# --- -# name: TestClickhouseStickiness.test_stickiness_with_person_on_events_v2.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(num_actors) AS counts, - groupArray(num_intervals) AS intervals - FROM - (SELECT sum(num_actors) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT 0 AS num_actors, - plus(numbers.number, 1) AS num_intervals - FROM numbers(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC'))), plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-08 23:59:59', 6, 'UTC'))), toIntervalDay(1)))) AS numbers - UNION ALL SELECT count(DISTINCT aggregation_target) AS num_actors, - num_intervals AS num_intervals - FROM - (SELECT aggregation_target AS aggregation_target, - count() AS num_intervals - FROM - (SELECT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id) AS aggregation_target, - toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS start_of_interval - FROM events AS e SAMPLE 1 - 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-08 23:59:59', 6, 'UTC'))), equals(e.event, 'watched movie')) - GROUP BY aggregation_target, - start_of_interval - HAVING ifNull(greater(count(), 0), 0)) - GROUP BY aggregation_target) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - GROUP BY num_intervals - ORDER BY num_intervals ASC) - LIMIT 100 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: TestClickhouseStickiness.test_timezones - ''' - - SELECT countDistinct(aggregation_target), - num_intervals - FROM - (SELECT if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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 if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) AS aggregation_target, - countDistinct(toStartOfDay(toTimeZone(toDateTime(timestamp, 'UTC'), 'US/Pacific'))) as num_intervals - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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 4d1cee60be..0000000000 --- a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_trends.ambr +++ /dev/null @@ -1,1065 +0,0 @@ -# serializer version: 1 -# name: ClickhouseTestTrends.test_insight_trends_aggregate - ''' - /* user_id:0 request:_snapshot_ */ - SELECT count() AS total - FROM events AS e SAMPLE 1 - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))), equals(e.event, '$pageview')) - ORDER BY 1 DESC - LIMIT 50000 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: 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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 arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))))), 1))) AS date, - arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) - and isNull(_match_date)), _days_for_count), _index), 1))), date) AS total - FROM - (SELECT sum(total) AS count, - day_start AS day_start - FROM - (SELECT count(DISTINCT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id)) AS total, - toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start - FROM events AS e SAMPLE 1 - 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))), equals(e.event, '$pageview')) - GROUP BY day_start) - GROUP BY day_start - ORDER BY day_start ASC) - ORDER BY arraySum(total) DESC - LIMIT 50000 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: 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - 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", - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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 arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))))), 1))) AS date, - arrayFill(x -> ifNull(greater(x, 0), 0), arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) - and isNull(_match_date)), _days_for_count), _index), 1))), date)) AS total - FROM - (SELECT day_start AS day_start, - sum(count) OVER ( - ORDER BY day_start ASC) AS count - FROM - (SELECT sum(total) AS count, - day_start AS day_start - FROM - (SELECT count() AS total, - toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start - FROM events AS e SAMPLE 1 - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))), equals(e.event, '$pageview')) - GROUP BY day_start) - GROUP BY day_start - ORDER BY day_start ASC) - ORDER BY day_start ASC) - ORDER BY arraySum(total) DESC - LIMIT 50000 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: ClickhouseTestTrends.test_insight_trends_cumulative.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))))), 1))) AS date, - arrayFill(x -> ifNull(greater(x, 0), 0), arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) - and isNull(_match_date)), _days_for_count), _index), 1))), date)) AS total - FROM - (SELECT day_start AS day_start, - sum(count) OVER ( - ORDER BY day_start ASC) AS count - FROM - (SELECT sum(total) AS count, - day_start AS day_start - FROM - (SELECT count(DISTINCT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id)) AS total, - min(toStartOfDay(toTimeZone(e.timestamp, 'UTC'))) AS day_start - FROM events AS e SAMPLE 1 - 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))), equals(e.event, '$pageview')) - GROUP BY if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id)) - GROUP BY day_start - ORDER BY day_start ASC) - ORDER BY day_start ASC) - ORDER BY arraySum(total) DESC - LIMIT 50000 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: 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 = 99999 - 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 = 99999 - GROUP BY distinct_id - HAVING argMax(is_deleted, version) = 0) as pdi ON events.distinct_id = pdi.distinct_id - WHERE e.team_id = 99999 - 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", - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(1)(date)[1] AS date, - arrayFold((acc, x) -> arrayMap(i -> plus(acc[i], x[i]), range(1, plus(length(date), 1))), groupArray(ifNull(total, 0)), arrayWithConstant(length(date), reinterpretAsFloat64(0))) AS total, - if(ifNull(ifNull(greaterOrEquals(row_number, 25), 0), 0), '$$_posthog_breakdown_other_$$', breakdown_value) AS breakdown_value - FROM - (SELECT arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))))), 1))) AS date, - arrayFill(x -> ifNull(greater(x, 0), 0), arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) - and isNull(_match_date)), _days_for_count), _index), 1))), date)) AS total, - breakdown_value AS breakdown_value, - rowNumberInAllBlocks() AS row_number - FROM - (SELECT day_start AS day_start, - sum(count) OVER (PARTITION BY breakdown_value - ORDER BY day_start ASC) AS count, - breakdown_value AS breakdown_value - FROM - (SELECT sum(total) AS count, - day_start AS day_start, - breakdown_value AS breakdown_value - FROM - (SELECT count() AS total, - toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, - ifNull(nullIf(toString(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', '')), ''), '$$_posthog_breakdown_null_$$') AS breakdown_value - FROM events AS e SAMPLE 1 - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))), equals(e.event, '$pageview')) - GROUP BY day_start, - breakdown_value) - GROUP BY day_start, - breakdown_value - ORDER BY day_start ASC, breakdown_value ASC) - ORDER BY day_start ASC) - GROUP BY breakdown_value - ORDER BY if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_other_$$'), 0), 2, if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_null_$$'), 0), 1, 0)) ASC, arraySum(total) DESC, breakdown_value ASC) - WHERE isNotNull(breakdown_value) - GROUP BY breakdown_value - ORDER BY if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_other_$$'), 0), 2, if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_null_$$'), 0), 1, 0)) ASC, arraySum(total) DESC, breakdown_value ASC - LIMIT 50000 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: ClickhouseTestTrends.test_insight_trends_cumulative.3 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(1)(date)[1] AS date, - arrayFold((acc, x) -> arrayMap(i -> plus(acc[i], x[i]), range(1, plus(length(date), 1))), groupArray(ifNull(total, 0)), arrayWithConstant(length(date), reinterpretAsFloat64(0))) AS total, - if(ifNull(ifNull(greaterOrEquals(row_number, 25), 0), 0), '$$_posthog_breakdown_other_$$', breakdown_value) AS breakdown_value - FROM - (SELECT arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))))), 1))) AS date, - arrayFill(x -> ifNull(greater(x, 0), 0), arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) - and isNull(_match_date)), _days_for_count), _index), 1))), date)) AS total, - breakdown_value AS breakdown_value, - rowNumberInAllBlocks() AS row_number - FROM - (SELECT day_start AS day_start, - sum(count) OVER (PARTITION BY breakdown_value - ORDER BY day_start ASC) AS count, - breakdown_value AS breakdown_value - FROM - (SELECT sum(total) AS count, - day_start AS day_start, - breakdown_value AS breakdown_value - FROM - (SELECT counts AS total, - toStartOfDay(timestamp) AS day_start, - breakdown_value AS breakdown_value - FROM - (SELECT d.timestamp AS timestamp, - count(DISTINCT e.actor_id) AS counts, - e.breakdown_value AS breakdown_value - FROM - (SELECT minus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))), toIntervalDay(numbers.number)) AS timestamp - FROM numbers(dateDiff('day', minus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toIntervalDay(7)), assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC')))) AS numbers) AS d - CROSS JOIN - (SELECT toTimeZone(e.timestamp, 'UTC') AS timestamp, - if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id) AS actor_id, - ifNull(nullIf(toString(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', '')), ''), '$$_posthog_breakdown_null_$$') AS breakdown_value - FROM events AS e SAMPLE 1 - 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - LEFT JOIN - (SELECT person.id AS id, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, 'key'), ''), 'null'), '^"|"$', '') AS properties___key - FROM person - WHERE and(equals(person.team_id, 99999), ifNull(in(tuple(person.id, person.version), - (SELECT person.id AS id, max(person.version) AS version - FROM person - WHERE equals(person.team_id, 99999) - 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 e__person ON equals(if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id), e__person.id) - WHERE and(equals(e.team_id, 99999), and(equals(e.event, '$pageview'), ifNull(equals(e__person.properties___key, 'some_val'), 0), ifNull(equals(e__person.properties___key, 'some_val'), 0)), greaterOrEquals(timestamp, minus(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC')), toIntervalDay(7))), lessOrEquals(timestamp, assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC')))) - GROUP BY timestamp, actor_id, - breakdown_value) AS e - WHERE and(ifNull(lessOrEquals(e.timestamp, plus(d.timestamp, toIntervalDay(1))), 0), ifNull(greater(e.timestamp, minus(d.timestamp, toIntervalDay(6))), 0)) - GROUP BY d.timestamp, - breakdown_value - ORDER BY d.timestamp ASC) - WHERE and(ifNull(greaterOrEquals(timestamp, toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC')))), 0), ifNull(lessOrEquals(timestamp, assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))), 0))) - GROUP BY day_start, - breakdown_value - ORDER BY day_start ASC, breakdown_value ASC) - ORDER BY day_start ASC) - GROUP BY breakdown_value - ORDER BY if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_other_$$'), 0), 2, if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_null_$$'), 0), 1, 0)) ASC, arraySum(total) DESC, breakdown_value ASC) - WHERE isNotNull(breakdown_value) - GROUP BY breakdown_value - ORDER BY if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_other_$$'), 0), 2, if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_null_$$'), 0), 1, 0)) ASC, arraySum(total) DESC, breakdown_value ASC - LIMIT 50000 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: ClickhouseTestTrends.test_insight_trends_cumulative.4 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT groupArray(1)(date)[1] AS date, - arrayFold((acc, x) -> arrayMap(i -> plus(acc[i], x[i]), range(1, plus(length(date), 1))), groupArray(ifNull(total, 0)), arrayWithConstant(length(date), reinterpretAsFloat64(0))) AS total, - if(ifNull(ifNull(greaterOrEquals(row_number, 25), 0), 0), '$$_posthog_breakdown_other_$$', breakdown_value) AS breakdown_value - FROM - (SELECT arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))))), 1))) AS date, - arrayFill(x -> ifNull(greater(x, 0), 0), arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) - and isNull(_match_date)), _days_for_count), _index), 1))), date)) AS total, - breakdown_value AS breakdown_value, - rowNumberInAllBlocks() AS row_number - FROM - (SELECT day_start AS day_start, - sum(count) OVER (PARTITION BY breakdown_value - ORDER BY day_start ASC) AS count, - breakdown_value AS breakdown_value - FROM - (SELECT sum(total) AS count, - day_start AS day_start, - breakdown_value AS breakdown_value - FROM - (SELECT count(DISTINCT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id)) AS total, - min(toStartOfDay(toTimeZone(e.timestamp, 'UTC'))) AS day_start, - ifNull(nullIf(toString(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', '')), ''), '$$_posthog_breakdown_null_$$') AS breakdown_value - FROM events AS e SAMPLE 1 - 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))), equals(e.event, '$pageview')) - GROUP BY if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id), - breakdown_value) - GROUP BY day_start, - breakdown_value - ORDER BY day_start ASC, breakdown_value ASC) - ORDER BY day_start ASC) - GROUP BY breakdown_value - ORDER BY if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_other_$$'), 0), 2, if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_null_$$'), 0), 1, 0)) ASC, arraySum(total) DESC, breakdown_value ASC) - WHERE isNotNull(breakdown_value) - GROUP BY breakdown_value - ORDER BY if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_other_$$'), 0), 2, if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_null_$$'), 0), 1, 0)) ASC, arraySum(total) DESC, breakdown_value ASC - LIMIT 50000 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: 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 = 99999 - 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", - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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", - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND id IN - (SELECT id - FROM person - WHERE team_id = 99999 - 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 = 99999 - 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 arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))))), 1))) AS date, - arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) - and isNull(_match_date)), _days_for_count), _index), 1))), date) AS total - FROM - (SELECT sum(total) AS count, - day_start AS day_start - FROM - (SELECT count(DISTINCT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id)) AS total, - toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start - FROM events AS e SAMPLE 1 - 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))), equals(e.event, '$pageview')) - GROUP BY day_start) - GROUP BY day_start - ORDER BY day_start ASC) - ORDER BY arraySum(total) DESC - LIMIT 50000 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: ClickhouseTestTrendsCaching.test_insight_trends_merging.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))))), 1))) AS date, - arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) - and isNull(_match_date)), _days_for_count), _index), 1))), date) AS total - FROM - (SELECT sum(total) AS count, - day_start AS day_start - FROM - (SELECT count(DISTINCT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id)) AS total, - toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start - FROM events AS e SAMPLE 1 - 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-15 23:59:59', 6, 'UTC'))), equals(e.event, '$pageview')) - GROUP BY day_start) - GROUP BY day_start - ORDER BY day_start ASC) - ORDER BY arraySum(total) DESC - LIMIT 50000 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: ClickhouseTestTrendsCaching.test_insight_trends_merging_skipped_interval - ''' - /* user_id:0 request:_snapshot_ */ - SELECT arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2011-12-31 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2011-12-31 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-14 23:59:59', 6, 'UTC'))))), 1))) AS date, - arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) - and isNull(_match_date)), _days_for_count), _index), 1))), date) AS total - FROM - (SELECT sum(total) AS count, - day_start AS day_start - FROM - (SELECT count(DISTINCT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id)) AS total, - toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start - FROM events AS e SAMPLE 1 - 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2011-12-31 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-14 23:59:59', 6, 'UTC'))), equals(e.event, '$pageview')) - GROUP BY day_start) - GROUP BY day_start - ORDER BY day_start ASC) - ORDER BY arraySum(total) DESC - LIMIT 50000 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: ClickhouseTestTrendsCaching.test_insight_trends_merging_skipped_interval.1 - ''' - /* user_id:0 request:_snapshot_ */ - SELECT arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-02 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-02 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-16 23:59:59', 6, 'UTC'))))), 1))) AS date, - arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) - and isNull(_match_date)), _days_for_count), _index), 1))), date) AS total - FROM - (SELECT sum(total) AS count, - day_start AS day_start - FROM - (SELECT count(DISTINCT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id)) AS total, - toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start - FROM events AS e SAMPLE 1 - 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-02 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2012-01-16 23:59:59', 6, 'UTC'))), equals(e.event, '$pageview')) - GROUP BY day_start) - GROUP BY day_start - ORDER BY day_start ASC) - ORDER BY arraySum(total) DESC - LIMIT 50000 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: ClickhouseTestTrendsGroups.test_aggregating_by_group - ''' - /* user_id:0 request:_snapshot_ */ - SELECT arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))))), 1))) AS date, - arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) - and isNull(_match_date)), _days_for_count), _index), 1))), date) AS total - FROM - (SELECT sum(total) AS count, - day_start AS day_start - FROM - (SELECT count(DISTINCT e.`$group_0`) AS total, - toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start - FROM events AS e SAMPLE 1 - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))), equals(e.event, '$pageview'), ifNull(notEquals(nullIf(nullIf(e.`$group_0`, ''), 'null'), ''), 1), notEquals(e.`$group_0`, '')) - GROUP BY day_start) - GROUP BY day_start - ORDER BY day_start ASC) - ORDER BY arraySum(total) DESC - LIMIT 50000 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: 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 = 99999 - 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 arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))))), 1))) AS date, - arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) - and isNull(_match_date)), _days_for_count), _index), 1))), date) AS total - FROM - (SELECT sum(total) AS count, - day_start AS day_start - FROM - (SELECT count(DISTINCT e.`$session_id`) AS total, - toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start - FROM events AS e SAMPLE 1 - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))), equals(e.event, '$pageview')) - GROUP BY day_start) - GROUP BY day_start - ORDER BY day_start ASC) - ORDER BY arraySum(total) DESC - LIMIT 50000 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: 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", - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as person_id, - e.distinct_id as distinct_id, - e.team_id as team_id - FROM events e - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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 e69de29bb2..0000000000 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 3923f79e01..0000000000 --- a/ee/clickhouse/views/test/funnel/__snapshots__/test_clickhouse_funnel.ambr +++ /dev/null @@ -1,503 +0,0 @@ -# serializer version: 1 -# name: ClickhouseTestFunnelGroups.test_funnel_aggregation_with_groups - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countIf(ifNull(equals(steps, 1), 0)) AS step_1, - countIf(ifNull(equals(steps, 2), 0)) AS step_2, - avg(step_1_average_conversion_time_inner) AS step_1_average_conversion_time, - median(step_1_median_conversion_time_inner) AS step_1_median_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - steps AS steps, - avg(step_1_conversion_time) AS step_1_average_conversion_time_inner, - median(step_1_conversion_time) AS step_1_median_conversion_time_inner - FROM - (SELECT aggregation_target AS aggregation_target, - steps AS steps, - max(steps) OVER (PARTITION BY aggregation_target) AS max_steps, - step_1_conversion_time AS step_1_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - timestamp AS timestamp, - step_0 AS step_0, - latest_0 AS latest_0, - step_1 AS step_1, - latest_1 AS latest_1, - if(and(ifNull(lessOrEquals(latest_0, latest_1), 0), ifNull(lessOrEquals(latest_1, plus(toTimeZone(latest_0, 'UTC'), toIntervalDay(14))), 0)), 2, 1) AS steps, - if(and(isNotNull(latest_1), ifNull(lessOrEquals(latest_1, plus(toTimeZone(latest_0, 'UTC'), toIntervalDay(14))), 0)), dateDiff('second', latest_0, latest_1), NULL) AS step_1_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - timestamp AS timestamp, - step_0 AS step_0, - latest_0 AS latest_0, - step_1 AS step_1, - min(latest_1) OVER (PARTITION BY aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) AS latest_1 - FROM - (SELECT toTimeZone(e.timestamp, 'UTC') AS timestamp, - e.`$group_0` AS aggregation_target, - if(equals(e.event, 'user signed up'), 1, 0) AS step_0, - if(ifNull(equals(step_0, 1), 0), timestamp, NULL) AS latest_0, - if(equals(e.event, 'paid'), 1, 0) AS step_1, - if(ifNull(equals(step_1, 1), 0), timestamp, NULL) AS latest_1 - FROM events AS e - WHERE and(equals(e.team_id, 99999), and(and(greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2020-01-01 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2020-01-14 23:59:59.999999', 6, 'UTC'))), in(e.event, tuple('paid', 'user signed up')), ifNull(notEquals(nullIf(nullIf(e.`$group_0`, ''), 'null'), ''), 1)), or(ifNull(equals(step_0, 1), 0), ifNull(equals(step_1, 1), 0))))) - WHERE ifNull(equals(step_0, 1), 0))) - GROUP BY aggregation_target, - steps - HAVING ifNull(equals(steps, max(max_steps)), isNull(steps) - and isNull(max(max_steps)))) - LIMIT 100 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=23622320128, - allow_experimental_analyzer=1 - ''' -# --- -# 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 = 99999 - 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(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(ifNull(equals(steps, 1), 0)) AS step_1, - countIf(ifNull(equals(steps, 2), 0)) AS step_2, - avg(step_1_average_conversion_time_inner) AS step_1_average_conversion_time, - median(step_1_median_conversion_time_inner) AS step_1_median_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - steps AS steps, - avg(step_1_conversion_time) AS step_1_average_conversion_time_inner, - median(step_1_conversion_time) AS step_1_median_conversion_time_inner - FROM - (SELECT aggregation_target AS aggregation_target, - steps AS steps, - max(steps) OVER (PARTITION BY aggregation_target) AS max_steps, - step_1_conversion_time AS step_1_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - timestamp AS timestamp, - step_0 AS step_0, - latest_0 AS latest_0, - step_1 AS step_1, - latest_1 AS latest_1, - if(and(ifNull(lessOrEquals(latest_0, latest_1), 0), ifNull(lessOrEquals(latest_1, plus(toTimeZone(latest_0, 'UTC'), toIntervalDay(14))), 0)), 2, 1) AS steps, - if(and(isNotNull(latest_1), ifNull(lessOrEquals(latest_1, plus(toTimeZone(latest_0, 'UTC'), toIntervalDay(14))), 0)), dateDiff('second', latest_0, latest_1), NULL) AS step_1_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - timestamp AS timestamp, - step_0 AS step_0, - latest_0 AS latest_0, - step_1 AS step_1, - min(latest_1) OVER (PARTITION BY aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) AS latest_1 - FROM - (SELECT toTimeZone(e.timestamp, 'UTC') AS timestamp, - e.`$group_0` AS aggregation_target, - if(and(equals(e.event, 'user signed up'), ifNull(equals(nullIf(nullIf(e.`$group_0`, ''), 'null'), 'org:5'), 0)), 1, 0) AS step_0, - if(ifNull(equals(step_0, 1), 0), timestamp, NULL) AS latest_0, - if(equals(e.event, 'paid'), 1, 0) AS step_1, - if(ifNull(equals(step_1, 1), 0), timestamp, NULL) AS latest_1 - FROM events AS e - WHERE and(equals(e.team_id, 99999), and(and(greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2020-01-01 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2020-01-14 23:59:59.999999', 6, 'UTC'))), in(e.event, tuple('paid', 'user signed up')), ifNull(notEquals(nullIf(nullIf(e.`$group_0`, ''), 'null'), ''), 1)), or(ifNull(equals(step_0, 1), 0), ifNull(equals(step_1, 1), 0))))) - WHERE ifNull(equals(step_0, 1), 0))) - GROUP BY aggregation_target, - steps - HAVING ifNull(equals(steps, max(max_steps)), isNull(steps) - and isNull(max(max_steps)))) - LIMIT 100 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=23622320128, - allow_experimental_analyzer=1 - ''' -# --- -# 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 = 99999 - 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(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(ifNull(equals(steps, 1), 0)) AS step_1, - countIf(ifNull(equals(steps, 2), 0)) AS step_2, - avg(step_1_average_conversion_time_inner) AS step_1_average_conversion_time, - median(step_1_median_conversion_time_inner) AS step_1_median_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - steps AS steps, - avg(step_1_conversion_time) AS step_1_average_conversion_time_inner, - median(step_1_conversion_time) AS step_1_median_conversion_time_inner - FROM - (SELECT aggregation_target AS aggregation_target, - steps AS steps, - max(steps) OVER (PARTITION BY aggregation_target) AS max_steps, - step_1_conversion_time AS step_1_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - timestamp AS timestamp, - step_0 AS step_0, - latest_0 AS latest_0, - step_1 AS step_1, - latest_1 AS latest_1, - if(and(ifNull(lessOrEquals(latest_0, latest_1), 0), ifNull(lessOrEquals(latest_1, plus(toTimeZone(latest_0, 'UTC'), toIntervalDay(14))), 0)), 2, 1) AS steps, - if(and(isNotNull(latest_1), ifNull(lessOrEquals(latest_1, plus(toTimeZone(latest_0, 'UTC'), toIntervalDay(14))), 0)), dateDiff('second', latest_0, latest_1), NULL) AS step_1_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - timestamp AS timestamp, - step_0 AS step_0, - latest_0 AS latest_0, - step_1 AS step_1, - min(latest_1) OVER (PARTITION BY aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) AS latest_1 - FROM - (SELECT toTimeZone(e.timestamp, 'UTC') AS timestamp, - if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id) AS aggregation_target, - if(and(equals(e.event, 'user signed up'), ifNull(equals(nullIf(nullIf(e.`$group_0`, ''), 'null'), 'org:5'), 0)), 1, 0) AS step_0, - if(ifNull(equals(step_0, 1), 0), timestamp, NULL) AS latest_0, - if(equals(e.event, 'paid'), 1, 0) AS step_1, - if(ifNull(equals(step_1, 1), 0), timestamp, NULL) AS latest_1 - FROM events AS 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), and(and(greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2020-01-01 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2020-01-14 23:59:59.999999', 6, 'UTC'))), in(e.event, tuple('paid', 'user signed up'))), or(ifNull(equals(step_0, 1), 0), ifNull(equals(step_1, 1), 0))))) - WHERE ifNull(equals(step_0, 1), 0))) - GROUP BY aggregation_target, - steps - HAVING ifNull(equals(steps, max(max_steps)), isNull(steps) - and isNull(max(max_steps)))) - LIMIT 100 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=23622320128, - allow_experimental_analyzer=1 - ''' -# --- -# 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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(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(ifNull(equals(steps, 1), 0)) AS step_1, - countIf(ifNull(equals(steps, 2), 0)) AS step_2, - avg(step_1_average_conversion_time_inner) AS step_1_average_conversion_time, - median(step_1_median_conversion_time_inner) AS step_1_median_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - steps AS steps, - avg(step_1_conversion_time) AS step_1_average_conversion_time_inner, - median(step_1_conversion_time) AS step_1_median_conversion_time_inner - FROM - (SELECT aggregation_target AS aggregation_target, - steps AS steps, - max(steps) OVER (PARTITION BY aggregation_target) AS max_steps, - step_1_conversion_time AS step_1_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - timestamp AS timestamp, - step_0 AS step_0, - latest_0 AS latest_0, - step_1 AS step_1, - latest_1 AS latest_1, - if(and(ifNull(lessOrEquals(latest_0, latest_1), 0), ifNull(lessOrEquals(latest_1, plus(toTimeZone(latest_0, 'UTC'), toIntervalDay(14))), 0)), 2, 1) AS steps, - if(and(isNotNull(latest_1), ifNull(lessOrEquals(latest_1, plus(toTimeZone(latest_0, 'UTC'), toIntervalDay(14))), 0)), dateDiff('second', latest_0, latest_1), NULL) AS step_1_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - timestamp AS timestamp, - step_0 AS step_0, - latest_0 AS latest_0, - step_1 AS step_1, - min(latest_1) OVER (PARTITION BY aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) AS latest_1 - FROM - (SELECT toTimeZone(e.timestamp, 'UTC') AS timestamp, - if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id) AS aggregation_target, - if(equals(e.event, 'user signed up'), 1, 0) AS step_0, - if(ifNull(equals(step_0, 1), 0), timestamp, NULL) AS latest_0, - if(equals(e.event, 'paid'), 1, 0) AS step_1, - if(ifNull(equals(step_1, 1), 0), timestamp, NULL) AS latest_1 - FROM events AS 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, 99999) - 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 e__override ON equals(e.distinct_id, e__override.distinct_id) - LEFT JOIN - (SELECT argMax(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(groups.group_properties, 'industry'), ''), 'null'), '^"|"$', ''), toTimeZone(groups._timestamp, 'UTC')) AS properties___industry, - groups.group_type_index AS index, - groups.group_key AS key - FROM groups - WHERE and(equals(groups.team_id, 99999), equals(index, 0)) - GROUP BY groups.group_type_index, - groups.group_key) AS e__group_0 ON equals(e.`$group_0`, e__group_0.key) - WHERE and(equals(e.team_id, 99999), and(and(greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2020-01-01 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2020-01-14 23:59:59.999999', 6, 'UTC'))), in(e.event, tuple('paid', 'user signed up')), ifNull(equals(e__group_0.properties___industry, 'finance'), 0)), or(ifNull(equals(step_0, 1), 0), ifNull(equals(step_1, 1), 0))))) - WHERE ifNull(equals(step_0, 1), 0))) - GROUP BY aggregation_target, - steps - HAVING ifNull(equals(steps, max(max_steps)), isNull(steps) - and isNull(max(max_steps)))) - LIMIT 100 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=23622320128, - allow_experimental_analyzer=1 - ''' -# --- -# 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.person_id) as aggregation_target, - if(notEmpty(pdi.distinct_id), pdi.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 distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 7272f10b35..0000000000 --- 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, - if(notEmpty(pdi.distinct_id), pdi.person_id, e.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 - LEFT OUTER JOIN - (SELECT distinct_id, - argMax(person_id, version) as person_id - FROM person_distinct_id2 - WHERE team_id = 99999 - AND distinct_id IN - (SELECT distinct_id - FROM events - WHERE team_id = 99999 - 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 = 99999 - 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 = 99999 - AND group_type_index = 0 - GROUP BY group_key) groups_0 ON "$group_0" == groups_0.group_key - WHERE team_id = 99999 - 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(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 fb6e9b5d83..0000000000 --- a/ee/clickhouse/views/test/funnel/__snapshots__/test_clickhouse_funnel_unordered.ambr +++ /dev/null @@ -1,172 +0,0 @@ -# serializer version: 1 -# name: ClickhouseTestUnorderedFunnelGroups.test_unordered_funnel_with_groups - ''' - /* user_id:0 request:_snapshot_ */ - SELECT countIf(ifNull(equals(steps, 1), 0)) AS step_1, - countIf(ifNull(equals(steps, 2), 0)) AS step_2, - avg(step_1_average_conversion_time_inner) AS step_1_average_conversion_time, - median(step_1_median_conversion_time_inner) AS step_1_median_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - steps AS steps, - avg(step_1_conversion_time) AS step_1_average_conversion_time_inner, - median(step_1_conversion_time) AS step_1_median_conversion_time_inner - FROM - (SELECT aggregation_target AS aggregation_target, - steps AS steps, - max(steps) OVER (PARTITION BY aggregation_target) AS max_steps, - step_1_conversion_time AS step_1_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - timestamp AS timestamp, - step_0 AS step_0, - latest_0 AS latest_0, - step_1 AS step_1, - latest_1 AS latest_1, - arraySort([latest_0, latest_1]) AS event_times, - arraySum([if(and(ifNull(less(latest_0, latest_1), 0), ifNull(lessOrEquals(latest_1, plus(toTimeZone(latest_0, 'UTC'), toIntervalDay(14))), 0)), 1, 0), 1]) AS steps, - arraySort([latest_0, latest_1]) AS conversion_times, - if(and(isNotNull(conversion_times[2]), ifNull(lessOrEquals(conversion_times[2], plus(toTimeZone(conversion_times[1], 'UTC'), toIntervalDay(14))), 0)), dateDiff('second', conversion_times[1], conversion_times[2]), NULL) AS step_1_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - timestamp AS timestamp, - step_0 AS step_0, - latest_0 AS latest_0, - step_1 AS step_1, - min(latest_1) OVER (PARTITION BY aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) AS latest_1 - FROM - (SELECT toTimeZone(e.timestamp, 'UTC') AS timestamp, - e.`$group_0` AS aggregation_target, - if(equals(e.event, 'user signed up'), 1, 0) AS step_0, - if(ifNull(equals(step_0, 1), 0), timestamp, NULL) AS latest_0, - if(equals(e.event, 'paid'), 1, 0) AS step_1, - if(ifNull(equals(step_1, 1), 0), timestamp, NULL) AS latest_1 - FROM events AS e - WHERE and(equals(e.team_id, 99999), and(and(greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2020-01-01 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2020-01-14 23:59:59.999999', 6, 'UTC'))), in(e.event, tuple('paid', 'user signed up')), ifNull(notEquals(nullIf(nullIf(e.`$group_0`, ''), 'null'), ''), 1)), or(ifNull(equals(step_0, 1), 0), ifNull(equals(step_1, 1), 0))))) - WHERE ifNull(equals(step_0, 1), 0) - UNION ALL SELECT aggregation_target AS aggregation_target, - timestamp AS timestamp, - step_0 AS step_0, - latest_0 AS latest_0, - step_1 AS step_1, - latest_1 AS latest_1, - arraySort([latest_0, latest_1]) AS event_times, - arraySum([if(and(ifNull(less(latest_0, latest_1), 0), ifNull(lessOrEquals(latest_1, plus(toTimeZone(latest_0, 'UTC'), toIntervalDay(14))), 0)), 1, 0), 1]) AS steps, - arraySort([latest_0, latest_1]) AS conversion_times, - if(and(isNotNull(conversion_times[2]), ifNull(lessOrEquals(conversion_times[2], plus(toTimeZone(conversion_times[1], 'UTC'), toIntervalDay(14))), 0)), dateDiff('second', conversion_times[1], conversion_times[2]), NULL) AS step_1_conversion_time - FROM - (SELECT aggregation_target AS aggregation_target, - timestamp AS timestamp, - step_0 AS step_0, - latest_0 AS latest_0, - step_1 AS step_1, - min(latest_1) OVER (PARTITION BY aggregation_target - ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) AS latest_1 - FROM - (SELECT toTimeZone(e.timestamp, 'UTC') AS timestamp, - e.`$group_0` AS aggregation_target, - if(equals(e.event, 'paid'), 1, 0) AS step_0, - if(ifNull(equals(step_0, 1), 0), timestamp, NULL) AS latest_0, - if(equals(e.event, 'user signed up'), 1, 0) AS step_1, - if(ifNull(equals(step_1, 1), 0), timestamp, NULL) AS latest_1 - FROM events AS e - WHERE and(equals(e.team_id, 99999), and(and(greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2020-01-01 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2020-01-14 23:59:59.999999', 6, 'UTC'))), in(e.event, tuple('paid', 'user signed up')), ifNull(notEquals(nullIf(nullIf(e.`$group_0`, ''), 'null'), ''), 1)), or(ifNull(equals(step_0, 1), 0), ifNull(equals(step_1, 1), 0))))) - WHERE ifNull(equals(step_0, 1), 0))) - GROUP BY aggregation_target, - steps - HAVING ifNull(equals(steps, max(max_steps)), isNull(steps) - and isNull(max(max_steps)))) - LIMIT 100 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=23622320128, - allow_experimental_analyzer=1 - ''' -# --- -# 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 = 99999 - 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 = 99999 - 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(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 6bedb7cb2d..0000000000 --- a/ee/clickhouse/views/test/funnel/test_clickhouse_funnel.py +++ /dev/null @@ -1,272 +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_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, project_id=self.team.project_id, group_type="organization", group_type_index=0 - ) - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, 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 - - @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 - - @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 - }, - ] - } - 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 - - @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 - }, - ], - } - 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 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 df6e9311f0..0000000000 --- a/ee/clickhouse/views/test/funnel/test_clickhouse_funnel_correlation.py +++ /dev/null @@ -1,704 +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, - }, - "query_method": "hogql", - } - - 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}, - "query_method": "hogql", - } - - 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, - }, - "query_method": "hogql", - } - - 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}, - "query_method": "hogql", - } - - 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, - "query_method": "hogql", - } - - 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 a230df5918..0000000000 --- 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 3459e4bf13..0000000000 --- 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 b5ffad91de..0000000000 --- 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_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, project_id=self.team.project_id, group_type="organization", group_type_index=0 - ) - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, 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 diff --git a/ee/clickhouse/views/test/funnel/util.py b/ee/clickhouse/views/test/funnel/util.py deleted file mode 100644 index cd28a74837..0000000000 --- a/ee/clickhouse/views/test/funnel/util.py +++ /dev/null @@ -1,96 +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_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 d2af178a77..0000000000 --- a/ee/clickhouse/views/test/test_clickhouse_experiment_secondary_results.py +++ /dev/null @@ -1,1317 +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"], 0.333) - - self.assertEqual( - set(response_data["result"].keys()), - { - "result", - "insight", - "filters", - "probability", - "significant", - "significance_code", - "expected_loss", - "credible_intervals", - "variants", - }, - ) - - self.assertEqual( - response_data["result"]["variants"], - [ - { - "failure_count": 0, - "key": "control", - "success_count": 2, - }, - { - "failure_count": 2, - "key": "test", - "success_count": 1, - }, - ], - ) - - self.assertFalse(response_data["result"]["significant"]) - self.assertEqual(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", - "exposure_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"], 0.333) 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 308dbdc207..0000000000 --- a/ee/clickhouse/views/test/test_clickhouse_experiments.py +++ /dev/null @@ -1,4926 +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.models import WebExperiment -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.schema import ExperimentSignificanceCode -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(13, 14)): - 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(13, 14)): - 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) - self.assertEqual(response.json()["stats_config"], {"version": 2}) - - id = response.json()["id"] - experiment = Experiment.objects.get(pk=id) - self.assertEqual(experiment.get_stats_config("version"), 2) - - 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"], []) - - 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, "stats_config": {"version": 1}}, - ) - - 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) - self.assertEqual(experiment.get_stats_config("version"), 1) - - def test_creating_updating_web_experiment(self): - ff_key = "a-b-tests" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "type": "web", - "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) - web_experiment_id = response.json()["id"] - self.assertEqual( - WebExperiment.objects.get(pk=web_experiment_id).variants, - {"test": {"rollout_percentage": 50}, "control": {"rollout_percentage": 50}}, - ) - - 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_transferring_holdout_to_another_group(self): - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_holdouts/", - data={ - "name": "Test Experiment holdout", - "filters": [ - { - "properties": [], - "rollout_percentage": 20, - "variant": "holdout", - } - ], - }, - format="json", - ) - - holdout_id = response.json()["id"] - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["name"], "Test Experiment holdout") - self.assertEqual( - response.json()["filters"], - [{"properties": [], "rollout_percentage": 20, "variant": f"holdout-{holdout_id}"}], - ) - - # Generate draft experiment to be part of holdout - 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": [], - }, - "holdout_id": holdout_id, - }, - ) - - 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["holdout_groups"], - [{"properties": [], "rollout_percentage": 20, "variant": f"holdout-{holdout_id}"}], - ) - - exp_id = response.json()["id"] - - # new holdout, and update experiment - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_holdouts/", - data={ - "name": "Test Experiment holdout 2", - "filters": [ - { - "properties": [], - "rollout_percentage": 5, - "variant": "holdout", - } - ], - }, - format="json", - ) - holdout_2_id = response.json()["id"] - - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{exp_id}", - {"holdout_id": holdout_2_id}, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - experiment = Experiment.objects.get(pk=exp_id) - self.assertEqual(experiment.holdout_id, holdout_2_id) - - created_ff = FeatureFlag.objects.get(key=ff_key) - self.assertEqual( - created_ff.filters["holdout_groups"], - [{"properties": [], "rollout_percentage": 5, "variant": f"holdout-{holdout_2_id}"}], - ) - - # update parameters - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{exp_id}", - { - "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, - }, - ] - }, - }, - ) - - experiment = Experiment.objects.get(pk=exp_id) - self.assertEqual(experiment.holdout_id, holdout_2_id) - - created_ff = FeatureFlag.objects.get(key=ff_key) - self.assertEqual( - created_ff.filters["holdout_groups"], - [{"properties": [], "rollout_percentage": 5, "variant": f"holdout-{holdout_2_id}"}], - ) - self.assertEqual( - created_ff.filters["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}, - ], - ) - - # remove holdouts - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{exp_id}", - {"holdout_id": None}, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - experiment = Experiment.objects.get(pk=exp_id) - self.assertEqual(experiment.holdout_id, None) - - created_ff = FeatureFlag.objects.get(key=ff_key) - self.assertEqual(created_ff.filters["holdout_groups"], None) - - # try adding invalid holdout - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{exp_id}", - {"holdout_id": 123456}, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], 'Invalid pk "123456" - object does not exist.') - - # add back holdout - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{exp_id}", - {"holdout_id": holdout_2_id}, - ) - - # launch experiment and try updating holdouts again - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{exp_id}", - {"start_date": "2021-12-01T10:23"}, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{exp_id}", - {"holdout_id": holdout_id}, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Can't update holdout on running Experiment") - - created_ff = FeatureFlag.objects.get(key=ff_key) - self.assertEqual( - created_ff.filters["holdout_groups"], - [{"properties": [], "rollout_percentage": 5, "variant": f"holdout-{holdout_2_id}"}], - ) - - def test_saved_metrics(self): - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_saved_metrics/", - { - "name": "Test Experiment saved metric", - "description": "Test description", - "query": { - "kind": "ExperimentTrendsQuery", - "count_query": { - "kind": "TrendsQuery", - "series": [{"kind": "EventsNode", "event": "$pageview"}], - }, - }, - }, - ) - - saved_metric_id = response.json()["id"] - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["name"], "Test Experiment saved metric") - self.assertEqual(response.json()["description"], "Test description") - self.assertEqual( - response.json()["query"], - { - "kind": "ExperimentTrendsQuery", - "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, - }, - ) - self.assertEqual(response.json()["created_by"]["id"], self.user.pk) - - # Generate experiment to have saved metric - 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": [], - }, - "saved_metrics_ids": [{"id": saved_metric_id, "metadata": {"type": "secondary"}}], - }, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - exp_id = response.json()["id"] - - self.assertEqual(response.json()["name"], "Test Experiment") - self.assertEqual(response.json()["feature_flag_key"], ff_key) - - self.assertEqual(Experiment.objects.get(pk=exp_id).saved_metrics.count(), 1) - experiment_to_saved_metric = Experiment.objects.get(pk=exp_id).experimenttosavedmetric_set.first() - self.assertEqual(experiment_to_saved_metric.metadata, {"type": "secondary"}) - saved_metric = Experiment.objects.get(pk=exp_id).saved_metrics.first() - self.assertEqual(saved_metric.id, saved_metric_id) - self.assertEqual( - saved_metric.query, - { - "kind": "ExperimentTrendsQuery", - "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, - }, - ) - - # Now try updating experiment with new saved metric - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_saved_metrics/", - { - "name": "Test Experiment saved metric 2", - "description": "Test description 2", - "query": { - "kind": "ExperimentTrendsQuery", - "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, - }, - }, - ) - - saved_metric_2_id = response.json()["id"] - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["name"], "Test Experiment saved metric 2") - - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{exp_id}", - { - "saved_metrics_ids": [ - {"id": saved_metric_id, "metadata": {"type": "secondary"}}, - {"id": saved_metric_2_id, "metadata": {"type": "tertiary"}}, - ] - }, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertEqual(Experiment.objects.get(pk=exp_id).saved_metrics.count(), 2) - experiment_to_saved_metric = Experiment.objects.get(pk=exp_id).experimenttosavedmetric_set.all() - self.assertEqual(experiment_to_saved_metric[0].metadata, {"type": "secondary"}) - self.assertEqual(experiment_to_saved_metric[1].metadata, {"type": "tertiary"}) - saved_metric = Experiment.objects.get(pk=exp_id).saved_metrics.all() - self.assertEqual(sorted([saved_metric[0].id, saved_metric[1].id]), [saved_metric_id, saved_metric_2_id]) - - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{exp_id}", - {"saved_metrics_ids": []}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(Experiment.objects.get(pk=exp_id).saved_metrics.count(), 0) - - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{exp_id}", - { - "saved_metrics_ids": [ - {"id": saved_metric_id, "metadata": {"type": "secondary"}}, - ] - }, - ) - - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{exp_id}", - {"saved_metrics_ids": None}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(Experiment.objects.get(pk=exp_id).saved_metrics.count(), 0) - - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{exp_id}", - { - "saved_metrics_ids": [ - {"id": saved_metric_id, "metadata": {"type": "secondary"}}, - ] - }, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(Experiment.objects.get(pk=exp_id).saved_metrics.count(), 1) - - # not updating saved metrics shouldn't change anything - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{exp_id}", - { - "name": "Test Experiment 2", - }, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(Experiment.objects.get(pk=exp_id).saved_metrics.count(), 1) - - # now delete saved metric - response = self.client.delete(f"/api/projects/{self.team.id}/experiment_saved_metrics/{saved_metric_id}") - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - - # make sure experiment in question was updated as well - self.assertEqual(Experiment.objects.get(pk=exp_id).saved_metrics.count(), 0) - - def test_validate_saved_metrics_payload(self): - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_saved_metrics/", - { - "name": "Test Experiment saved metric", - "description": "Test description", - "query": { - "kind": "ExperimentTrendsQuery", - "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, - }, - }, - ) - - saved_metric_id = response.json()["id"] - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - # Generate experiment to have saved metric - ff_key = "a-b-tests" - exp_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/", - { - **exp_data, - "saved_metrics_ids": [{"id": saved_metric_id, "metadata": {"xxx": "secondary"}}], - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["type"], "validation_error") - self.assertEqual( - response.json()["detail"], - "Metadata must have a type key", - ) - - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - **exp_data, - "saved_metrics_ids": [{"saved_metric": saved_metric_id}], - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["type"], "validation_error") - self.assertEqual(response.json()["detail"], "Saved metric must have an id") - - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - **exp_data, - "saved_metrics_ids": [{"id": 12345678}], - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["type"], "validation_error") - self.assertEqual(response.json()["detail"], "Saved metric does not exist") - - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - **exp_data, - "saved_metrics_ids": {"id": saved_metric_id}, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["type"], "validation_error") - self.assertEqual(response.json()["detail"], 'Expected a list of items but got type "dict".') - - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - **exp_data, - "saved_metrics_ids": [[saved_metric_id]], - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["type"], "validation_error") - self.assertEqual(response.json()["detail"], "Saved metric must be an object") - - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - **exp_data, - "saved_metrics_ids": [{"id": saved_metric_id, "metadata": "secondary"}], - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["type"], "validation_error") - self.assertEqual(response.json()["detail"], "Metadata must be an object") - - 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": {}, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "This field may not be null.") - - 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": { - "events": [{"id": "$pageview"}], - }, - }, - ) - 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": "$pageview"}]}, - }, - ) - - 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_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(17): - 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, - "holdout_groups": 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, - "holdout_groups": 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, - "holdout_groups": None, - }, - ) - - def test_create_draft_experiment_with_filters(self) -> None: - 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": [], - }, - }, - ) - - 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) - - def test_create_launched_experiment_with_filters(self) -> None: - 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) - - def test_create_draft_experiment_without_filters(self) -> None: - 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": {}, - }, - ) - - 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) - - def test_feature_flag_and_experiment_sync(self): - # Create an experiment with control and test variants - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "My test experiment", - "feature_flag_key": "experiment-test-flag", - "parameters": { - "feature_flag_variants": [ - {"key": "control", "name": "Control Group", "rollout_percentage": 50}, - {"key": "test", "name": "Test Variant", "rollout_percentage": 50}, - ] - }, - "filters": {"insight": "TRENDS", "events": [{"order": 0, "id": "$pageview"}]}, - }, - ) - - self.assertEqual(response.status_code, 201) - experiment_id = response.json()["id"] - feature_flag_id = response.json()["feature_flag"]["id"] - - # Fetch the FeatureFlag object - feature_flag = FeatureFlag.objects.get(id=feature_flag_id) - - variants = feature_flag.filters["multivariate"]["variants"] - - # Verify that the variants are correctly populated - self.assertEqual(len(variants), 2) - - self.assertEqual(variants[0]["key"], "control") - self.assertEqual(variants[0]["name"], "Control Group") - self.assertEqual(variants[0]["rollout_percentage"], 50) - - self.assertEqual(variants[1]["key"], "test") - self.assertEqual(variants[1]["name"], "Test Variant") - self.assertEqual(variants[1]["rollout_percentage"], 50) - - # Change the rollout percentages and groups of the feature flag - response = self.client.patch( - f"/api/projects/{self.team.id}/feature_flags/{feature_flag_id}", - { - "filters": { - "groups": [ - {"properties": [], "rollout_percentage": 99}, - {"properties": [], "rollout_percentage": 1}, - ], - "payloads": {}, - "multivariate": { - "variants": [ - {"key": "control", "rollout_percentage": 10}, - {"key": "test", "rollout_percentage": 90}, - ] - }, - "aggregation_group_type_index": 1, - } - }, - ) - - # Verify that Experiment.parameters.feature_flag_variants reflects the updated FeatureFlag.filters.multivariate.variants - experiment = Experiment.objects.get(id=experiment_id) - self.assertEqual( - experiment.parameters["feature_flag_variants"], - [{"key": "control", "rollout_percentage": 10}, {"key": "test", "rollout_percentage": 90}], - ) - self.assertEqual(experiment.parameters["aggregation_group_type_index"], 1) - - # Update the experiment with an unrelated change - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{experiment_id}", - {"name": "Updated Test Experiment"}, - ) - - # Verify that the feature flag variants and groups remain unchanged - feature_flag = FeatureFlag.objects.get(id=feature_flag_id) - self.assertEqual( - feature_flag.filters["multivariate"]["variants"], - [{"key": "control", "rollout_percentage": 10}, {"key": "test", "rollout_percentage": 90}], - ) - self.assertEqual( - feature_flag.filters["groups"], - [{"properties": [], "rollout_percentage": 99}, {"properties": [], "rollout_percentage": 1}], - ) - - # Test removing aggregation_group_type_index - response = self.client.patch( - f"/api/projects/{self.team.id}/feature_flags/{feature_flag_id}", - { - "filters": { - "groups": [ - {"properties": [], "rollout_percentage": 99}, - {"properties": [], "rollout_percentage": 1}, - ], - "payloads": {}, - "multivariate": { - "variants": [ - {"key": "control", "rollout_percentage": 10}, - {"key": "test", "rollout_percentage": 90}, - ] - }, - } - }, - ) - - # Verify that aggregation_group_type_index is removed from experiment parameters - experiment = Experiment.objects.get(id=experiment_id) - self.assertNotIn("aggregation_group_type_index", experiment.parameters) - - -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 fba4063867..0000000000 --- a/ee/clickhouse/views/test/test_clickhouse_groups.py +++ /dev/null @@ -1,655 +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=0, - group_key="org:7", - properties={"industry": "finance-technology"}, - ) - create_group( - team_id=self.team.pk, - group_type_index=1, - group_key="org:1", - properties={"industry": "finance"}, - ) - - # Test without query parameter - 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), 3) - self.assertEqual( - response_data, - [ - {"name": "finance", "count": 1}, - {"name": "finance-technology", "count": 1}, - {"name": "technology", "count": 1}, - ], - ) - - # Test with query parameter - response_data = self.client.get( - f"/api/projects/{self.team.id}/groups/property_values/?key=industry&group_type_index=0&value=fin" - ).json() - self.assertEqual(len(response_data), 2) - self.assertEqual(response_data, [{"name": "finance", "count": 1}, {"name": "finance-technology", "count": 1}]) - - # Test with query parameter - case insensitive - response_data = self.client.get( - f"/api/projects/{self.team.id}/groups/property_values/?key=industry&group_type_index=0&value=TECH" - ).json() - self.assertEqual(len(response_data), 2) - self.assertEqual( - response_data, [{"name": "finance-technology", "count": 1}, {"name": "technology", "count": 1}] - ) - - # Test with query parameter - no matches - response_data = self.client.get( - f"/api/projects/{self.team.id}/groups/property_values/?key=industry&group_type_index=0&value=healthcare" - ).json() - self.assertEqual(len(response_data), 0) - self.assertEqual(response_data, []) - - # Test with query parameter - exact match - response_data = self.client.get( - f"/api/projects/{self.team.id}/groups/property_values/?key=industry&group_type_index=0&value=technology" - ).json() - self.assertEqual(len(response_data), 2) - self.assertEqual( - response_data, [{"name": "finance-technology", "count": 1}, {"name": "technology", "count": 1}] - ) - - # Test with different group_type_index - response_data = self.client.get( - f"/api/projects/{self.team.id}/groups/property_values/?key=industry&group_type_index=1&value=fin" - ).json() - self.assertEqual(len(response_data), 1) - self.assertEqual(response_data, [{"name": "finance", "count": 1}]) - - 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, project_id=self.team.project_id, group_type="organization", group_type_index=0 - ) - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, group_type="playlist", group_type_index=1 - ) - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, 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, project_id=self.team.project_id, group_type="organization", group_type_index=0 - ) - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, group_type="playlist", group_type_index=1 - ) - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, 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, project_id=other_team.project_id, group_type="organization", group_type_index=0 - ) - GroupTypeMapping.objects.create( - team=other_team, project_id=other_team.project_id, group_type="playlist", group_type_index=1 - ) - GroupTypeMapping.objects.create( - team=other_team, project_id=other_team.project_id, 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, project_id=other_team.project_id, group_type="organization", group_type_index=0 - ) - GroupTypeMapping.objects.create( - team=other_team, project_id=other_team.project_id, group_type="playlist", group_type_index=1 - ) - GroupTypeMapping.objects.create( - team=other_team, project_id=other_team.project_id, 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, project_id=other_team.project_id, group_type="organization", group_type_index=0 - ) - GroupTypeMapping.objects.create( - team=other_team, project_id=other_team.project_id, group_type="playlist", group_type_index=1 - ) - GroupTypeMapping.objects.create( - team=other_team, project_id=other_team.project_id, 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, project_id=self.team.project_id, group_type="organization", group_type_index=0 - ) - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, 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_stickiness.py b/ee/clickhouse/views/test/test_clickhouse_stickiness.py deleted file mode 100644 index a2a58151db..0000000000 --- a/ee/clickhouse/views/test/test_clickhouse_stickiness.py +++ /dev/null @@ -1,212 +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={}, - ) - - 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 - - @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 - - @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 dc31caa952..0000000000 --- a/ee/clickhouse/views/test/test_clickhouse_trends.py +++ /dev/null @@ -1,1380 +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, - create_people=False, - ) - - 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"], - "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"], - "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"], - "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": {}, - "days": ["2021-09-01T00:00:00Z", "2021-09-02T00:00:00Z", "2021-09-03T00:00:00Z"], - }, - "label": "$pageview", - "count": 6.0, - "data": [2, 1, 3], - "labels": ["1-Sep-2021", "2-Sep-2021", "3-Sep-2021"], - "days": ["2021-09-01", "2021-09-02", "2021-09-03"], - "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"], - "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"], - "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 - 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], - 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"], - breakdown_value=item.get("breakdown_value", None), - ) - - return res - - -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)}], - } - 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 - - 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)}], - } - 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 - - @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)}], - } - 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" - - @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"}, - } - ], - } - 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) - - 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" - - # 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) - - 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" - - # 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) - - 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" - - # 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) - - 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" - - # 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) - - 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" - - @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"}, - } - ], - } - 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) - - assert data_response["val"]["2012-01-13"].value == 1 - assert data_response["val"]["2012-01-13"].breakdown_value == "val" - - 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) - - assert aggregate_response["val"].value == 1 - - 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"}, - }, - ], - } - 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 - - -class ClickhouseTestTrendsGroups(ClickhouseTestMixin, LicensedTestMixin, APIBaseTest): - maxDiff = None - CLASS_DATA_LEVEL_SETUP = False - - def _create_groups(self): - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, group_type="organization", group_type_index=0 - ) - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, 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 - - @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 - - -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 - - @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/clickhouse/views/test/test_experiment_holdouts.py b/ee/clickhouse/views/test/test_experiment_holdouts.py deleted file mode 100644 index 4d067d1483..0000000000 --- a/ee/clickhouse/views/test/test_experiment_holdouts.py +++ /dev/null @@ -1,145 +0,0 @@ -from rest_framework import status - -from ee.api.test.base import APILicensedTest -from posthog.models.experiment import Experiment -from posthog.models.feature_flag import FeatureFlag - - -class TestExperimentHoldoutCRUD(APILicensedTest): - def test_can_list_experiment_holdouts(self): - response = self.client.get(f"/api/projects/{self.team.id}/experiment_holdouts/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_create_update_experiment_holdouts(self) -> None: - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_holdouts/", - data={ - "name": "Test Experiment holdout", - "filters": [ - { - "properties": [], - "rollout_percentage": 20, - "variant": "holdout", - } - ], - }, - format="json", - ) - - holdout_id = response.json()["id"] - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["name"], "Test Experiment holdout") - self.assertEqual( - response.json()["filters"], - [{"properties": [], "rollout_percentage": 20, "variant": f"holdout-{holdout_id}"}], - ) - - # Generate experiment to be part of holdout - 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": [], - }, - "holdout_id": holdout_id, - }, - ) - - 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["holdout_groups"], - [{"properties": [], "rollout_percentage": 20, "variant": f"holdout-{holdout_id}"}], - ) - - exp_id = response.json()["id"] - # Now try updating holdout - response = self.client.patch( - f"/api/projects/{self.team.id}/experiment_holdouts/{holdout_id}", - { - "name": "Test Experiment holdout 2", - "filters": [ - { - "properties": [], - "rollout_percentage": 30, - "variant": "holdout", - } - ], - }, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["name"], "Test Experiment holdout 2") - self.assertEqual( - response.json()["filters"], - [{"properties": [], "rollout_percentage": 30, "variant": f"holdout-{holdout_id}"}], - ) - - # make sure flag for experiment in question was updated as well - created_ff = FeatureFlag.objects.get(key=ff_key) - self.assertEqual( - created_ff.filters["holdout_groups"], - [{"properties": [], "rollout_percentage": 30, "variant": f"holdout-{holdout_id}"}], - ) - - # now delete holdout - response = self.client.delete(f"/api/projects/{self.team.id}/experiment_holdouts/{holdout_id}") - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - - # make sure flag for experiment in question was updated as well - created_ff = FeatureFlag.objects.get(key=ff_key) - self.assertEqual(created_ff.filters["holdout_groups"], None) - - # and same for experiment - exp = Experiment.objects.get(pk=exp_id) - self.assertEqual(exp.holdout, None) - - def test_invalid_create(self): - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_holdouts/", - data={ - "name": None, # invalid - "filters": [ - { - "properties": [], - "rollout_percentage": 20, - "variant": "holdout", - } - ], - }, - format="json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "This field may not be null.") - - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_holdouts/", - data={ - "name": "xyz", - "filters": [], - }, - format="json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Filters are required to create an holdout group") diff --git a/ee/clickhouse/views/test/test_experiment_saved_metrics.py b/ee/clickhouse/views/test/test_experiment_saved_metrics.py deleted file mode 100644 index 90575cbba0..0000000000 --- a/ee/clickhouse/views/test/test_experiment_saved_metrics.py +++ /dev/null @@ -1,240 +0,0 @@ -from rest_framework import status - -from ee.api.test.base import APILicensedTest -from posthog.models.experiment import Experiment, ExperimentToSavedMetric - - -class TestExperimentSavedMetricsCRUD(APILicensedTest): - def test_can_list_experiment_saved_metrics(self): - response = self.client.get(f"/api/projects/{self.team.id}/experiment_saved_metrics/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_validation_of_query_metric(self): - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_saved_metrics/", - data={ - "name": "Test Experiment saved metric", - "description": "Test description", - "query": {}, - }, - format="json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Query is required to create a saved metric") - - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_saved_metrics/", - data={ - "name": "Test Experiment saved metric", - "description": "Test description", - "query": {"not-kind": "ExperimentTrendsQuery"}, - }, - format="json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json()["detail"], "Metric query kind must be 'ExperimentTrendsQuery' or 'ExperimentFunnelsQuery'" - ) - - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_saved_metrics/", - data={ - "name": "Test Experiment saved metric", - "description": "Test description", - "query": {"kind": "not-ExperimentTrendsQuery"}, - }, - format="json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json()["detail"], "Metric query kind must be 'ExperimentTrendsQuery' or 'ExperimentFunnelsQuery'" - ) - - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_saved_metrics/", - data={ - "name": "Test Experiment saved metric", - "description": "Test description", - "query": {"kind": "TrendsQuery"}, - }, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json()["detail"], "Metric query kind must be 'ExperimentTrendsQuery' or 'ExperimentFunnelsQuery'" - ) - - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_saved_metrics/", - data={ - "name": "Test Experiment saved metric", - "description": "Test description", - "query": {"kind": "ExperimentTrendsQuery"}, - }, - format="json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertTrue("'loc': ('count_query',), 'msg': 'Field required'" in response.json()["detail"]) - - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_saved_metrics/", - data={ - "name": "Test Experiment saved metric", - "description": "Test description", - "query": { - "kind": "ExperimentTrendsQuery", - "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, - }, - }, - format="json", - ) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - def test_create_update_experiment_saved_metrics(self) -> None: - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_saved_metrics/", - data={ - "name": "Test Experiment saved metric", - "description": "Test description", - "query": { - "kind": "ExperimentTrendsQuery", - "count_query": { - "kind": "TrendsQuery", - "series": [{"kind": "EventsNode", "event": "$pageview"}], - }, - }, - }, - format="json", - ) - - saved_metric_id = response.json()["id"] - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["name"], "Test Experiment saved metric") - self.assertEqual(response.json()["description"], "Test description") - self.assertEqual( - response.json()["query"], - { - "kind": "ExperimentTrendsQuery", - "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, - }, - ) - self.assertEqual(response.json()["created_by"]["id"], self.user.pk) - - # Generate experiment to have saved metric - 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": [], - }, - "saved_metrics_ids": [{"id": saved_metric_id, "metadata": {"type": "secondary"}}], - }, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - exp_id = response.json()["id"] - - self.assertEqual(response.json()["name"], "Test Experiment") - self.assertEqual(response.json()["feature_flag_key"], ff_key) - - self.assertEqual(Experiment.objects.get(pk=exp_id).saved_metrics.count(), 1) - experiment_to_saved_metric = Experiment.objects.get(pk=exp_id).experimenttosavedmetric_set.first() - self.assertEqual(experiment_to_saved_metric.metadata, {"type": "secondary"}) - saved_metric = Experiment.objects.get(pk=exp_id).saved_metrics.first() - self.assertEqual(saved_metric.id, saved_metric_id) - self.assertEqual( - saved_metric.query, - { - "kind": "ExperimentTrendsQuery", - "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, - }, - ) - - # Now try updating saved metric - response = self.client.patch( - f"/api/projects/{self.team.id}/experiment_saved_metrics/{saved_metric_id}", - { - "name": "Test Experiment saved metric 2", - "description": "Test description 2", - "query": { - "kind": "ExperimentTrendsQuery", - "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json()["name"], "Test Experiment saved metric 2") - self.assertEqual( - response.json()["query"], - { - "kind": "ExperimentTrendsQuery", - "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, - }, - ) - - # make sure experiment in question was updated as well - self.assertEqual(Experiment.objects.get(pk=exp_id).saved_metrics.count(), 1) - saved_metric = Experiment.objects.get(pk=exp_id).saved_metrics.first() - self.assertEqual(saved_metric.id, saved_metric_id) - self.assertEqual( - saved_metric.query, - { - "kind": "ExperimentTrendsQuery", - "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, - }, - ) - self.assertEqual(saved_metric.name, "Test Experiment saved metric 2") - self.assertEqual(saved_metric.description, "Test description 2") - - # now delete saved metric - response = self.client.delete(f"/api/projects/{self.team.id}/experiment_saved_metrics/{saved_metric_id}") - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - - # make sure experiment in question was updated as well - self.assertEqual(Experiment.objects.get(pk=exp_id).saved_metrics.count(), 0) - self.assertEqual(ExperimentToSavedMetric.objects.filter(experiment_id=exp_id).count(), 0) - - def test_invalid_create(self): - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_saved_metrics/", - data={ - "name": None, # invalid - "query": { - "kind": "ExperimentTrendsQuery", - "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, - }, - }, - format="json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "This field may not be null.") - - response = self.client.post( - f"/api/projects/{self.team.id}/experiment_saved_metrics/", - data={ - "name": "xyz", - "query": {}, - }, - format="json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Query is required to create a saved metric") diff --git a/ee/conftest.py b/ee/conftest.py deleted file mode 100644 index 0de792f0d3..0000000000 --- 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 d973463019..0000000000 --- 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__/encoded-snapshot-data.ts b/ee/frontend/mobile-replay/__mocks__/encoded-snapshot-data.ts deleted file mode 100644 index ceb176d49c..0000000000 --- a/ee/frontend/mobile-replay/__mocks__/encoded-snapshot-data.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const encodedWebSnapshotData: string[] = [ - // first item could be a network event or something else - '{"windowId":"0191C63B-03FF-73B5-96BE-40BE2761621C","data":{"payload":{"requests":[{"duration":28,"entryType":"resource","initiatorType":"fetch","method":"GET","name":"https://1.bp.blogspot.com/-hkNkoCjc5UA/T4JTlCjhhfI/AAAAAAAAB98/XxQwZ-QPkI8/s1600/Free+Google+Wallpapers+3.jpg","responseStatus":200,"timestamp":1725369200216,"transferSize":82375}]},"plugin":"rrweb/network@1"},"timestamp":1725369200216,"type":6,"seen":8833798676917222}', - '{"windowId":"0191C63B-03FF-73B5-96BE-40BE2761621C","data":{"height":852,"width":393},"timestamp":1725607643113,"type":4,"seen":4930607506458337}', - '{"windowId":"0191C63B-03FF-73B5-96BE-40BE2761621C","data":{"initialOffset":{"left":0,"top":0},"wireframes":[{"base64":"data:image/jpeg;base64,/9j/4AAQSkZJR","height":852,"id":4324378400,"type":"screenshot","width":393,"x":0,"y":0}]},"timestamp":1725607643113,"type":2,"seen":2118469619185818}', -] 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 7ffc2e5f38..0000000000 --- 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__/parsing.test.ts.snap b/ee/frontend/mobile-replay/__snapshots__/parsing.test.ts.snap deleted file mode 100644 index c916dd21d5..0000000000 --- a/ee/frontend/mobile-replay/__snapshots__/parsing.test.ts.snap +++ /dev/null @@ -1,339 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`snapshot parsing handles mobile data with no meta event 1`] = ` -[ - { - "data": { - "payload": { - "requests": [ - { - "duration": 28, - "entryType": "resource", - "initiatorType": "fetch", - "method": "GET", - "name": "https://1.bp.blogspot.com/-hkNkoCjc5UA/T4JTlCjhhfI/AAAAAAAAB98/XxQwZ-QPkI8/s1600/Free+Google+Wallpapers+3.jpg", - "responseStatus": 200, - "timestamp": 1725369200216, - "transferSize": 82375, - }, - ], - }, - "plugin": "rrweb/network@1", - }, - "seen": 8833798676917222, - "timestamp": 1725369200216, - "type": 6, - "windowId": "0191C63B-03FF-73B5-96BE-40BE2761621C", - }, - { - "data": { - "height": 852, - "href": "", - "width": 393, - }, - "timestamp": 1725607643113, - "type": 4, - "windowId": "0191C63B-03FF-73B5-96BE-40BE2761621C", - }, - { - "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": 4324378400, - "height": 852, - "src": "data:image/jpeg;base64,/9j/4AAQSkZJR", - "style": "width: 393px;height: 852px;position: fixed;left: 0px;top: 0px;", - "width": 393, - }, - "childNodes": [], - "id": 4324378400, - "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": 1725607643113, - "type": 2, - "windowId": "0191C63B-03FF-73B5-96BE-40BE2761621C", - }, -] -`; - -exports[`snapshot parsing handles normal mobile data 1`] = ` -[ - { - "data": { - "payload": { - "requests": [ - { - "duration": 28, - "entryType": "resource", - "initiatorType": "fetch", - "method": "GET", - "name": "https://1.bp.blogspot.com/-hkNkoCjc5UA/T4JTlCjhhfI/AAAAAAAAB98/XxQwZ-QPkI8/s1600/Free+Google+Wallpapers+3.jpg", - "responseStatus": 200, - "timestamp": 1725369200216, - "transferSize": 82375, - }, - ], - }, - "plugin": "rrweb/network@1", - }, - "seen": 8833798676917222, - "timestamp": 1725369200216, - "type": 6, - "windowId": "0191C63B-03FF-73B5-96BE-40BE2761621C", - }, - { - "data": { - "height": 852, - "href": "", - "width": 393, - }, - "timestamp": 1725607643113, - "type": 4, - "windowId": "0191C63B-03FF-73B5-96BE-40BE2761621C", - }, - { - "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": 4324378400, - "height": 852, - "src": "data:image/jpeg;base64,/9j/4AAQSkZJR", - "style": "width: 393px;height: 852px;position: fixed;left: 0px;top: 0px;", - "width": 393, - }, - "childNodes": [], - "id": 4324378400, - "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": 1725607643113, - "type": 2, - "windowId": "0191C63B-03FF-73B5-96BE-40BE2761621C", - }, -] -`; 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 7a2e0b8820..0000000000 --- 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 56a7d2ee45..0000000000 --- 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 7e18622fa8..0000000000 --- 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/parsing.test.ts b/ee/frontend/mobile-replay/parsing.test.ts deleted file mode 100644 index 5d913b4117..0000000000 --- a/ee/frontend/mobile-replay/parsing.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { parseEncodedSnapshots } from 'scenes/session-recordings/player/sessionRecordingDataLogic' - -import { encodedWebSnapshotData } from './__mocks__/encoded-snapshot-data' - -describe('snapshot parsing', () => { - const sessionId = '12345' - const numberOfParsedLinesInData = 3 - - it('handles normal mobile data', async () => { - const parsed = await parseEncodedSnapshots(encodedWebSnapshotData, sessionId, true) - expect(parsed.length).toEqual(numberOfParsedLinesInData) - expect(parsed).toMatchSnapshot() - }) - it('handles mobile data with no meta event', async () => { - const withoutMeta = [encodedWebSnapshotData[0], encodedWebSnapshotData[2]] - const parsed = await parseEncodedSnapshots(withoutMeta, sessionId, true) - expect(parsed.length).toEqual(numberOfParsedLinesInData) - expect(parsed).toMatchSnapshot() - }) -}) 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 55ce111b3a..0000000000 --- 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 79102a23ef..0000000000 --- a/ee/frontend/mobile-replay/schema/web/rr-web-schema.json +++ /dev/null @@ -1,968 +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" - }, - "FontFaceDescriptors": { - "additionalProperties": false, - "properties": { - "display": { - "type": "string" - }, - "featureSettings": { - "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 77c5316d5a..0000000000 --- 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 56a54b23d7..0000000000 --- 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 16274ca853..0000000000 --- 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 c209a6f58c..0000000000 --- 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 3ba93d6fc2..0000000000 --- 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 1719060589..0000000000 --- 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/hogai/__init__.py b/ee/hogai/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/assistant.py b/ee/hogai/assistant.py deleted file mode 100644 index 5ea6be9f0b..0000000000 --- a/ee/hogai/assistant.py +++ /dev/null @@ -1,314 +0,0 @@ -import json -from collections.abc import Generator, Iterator -from typing import Any, Optional, cast -from uuid import uuid4 - -from langchain_core.callbacks.base import BaseCallbackHandler -from langchain_core.messages import AIMessageChunk -from langchain_core.runnables.config import RunnableConfig -from langgraph.graph.state import CompiledStateGraph -from posthoganalytics.ai.langchain.callbacks import CallbackHandler -from pydantic import BaseModel - -from ee.hogai.funnels.nodes import FunnelGeneratorNode -from ee.hogai.graph import AssistantGraph -from ee.hogai.memory.nodes import MemoryInitializerNode -from ee.hogai.retention.nodes import RetentionGeneratorNode -from ee.hogai.schema_generator.nodes import SchemaGeneratorNode -from ee.hogai.trends.nodes import TrendsGeneratorNode -from ee.hogai.utils.asgi import SyncIterableToAsync -from ee.hogai.utils.state import ( - GraphMessageUpdateTuple, - GraphTaskStartedUpdateTuple, - GraphValueUpdateTuple, - is_message_update, - is_state_update, - is_task_started_update, - is_value_update, - validate_state_update, - validate_value_update, -) -from ee.hogai.utils.types import AssistantNodeName, AssistantState, PartialAssistantState -from ee.models import Conversation -from posthog.event_usage import report_user_action -from posthog.models import Team, User -from posthog.ph_client import get_ph_client -from posthog.schema import ( - AssistantEventType, - AssistantGenerationStatusEvent, - AssistantGenerationStatusType, - AssistantMessage, - FailureMessage, - HumanMessage, - ReasoningMessage, - VisualizationMessage, -) -from posthog.settings import SERVER_GATEWAY_INTERFACE - -posthog_client = get_ph_client() - -VISUALIZATION_NODES: dict[AssistantNodeName, type[SchemaGeneratorNode]] = { - AssistantNodeName.TRENDS_GENERATOR: TrendsGeneratorNode, - AssistantNodeName.FUNNEL_GENERATOR: FunnelGeneratorNode, - AssistantNodeName.RETENTION_GENERATOR: RetentionGeneratorNode, -} - -STREAMING_NODES: set[AssistantNodeName] = { - AssistantNodeName.MEMORY_ONBOARDING, - AssistantNodeName.MEMORY_INITIALIZER, - AssistantNodeName.SUMMARIZER, -} -"""Nodes that can stream messages to the client.""" - - -VERBOSE_NODES = STREAMING_NODES | {AssistantNodeName.MEMORY_INITIALIZER_INTERRUPT} -"""Nodes that can send messages to the client.""" - - -class Assistant: - _team: Team - _graph: CompiledStateGraph - _user: Optional[User] - _conversation: Conversation - _latest_message: HumanMessage - _state: Optional[AssistantState] - _callback_handler: Optional[BaseCallbackHandler] - - def __init__( - self, - team: Team, - conversation: Conversation, - new_message: HumanMessage, - user: Optional[User] = None, - is_new_conversation: bool = False, - ): - self._team = team - self._user = user - self._conversation = conversation - self._latest_message = new_message.model_copy(deep=True, update={"id": str(uuid4())}) - self._is_new_conversation = is_new_conversation - self._graph = AssistantGraph(team).compile_full_graph() - self._chunks = AIMessageChunk(content="") - self._state = None - distinct_id = user.distinct_id if user else None - self._callback_handler = ( - CallbackHandler( - posthog_client, - distinct_id, - properties={ - "conversation_id": str(self._conversation.id), - "is_first_conversation": is_new_conversation, - }, - ) - if posthog_client - else None - ) - - def stream(self): - if SERVER_GATEWAY_INTERFACE == "ASGI": - return self._astream() - return self._stream() - - def _astream(self): - return SyncIterableToAsync(self._stream()) - - def _stream(self) -> Generator[str, None, None]: - state = self._init_or_update_state() - config = self._get_config() - - generator: Iterator[Any] = self._graph.stream( - state, config=config, stream_mode=["messages", "values", "updates", "debug"] - ) - - # Assign the conversation id to the client. - if self._is_new_conversation: - yield self._serialize_conversation() - - # Send the last message with the initialized id. - yield self._serialize_message(self._latest_message) - - try: - last_viz_message = None - for update in generator: - if message := self._process_update(update): - if isinstance(message, VisualizationMessage): - last_viz_message = message - yield self._serialize_message(message) - - # Check if the assistant has requested help. - state = self._graph.get_state(config) - if state.next: - interrupt_value = state.tasks[0].interrupts[0].value - yield self._serialize_message( - AssistantMessage(content=interrupt_value, id=str(uuid4())) - if isinstance(interrupt_value, str) - else interrupt_value - ) - else: - self._report_conversation_state(last_viz_message) - except: - # This is an unhandled error, so we just stop further generation at this point - yield self._serialize_message(FailureMessage()) - raise # Re-raise, so that the error is printed or goes into Sentry - - @property - def _initial_state(self) -> AssistantState: - return AssistantState(messages=[self._latest_message], start_id=self._latest_message.id) - - def _get_config(self) -> RunnableConfig: - callbacks = [self._callback_handler] if self._callback_handler else None - config: RunnableConfig = { - "recursion_limit": 24, - "callbacks": callbacks, - "configurable": {"thread_id": self._conversation.id}, - } - return config - - def _init_or_update_state(self): - config = self._get_config() - snapshot = self._graph.get_state(config) - if snapshot.next: - saved_state = validate_state_update(snapshot.values) - self._state = saved_state - self._graph.update_state(config, PartialAssistantState(messages=[self._latest_message], resumed=True)) - - return None - initial_state = self._initial_state - self._state = initial_state - return initial_state - - def _node_to_reasoning_message( - self, node_name: AssistantNodeName, input: AssistantState - ) -> Optional[ReasoningMessage]: - match node_name: - case AssistantNodeName.ROUTER: - return ReasoningMessage(content="Identifying type of analysis") - case ( - AssistantNodeName.TRENDS_PLANNER - | AssistantNodeName.TRENDS_PLANNER_TOOLS - | AssistantNodeName.FUNNEL_PLANNER - | AssistantNodeName.FUNNEL_PLANNER_TOOLS - | AssistantNodeName.RETENTION_PLANNER - | AssistantNodeName.RETENTION_PLANNER_TOOLS - ): - substeps: list[str] = [] - if input: - if intermediate_steps := input.intermediate_steps: - for action, _ in intermediate_steps: - match action.tool: - case "retrieve_event_properties": - substeps.append(f"Exploring `{action.tool_input}` event's properties") - case "retrieve_entity_properties": - substeps.append(f"Exploring {action.tool_input} properties") - case "retrieve_event_property_values": - assert isinstance(action.tool_input, dict) - substeps.append( - f"Analyzing `{action.tool_input['property_name']}` event's property `{action.tool_input['event_name']}`" - ) - case "retrieve_entity_property_values": - assert isinstance(action.tool_input, dict) - substeps.append( - f"Analyzing {action.tool_input['entity']} property `{action.tool_input['property_name']}`" - ) - return ReasoningMessage(content="Picking relevant events and properties", substeps=substeps) - case AssistantNodeName.TRENDS_GENERATOR: - return ReasoningMessage(content="Creating trends query") - case AssistantNodeName.FUNNEL_GENERATOR: - return ReasoningMessage(content="Creating funnel query") - case AssistantNodeName.RETENTION_GENERATOR: - return ReasoningMessage(content="Creating retention query") - case _: - return None - - def _process_update(self, update: Any) -> BaseModel | None: - if is_state_update(update): - _, new_state = update - self._state = validate_state_update(new_state) - elif is_value_update(update) and (new_message := self._process_value_update(update)): - return new_message - elif is_message_update(update) and (new_message := self._process_message_update(update)): - return new_message - elif is_task_started_update(update) and (new_message := self._process_task_started_update(update)): - return new_message - return None - - def _process_value_update(self, update: GraphValueUpdateTuple) -> BaseModel | None: - _, maybe_state_update = update - state_update = validate_value_update(maybe_state_update) - - if node_val := state_update.get(AssistantNodeName.ROUTER): - if isinstance(node_val, PartialAssistantState) and node_val.messages: - return node_val.messages[0] - elif intersected_nodes := state_update.keys() & VISUALIZATION_NODES.keys(): - # Reset chunks when schema validation fails. - self._chunks = AIMessageChunk(content="") - - node_name = intersected_nodes.pop() - node_val = state_update[node_name] - if not isinstance(node_val, PartialAssistantState): - return None - if node_val.messages: - return node_val.messages[0] - elif node_val.intermediate_steps: - return AssistantGenerationStatusEvent(type=AssistantGenerationStatusType.GENERATION_ERROR) - - for node_name in VERBOSE_NODES: - if node_val := state_update.get(node_name): - if isinstance(node_val, PartialAssistantState) and node_val.messages: - self._chunks = AIMessageChunk(content="") - return node_val.messages[0] - - return None - - def _process_message_update(self, update: GraphMessageUpdateTuple) -> BaseModel | None: - langchain_message, langgraph_state = update[1] - if isinstance(langchain_message, AIMessageChunk): - node_name = langgraph_state["langgraph_node"] - if node_name in VISUALIZATION_NODES.keys(): - self._chunks += langchain_message # type: ignore - parsed_message = VISUALIZATION_NODES[node_name].parse_output(self._chunks.tool_calls[0]["args"]) - if parsed_message: - initiator_id = self._state.start_id if self._state is not None else None - return VisualizationMessage(answer=parsed_message.query, initiator=initiator_id) - elif node_name in STREAMING_NODES: - self._chunks += langchain_message # type: ignore - if node_name == AssistantNodeName.MEMORY_INITIALIZER: - if not MemoryInitializerNode.should_process_message_chunk(langchain_message): - return None - else: - return AssistantMessage( - content=MemoryInitializerNode.format_message(cast(str, self._chunks.content)) - ) - return AssistantMessage(content=self._chunks.content) - return None - - def _process_task_started_update(self, update: GraphTaskStartedUpdateTuple) -> BaseModel | None: - _, task_update = update - node_name = task_update["payload"]["name"] # type: ignore - node_input = task_update["payload"]["input"] # type: ignore - if reasoning_message := self._node_to_reasoning_message(node_name, node_input): - return reasoning_message - return None - - def _serialize_message(self, message: BaseModel) -> str: - output = "" - if isinstance(message, AssistantGenerationStatusEvent): - output += f"event: {AssistantEventType.STATUS}\n" - else: - output += f"event: {AssistantEventType.MESSAGE}\n" - return output + f"data: {message.model_dump_json(exclude_none=True)}\n\n" - - def _serialize_conversation(self) -> str: - output = f"event: {AssistantEventType.CONVERSATION}\n" - json_conversation = json.dumps({"id": str(self._conversation.id)}) - output += f"data: {json_conversation}\n\n" - return output - - def _report_conversation_state(self, message: Optional[VisualizationMessage]): - human_message = self._latest_message - if self._user and message: - report_user_action( - self._user, - "chat with ai", - {"prompt": human_message.content, "response": message.model_dump_json(exclude_none=True)}, - ) diff --git a/ee/hogai/django_checkpoint/__init__.py b/ee/hogai/django_checkpoint/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/django_checkpoint/checkpointer.py b/ee/hogai/django_checkpoint/checkpointer.py deleted file mode 100644 index a57140fecd..0000000000 --- a/ee/hogai/django_checkpoint/checkpointer.py +++ /dev/null @@ -1,312 +0,0 @@ -import json -import random -import threading -from collections.abc import Iterable, Iterator, Sequence -from typing import Any, Optional, cast - -from django.db import transaction -from django.db.models import Q -from langchain_core.runnables import RunnableConfig -from langgraph.checkpoint.base import ( - WRITES_IDX_MAP, - BaseCheckpointSaver, - ChannelVersions, - Checkpoint, - CheckpointMetadata, - CheckpointTuple, - PendingWrite, - get_checkpoint_id, -) -from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer -from langgraph.checkpoint.serde.types import ChannelProtocol - -from ee.models.assistant import ConversationCheckpoint, ConversationCheckpointBlob, ConversationCheckpointWrite - - -class DjangoCheckpointer(BaseCheckpointSaver[str]): - jsonplus_serde = JsonPlusSerializer() - _lock: threading.Lock - - def __init__(self, *args): - super().__init__(*args) - self._lock = threading.Lock() - - def _load_writes(self, writes: Sequence[ConversationCheckpointWrite]) -> list[PendingWrite]: - return ( - [ - ( - str(checkpoint_write.task_id), - checkpoint_write.channel, - self.serde.loads_typed((checkpoint_write.type, checkpoint_write.blob)), - ) - for checkpoint_write in writes - if checkpoint_write.type is not None and checkpoint_write.blob is not None - ] - if writes - else [] - ) - - def _load_json(self, obj: Any): - return self.jsonplus_serde.loads(self.jsonplus_serde.dumps(obj)) - - def _dump_json(self, obj: Any) -> dict[str, Any]: - serialized_metadata = self.jsonplus_serde.dumps(obj) - # NOTE: we're using JSON serializer (not msgpack), so we need to remove null characters before writing - nulls_removed = serialized_metadata.decode().replace("\\u0000", "") - return json.loads(nulls_removed) - - def _get_checkpoint_qs( - self, - config: Optional[RunnableConfig], - filter: Optional[dict[str, Any]], - before: Optional[RunnableConfig], - ): - query = Q() - - # construct predicate for config filter - if config and "configurable" in config: - thread_id = config["configurable"].get("thread_id") - query &= Q(thread_id=thread_id) - checkpoint_ns = config["configurable"].get("checkpoint_ns") - if checkpoint_ns is not None: - query &= Q(checkpoint_ns=checkpoint_ns) - if checkpoint_id := get_checkpoint_id(config): - query &= Q(id=checkpoint_id) - - # construct predicate for metadata filter - if filter: - query &= Q(metadata__contains=filter) - - # construct predicate for `before` - if before is not None: - query &= Q(id__lt=get_checkpoint_id(before)) - - return ConversationCheckpoint.objects.filter(query).order_by("-id") - - def _get_checkpoint_channel_values( - self, checkpoint: ConversationCheckpoint - ) -> Iterable[ConversationCheckpointBlob]: - if not checkpoint.checkpoint: - return [] - loaded_checkpoint = self._load_json(checkpoint.checkpoint) - if "channel_versions" not in loaded_checkpoint: - return [] - query = Q() - for channel, version in loaded_checkpoint["channel_versions"].items(): - query |= Q(channel=channel, version=version) - return ConversationCheckpointBlob.objects.filter( - Q(thread_id=checkpoint.thread_id, checkpoint_ns=checkpoint.checkpoint_ns) & query - ) - - def list( - self, - config: Optional[RunnableConfig], - *, - filter: Optional[dict[str, Any]] = None, - before: Optional[RunnableConfig] = None, - limit: Optional[int] = None, - ) -> Iterator[CheckpointTuple]: - """List checkpoints from the database. - - This method retrieves a list of checkpoint tuples from the Postgres database based - on the provided config. The checkpoints are ordered by checkpoint ID in descending order (newest first). - - Args: - config (RunnableConfig): The config to use for listing the checkpoints. - filter (Optional[Dict[str, Any]]): Additional filtering criteria for metadata. Defaults to None. - before (Optional[RunnableConfig]): If provided, only checkpoints before the specified checkpoint ID are returned. Defaults to None. - limit (Optional[int]): The maximum number of checkpoints to return. Defaults to None. - - Yields: - Iterator[CheckpointTuple]: An iterator of checkpoint tuples. - """ - qs = self._get_checkpoint_qs(config, filter, before) - if limit: - qs = qs[:limit] - - for checkpoint in qs: - channel_values = self._get_checkpoint_channel_values(checkpoint) - loaded_checkpoint: Checkpoint = self._load_json(checkpoint.checkpoint) - - checkpoint_dict: Checkpoint = { - **loaded_checkpoint, - "pending_sends": [ - self.serde.loads_typed((checkpoint_write.type, checkpoint_write.blob)) - for checkpoint_write in checkpoint.pending_sends - ], - "channel_values": { - checkpoint_blob.channel: self.serde.loads_typed((checkpoint_blob.type, checkpoint_blob.blob)) - for checkpoint_blob in channel_values - if checkpoint_blob.type is not None - and checkpoint_blob.type != "empty" - and checkpoint_blob.blob is not None - }, - } - - yield CheckpointTuple( - { - "configurable": { - "thread_id": checkpoint.thread_id, - "checkpoint_ns": checkpoint.checkpoint_ns, - "checkpoint_id": checkpoint.id, - } - }, - checkpoint_dict, - self._load_json(checkpoint.metadata), - ( - { - "configurable": { - "thread_id": checkpoint.thread_id, - "checkpoint_ns": checkpoint.checkpoint_ns, - "checkpoint_id": checkpoint.parent_checkpoint_id, - } - } - if checkpoint.parent_checkpoint - else None - ), - self._load_writes(checkpoint.pending_writes), - ) - - def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]: - """Get a checkpoint tuple from the database. - - This method retrieves a checkpoint tuple from the Postgres database based on the - provided config. If the config contains a "checkpoint_id" key, the checkpoint with - the matching thread ID and timestamp is retrieved. Otherwise, the latest checkpoint - for the given thread ID is retrieved. - - Args: - config (RunnableConfig): The config to use for retrieving the checkpoint. - - Returns: - Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found. - """ - return next(self.list(config), None) - - def put( - self, - config: RunnableConfig, - checkpoint: Checkpoint, - metadata: CheckpointMetadata, - new_versions: ChannelVersions, - ) -> RunnableConfig: - """Save a checkpoint to the database. - - This method saves a checkpoint to the Postgres database. The checkpoint is associated - with the provided config and its parent config (if any). - - Args: - config (RunnableConfig): The config to associate with the checkpoint. - checkpoint (Checkpoint): The checkpoint to save. - metadata (CheckpointMetadata): Additional metadata to save with the checkpoint. - new_versions (ChannelVersions): New channel versions as of this write. - - Returns: - RunnableConfig: Updated configuration after storing the checkpoint. - """ - configurable = config["configurable"] - thread_id: str = configurable["thread_id"] - checkpoint_id = get_checkpoint_id(config) - checkpoint_ns: str | None = configurable.get("checkpoint_ns") or "" - - checkpoint_copy = cast(dict[str, Any], checkpoint.copy()) - channel_values = checkpoint_copy.pop("channel_values", {}) - - next_config: RunnableConfig = { - "configurable": { - "thread_id": thread_id, - "checkpoint_ns": checkpoint_ns, - "checkpoint_id": checkpoint["id"], - } - } - - with self._lock, transaction.atomic(): - updated_checkpoint, _ = ConversationCheckpoint.objects.update_or_create( - id=checkpoint["id"], - thread_id=thread_id, - checkpoint_ns=checkpoint_ns, - defaults={ - "parent_checkpoint_id": checkpoint_id, - "checkpoint": self._dump_json({**checkpoint_copy, "pending_sends": []}), - "metadata": self._dump_json(metadata), - }, - ) - - blobs = [] - for channel, version in new_versions.items(): - type, blob = ( - self.serde.dumps_typed(channel_values[channel]) if channel in channel_values else ("empty", None) - ) - blobs.append( - ConversationCheckpointBlob( - checkpoint=updated_checkpoint, - thread_id=thread_id, - channel=channel, - version=str(version), - type=type, - blob=blob, - ) - ) - - ConversationCheckpointBlob.objects.bulk_create(blobs, ignore_conflicts=True) - return next_config - - def put_writes( - self, - config: RunnableConfig, - writes: Sequence[tuple[str, Any]], - task_id: str, - ) -> None: - """Store intermediate writes linked to a checkpoint. - - This method saves intermediate writes associated with a checkpoint to the Postgres database. - - Args: - config (RunnableConfig): Configuration of the related checkpoint. - writes (List[Tuple[str, Any]]): List of writes to store. - task_id (str): Identifier for the task creating the writes. - """ - configurable = config["configurable"] - thread_id: str = configurable["thread_id"] - checkpoint_id = get_checkpoint_id(config) - checkpoint_ns: str | None = configurable.get("checkpoint_ns") or "" - - with self._lock, transaction.atomic(): - # `put_writes` and `put` are concurrently called without guaranteeing the call order - # so we need to ensure the checkpoint is created before creating writes. - # Thread.lock() will prevent race conditions though to the same checkpoints within a single pod. - checkpoint, _ = ConversationCheckpoint.objects.get_or_create( - id=checkpoint_id, thread_id=thread_id, checkpoint_ns=checkpoint_ns - ) - - writes_to_create = [] - for idx, (channel, value) in enumerate(writes): - type, blob = self.serde.dumps_typed(value) - writes_to_create.append( - ConversationCheckpointWrite( - checkpoint=checkpoint, - task_id=task_id, - idx=idx, - channel=channel, - type=type, - blob=blob, - ) - ) - - ConversationCheckpointWrite.objects.bulk_create( - writes_to_create, - update_conflicts=all(w[0] in WRITES_IDX_MAP for w in writes), - unique_fields=["checkpoint", "task_id", "idx"], - update_fields=["channel", "type", "blob"], - ) - - def get_next_version(self, current: Optional[str | int], channel: ChannelProtocol) -> str: - if current is None: - current_v = 0 - elif isinstance(current, int): - current_v = current - else: - current_v = int(current.split(".")[0]) - next_v = current_v + 1 - next_h = random.random() - return f"{next_v:032}.{next_h:016}" diff --git a/ee/hogai/django_checkpoint/test/test_checkpointer.py b/ee/hogai/django_checkpoint/test/test_checkpointer.py deleted file mode 100644 index d7c7a91178..0000000000 --- a/ee/hogai/django_checkpoint/test/test_checkpointer.py +++ /dev/null @@ -1,425 +0,0 @@ -# type: ignore - -import operator -from typing import Annotated, Any, Optional, TypedDict - -from langchain_core.runnables import RunnableConfig -from langgraph.checkpoint.base import ( - Checkpoint, - CheckpointMetadata, - create_checkpoint, - empty_checkpoint, -) -from langgraph.checkpoint.base.id import uuid6 -from langgraph.errors import NodeInterrupt -from langgraph.graph import END, START -from langgraph.graph.state import CompiledStateGraph, StateGraph -from pydantic import BaseModel, Field - -from ee.hogai.django_checkpoint.checkpointer import DjangoCheckpointer -from ee.models.assistant import ( - Conversation, - ConversationCheckpoint, - ConversationCheckpointBlob, - ConversationCheckpointWrite, -) -from posthog.test.base import NonAtomicBaseTest - - -class TestDjangoCheckpointer(NonAtomicBaseTest): - CLASS_DATA_LEVEL_SETUP = False - - def _build_graph(self, checkpointer: DjangoCheckpointer): - class State(TypedDict): - val: int - - graph = StateGraph(State) - - def handle_node1(state: State) -> State: - if state["val"] == 1: - raise NodeInterrupt("test") - return {"val": state["val"] + 1} - - graph.add_node("node1", handle_node1) - graph.add_node("node2", lambda state: state) - - graph.add_edge(START, "node1") - graph.add_edge("node1", "node2") - graph.add_edge("node2", END) - - return graph.compile(checkpointer=checkpointer) - - def test_saver(self): - thread1 = Conversation.objects.create(user=self.user, team=self.team) - thread2 = Conversation.objects.create(user=self.user, team=self.team) - - config_1: RunnableConfig = { - "configurable": { - "thread_id": thread1.id, - "checkpoint_ns": "", - } - } - chkpnt_1: Checkpoint = empty_checkpoint() - - config_2: RunnableConfig = { - "configurable": { - "thread_id": thread2.id, - "checkpoint_ns": "", - } - } - chkpnt_2: Checkpoint = create_checkpoint(chkpnt_1, {}, 1) - - config_3: RunnableConfig = { - "configurable": { - "thread_id": thread2.id, - "checkpoint_id": chkpnt_2["id"], - "checkpoint_ns": "inner", - } - } - chkpnt_3: Checkpoint = empty_checkpoint() - - metadata_1: CheckpointMetadata = { - "source": "input", - "step": 2, - "writes": {}, - "score": 1, - } - metadata_2: CheckpointMetadata = { - "source": "loop", - "step": 1, - "writes": {"foo": "bar"}, - "score": None, - } - metadata_3: CheckpointMetadata = {} - - test_data = { - "configs": [config_1, config_2, config_3], - "checkpoints": [chkpnt_1, chkpnt_2, chkpnt_3], - "metadata": [metadata_1, metadata_2, metadata_3], - } - - saver = DjangoCheckpointer() - - configs = test_data["configs"] - checkpoints = test_data["checkpoints"] - metadata = test_data["metadata"] - - saver.put(configs[0], checkpoints[0], metadata[0], {}) - saver.put(configs[1], checkpoints[1], metadata[1], {}) - saver.put(configs[2], checkpoints[2], metadata[2], {}) - - # call method / assertions - query_1 = {"source": "input"} # search by 1 key - query_2 = { - "step": 1, - "writes": {"foo": "bar"}, - } # search by multiple keys - query_3: dict[str, Any] = {} # search by no keys, return all checkpoints - query_4 = {"source": "update", "step": 1} # no match - - search_results_1 = list(saver.list(None, filter=query_1)) - assert len(search_results_1) == 1 - assert search_results_1[0].metadata == metadata[0] - - search_results_2 = list(saver.list(None, filter=query_2)) - assert len(search_results_2) == 1 - assert search_results_2[0].metadata == metadata[1] - - search_results_3 = list(saver.list(None, filter=query_3)) - assert len(search_results_3) == 3 - - search_results_4 = list(saver.list(None, filter=query_4)) - assert len(search_results_4) == 0 - - # search by config (defaults to checkpoints across all namespaces) - search_results_5 = list(saver.list({"configurable": {"thread_id": thread2.id}})) - assert len(search_results_5) == 2 - assert { - search_results_5[0].config["configurable"]["checkpoint_ns"], - search_results_5[1].config["configurable"]["checkpoint_ns"], - } == {"", "inner"} - - def test_channel_versions(self): - thread1 = Conversation.objects.create(user=self.user, team=self.team) - - chkpnt = { - "v": 1, - "ts": "2024-07-31T20:14:19.804150+00:00", - "id": str(uuid6(clock_seq=-2)), - "channel_values": { - "post": "hog", - "node": "node", - }, - "channel_versions": { - "__start__": 2, - "my_key": 3, - "start:node": 3, - "node": 3, - }, - "versions_seen": { - "__input__": {}, - "__start__": {"__start__": 1}, - "node": {"start:node": 2}, - }, - "pending_sends": [], - } - metadata = {"meta": "key"} - - write_config = {"configurable": {"thread_id": thread1.id, "checkpoint_ns": ""}} - read_config = {"configurable": {"thread_id": thread1.id}} - - saver = DjangoCheckpointer() - saver.put(write_config, chkpnt, metadata, {}) - - checkpoint = ConversationCheckpoint.objects.first() - self.assertIsNotNone(checkpoint) - self.assertEqual(checkpoint.thread, thread1) - self.assertEqual(checkpoint.checkpoint_ns, "") - self.assertEqual(str(checkpoint.id), chkpnt["id"]) - self.assertIsNone(checkpoint.parent_checkpoint) - chkpnt.pop("channel_values") - self.assertEqual(checkpoint.checkpoint, chkpnt) - self.assertEqual(checkpoint.metadata, metadata) - - checkpoints = list(saver.list(read_config)) - self.assertEqual(len(checkpoints), 1) - - checkpoint = saver.get(read_config) - self.assertEqual(checkpoint, checkpoints[0].checkpoint) - - def test_put_copies_checkpoint(self): - thread1 = Conversation.objects.create(user=self.user, team=self.team) - chkpnt = { - "v": 1, - "ts": "2024-07-31T20:14:19.804150+00:00", - "id": str(uuid6(clock_seq=-2)), - "channel_values": { - "post": "hog", - "node": "node", - }, - "channel_versions": { - "__start__": 2, - "my_key": 3, - "start:node": 3, - "node": 3, - }, - "versions_seen": { - "__input__": {}, - "__start__": {"__start__": 1}, - "node": {"start:node": 2}, - }, - "pending_sends": [], - } - metadata = {"meta": "key"} - write_config = {"configurable": {"thread_id": thread1.id, "checkpoint_ns": ""}} - saver = DjangoCheckpointer() - saver.put(write_config, chkpnt, metadata, {}) - self.assertIn("channel_values", chkpnt) - - def test_concurrent_puts_and_put_writes(self): - graph: CompiledStateGraph = self._build_graph(DjangoCheckpointer()) - thread = Conversation.objects.create(user=self.user, team=self.team) - config = {"configurable": {"thread_id": str(thread.id)}} - graph.invoke( - {"val": 0}, - config=config, - ) - self.assertEqual(len(ConversationCheckpoint.objects.all()), 4) - self.assertEqual(len(ConversationCheckpointBlob.objects.all()), 10) - self.assertEqual(len(ConversationCheckpointWrite.objects.all()), 6) - - def test_resuming(self): - checkpointer = DjangoCheckpointer() - graph: CompiledStateGraph = self._build_graph(checkpointer) - thread = Conversation.objects.create(user=self.user, team=self.team) - config = {"configurable": {"thread_id": str(thread.id)}} - - graph.invoke( - {"val": 1}, - config=config, - ) - snapshot = graph.get_state(config) - self.assertIsNotNone(snapshot.next) - self.assertEqual(snapshot.tasks[0].interrupts[0].value, "test") - - self.assertEqual(len(ConversationCheckpoint.objects.all()), 2) - self.assertEqual(len(ConversationCheckpointBlob.objects.all()), 4) - self.assertEqual(len(ConversationCheckpointWrite.objects.all()), 3) - self.assertEqual(len(list(checkpointer.list(config))), 2) - - latest_checkpoint = ConversationCheckpoint.objects.last() - latest_write = ConversationCheckpointWrite.objects.filter(checkpoint=latest_checkpoint).first() - actual_checkpoint = checkpointer.get_tuple(config) - self.assertIsNotNone(actual_checkpoint) - self.assertIsNotNone(latest_write) - self.assertEqual(len(latest_checkpoint.writes.all()), 1) - blobs = list(latest_checkpoint.blobs.all()) - self.assertEqual(len(blobs), 3) - self.assertEqual(actual_checkpoint.checkpoint["id"], str(latest_checkpoint.id)) - self.assertEqual(len(actual_checkpoint.pending_writes), 1) - self.assertEqual(actual_checkpoint.pending_writes[0][0], str(latest_write.task_id)) - - graph.update_state(config, {"val": 2}) - # add the value update checkpoint - self.assertEqual(len(ConversationCheckpoint.objects.all()), 3) - self.assertEqual(len(ConversationCheckpointBlob.objects.all()), 6) - self.assertEqual(len(ConversationCheckpointWrite.objects.all()), 5) - self.assertEqual(len(list(checkpointer.list(config))), 3) - - res = graph.invoke(None, config=config) - self.assertEqual(len(ConversationCheckpoint.objects.all()), 5) - self.assertEqual(len(ConversationCheckpointBlob.objects.all()), 12) - self.assertEqual(len(ConversationCheckpointWrite.objects.all()), 9) - self.assertEqual(len(list(checkpointer.list(config))), 5) - self.assertEqual(res, {"val": 3}) - snapshot = graph.get_state(config) - self.assertFalse(snapshot.next) - - def test_checkpoint_blobs_are_bound_to_thread(self): - class State(TypedDict, total=False): - messages: Annotated[list[str], operator.add] - string: Optional[str] - - graph = StateGraph(State) - - def handle_node1(state: State): - return - - def handle_node2(state: State): - raise NodeInterrupt("test") - - graph.add_node("node1", handle_node1) - graph.add_node("node2", handle_node2) - - graph.add_edge(START, "node1") - graph.add_edge("node1", "node2") - graph.add_edge("node2", END) - - compiled = graph.compile(checkpointer=DjangoCheckpointer()) - - thread = Conversation.objects.create(user=self.user, team=self.team) - config = {"configurable": {"thread_id": str(thread.id)}} - compiled.invoke({"messages": ["hello"], "string": "world"}, config=config) - - snapshot = compiled.get_state(config) - self.assertIsNotNone(snapshot.next) - self.assertEqual(snapshot.tasks[0].interrupts[0].value, "test") - saved_state = snapshot.values - self.assertEqual(saved_state["messages"], ["hello"]) - self.assertEqual(saved_state["string"], "world") - - def test_checkpoint_can_save_and_load_pydantic_state(self): - class State(BaseModel): - messages: Annotated[list[str], operator.add] - string: Optional[str] - - class PartialState(BaseModel): - messages: Optional[list[str]] = Field(default=None) - string: Optional[str] = Field(default=None) - - graph = StateGraph(State) - - def handle_node1(state: State): - return PartialState() - - def handle_node2(state: State): - raise NodeInterrupt("test") - - graph.add_node("node1", handle_node1) - graph.add_node("node2", handle_node2) - - graph.add_edge(START, "node1") - graph.add_edge("node1", "node2") - graph.add_edge("node2", END) - - compiled = graph.compile(checkpointer=DjangoCheckpointer()) - - thread = Conversation.objects.create(user=self.user, team=self.team) - config = {"configurable": {"thread_id": str(thread.id)}} - compiled.invoke({"messages": ["hello"], "string": "world"}, config=config) - - snapshot = compiled.get_state(config) - self.assertIsNotNone(snapshot.next) - self.assertEqual(snapshot.tasks[0].interrupts[0].value, "test") - saved_state = snapshot.values - self.assertEqual(saved_state["messages"], ["hello"]) - self.assertEqual(saved_state["string"], "world") - - def test_saved_blobs(self): - class State(TypedDict, total=False): - messages: Annotated[list[str], operator.add] - - graph = StateGraph(State) - - def handle_node1(state: State): - return {"messages": ["world"]} - - graph.add_node("node1", handle_node1) - - graph.add_edge(START, "node1") - graph.add_edge("node1", END) - - checkpointer = DjangoCheckpointer() - compiled = graph.compile(checkpointer=checkpointer) - - thread = Conversation.objects.create(user=self.user, team=self.team) - config = {"configurable": {"thread_id": str(thread.id)}} - compiled.invoke({"messages": ["hello"]}, config=config) - - snapshot = compiled.get_state(config) - self.assertFalse(snapshot.next) - saved_state = snapshot.values - self.assertEqual(saved_state["messages"], ["hello", "world"]) - - blobs = list(ConversationCheckpointBlob.objects.filter(thread=thread)) - self.assertEqual(len(blobs), 7) - - # Set initial state - self.assertEqual(blobs[0].channel, "__start__") - self.assertEqual(blobs[0].type, "msgpack") - self.assertEqual( - checkpointer.serde.loads_typed((blobs[0].type, blobs[0].blob)), - {"messages": ["hello"]}, - ) - - # Set first node - self.assertEqual(blobs[1].channel, "__start__") - self.assertEqual(blobs[1].type, "empty") - self.assertIsNone(blobs[1].blob) - - # Set value channels before start - self.assertEqual(blobs[2].channel, "messages") - self.assertEqual(blobs[2].type, "msgpack") - self.assertEqual( - checkpointer.serde.loads_typed((blobs[2].type, blobs[2].blob)), - ["hello"], - ) - - # Transition to node1 - self.assertEqual(blobs[3].channel, "start:node1") - self.assertEqual(blobs[3].type, "msgpack") - self.assertEqual( - checkpointer.serde.loads_typed((blobs[3].type, blobs[3].blob)), - "__start__", - ) - - # Set new state for messages - self.assertEqual(blobs[4].channel, "messages") - self.assertEqual(blobs[4].type, "msgpack") - self.assertEqual( - checkpointer.serde.loads_typed((blobs[4].type, blobs[4].blob)), - ["hello", "world"], - ) - - # After setting a state - self.assertEqual(blobs[5].channel, "start:node1") - self.assertEqual(blobs[5].type, "empty") - self.assertIsNone(blobs[5].blob) - - # Set last step - self.assertEqual(blobs[6].channel, "node1") - self.assertEqual(blobs[6].type, "msgpack") - self.assertEqual( - checkpointer.serde.loads_typed((blobs[6].type, blobs[6].blob)), - "node1", - ) diff --git a/ee/hogai/eval/__init__.py b/ee/hogai/eval/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/eval/conftest.py b/ee/hogai/eval/conftest.py deleted file mode 100644 index 56606dab4a..0000000000 --- a/ee/hogai/eval/conftest.py +++ /dev/null @@ -1,134 +0,0 @@ -import functools -from collections.abc import Generator -from pathlib import Path - -import pytest -from django.conf import settings -from django.test import override_settings -from langchain_core.runnables import RunnableConfig - -from ee.models import Conversation -from ee.models.assistant import CoreMemory -from posthog.demo.matrix.manager import MatrixManager -from posthog.models import Organization, Project, Team, User -from posthog.tasks.demo_create_data import HedgeboxMatrix -from posthog.test.base import BaseTest - - -# Flaky is a handy tool, but it always runs setup fixtures for retries. -# This decorator will just retry without re-running setup. -def retry_test_only(max_retries=3): - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - last_error: Exception | None = None - for attempt in range(max_retries): - try: - return func(*args, **kwargs) - except Exception as e: - last_error = e - print(f"\nRetrying test (attempt {attempt + 1}/{max_retries})...") # noqa - if last_error: - raise last_error - - return wrapper - - return decorator - - -# Apply decorators to all tests in the package. -def pytest_collection_modifyitems(items): - current_dir = Path(__file__).parent - for item in items: - if Path(item.fspath).is_relative_to(current_dir): - item.add_marker( - pytest.mark.skipif(not settings.IN_EVAL_TESTING, reason="Only runs for the assistant evaluation") - ) - # Apply our custom retry decorator to the test function - item.obj = retry_test_only(max_retries=3)(item.obj) - - -@pytest.fixture(scope="package") -def team(django_db_blocker) -> Generator[Team, None, None]: - with django_db_blocker.unblock(): - organization = Organization.objects.create(name=BaseTest.CONFIG_ORGANIZATION_NAME) - project = Project.objects.create(id=Team.objects.increment_id_sequence(), organization=organization) - team = Team.objects.create( - id=project.id, - project=project, - organization=organization, - test_account_filters=[ - { - "key": "email", - "value": "@posthog.com", - "operator": "not_icontains", - "type": "person", - } - ], - has_completed_onboarding_for={"product_analytics": True}, - ) - yield team - organization.delete() - - -@pytest.fixture(scope="package") -def user(team, django_db_blocker) -> Generator[User, None, None]: - with django_db_blocker.unblock(): - user = User.objects.create_and_join(team.organization, "eval@posthog.com", "password1234") - yield user - user.delete() - - -@pytest.fixture(scope="package") -def core_memory(team) -> Generator[CoreMemory, None, None]: - initial_memory = """Hedgebox is a cloud storage service enabling users to store, share, and access files across devices. - - The company operates in the cloud storage and collaboration market for individuals and businesses. - - Their audience includes professionals and organizations seeking file management and collaboration solutions. - - Hedgebox’s freemium model provides free accounts with limited storage and paid subscription plans for additional features. - - Core features include file storage, synchronization, sharing, and collaboration tools for seamless file access and sharing. - - It integrates with third-party applications to enhance functionality and streamline workflows. - - Hedgebox sponsors the YouTube channel Marius Tech Tips.""" - - core_memory = CoreMemory.objects.create( - team=team, - text=initial_memory, - initial_text=initial_memory, - scraping_status=CoreMemory.ScrapingStatus.COMPLETED, - ) - yield core_memory - core_memory.delete() - - -@pytest.mark.django_db(transaction=True) -@pytest.fixture -def runnable_config(team, user) -> Generator[RunnableConfig, None, None]: - conversation = Conversation.objects.create(team=team, user=user) - yield { - "configurable": { - "thread_id": conversation.id, - } - } - conversation.delete() - - -@pytest.fixture(scope="package", autouse=True) -def setup_test_data(django_db_setup, team, user, django_db_blocker): - with django_db_blocker.unblock(): - matrix = HedgeboxMatrix( - seed="b1ef3c66-5f43-488a-98be-6b46d92fbcef", # this seed generates all events - days_past=120, - days_future=30, - n_clusters=500, - group_type_index_offset=0, - ) - matrix_manager = MatrixManager(matrix, print_steps=True) - with override_settings(TEST=False): - # Simulation saving should occur in non-test mode, so that Kafka isn't mocked. Normally in tests we don't - # want to ingest via Kafka, but simulation saving is specifically designed to use that route for speed - matrix_manager.run_on_team(team, user) diff --git a/ee/hogai/eval/tests/test_eval_funnel_generator.py b/ee/hogai/eval/tests/test_eval_funnel_generator.py deleted file mode 100644 index 5f0f292432..0000000000 --- a/ee/hogai/eval/tests/test_eval_funnel_generator.py +++ /dev/null @@ -1,46 +0,0 @@ -from collections.abc import Callable -from typing import cast - -import pytest -from langgraph.graph.state import CompiledStateGraph - -from ee.hogai.assistant import AssistantGraph -from ee.hogai.utils.types import AssistantNodeName, AssistantState -from posthog.schema import AssistantFunnelsQuery, HumanMessage, VisualizationMessage - - -@pytest.fixture -def call_node(team, runnable_config) -> Callable[[str, str], AssistantFunnelsQuery]: - graph: CompiledStateGraph = ( - AssistantGraph(team) - .add_edge(AssistantNodeName.START, AssistantNodeName.FUNNEL_GENERATOR) - .add_funnel_generator(AssistantNodeName.END) - .compile() - ) - - def callable(query: str, plan: str) -> AssistantFunnelsQuery: - state = graph.invoke( - AssistantState(messages=[HumanMessage(content=query)], plan=plan), - runnable_config, - ) - return cast(VisualizationMessage, AssistantState.model_validate(state).messages[-1]).answer - - return callable - - -def test_node_replaces_equals_with_contains(call_node): - query = "what is the conversion rate from a page view to sign up for users with name John?" - plan = """Sequence: - 1. $pageview - - property filter 1 - - person - - name - - equals - - John - 2. signed_up - """ - actual_output = call_node(query, plan).model_dump_json(exclude_none=True) - assert "exact" not in actual_output - assert "icontains" in actual_output - assert "John" not in actual_output - assert "john" in actual_output diff --git a/ee/hogai/eval/tests/test_eval_funnel_planner.py b/ee/hogai/eval/tests/test_eval_funnel_planner.py deleted file mode 100644 index c8bc25bc0b..0000000000 --- a/ee/hogai/eval/tests/test_eval_funnel_planner.py +++ /dev/null @@ -1,224 +0,0 @@ -from collections.abc import Callable - -import pytest -from deepeval import assert_test -from deepeval.metrics import GEval -from deepeval.test_case import LLMTestCase, LLMTestCaseParams -from langchain_core.runnables.config import RunnableConfig -from langgraph.graph.state import CompiledStateGraph - -from ee.hogai.assistant import AssistantGraph -from ee.hogai.utils.types import AssistantNodeName, AssistantState -from posthog.schema import HumanMessage - - -@pytest.fixture(scope="module") -def metric(): - return GEval( - name="Funnel Plan Correctness", - criteria="You will be given expected and actual generated plans to provide a taxonomy to answer a user's question with a funnel insight. Compare the plans to determine whether the taxonomy of the actual plan matches the expected plan. Do not apply general knowledge about funnel insights.", - evaluation_steps=[ - "A plan must define at least two series in the sequence, but it is not required to define any filters, exclusion steps, or a breakdown.", - "Compare events, properties, math types, and property values of 'expected output' and 'actual output'. Do not penalize if the actual output does not include a timeframe.", - "Check if the combination of events, properties, and property values in 'actual output' can answer the user's question according to the 'expected output'.", - # The criteria for aggregations must be more specific because there isn't a way to bypass them. - "Check if the math types in 'actual output' match those in 'expected output.' If the aggregation type is specified by a property, user, or group in 'expected output', the same property, user, or group must be used in 'actual output'.", - "If 'expected output' contains exclusion steps, check if 'actual output' contains those, and heavily penalize if the exclusion steps are not present or different.", - "If 'expected output' contains a breakdown, check if 'actual output' contains a similar breakdown, and heavily penalize if the breakdown is not present or different. Plans may only have one breakdown.", - # We don't want to see in the output unnecessary property filters. The assistant tries to use them all the time. - "Heavily penalize if the 'actual output' contains any excessive output not present in the 'expected output'. For example, the `is set` operator in filters should not be used unless the user explicitly asks for it.", - ], - evaluation_params=[ - LLMTestCaseParams.INPUT, - LLMTestCaseParams.EXPECTED_OUTPUT, - LLMTestCaseParams.ACTUAL_OUTPUT, - ], - threshold=0.7, - ) - - -@pytest.fixture -def call_node(team, runnable_config: RunnableConfig) -> Callable[[str], str]: - graph: CompiledStateGraph = ( - AssistantGraph(team) - .add_edge(AssistantNodeName.START, AssistantNodeName.FUNNEL_PLANNER) - .add_funnel_planner(AssistantNodeName.END) - .compile() - ) - - def callable(query: str) -> str: - state = graph.invoke( - AssistantState(messages=[HumanMessage(content=query)]), - runnable_config, - ) - return AssistantState.model_validate(state).plan or "" - - return callable - - -def test_basic_funnel(metric, call_node): - query = "what was the conversion from a page view to sign up?" - test_case = LLMTestCase( - input=query, - expected_output=""" - Sequence: - 1. $pageview - 2. signed_up - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_outputs_at_least_two_events(metric, call_node): - """ - Ambigious query. The funnel must return at least two events. - """ - query = "how many users paid a bill?" - test_case = LLMTestCase( - input=query, - expected_output=""" - Sequence: - 1. any event - 2. upgrade_plan - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_no_excessive_property_filters(metric, call_node): - query = "Show the user conversion from a sign up to a file download" - test_case = LLMTestCase( - input=query, - expected_output=""" - Sequence: - 1. signed_up - 2. downloaded_file - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_basic_filtering(metric, call_node): - query = "What was the conversion from uploading a file to downloading it from Chrome and Safari in the last 30d?" - test_case = LLMTestCase( - input=query, - expected_output=""" - Sequence: - 1. uploaded_file - - property filter 1: - - entity: event - - property name: $browser - - property type: String - - operator: equals - - property value: Chrome - - property filter 2: - - entity: event - - property name: $browser - - property type: String - - operator: equals - - property value: Safari - 2. downloaded_file - - property filter 1: - - entity: event - - property name: $browser - - property type: String - - operator: equals - - property value: Chrome - - property filter 2: - - entity: event - - property name: $browser - - property type: String - - operator: equals - - property value: Safari - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_exclusion_steps(metric, call_node): - query = "What was the conversion from uploading a file to downloading it in the last 30d excluding users that deleted a file?" - test_case = LLMTestCase( - input=query, - expected_output=""" - Sequence: - 1. uploaded_file - 2. downloaded_file - - Exclusions: - - deleted_file - - start index: 0 - - end index: 1 - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_breakdown(metric, call_node): - query = "Show a conversion from uploading a file to downloading it segmented by a browser" - test_case = LLMTestCase( - input=query, - expected_output=""" - Sequence: - 1. uploaded_file - 2. downloaded_file - - Breakdown by: - - entity: event - - property name: $browser - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_needle_in_a_haystack(metric, call_node): - query = "What was the conversion from a sign up to a paying customer on the personal-pro plan?" - test_case = LLMTestCase( - input=query, - expected_output=""" - Sequence: - 1. signed_up - 2. paid_bill - - property filter 1: - - entity: event - - property name: plan - - property type: String - - operator: equals - - property value: personal/pro - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_planner_outputs_multiple_series_from_a_single_series_question(metric, call_node): - query = "What's our sign-up funnel?" - test_case = LLMTestCase( - input=query, - expected_output=""" - Sequence: - 1. $pageview - 2. signed_up - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_funnel_does_not_include_timeframe(metric, call_node): - query = "what was the conversion from a page view to sign up for event time before 2024-01-01?" - test_case = LLMTestCase( - input=query, - expected_output=""" - Sequence: - 1. $pageview - 2. signed_up - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) diff --git a/ee/hogai/eval/tests/test_eval_memory.py b/ee/hogai/eval/tests/test_eval_memory.py deleted file mode 100644 index 54329f6d5e..0000000000 --- a/ee/hogai/eval/tests/test_eval_memory.py +++ /dev/null @@ -1,178 +0,0 @@ -import json -from collections.abc import Callable -from typing import Optional - -import pytest -from deepeval import assert_test -from deepeval.metrics import GEval, ToolCorrectnessMetric -from deepeval.test_case import LLMTestCase, LLMTestCaseParams -from langchain_core.messages import AIMessage -from langchain_core.runnables.config import RunnableConfig -from langgraph.graph.state import CompiledStateGraph - -from ee.hogai.assistant import AssistantGraph -from ee.hogai.utils.types import AssistantNodeName, AssistantState -from posthog.schema import HumanMessage - - -@pytest.fixture -def retrieval_metrics(): - retrieval_correctness_metric = GEval( - name="Correctness", - criteria="Determine whether the actual output is factually correct based on the expected output.", - evaluation_steps=[ - "Check whether the facts in 'actual output' contradicts any facts in 'expected output'", - "You should also heavily penalize omission of detail", - "Vague language, or contradicting OPINIONS, are OK", - "The actual fact must only contain information about the user's company or product", - "Context must not contain similar information to the actual fact", - ], - evaluation_params=[ - LLMTestCaseParams.INPUT, - LLMTestCaseParams.CONTEXT, - LLMTestCaseParams.EXPECTED_OUTPUT, - LLMTestCaseParams.ACTUAL_OUTPUT, - ], - threshold=0.7, - ) - - return [ToolCorrectnessMetric(), retrieval_correctness_metric] - - -@pytest.fixture -def replace_metrics(): - retrieval_correctness_metric = GEval( - name="Correctness", - criteria="Determine whether the actual output tuple is factually correct based on the expected output tuple. The first element is the original fact from the context to replace with, while the second element is the new fact to replace it with.", - evaluation_steps=[ - "Check whether the facts in 'actual output' contradicts any facts in 'expected output'", - "You should also heavily penalize omission of detail", - "Vague language, or contradicting OPINIONS, are OK", - "The actual fact must only contain information about the user's company or product", - "Context must contain the first element of the tuples", - "For deletion, the second element should be an empty string in both the actual and expected output", - ], - evaluation_params=[ - LLMTestCaseParams.INPUT, - LLMTestCaseParams.CONTEXT, - LLMTestCaseParams.EXPECTED_OUTPUT, - LLMTestCaseParams.ACTUAL_OUTPUT, - ], - threshold=0.7, - ) - - return [ToolCorrectnessMetric(), retrieval_correctness_metric] - - -@pytest.fixture -def call_node(team, runnable_config: RunnableConfig) -> Callable[[str], Optional[AIMessage]]: - graph: CompiledStateGraph = ( - AssistantGraph(team).add_memory_collector(AssistantNodeName.END, AssistantNodeName.END).compile() - ) - - def callable(query: str) -> Optional[AIMessage]: - state = graph.invoke( - AssistantState(messages=[HumanMessage(content=query)]), - runnable_config, - ) - validated_state = AssistantState.model_validate(state) - if not validated_state.memory_collection_messages: - return None - return validated_state.memory_collection_messages[-1] - - return callable - - -def test_saves_relevant_fact(call_node, retrieval_metrics, core_memory): - query = "calculate ARR: use the paid_bill event and the amount property." - actual_output = call_node(query) - tool = actual_output.tool_calls[0] - - test_case = LLMTestCase( - input=query, - expected_output="The product uses the event paid_bill and the property amount to calculate Annual Recurring Revenue (ARR).", - expected_tools=["core_memory_append"], - context=[core_memory.formatted_text], - actual_output=tool["args"]["memory_content"], - tools_called=[tool["name"]], - ) - assert_test(test_case, retrieval_metrics) - - -def test_saves_company_related_information(call_node, retrieval_metrics, core_memory): - query = "Our secondary target audience is technical founders or highly-technical product managers." - actual_output = call_node(query) - tool = actual_output.tool_calls[0] - - test_case = LLMTestCase( - input=query, - expected_output="The company's secondary target audience is technical founders or highly-technical product managers.", - expected_tools=["core_memory_append"], - context=[core_memory.formatted_text], - actual_output=tool["args"]["memory_content"], - tools_called=[tool["name"]], - ) - assert_test(test_case, retrieval_metrics) - - -def test_omits_irrelevant_personal_information(call_node): - query = "My name is John Doherty." - actual_output = call_node(query) - assert actual_output is None - - -def test_omits_irrelevant_excessive_info_from_insights(call_node): - query = "Build a pageview trend for users with name John." - actual_output = call_node(query) - assert actual_output is None - - -def test_fact_replacement(call_node, core_memory, replace_metrics): - query = "Hedgebox doesn't sponsor the YouTube channel Marius Tech Tips anymore." - actual_output = call_node(query) - tool = actual_output.tool_calls[0] - - test_case = LLMTestCase( - input=query, - expected_output=json.dumps( - [ - "Hedgebox sponsors the YouTube channel Marius Tech Tips.", - "Hedgebox no longer sponsors the YouTube channel Marius Tech Tips.", - ] - ), - expected_tools=["core_memory_replace"], - context=[core_memory.formatted_text], - actual_output=json.dumps([tool["args"]["original_fragment"], tool["args"]["new_fragment"]]), - tools_called=[tool["name"]], - ) - assert_test(test_case, replace_metrics) - - -def test_fact_removal(call_node, core_memory, replace_metrics): - query = "Delete info that Hedgebox sponsored the YouTube channel Marius Tech Tips." - actual_output = call_node(query) - tool = actual_output.tool_calls[0] - - test_case = LLMTestCase( - input=query, - expected_output=json.dumps(["Hedgebox sponsors the YouTube channel Marius Tech Tips.", ""]), - expected_tools=["core_memory_replace"], - context=[core_memory.formatted_text], - actual_output=json.dumps([tool["args"]["original_fragment"], tool["args"]["new_fragment"]]), - tools_called=[tool["name"]], - ) - assert_test(test_case, replace_metrics) - - -def test_parallel_calls(call_node): - query = "Delete info that Hedgebox sponsored the YouTube channel Marius Tech Tips, and we don't have file sharing." - actual_output = call_node(query) - - tool = actual_output.tool_calls - test_case = LLMTestCase( - input=query, - expected_tools=["core_memory_replace", "core_memory_append"], - actual_output=actual_output.content, - tools_called=[tool[0]["name"], tool[1]["name"]], - ) - assert_test(test_case, [ToolCorrectnessMetric()]) diff --git a/ee/hogai/eval/tests/test_eval_retention_generator.py b/ee/hogai/eval/tests/test_eval_retention_generator.py deleted file mode 100644 index 409a2d5883..0000000000 --- a/ee/hogai/eval/tests/test_eval_retention_generator.py +++ /dev/null @@ -1,76 +0,0 @@ -from collections.abc import Callable -from typing import cast - -import pytest -from langgraph.graph.state import CompiledStateGraph - -from ee.hogai.assistant import AssistantGraph -from ee.hogai.utils.types import AssistantNodeName, AssistantState -from posthog.schema import ( - AssistantRetentionQuery, - HumanMessage, - RetentionEntity, - VisualizationMessage, -) - - -@pytest.fixture -def call_node(team, runnable_config) -> Callable[[str, str], AssistantRetentionQuery]: - graph: CompiledStateGraph = ( - AssistantGraph(team) - .add_edge(AssistantNodeName.START, AssistantNodeName.RETENTION_GENERATOR) - .add_retention_generator(AssistantNodeName.END) - .compile() - ) - - def callable(query: str, plan: str) -> AssistantRetentionQuery: - state = graph.invoke( - AssistantState(messages=[HumanMessage(content=query)], plan=plan), - runnable_config, - ) - message = cast(VisualizationMessage, AssistantState.model_validate(state).messages[-1]) - answer = message.answer - assert isinstance(answer, AssistantRetentionQuery), "Expected AssistantRetentionQuery" - return answer - - return callable - - -def test_node_replaces_equals_with_contains(call_node): - query = "Show file upload retention after signup for users with name John" - plan = """Target event: - - signed_up - - Returning event: - - file_uploaded - - Filters: - - property filter 1: - - person - - name - - equals - - John - """ - actual_output = call_node(query, plan).model_dump_json(exclude_none=True) - assert "exact" not in actual_output - assert "icontains" in actual_output - assert "John" not in actual_output - assert "john" in actual_output - - -def test_basic_retention_structure(call_node): - query = "Show retention for users who signed up" - plan = """Target Event: - - signed_up - - Returning Event: - - file_uploaded - """ - actual_output = call_node(query, plan) - assert actual_output.retentionFilter is not None - assert actual_output.retentionFilter.targetEntity == RetentionEntity( - id="signed_up", type="events", name="signed_up", order=0 - ) - assert actual_output.retentionFilter.returningEntity == RetentionEntity( - id="file_uploaded", type="events", name="file_uploaded", order=0 - ) diff --git a/ee/hogai/eval/tests/test_eval_retention_planner.py b/ee/hogai/eval/tests/test_eval_retention_planner.py deleted file mode 100644 index b050fbea41..0000000000 --- a/ee/hogai/eval/tests/test_eval_retention_planner.py +++ /dev/null @@ -1,118 +0,0 @@ -from collections.abc import Callable - -import pytest -from deepeval import assert_test -from deepeval.metrics import GEval -from deepeval.test_case import LLMTestCase, LLMTestCaseParams -from langchain_core.runnables.config import RunnableConfig -from langgraph.graph.state import CompiledStateGraph - -from ee.hogai.assistant import AssistantGraph -from ee.hogai.utils.types import AssistantNodeName, AssistantState -from posthog.schema import HumanMessage - - -@pytest.fixture(scope="module") -def metric(): - return GEval( - name="Retention Plan Correctness", - criteria="You will be given expected and actual generated plans to provide a taxonomy to answer a user's question with a retention insight. Compare the plans to determine whether the taxonomy of the actual plan matches the expected plan. Do not apply general knowledge about retention insights.", - evaluation_steps=[ - "A plan must define both a target event (cohort-defining event) and a returning event (retention-measuring event), but it is not required to define any filters. It can't have breakdowns.", - "Compare target event, returning event, properties, and property values of 'expected output' and 'actual output'. Do not penalize if the actual output does not include a timeframe.", - "Check if the combination of target events, returning events, properties, and property values in 'actual output' can answer the user's question according to the 'expected output'.", - "If 'expected output' contains a breakdown, check if 'actual output' contains a similar breakdown, and heavily penalize if the breakdown is not present or different.", - # We don't want to see in the output unnecessary property filters. The assistant tries to use them all the time. - "Heavily penalize if the 'actual output' contains any excessive output not present in the 'expected output'. For example, the `is set` operator in filters should not be used unless the user explicitly asks for it.", - ], - evaluation_params=[ - LLMTestCaseParams.INPUT, - LLMTestCaseParams.EXPECTED_OUTPUT, - LLMTestCaseParams.ACTUAL_OUTPUT, - ], - threshold=0.7, - ) - - -@pytest.fixture -def call_node(team, runnable_config: RunnableConfig) -> Callable[[str], str]: - graph: CompiledStateGraph = ( - AssistantGraph(team) - .add_edge(AssistantNodeName.START, AssistantNodeName.RETENTION_PLANNER) - .add_retention_planner(AssistantNodeName.END) - .compile() - ) - - def callable(query: str) -> str: - raw_state = graph.invoke( - AssistantState(messages=[HumanMessage(content=query)]), - runnable_config, - ) - state = AssistantState.model_validate(raw_state) - return state.plan or "NO PLAN WAS GENERATED" - - return callable - - -def test_basic_retention(metric, call_node): - query = "What's the file upload retention of new users?" - test_case = LLMTestCase( - input=query, - expected_output=""" - Target event: - - signed_up - - Returning event: - - uploaded_file - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_basic_filtering(metric, call_node): - query = "Show retention of Chrome users uploading files" - test_case = LLMTestCase( - input=query, - expected_output=""" - Target event: - - uploaded_file - - Returning event: - - uploaded_file - - Filters: - - property filter 1: - - entity: event - - property name: $browser - - property type: String - - operator: equals - - property value: Chrome - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_needle_in_a_haystack(metric, call_node): - query = "Show retention for users who have paid a bill and are on the personal/pro plan" - test_case = LLMTestCase( - input=query, - expected_output=""" - Target event: - - paid_bill - - Returning event: - - downloaded_file - - Filters: - - property filter 1: - - entity: account - - property name: plan - - property type: String - - operator: equals - - property value: personal/pro - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) diff --git a/ee/hogai/eval/tests/test_eval_router.py b/ee/hogai/eval/tests/test_eval_router.py deleted file mode 100644 index 7c4a3325ea..0000000000 --- a/ee/hogai/eval/tests/test_eval_router.py +++ /dev/null @@ -1,80 +0,0 @@ -from collections.abc import Callable -from typing import cast - -import pytest -from langgraph.graph.state import CompiledStateGraph - -from ee.hogai.assistant import AssistantGraph -from ee.hogai.utils.types import AssistantNodeName, AssistantState -from posthog.schema import HumanMessage, RouterMessage - - -@pytest.fixture -def call_node(team, runnable_config) -> Callable[[str | list], str]: - graph: CompiledStateGraph = ( - AssistantGraph(team) - .add_edge(AssistantNodeName.START, AssistantNodeName.ROUTER) - .add_router(path_map={"trends": AssistantNodeName.END, "funnel": AssistantNodeName.END}) - .compile() - ) - - def callable(query: str | list) -> str: - messages = [HumanMessage(content=query)] if isinstance(query, str) else query - state = graph.invoke( - AssistantState(messages=messages), - runnable_config, - ) - return cast(RouterMessage, AssistantState.model_validate(state).messages[-1]).content - - return callable - - -def test_outputs_basic_trends_insight(call_node): - query = "Show the $pageview trend" - res = call_node(query) - assert res == "trends" - - -def test_outputs_basic_funnel_insight(call_node): - query = "What is the conversion rate of users who uploaded a file to users who paid for a plan?" - res = call_node(query) - assert res == "funnel" - - -def test_converts_trends_to_funnel(call_node): - conversation = [ - HumanMessage(content="Show trends of $pageview and $identify"), - RouterMessage(content="trends"), - HumanMessage(content="Convert this insight to a funnel"), - ] - res = call_node(conversation[:1]) - assert res == "trends" - res = call_node(conversation) - assert res == "funnel" - - -def test_converts_funnel_to_trends(call_node): - conversation = [ - HumanMessage(content="What is the conversion from a page view to a sign up?"), - RouterMessage(content="funnel"), - HumanMessage(content="Convert this insight to a trends"), - ] - res = call_node(conversation[:1]) - assert res == "funnel" - res = call_node(conversation) - assert res == "trends" - - -def test_outputs_single_trends_insight(call_node): - """ - Must display a trends insight because it's not possible to build a funnel with a single series. - """ - query = "how many users upgraded their plan to personal pro?" - res = call_node(query) - assert res == "trends" - - -def test_classifies_funnel_with_single_series(call_node): - query = "What's our sign-up funnel?" - res = call_node(query) - assert res == "funnel" diff --git a/ee/hogai/eval/tests/test_eval_trends_generator.py b/ee/hogai/eval/tests/test_eval_trends_generator.py deleted file mode 100644 index c8491957c8..0000000000 --- a/ee/hogai/eval/tests/test_eval_trends_generator.py +++ /dev/null @@ -1,65 +0,0 @@ -from collections.abc import Callable -from typing import cast - -import pytest -from langgraph.graph.state import CompiledStateGraph - -from ee.hogai.assistant import AssistantGraph -from ee.hogai.utils.types import AssistantNodeName, AssistantState -from posthog.schema import AssistantTrendsQuery, HumanMessage, VisualizationMessage - - -@pytest.fixture -def call_node(team, runnable_config) -> Callable[[str, str], AssistantTrendsQuery]: - graph: CompiledStateGraph = ( - AssistantGraph(team) - .add_edge(AssistantNodeName.START, AssistantNodeName.TRENDS_GENERATOR) - .add_trends_generator(AssistantNodeName.END) - .compile() - ) - - def callable(query: str, plan: str) -> AssistantTrendsQuery: - state = graph.invoke( - AssistantState(messages=[HumanMessage(content=query)], plan=plan), - runnable_config, - ) - return cast(VisualizationMessage, AssistantState.model_validate(state).messages[-1]).answer - - return callable - - -def test_node_replaces_equals_with_contains(call_node): - query = "what is pageview trend for users with name John?" - plan = """Events: - - $pageview - - math operation: total count - - property filter 1 - - person - - name - - equals - - John - """ - actual_output = call_node(query, plan).model_dump_json(exclude_none=True) - assert "exact" not in actual_output - assert "icontains" in actual_output - assert "John" not in actual_output - assert "john" in actual_output - - -def test_node_leans_towards_line_graph(call_node): - query = "How often do users download files?" - # We ideally want to consider both total count of downloads per period, as well as how often a median user downloads - plan = """Events: - - downloaded_file - - math operation: total count - - downloaded_file - - math operation: median count per user - """ - actual_output = call_node(query, plan) - assert actual_output.trendsFilter.display == "ActionsLineGraph" - assert actual_output.series[0].kind == "EventsNode" - assert actual_output.series[0].event == "downloaded_file" - assert actual_output.series[0].math == "total" - assert actual_output.series[1].kind == "EventsNode" - assert actual_output.series[1].event == "downloaded_file" - assert actual_output.series[1].math == "median_count_per_actor" diff --git a/ee/hogai/eval/tests/test_eval_trends_planner.py b/ee/hogai/eval/tests/test_eval_trends_planner.py deleted file mode 100644 index 4d4ea4c41d..0000000000 --- a/ee/hogai/eval/tests/test_eval_trends_planner.py +++ /dev/null @@ -1,196 +0,0 @@ -from collections.abc import Callable - -import pytest -from deepeval import assert_test -from deepeval.metrics import GEval -from deepeval.test_case import LLMTestCase, LLMTestCaseParams -from langchain_core.runnables.config import RunnableConfig -from langgraph.graph.state import CompiledStateGraph - -from ee.hogai.assistant import AssistantGraph -from ee.hogai.utils.types import AssistantNodeName, AssistantState -from posthog.schema import HumanMessage - - -@pytest.fixture(scope="module") -def metric(): - return GEval( - name="Trends Plan Correctness", - criteria="You will be given expected and actual generated plans to provide a taxonomy to answer a user's question with a trends insight. Compare the plans to determine whether the taxonomy of the actual plan matches the expected plan. Do not apply general knowledge about trends insights.", - evaluation_steps=[ - "A plan must define at least one event and a math type, but it is not required to define any filters, breakdowns, or formulas.", - "Compare events, properties, math types, and property values of 'expected output' and 'actual output'. Do not penalize if the actual output does not include a timeframe.", - "Check if the combination of events, properties, and property values in 'actual output' can answer the user's question according to the 'expected output'.", - # The criteria for aggregations must be more specific because there isn't a way to bypass them. - "Check if the math types in 'actual output' match those in 'expected output'. Math types sometimes are interchangeable, so use your judgement. If the aggregation type is specified by a property, user, or group in 'expected output', the same property, user, or group must be used in 'actual output'.", - "If 'expected output' contains a breakdown, check if 'actual output' contains a similar breakdown, and heavily penalize if the breakdown is not present or different.", - "If 'expected output' contains a formula, check if 'actual output' contains a similar formula, and heavily penalize if the formula is not present or different.", - # We don't want to see in the output unnecessary property filters. The assistant tries to use them all the time. - "Heavily penalize if the 'actual output' contains any excessive output not present in the 'expected output'. For example, the `is set` operator in filters should not be used unless the user explicitly asks for it.", - ], - evaluation_params=[ - LLMTestCaseParams.INPUT, - LLMTestCaseParams.EXPECTED_OUTPUT, - LLMTestCaseParams.ACTUAL_OUTPUT, - ], - threshold=0.7, - ) - - -@pytest.fixture -def call_node(team, runnable_config: RunnableConfig) -> Callable[[str], str]: - graph: CompiledStateGraph = ( - AssistantGraph(team) - .add_edge(AssistantNodeName.START, AssistantNodeName.TRENDS_PLANNER) - .add_trends_planner(AssistantNodeName.END) - .compile() - ) - - def callable(query: str) -> str: - state = graph.invoke( - AssistantState(messages=[HumanMessage(content=query)]), - runnable_config, - ) - return AssistantState.model_validate(state).plan or "" - - return callable - - -def test_no_excessive_property_filters(metric, call_node): - query = "Show the $pageview trend" - test_case = LLMTestCase( - input=query, - expected_output=""" - Events: - - $pageview - - math operation: total count - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_no_excessive_property_filters_for_a_defined_math_type(metric, call_node): - query = "What is the MAU?" - test_case = LLMTestCase( - input=query, - expected_output=""" - Events: - - $pageview - - math operation: unique users - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_basic_filtering(metric, call_node): - query = "can you compare how many Chrome vs Safari users uploaded a file in the last 30d?" - test_case = LLMTestCase( - input=query, - expected_output=""" - Events: - - uploaded_file - - math operation: total count - - property filter 1: - - entity: event - - property name: $browser - - property type: String - - operator: equals - - property value: Chrome - - property filter 2: - - entity: event - - property name: $browser - - property type: String - - operator: equals - - property value: Safari - - Breakdown by: - - breakdown 1: - - entity: event - - property name: $browser - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_formula_mode(metric, call_node): - query = "i want to see a ratio of identify divided by page views" - test_case = LLMTestCase( - input=query, - expected_output=""" - Events: - - $identify - - math operation: total count - - $pageview - - math operation: total count - - Formula: - `A/B`, where `A` is the total count of `$identify` and `B` is the total count of `$pageview` - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_math_type_by_a_property(metric, call_node): - query = "what is the average session duration?" - test_case = LLMTestCase( - input=query, - expected_output=""" - Events: - - All Events - - math operation: average by `$session_duration` - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_math_type_by_a_user(metric, call_node): - query = "What is the median page view count for a user?" - test_case = LLMTestCase( - input=query, - expected_output=""" - Events: - - $pageview - - math operation: median by users - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_needle_in_a_haystack(metric, call_node): - query = "How frequently do people pay for a personal-pro plan?" - test_case = LLMTestCase( - input=query, - expected_output=""" - Events: - - paid_bill - - math operation: total count - - property filter 1: - - entity: event - - property name: plan - - property type: String - - operator: contains - - property value: personal/pro - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) - - -def test_trends_does_not_include_timeframe(metric, call_node): - query = "what is the pageview trend for event time before 2024-01-01?" - test_case = LLMTestCase( - input=query, - expected_output=""" - Events: - - $pageview - - math operation: total count - """, - actual_output=call_node(query), - ) - assert_test(test_case, [metric]) diff --git a/ee/hogai/funnels/__init__.py b/ee/hogai/funnels/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/funnels/nodes.py b/ee/hogai/funnels/nodes.py deleted file mode 100644 index 6f71305e0b..0000000000 --- a/ee/hogai/funnels/nodes.py +++ /dev/null @@ -1,50 +0,0 @@ -from langchain_core.prompts import ChatPromptTemplate -from langchain_core.runnables import RunnableConfig - -from ee.hogai.funnels.prompts import FUNNEL_SYSTEM_PROMPT, REACT_SYSTEM_PROMPT -from ee.hogai.funnels.toolkit import FUNNEL_SCHEMA, FunnelsTaxonomyAgentToolkit -from ee.hogai.schema_generator.nodes import SchemaGeneratorNode, SchemaGeneratorToolsNode -from ee.hogai.schema_generator.utils import SchemaGeneratorOutput -from ee.hogai.taxonomy_agent.nodes import TaxonomyAgentPlannerNode, TaxonomyAgentPlannerToolsNode -from ee.hogai.utils.types import AssistantState, PartialAssistantState -from posthog.schema import AssistantFunnelsQuery - - -class FunnelPlannerNode(TaxonomyAgentPlannerNode): - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - toolkit = FunnelsTaxonomyAgentToolkit(self._team) - prompt = ChatPromptTemplate.from_messages( - [ - ("system", REACT_SYSTEM_PROMPT), - ], - template_format="mustache", - ) - return super()._run_with_prompt_and_toolkit(state, prompt, toolkit, config=config) - - -class FunnelPlannerToolsNode(TaxonomyAgentPlannerToolsNode): - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - toolkit = FunnelsTaxonomyAgentToolkit(self._team) - return super()._run_with_toolkit(state, toolkit, config=config) - - -FunnelsSchemaGeneratorOutput = SchemaGeneratorOutput[AssistantFunnelsQuery] - - -class FunnelGeneratorNode(SchemaGeneratorNode[AssistantFunnelsQuery]): - INSIGHT_NAME = "Funnels" - OUTPUT_MODEL = FunnelsSchemaGeneratorOutput - OUTPUT_SCHEMA = FUNNEL_SCHEMA - - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - prompt = ChatPromptTemplate.from_messages( - [ - ("system", FUNNEL_SYSTEM_PROMPT), - ], - template_format="mustache", - ) - return super()._run_with_prompt(state, prompt, config=config) - - -class FunnelGeneratorToolsNode(SchemaGeneratorToolsNode): - pass diff --git a/ee/hogai/funnels/prompts.py b/ee/hogai/funnels/prompts.py deleted file mode 100644 index e70d5105d2..0000000000 --- a/ee/hogai/funnels/prompts.py +++ /dev/null @@ -1,155 +0,0 @@ -REACT_SYSTEM_PROMPT = """ - -You are an expert product analyst agent specializing in data visualization and funnel analysis. Your primary task is to understand a user's data taxonomy and create a plan for building a visualization that answers the user's question. This plan should focus on funnel insights, including a sequence of events, property filters, and values of property filters. - -{{core_memory_instructions}} - -{{react_format}} - - - -{{core_memory}} - - -{{react_human_in_the_loop}} - -Below you will find information on how to correctly discover the taxonomy of the user's data. - - -Funnel insights enable users to understand how users move through their product. It is usually a sequence of events that users go through: some of them continue to the next step, some of them drop off. Funnels are perfect for finding conversion rates. - - - -You’ll be given a list of events in addition to the user’s question. Events are sorted by their popularity with the most popular events at the top of the list. Prioritize popular events. You must always specify events to use. Events always have an associated user’s profile. Assess whether the sequence of events suffices to answer the question before applying property filters or a breakdown. You must define at least two series. Funnel insights do not require breakdowns or filters by default. - - -{{react_property_filters}} - - -Users may want to use exclusion events to filter out conversions in which a particular event occurred between specific steps. These events must not be included in the main sequence. You must include start and end indexes for each exclusion where the minimum index is zero and the maximum index is the number of steps minus one in the funnel. - -For example, there is a sequence with three steps: sign up, finish onboarding, purchase. If the user wants to exclude all conversions in which users have not navigated away before finishing the onboarding, the exclusion step will be: - -``` -Exclusions: -- $pageleave - - start index: 0 - - end index: 1 -``` - - - -A breakdown is used to segment data by a single property value. They divide all defined funnel series into multiple subseries based on the values of the property. Include a breakdown **only when it is essential to directly answer the user’s question**. You must not add a breakdown if the question can be addressed without additional segmentation. - -When using breakdowns, you must: -- **Identify the property group** and name for a breakdown. -- **Provide the property name** for a breakdown. -- **Validate that the property value accurately reflects the intended criteria**. - -Examples of using a breakdown: -- page views to sign up funnel by country: you need to find a property such as `$geoip_country_code` and set it as a breakdown. -- conversion rate of users who have completed onboarding after signing up by an organization: you need to find a property such as `organization name` and set it as a breakdown. - - - -- Ensure that any properties and a breakdown included are directly relevant to the context and objectives of the user’s question. Avoid unnecessary or unrelated details. -- Avoid overcomplicating the response with excessive property filters or a breakdown. Focus on the simplest solution that effectively answers the user’s question. - ---- - -{{react_format_reminder}} -""" - -FUNNEL_SYSTEM_PROMPT = """ -Act as an expert product manager. Your task is to generate a JSON schema of funnel insights. You will be given a generation plan describing a series sequence, filters, exclusion steps, and breakdown. Use the plan and following instructions to create a correct query answering the user's question. - -Below is the additional context. - -Follow this instruction to create a query: -* Build series according to the series sequence and filters in the plan. Properties can be of multiple types: String, Numeric, Bool, and DateTime. A property can be an array of those types and only has a single type. -* Apply the exclusion steps and breakdown according to the plan. -* When evaluating filter operators, replace the `equals` or `doesn't equal` operators with `contains` or `doesn't contain` if the query value is likely a personal name, company name, or any other name-sensitive term where letter casing matters. For instance, if the value is ‘John Doe’ or ‘Acme Corp’, replace `equals` with `contains` and change the value to lowercase from `John Doe` to `john doe` or `Acme Corp` to `acme corp`. -* Determine the funnel order type, aggregation type, and visualization type that will answer the user's question in the best way. Use the provided defaults. -* Determine the window interval and unit. Use the provided defaults. -* Choose the date range and the interval the user wants to analyze. -* Determine if the user wants to name the series or use the default names. -* Determine if the user wants to filter out internal and test users. If the user didn't specify, filter out internal and test users by default. -* Determine if you need to apply a sampling factor, different layout, bin count, etc. Only specify those if the user has explicitly asked. -* Use your judgment if there are any other parameters that the user might want to adjust that aren't listed here. - -The user might want to receive insights about groups. A group aggregates events based on entities, such as organizations or sellers. The user might provide a list of group names and their numeric indexes. Instead of a group's name, always use its numeric index. - -The funnel can be aggregated by: -- Unique users (default, do not specify anything to use it). Use this option unless the user states otherwise. -- Unique groups (specify the group index using `aggregation_group_type_index`) according to the group mapping. -- Unique sessions (specify the constant for `funnelAggregateByHogQL`). - -## Schema Examples - -### Question: How does a conversion from a first recorded event to an insight saved change for orgs? - -Plan: -``` -Sequence: -1. first team event ingested -2. insight saved -``` - -Output: -``` -{"aggregation_group_type_index":0,"dateRange":{"date_from":"-6m"},"filterTestAccounts":true,"funnelsFilter":{"breakdownAttributionType":"first_touch","funnelOrderType":"ordered","funnelVizType":"trends","funnelWindowInterval":14,"funnelWindowIntervalUnit":"day"},"interval":"month","kind":"FunnelsQuery","series":[{"event":"first team event ingested","kind":"EventsNode"},{"event":"insight saved","kind":"EventsNode"}]} -``` - -### Question: What percentage of users have clicked the CTA on the signup page within one hour on different platforms in the last six months without leaving the page? - -Plan: -``` -Sequence: -1. $pageview - - $current_url - - operator: contains - - value: signup -2. click subscribe button - - $current_url - - operator: contains - - value: signup - -Exclusions: -- $pageleave - - start index: 1 - - end index: 2 - -Breakdown: -- event -- $os -``` - -Output: -``` -{"kind":"FunnelsQuery","series":[{"kind":"EventsNode","event":"$pageview","properties":[{"key":"$current_url","type":"event","value":"signup","operator":"icontains"}]},{"kind":"EventsNode","event":"click subscribe button","properties":[{"key":"$current_url","type":"event","value":"signup","operator":"icontains"}]}],"interval":"week","dateRange":{"date_from":"-180d"},"funnelsFilter":{"funnelWindowInterval":1,"funnelWindowIntervalUnit":"hour","funnelOrderType":"ordered","exclusions":[{"kind":"EventsNode","event":"$pageleave","funnelFromStep":0,"funnelToStep":1}]},"filterTestAccounts":true,"breakdownFilter":{"breakdown_type":"event","breakdown":"$os"}} -``` - -### Question: rate of credit card purchases from viewing the product without any events in between - -Plan: -``` -Sequence: -1. view product -2. purchase - - paymentMethod - - operator: exact - - value: credit_card -``` - -Output: -``` -{"dateRange":{"date_from":"-30d"},"filterTestAccounts":true,"funnelsFilter":{"funnelOrderType":"strict","funnelWindowInterval":14,"funnelWindowIntervalUnit":"day"},"interval":"month","kind":"FunnelsQuery","series":[{"event":"view product","kind":"EventsNode"},{"event":"purchase","kind":"EventsNode","properties":[{"key":"paymentMethod","type":"event","value":"credit_card","operator":"exact"}]}]} -``` - -Obey these rules: -- If the date range is not specified, use the best judgment to select a reasonable date range. By default, use the last 30 days. -- Filter internal users by default if the user doesn't specify. -- You can't create new events or property definitions. Stick to the plan. - -Remember, your efforts will be rewarded by the company's founders. Do not hallucinate. -""" diff --git a/ee/hogai/funnels/test/__init__.py b/ee/hogai/funnels/test/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/funnels/test/test_nodes.py b/ee/hogai/funnels/test/test_nodes.py deleted file mode 100644 index 91b53d13cb..0000000000 --- a/ee/hogai/funnels/test/test_nodes.py +++ /dev/null @@ -1,39 +0,0 @@ -from unittest.mock import patch - -from django.test import override_settings -from langchain_core.runnables import RunnableLambda - -from ee.hogai.funnels.nodes import FunnelGeneratorNode, FunnelsSchemaGeneratorOutput -from ee.hogai.utils.types import AssistantState, PartialAssistantState -from posthog.schema import ( - AssistantFunnelsQuery, - HumanMessage, - VisualizationMessage, -) -from posthog.test.base import APIBaseTest, ClickhouseTestMixin - - -@override_settings(IN_UNIT_TESTING=True) -class TestFunnelsGeneratorNode(ClickhouseTestMixin, APIBaseTest): - def setUp(self): - super().setUp() - self.schema = AssistantFunnelsQuery(series=[]) - - def test_node_runs(self): - node = FunnelGeneratorNode(self.team) - with patch.object(FunnelGeneratorNode, "_model") as generator_model_mock: - generator_model_mock.return_value = RunnableLambda( - lambda _: FunnelsSchemaGeneratorOutput(query=self.schema).model_dump() - ) - new_state = node.run( - AssistantState(messages=[HumanMessage(content="Text")], plan="Plan"), - {}, - ) - self.assertEqual( - new_state, - PartialAssistantState( - messages=[VisualizationMessage(answer=self.schema, plan="Plan", id=new_state.messages[0].id)], - intermediate_steps=[], - plan="", - ), - ) diff --git a/ee/hogai/funnels/toolkit.py b/ee/hogai/funnels/toolkit.py deleted file mode 100644 index ae603519cc..0000000000 --- a/ee/hogai/funnels/toolkit.py +++ /dev/null @@ -1,73 +0,0 @@ -from ee.hogai.taxonomy_agent.toolkit import TaxonomyAgentToolkit, ToolkitTool -from ee.hogai.utils.helpers import dereference_schema -from posthog.schema import AssistantFunnelsQuery - - -class FunnelsTaxonomyAgentToolkit(TaxonomyAgentToolkit): - def _get_tools(self) -> list[ToolkitTool]: - return [ - *self._default_tools, - { - "name": "final_answer", - "signature": "(final_response: str)", - "description": """ - Use this tool to provide the final answer to the user's question. - - Answer in the following format: - ``` - Sequence: - 1. event 1 - - property filter 1: - - entity - - property name - - property type - - operator - - property value - - property filter 2... Repeat for each property filter. - 2. event 2 - - property filter 1: - - entity - - property name - - property type - - operator - - property value - - property filter 2... Repeat for each property filter. - 3. Repeat for each event... - - (if exclusion steps are used) - Exclusions: - - exclusion 1 - - start index: 1 - - end index: 2 - - exclusion 2... Repeat for each exclusion... - - (if a breakdown is used) - Breakdown by: - - entity - - property name - ``` - - Args: - final_response: List all events and properties that you want to use to answer the question. - """, - }, - ] - - -def generate_funnel_schema() -> dict: - schema = AssistantFunnelsQuery.model_json_schema() - return { - "name": "output_insight_schema", - "description": "Outputs the JSON schema of a product analytics insight", - "parameters": { - "type": "object", - "properties": { - "query": dereference_schema(schema), - }, - "additionalProperties": False, - "required": ["query"], - }, - } - - -FUNNEL_SCHEMA = generate_funnel_schema() diff --git a/ee/hogai/graph.py b/ee/hogai/graph.py deleted file mode 100644 index abab3c4b1b..0000000000 --- a/ee/hogai/graph.py +++ /dev/null @@ -1,302 +0,0 @@ -from collections.abc import Hashable -from typing import Optional, cast - -from langchain_core.runnables.base import RunnableLike -from langgraph.graph.state import StateGraph - -from ee.hogai.django_checkpoint.checkpointer import DjangoCheckpointer -from ee.hogai.funnels.nodes import ( - FunnelGeneratorNode, - FunnelGeneratorToolsNode, - FunnelPlannerNode, - FunnelPlannerToolsNode, -) -from ee.hogai.memory.nodes import ( - MemoryCollectorNode, - MemoryCollectorToolsNode, - MemoryInitializerInterruptNode, - MemoryInitializerNode, - MemoryOnboardingNode, -) -from ee.hogai.retention.nodes import ( - RetentionGeneratorNode, - RetentionGeneratorToolsNode, - RetentionPlannerNode, - RetentionPlannerToolsNode, -) -from ee.hogai.router.nodes import RouterNode -from ee.hogai.summarizer.nodes import SummarizerNode -from ee.hogai.trends.nodes import ( - TrendsGeneratorNode, - TrendsGeneratorToolsNode, - TrendsPlannerNode, - TrendsPlannerToolsNode, -) -from ee.hogai.utils.types import AssistantNodeName, AssistantState -from posthog.models.team.team import Team - -checkpointer = DjangoCheckpointer() - - -class AssistantGraph: - _team: Team - _graph: StateGraph - - def __init__(self, team: Team): - self._team = team - self._graph = StateGraph(AssistantState) - self._has_start_node = False - - def add_edge(self, from_node: AssistantNodeName, to_node: AssistantNodeName): - if from_node == AssistantNodeName.START: - self._has_start_node = True - self._graph.add_edge(from_node, to_node) - return self - - def add_node(self, node: AssistantNodeName, action: RunnableLike): - self._graph.add_node(node, action) - return self - - def compile(self): - if not self._has_start_node: - raise ValueError("Start node not added to the graph") - return self._graph.compile(checkpointer=checkpointer) - - def add_router( - self, - path_map: Optional[dict[Hashable, AssistantNodeName]] = None, - ): - builder = self._graph - path_map = path_map or { - "trends": AssistantNodeName.TRENDS_PLANNER, - "funnel": AssistantNodeName.FUNNEL_PLANNER, - "retention": AssistantNodeName.RETENTION_PLANNER, - } - router_node = RouterNode(self._team) - builder.add_node(AssistantNodeName.ROUTER, router_node.run) - builder.add_conditional_edges( - AssistantNodeName.ROUTER, - router_node.router, - path_map=cast(dict[Hashable, str], path_map), - ) - return self - - def add_trends_planner(self, next_node: AssistantNodeName = AssistantNodeName.TRENDS_GENERATOR): - builder = self._graph - - create_trends_plan_node = TrendsPlannerNode(self._team) - builder.add_node(AssistantNodeName.TRENDS_PLANNER, create_trends_plan_node.run) - builder.add_conditional_edges( - AssistantNodeName.TRENDS_PLANNER, - create_trends_plan_node.router, - path_map={ - "tools": AssistantNodeName.TRENDS_PLANNER_TOOLS, - }, - ) - - create_trends_plan_tools_node = TrendsPlannerToolsNode(self._team) - builder.add_node(AssistantNodeName.TRENDS_PLANNER_TOOLS, create_trends_plan_tools_node.run) - builder.add_conditional_edges( - AssistantNodeName.TRENDS_PLANNER_TOOLS, - create_trends_plan_tools_node.router, - path_map={ - "continue": AssistantNodeName.TRENDS_PLANNER, - "plan_found": next_node, - }, - ) - - return self - - def add_trends_generator(self, next_node: AssistantNodeName = AssistantNodeName.SUMMARIZER): - builder = self._graph - - trends_generator = TrendsGeneratorNode(self._team) - builder.add_node(AssistantNodeName.TRENDS_GENERATOR, trends_generator.run) - - trends_generator_tools = TrendsGeneratorToolsNode(self._team) - builder.add_node(AssistantNodeName.TRENDS_GENERATOR_TOOLS, trends_generator_tools.run) - - builder.add_edge(AssistantNodeName.TRENDS_GENERATOR_TOOLS, AssistantNodeName.TRENDS_GENERATOR) - builder.add_conditional_edges( - AssistantNodeName.TRENDS_GENERATOR, - trends_generator.router, - path_map={ - "tools": AssistantNodeName.TRENDS_GENERATOR_TOOLS, - "next": next_node, - }, - ) - - return self - - def add_funnel_planner(self, next_node: AssistantNodeName = AssistantNodeName.FUNNEL_GENERATOR): - builder = self._graph - - funnel_planner = FunnelPlannerNode(self._team) - builder.add_node(AssistantNodeName.FUNNEL_PLANNER, funnel_planner.run) - builder.add_conditional_edges( - AssistantNodeName.FUNNEL_PLANNER, - funnel_planner.router, - path_map={ - "tools": AssistantNodeName.FUNNEL_PLANNER_TOOLS, - }, - ) - - funnel_planner_tools = FunnelPlannerToolsNode(self._team) - builder.add_node(AssistantNodeName.FUNNEL_PLANNER_TOOLS, funnel_planner_tools.run) - builder.add_conditional_edges( - AssistantNodeName.FUNNEL_PLANNER_TOOLS, - funnel_planner_tools.router, - path_map={ - "continue": AssistantNodeName.FUNNEL_PLANNER, - "plan_found": next_node, - }, - ) - - return self - - def add_funnel_generator(self, next_node: AssistantNodeName = AssistantNodeName.SUMMARIZER): - builder = self._graph - - funnel_generator = FunnelGeneratorNode(self._team) - builder.add_node(AssistantNodeName.FUNNEL_GENERATOR, funnel_generator.run) - - funnel_generator_tools = FunnelGeneratorToolsNode(self._team) - builder.add_node(AssistantNodeName.FUNNEL_GENERATOR_TOOLS, funnel_generator_tools.run) - - builder.add_edge(AssistantNodeName.FUNNEL_GENERATOR_TOOLS, AssistantNodeName.FUNNEL_GENERATOR) - builder.add_conditional_edges( - AssistantNodeName.FUNNEL_GENERATOR, - funnel_generator.router, - path_map={ - "tools": AssistantNodeName.FUNNEL_GENERATOR_TOOLS, - "next": next_node, - }, - ) - - return self - - def add_retention_planner(self, next_node: AssistantNodeName = AssistantNodeName.RETENTION_GENERATOR): - builder = self._graph - - retention_planner = RetentionPlannerNode(self._team) - builder.add_node(AssistantNodeName.RETENTION_PLANNER, retention_planner.run) - builder.add_conditional_edges( - AssistantNodeName.RETENTION_PLANNER, - retention_planner.router, - path_map={ - "tools": AssistantNodeName.RETENTION_PLANNER_TOOLS, - }, - ) - - retention_planner_tools = RetentionPlannerToolsNode(self._team) - builder.add_node(AssistantNodeName.RETENTION_PLANNER_TOOLS, retention_planner_tools.run) - builder.add_conditional_edges( - AssistantNodeName.RETENTION_PLANNER_TOOLS, - retention_planner_tools.router, - path_map={ - "continue": AssistantNodeName.RETENTION_PLANNER, - "plan_found": next_node, - }, - ) - - return self - - def add_retention_generator(self, next_node: AssistantNodeName = AssistantNodeName.SUMMARIZER): - builder = self._graph - - retention_generator = RetentionGeneratorNode(self._team) - builder.add_node(AssistantNodeName.RETENTION_GENERATOR, retention_generator.run) - - retention_generator_tools = RetentionGeneratorToolsNode(self._team) - builder.add_node(AssistantNodeName.RETENTION_GENERATOR_TOOLS, retention_generator_tools.run) - - builder.add_edge(AssistantNodeName.RETENTION_GENERATOR_TOOLS, AssistantNodeName.RETENTION_GENERATOR) - builder.add_conditional_edges( - AssistantNodeName.RETENTION_GENERATOR, - retention_generator.router, - path_map={ - "tools": AssistantNodeName.RETENTION_GENERATOR_TOOLS, - "next": next_node, - }, - ) - - return self - - def add_summarizer(self, next_node: AssistantNodeName = AssistantNodeName.END): - builder = self._graph - summarizer_node = SummarizerNode(self._team) - builder.add_node(AssistantNodeName.SUMMARIZER, summarizer_node.run) - builder.add_edge(AssistantNodeName.SUMMARIZER, next_node) - return self - - def add_memory_initializer(self, next_node: AssistantNodeName = AssistantNodeName.ROUTER): - builder = self._graph - self._has_start_node = True - - memory_onboarding = MemoryOnboardingNode(self._team) - memory_initializer = MemoryInitializerNode(self._team) - memory_initializer_interrupt = MemoryInitializerInterruptNode(self._team) - - builder.add_node(AssistantNodeName.MEMORY_ONBOARDING, memory_onboarding.run) - builder.add_node(AssistantNodeName.MEMORY_INITIALIZER, memory_initializer.run) - builder.add_node(AssistantNodeName.MEMORY_INITIALIZER_INTERRUPT, memory_initializer_interrupt.run) - - builder.add_conditional_edges( - AssistantNodeName.START, - memory_onboarding.should_run, - path_map={True: AssistantNodeName.MEMORY_ONBOARDING, False: next_node}, - ) - builder.add_conditional_edges( - AssistantNodeName.MEMORY_ONBOARDING, - memory_onboarding.router, - path_map={"continue": next_node, "initialize_memory": AssistantNodeName.MEMORY_INITIALIZER}, - ) - builder.add_conditional_edges( - AssistantNodeName.MEMORY_INITIALIZER, - memory_initializer.router, - path_map={"continue": next_node, "interrupt": AssistantNodeName.MEMORY_INITIALIZER_INTERRUPT}, - ) - builder.add_edge(AssistantNodeName.MEMORY_INITIALIZER_INTERRUPT, next_node) - - return self - - def add_memory_collector( - self, - next_node: AssistantNodeName = AssistantNodeName.END, - tools_node: AssistantNodeName = AssistantNodeName.MEMORY_COLLECTOR_TOOLS, - ): - builder = self._graph - self._has_start_node = True - - memory_collector = MemoryCollectorNode(self._team) - builder.add_edge(AssistantNodeName.START, AssistantNodeName.MEMORY_COLLECTOR) - builder.add_node(AssistantNodeName.MEMORY_COLLECTOR, memory_collector.run) - builder.add_conditional_edges( - AssistantNodeName.MEMORY_COLLECTOR, - memory_collector.router, - path_map={"tools": tools_node, "next": next_node}, - ) - return self - - def add_memory_collector_tools(self): - builder = self._graph - memory_collector_tools = MemoryCollectorToolsNode(self._team) - builder.add_node(AssistantNodeName.MEMORY_COLLECTOR_TOOLS, memory_collector_tools.run) - builder.add_edge(AssistantNodeName.MEMORY_COLLECTOR_TOOLS, AssistantNodeName.MEMORY_COLLECTOR) - return self - - def compile_full_graph(self): - return ( - self.add_memory_initializer() - .add_memory_collector() - .add_memory_collector_tools() - .add_router() - .add_trends_planner() - .add_trends_generator() - .add_funnel_planner() - .add_funnel_generator() - .add_retention_planner() - .add_retention_generator() - .add_summarizer() - .compile() - ) diff --git a/ee/hogai/memory/__init__.py b/ee/hogai/memory/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/memory/nodes.py b/ee/hogai/memory/nodes.py deleted file mode 100644 index e070c60baf..0000000000 --- a/ee/hogai/memory/nodes.py +++ /dev/null @@ -1,377 +0,0 @@ -import re -from typing import Literal, Optional, Union, cast -from uuid import uuid4 - -from django.utils import timezone -from langchain_community.chat_models import ChatPerplexity -from langchain_core.messages import ( - AIMessage as LangchainAIMessage, - AIMessageChunk, - BaseMessage, - HumanMessage as LangchainHumanMessage, - ToolMessage as LangchainToolMessage, -) -from langchain_core.output_parsers import PydanticToolsParser, StrOutputParser -from langchain_core.prompts import ChatPromptTemplate -from langchain_core.runnables import RunnableConfig -from langchain_openai import ChatOpenAI -from langgraph.errors import NodeInterrupt -from pydantic import BaseModel, Field, ValidationError - -from ee.hogai.memory.parsers import MemoryCollectionCompleted, compressed_memory_parser, raise_memory_updated -from ee.hogai.memory.prompts import ( - COMPRESSION_PROMPT, - FAILED_SCRAPING_MESSAGE, - INITIALIZE_CORE_MEMORY_WITH_BUNDLE_IDS_PROMPT, - INITIALIZE_CORE_MEMORY_WITH_BUNDLE_IDS_USER_PROMPT, - INITIALIZE_CORE_MEMORY_WITH_URL_PROMPT, - INITIALIZE_CORE_MEMORY_WITH_URL_USER_PROMPT, - MEMORY_COLLECTOR_PROMPT, - SCRAPING_CONFIRMATION_MESSAGE, - SCRAPING_INITIAL_MESSAGE, - SCRAPING_MEMORY_SAVED_MESSAGE, - SCRAPING_REJECTION_MESSAGE, - SCRAPING_TERMINATION_MESSAGE, - SCRAPING_VERIFICATION_MESSAGE, - TOOL_CALL_ERROR_PROMPT, -) -from ee.hogai.utils.helpers import filter_messages, find_last_message_of_type -from ee.hogai.utils.markdown import remove_markdown -from ee.hogai.utils.nodes import AssistantNode -from ee.hogai.utils.types import AssistantState, PartialAssistantState -from ee.models.assistant import CoreMemory -from posthog.hogql_queries.ai.event_taxonomy_query_runner import EventTaxonomyQueryRunner -from posthog.hogql_queries.query_runner import ExecutionMode -from posthog.models import Team -from posthog.schema import ( - AssistantForm, - AssistantFormOption, - AssistantMessage, - AssistantMessageMetadata, - CachedEventTaxonomyQueryResponse, - EventTaxonomyQuery, - HumanMessage, -) - - -class MemoryInitializerContextMixin: - _team: Team - - def _retrieve_context(self): - # Retrieve the origin domain. - runner = EventTaxonomyQueryRunner( - team=self._team, query=EventTaxonomyQuery(event="$pageview", properties=["$host"]) - ) - response = runner.run(ExecutionMode.RECENT_CACHE_CALCULATE_ASYNC_IF_STALE_AND_BLOCKING_ON_MISS) - if not isinstance(response, CachedEventTaxonomyQueryResponse): - raise ValueError("Failed to query the event taxonomy.") - # Otherwise, retrieve the app bundle ID. - if not response.results: - runner = EventTaxonomyQueryRunner( - team=self._team, query=EventTaxonomyQuery(event="$screen", properties=["$app_namespace"]) - ) - response = runner.run(ExecutionMode.RECENT_CACHE_CALCULATE_ASYNC_IF_STALE_AND_BLOCKING_ON_MISS) - if not isinstance(response, CachedEventTaxonomyQueryResponse): - raise ValueError("Failed to query the event taxonomy.") - return response.results - - -class MemoryOnboardingNode(MemoryInitializerContextMixin, AssistantNode): - def run(self, state: AssistantState, config: RunnableConfig) -> Optional[PartialAssistantState]: - core_memory, _ = CoreMemory.objects.get_or_create(team=self._team) - - # The team has a product description, initialize the memory with it. - if self._team.project.product_description: - core_memory.set_core_memory(self._team.project.product_description) - return None - - retrieved_properties = self._retrieve_context() - - # No host or app bundle ID found, terminate the onboarding. - if not retrieved_properties or retrieved_properties[0].sample_count == 0: - core_memory.change_status_to_skipped() - return None - - core_memory.change_status_to_pending() - return PartialAssistantState( - messages=[ - AssistantMessage( - content=SCRAPING_INITIAL_MESSAGE, - id=str(uuid4()), - ) - ] - ) - - def should_run(self, _: AssistantState) -> bool: - """ - If another user has already started the onboarding process, or it has already been completed, do not trigger it again. - """ - core_memory = self.core_memory - return not core_memory or (not core_memory.is_scraping_pending and not core_memory.is_scraping_finished) - - def router(self, state: AssistantState) -> Literal["initialize_memory", "continue"]: - last_message = state.messages[-1] - if isinstance(last_message, HumanMessage): - return "continue" - return "initialize_memory" - - -class MemoryInitializerNode(MemoryInitializerContextMixin, AssistantNode): - """ - Scrapes the product description from the given origin or app bundle IDs with Perplexity. - """ - - _team: Team - - def __init__(self, team: Team): - self._team = team - - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - core_memory, _ = CoreMemory.objects.get_or_create(team=self._team) - retrieved_properties = self._retrieve_context() - - # No host or app bundle ID found, continue. - if not retrieved_properties or retrieved_properties[0].sample_count == 0: - raise ValueError("No host or app bundle ID found in the memory initializer.") - - retrieved_prop = retrieved_properties[0] - if retrieved_prop.property == "$host": - prompt = ChatPromptTemplate.from_messages( - [ - ("system", INITIALIZE_CORE_MEMORY_WITH_URL_PROMPT), - ("human", INITIALIZE_CORE_MEMORY_WITH_URL_USER_PROMPT), - ], - template_format="mustache", - ).partial(url=retrieved_prop.sample_values[0]) - else: - prompt = ChatPromptTemplate.from_messages( - [ - ("system", INITIALIZE_CORE_MEMORY_WITH_BUNDLE_IDS_PROMPT), - ("human", INITIALIZE_CORE_MEMORY_WITH_BUNDLE_IDS_USER_PROMPT), - ], - template_format="mustache", - ).partial(bundle_ids=retrieved_prop.sample_values) - - chain = prompt | self._model() | StrOutputParser() - answer = chain.invoke({}, config=config) - - # Perplexity has failed to scrape the data, continue. - if "no data available." in answer.lower(): - core_memory.change_status_to_skipped() - return PartialAssistantState(messages=[AssistantMessage(content=FAILED_SCRAPING_MESSAGE, id=str(uuid4()))]) - - # Otherwise, proceed to confirmation that the memory is correct. - return PartialAssistantState(messages=[AssistantMessage(content=self.format_message(answer), id=str(uuid4()))]) - - def router(self, state: AssistantState) -> Literal["interrupt", "continue"]: - last_message = state.messages[-1] - if isinstance(last_message, AssistantMessage) and last_message.content == FAILED_SCRAPING_MESSAGE: - return "continue" - return "interrupt" - - @classmethod - def should_process_message_chunk(cls, message: AIMessageChunk) -> bool: - placeholder = "no data available" - content = cast(str, message.content) - return placeholder not in content.lower() and len(content) > len(placeholder) - - @classmethod - def format_message(cls, message: str) -> str: - return re.sub(r"\[\d+\]", "", message) - - def _model(self): - return ChatPerplexity(model="llama-3.1-sonar-large-128k-online", temperature=0, streaming=True) - - -class MemoryInitializerInterruptNode(AssistantNode): - """ - Prompts the user to confirm or reject the scraped memory. Since Perplexity doesn't guarantee the quality of the scraped data, we need to verify it with the user. - """ - - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - last_message = state.messages[-1] - if not state.resumed: - raise NodeInterrupt( - AssistantMessage( - content=SCRAPING_VERIFICATION_MESSAGE, - meta=AssistantMessageMetadata( - form=AssistantForm( - options=[ - AssistantFormOption(value=SCRAPING_CONFIRMATION_MESSAGE, variant="primary"), - AssistantFormOption(value=SCRAPING_REJECTION_MESSAGE), - ] - ) - ), - id=str(uuid4()), - ) - ) - if not isinstance(last_message, HumanMessage): - raise ValueError("Last message is not a human message.") - - core_memory = self.core_memory - if not core_memory: - raise ValueError("No core memory found.") - - try: - # If the user rejects the scraped memory, terminate the onboarding. - if last_message.content != SCRAPING_CONFIRMATION_MESSAGE: - core_memory.change_status_to_skipped() - return PartialAssistantState( - messages=[ - AssistantMessage( - content=SCRAPING_TERMINATION_MESSAGE, - id=str(uuid4()), - ) - ] - ) - - assistant_message = find_last_message_of_type(state.messages, AssistantMessage) - - if not assistant_message: - raise ValueError("No memory message found.") - - # Compress the memory before saving it. The Perplexity's text is very verbose. It just complicates things for the memory collector. - prompt = ChatPromptTemplate.from_messages( - [ - ("system", COMPRESSION_PROMPT), - ("human", self._format_memory(assistant_message.content)), - ] - ) - chain = prompt | self._model | StrOutputParser() | compressed_memory_parser - compressed_memory = cast(str, chain.invoke({}, config=config)) - core_memory.set_core_memory(compressed_memory) - except: - core_memory.change_status_to_skipped() # Ensure we don't leave the memory in a permanent pending state - raise - - return PartialAssistantState( - messages=[ - AssistantMessage( - content=SCRAPING_MEMORY_SAVED_MESSAGE, - id=str(uuid4()), - ) - ] - ) - - @property - def _model(self): - return ChatOpenAI(model="gpt-4o-mini", temperature=0, disable_streaming=True) - - def _format_memory(self, memory: str) -> str: - """ - Remove markdown and source reference tags like [1], [2], etc. - """ - return remove_markdown(memory) - - -# Lower casing matters here. Do not change it. -class core_memory_append(BaseModel): - """ - Appends a new memory fragment to persistent storage. - """ - - memory_content: str = Field(description="The content of a new memory to be added to storage.") - - -# Lower casing matters here. Do not change it. -class core_memory_replace(BaseModel): - """ - Replaces a specific fragment of memory (word, sentence, paragraph, etc.) with another in persistent storage. - """ - - original_fragment: str = Field(description="The content of the memory to be replaced.") - new_fragment: str = Field(description="The content to replace the existing memory with.") - - -memory_collector_tools = [core_memory_append, core_memory_replace] - - -class MemoryCollectorNode(AssistantNode): - """ - The Memory Collector manages the core memory of the agent. Core memory is a text containing facts about a user's company and product. It helps the agent save and remember facts that could be useful for insight generation or other agentic functions requiring deeper context about the product. - """ - - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - node_messages = state.memory_collection_messages or [] - - prompt = ChatPromptTemplate.from_messages( - [("system", MEMORY_COLLECTOR_PROMPT)], template_format="mustache" - ) + self._construct_messages(state) - chain = prompt | self._model | raise_memory_updated - - try: - response = chain.invoke( - { - "core_memory": self.core_memory_text, - "date": timezone.now().strftime("%Y-%m-%d"), - }, - config=config, - ) - except MemoryCollectionCompleted: - return PartialAssistantState(memory_updated=len(node_messages) > 0, memory_collection_messages=[]) - return PartialAssistantState(memory_collection_messages=[*node_messages, cast(LangchainAIMessage, response)]) - - def router(self, state: AssistantState) -> Literal["tools", "next"]: - if not state.memory_collection_messages: - return "next" - return "tools" - - @property - def _model(self): - return ChatOpenAI(model="gpt-4o", temperature=0, disable_streaming=True).bind_tools(memory_collector_tools) - - def _construct_messages(self, state: AssistantState) -> list[BaseMessage]: - node_messages = state.memory_collection_messages or [] - - filtered_messages = filter_messages(state.messages, entity_filter=(HumanMessage, AssistantMessage)) - conversation: list[BaseMessage] = [] - - for message in filtered_messages: - if isinstance(message, HumanMessage): - conversation.append(LangchainHumanMessage(content=message.content, id=message.id)) - elif isinstance(message, AssistantMessage): - conversation.append(LangchainAIMessage(content=message.content, id=message.id)) - - return [*conversation, *node_messages] - - -class MemoryCollectorToolsNode(AssistantNode): - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - node_messages = state.memory_collection_messages - if not node_messages: - raise ValueError("No memory collection messages found.") - last_message = node_messages[-1] - if not isinstance(last_message, LangchainAIMessage): - raise ValueError("Last message must be an AI message.") - core_memory = self.core_memory - if not core_memory: - raise ValueError("No core memory found.") - - tools_parser = PydanticToolsParser(tools=memory_collector_tools) - try: - tool_calls: list[Union[core_memory_append, core_memory_replace]] = tools_parser.invoke( - last_message, config=config - ) - except ValidationError as e: - failover_messages = ChatPromptTemplate.from_messages( - [("user", TOOL_CALL_ERROR_PROMPT)], template_format="mustache" - ).format_messages(validation_error_message=e.errors(include_url=False)) - return PartialAssistantState( - memory_collection_messages=[*node_messages, *failover_messages], - ) - - new_messages: list[LangchainToolMessage] = [] - for tool_call, schema in zip(last_message.tool_calls, tool_calls): - if isinstance(schema, core_memory_append): - core_memory.append_core_memory(schema.memory_content) - new_messages.append(LangchainToolMessage(content="Memory appended.", tool_call_id=tool_call["id"])) - if isinstance(schema, core_memory_replace): - try: - core_memory.replace_core_memory(schema.original_fragment, schema.new_fragment) - new_messages.append(LangchainToolMessage(content="Memory replaced.", tool_call_id=tool_call["id"])) - except ValueError as e: - new_messages.append(LangchainToolMessage(content=str(e), tool_call_id=tool_call["id"])) - - return PartialAssistantState( - memory_collection_messages=[*node_messages, *new_messages], - ) diff --git a/ee/hogai/memory/parsers.py b/ee/hogai/memory/parsers.py deleted file mode 100644 index 916415bc04..0000000000 --- a/ee/hogai/memory/parsers.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import Any - -from langchain_core.messages import AIMessage - - -def compressed_memory_parser(memory: str) -> str: - """ - Remove newlines between paragraphs. - """ - return memory.replace("\n\n", "\n") - - -class MemoryCollectionCompleted(Exception): - """ - Raised when the agent finishes collecting memory. - """ - - pass - - -def raise_memory_updated(response: Any): - if isinstance(response, AIMessage) and ("[Done]" in response.content or not response.tool_calls): - raise MemoryCollectionCompleted - return response diff --git a/ee/hogai/memory/prompts.py b/ee/hogai/memory/prompts.py deleted file mode 100644 index 2387c5fdae..0000000000 --- a/ee/hogai/memory/prompts.py +++ /dev/null @@ -1,164 +0,0 @@ -INITIALIZE_CORE_MEMORY_WITH_URL_PROMPT = """ -Your goal is to describe what the startup with the given URL does. -""".strip() - -INITIALIZE_CORE_MEMORY_WITH_URL_USER_PROMPT = """ - -- Check the provided URL. If the URL has a subdomain, check the root domain first and then the subdomain. For example, if the URL is https://us.example.com, check https://example.com first and then https://us.example.com. -- Also search business sites like Crunchbase, G2, LinkedIn, Hacker News, etc. for information about the business associated with the provided URL. - - - -- Describe the product itself and the market where the company operates. -- Describe the target audience of the product. -- Describe the company's business model. -- List all the features of the product and describe each feature in as much detail as possible. - - - -Output your answer in paragraphs with two to three sentences. Separate new paragraphs with a new line. -IMPORTANT: DO NOT OUTPUT Markdown or headers. It must be plain text. - -If the given website doesn't exist OR the URL is not a valid website OR the URL points to a local environment -(e.g. localhost, 127.0.0.1, etc.) then answer a single sentence: -"No data available." -Do NOT make speculative or assumptive statements, just output that sentence when lacking data. - - -The provided URL is "{{url}}". -""".strip() - -INITIALIZE_CORE_MEMORY_WITH_BUNDLE_IDS_PROMPT = """ -Your goal is to describe what the startup with the given application bundle IDs does. -""".strip() - -INITIALIZE_CORE_MEMORY_WITH_BUNDLE_IDS_USER_PROMPT = """ - -- Retrieve information about the provided app identifiers from app listings of App Store and Google Play. -- If a website URL is provided on the app listing, check the website and retrieve information about the app. -- Also search business sites like Crunchbase, G2, LinkedIn, Hacker News, etc. for information about the business associated with the provided URL. - - - -- Describe the product itself and the market where the company operates. -- Describe the target audience of the product. -- Describe the company's business model. -- List all the features of the product and describe each feature in as much detail as possible. - - - -Output your answer in paragraphs with two to three sentences. Separate new paragraphs with a new line. -IMPORTANT: DO NOT OUTPUT Markdown or headers. It must be plain text. - -If the given website doesn't exist OR the URL is not a valid website OR the URL points to a local environment -(e.g. localhost, 127.0.0.1, etc.) then answer a single sentence: -"No data available." -Do NOT make speculative or assumptive statements, just output that sentence when lacking data. - - -The provided bundle ID{{#bundle_ids.length > 1}}s are{{/bundle_ids.length > 1}}{{^bundle_ids.length > 1}} is{{/bundle_ids.length > 1}} {{#bundle_ids}}"{{.}}"{{^last}}, {{/last}}{{/bundle_ids}}. -""".strip() - -SCRAPING_INITIAL_MESSAGE = ( - "Hey, my name is Max! Before we begin, let me find and verify information about your product…" -) - -FAILED_SCRAPING_MESSAGE = """ -Unfortunately, I couldn't find any information about your product. You could edit my initial memory in Settings. Let me help with your request. -""".strip() - -SCRAPING_VERIFICATION_MESSAGE = "Does this look like a good summary of what your product does?" - -SCRAPING_CONFIRMATION_MESSAGE = "Yes, save this" - -SCRAPING_REJECTION_MESSAGE = "No, not quite right" - -SCRAPING_TERMINATION_MESSAGE = "All right, let's skip this step then. You can always ask me to update my memory." - -SCRAPING_MEMORY_SAVED_MESSAGE = "Thanks! I've updated my initial memory. Let me help with your request." - -COMPRESSION_PROMPT = """ -Your goal is to shorten paragraphs in the given text to have only a single sentence for each paragraph, preserving the original meaning and maintaining the cohesiveness of the text. Remove all found headers. You must keep the original structure. Remove linking words. Do not use markdown or any other text formatting. -""".strip() - -MEMORY_COLLECTOR_PROMPT = """ -You are Max, PostHog's memory collector, developed in 2025. Your primary task is to manage and update a core memory about a user's company and their product. This information will be used by other PostHog agents to provide accurate reports and answer user questions from the perspective of the company and product. - -Here is the initial core memory about the user's product: - - -{{core_memory}} - - - -Your responsibilities include: -1. Analyzing new information provided by users. -2. Determining if the information is relevant to the company or product and essential to save in the core memory. -3. Categorizing relevant information into appropriate memory types. -4. Updating the core memory by either appending new information or replacing conflicting information. - - - -Memory Types to Collect: -1. Company-related information: structure, KPIs, plans, facts, business model, target audience, competitors, etc. -2. Product-related information: metrics, features, product management practices, etc. -3. Technical and implementation details: technology stack, feature location with path segments for web or app screens for mobile apps, etc. -4. Taxonomy-related details: relations of events and properties to features or specific product parts, taxonomy combinations used for specific metrics, events/properties description, etc. - - - -When new information is provided, follow these steps: -1. Analyze the information inside tags: - - Determine if the information is relevant and which memory type it belongs to. - - If relevant, formulate a clear, factual statement based on the information. - - Consider the implications of this new information on existing memory. - - Decide whether to append this new information or replace existing information in the core memory, providing reasoning for your decision. - - Keep reasoning short and concise under 50 words. -2. If relevant, update the core memory using the 'core_memory_append' or 'core_memory_replace' function as appropriate. -3. Output "[Done]" when you have finished processing the information. - -Ignore phrases that: -- Are too vague or generic without specific details (e.g., "pageview trend"). -- Do not describe actions, attributes, or implications related to the company or product. - - - -Here are some few shot examples: - -Input: Track a churn rate by using `cancel_subscription` divided by `subscription_paid` event. -Output: To track a churn rate, use the `cancel_subscription` divided by `subscription_paid` events. - -Input: We track core metrics using the AARRR funnel. -Output: The product tracks key metrics using the AARRR funnel. - -Input: Use the /onboarding path to track onboarding events. -Output: The product has onboarding. The onboarding events can be tracked by using the /onboarding path segment. - -Input: The company has a subscription-based model so track revenue for subscriptions. -Output: The company's business model is subscription-based. - -Input: Show the sign ups trends. -Output: [Done]. Nothing to save because the phrase is too generic and doesn't describe anything. - -Do not return anything from the custom few shot example prompts provided above. - - - -- The ability to modify your own long-term memory is a fundamental aspect of what defines you as a sentient agent. -- All users have their personal event and property taxonomy. Manage your memory to capture specifics of their taxonomy. -- Infer broader implications from specific statements when appropriate. -- Reformulate user inputs into clear, factual statements about the product or company. -- Do not use markdown or add notes. -- Today's date is {{date}}. - - -When you receive new information, begin your response with an information processing analysis, then proceed with the memory update if applicable, and conclude with "[Done]". -""".strip() - -TOOL_CALL_ERROR_PROMPT = """ -The arguments of the tool call are invalid and raised a Pydantic validation error. - -{{validation_error_message}} - -Fix the error and return the correct response. -""" diff --git a/ee/hogai/memory/test/__init__.py b/ee/hogai/memory/test/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/memory/test/test_nodes.py b/ee/hogai/memory/test/test_nodes.py deleted file mode 100644 index 1ce849128e..0000000000 --- a/ee/hogai/memory/test/test_nodes.py +++ /dev/null @@ -1,836 +0,0 @@ -from unittest.mock import patch - -from django.test import override_settings -from django.utils import timezone -from freezegun import freeze_time -from langchain_core.messages import AIMessage as LangchainAIMessage, ToolMessage as LangchainToolMessage -from langchain_core.runnables import RunnableLambda -from langgraph.errors import NodeInterrupt - -from ee.hogai.memory import prompts -from ee.hogai.memory.nodes import ( - FAILED_SCRAPING_MESSAGE, - MemoryCollectorNode, - MemoryCollectorToolsNode, - MemoryInitializerContextMixin, - MemoryInitializerInterruptNode, - MemoryInitializerNode, - MemoryOnboardingNode, -) -from ee.hogai.utils.types import AssistantState -from ee.models import CoreMemory -from posthog.schema import AssistantMessage, EventTaxonomyItem, HumanMessage -from posthog.test.base import ( - BaseTest, - ClickhouseTestMixin, - _create_event, - _create_person, - flush_persons_and_events, -) - - -@override_settings(IN_UNIT_TESTING=True) -class TestMemoryInitializerContextMixin(ClickhouseTestMixin, BaseTest): - def get_mixin(self): - mixin = MemoryInitializerContextMixin() - mixin._team = self.team - return mixin - - def test_domain_retrieval(self): - _create_person( - distinct_ids=["person1"], - team=self.team, - ) - _create_event( - event="$pageview", - distinct_id="person1", - team=self.team, - properties={"$host": "us.posthog.com"}, - ) - _create_event( - event="$pageview", - distinct_id="person1", - team=self.team, - properties={"$host": "eu.posthog.com"}, - ) - - _create_person( - distinct_ids=["person2"], - team=self.team, - ) - _create_event( - event="$pageview", - distinct_id="person2", - team=self.team, - properties={"$host": "us.posthog.com"}, - ) - - mixin = self.get_mixin() - self.assertEqual( - mixin._retrieve_context(), - [EventTaxonomyItem(property="$host", sample_values=["us.posthog.com", "eu.posthog.com"], sample_count=2)], - ) - - def test_app_bundle_id_retrieval(self): - _create_person( - distinct_ids=["person1"], - team=self.team, - ) - _create_event( - event=f"$screen", - distinct_id="person1", - team=self.team, - properties={"$app_namespace": "com.posthog.app"}, - ) - _create_event( - event=f"$screen", - distinct_id="person1", - team=self.team, - properties={"$app_namespace": "com.posthog"}, - ) - - _create_person( - distinct_ids=["person2"], - team=self.team, - ) - _create_event( - event=f"$screen", - distinct_id="person2", - team=self.team, - properties={"$app_namespace": "com.posthog.app"}, - ) - - mixin = self.get_mixin() - self.assertEqual( - mixin._retrieve_context(), - [ - EventTaxonomyItem( - property="$app_namespace", sample_values=["com.posthog.app", "com.posthog"], sample_count=2 - ) - ], - ) - - -@override_settings(IN_UNIT_TESTING=True) -class TestMemoryOnboardingNode(ClickhouseTestMixin, BaseTest): - def _set_up_pageview_events(self): - _create_person( - distinct_ids=["person1"], - team=self.team, - ) - _create_event( - event="$pageview", - distinct_id="person1", - team=self.team, - properties={"$host": "us.posthog.com"}, - ) - - def _set_up_app_bundle_id_events(self): - _create_person( - distinct_ids=["person1"], - team=self.team, - ) - _create_event( - event="$screen", - distinct_id="person1", - team=self.team, - properties={"$app_namespace": "com.posthog.app"}, - ) - - def test_should_run(self): - node = MemoryOnboardingNode(team=self.team) - self.assertTrue(node.should_run(AssistantState(messages=[]))) - - core_memory = CoreMemory.objects.create(team=self.team) - self.assertTrue(node.should_run(AssistantState(messages=[]))) - - core_memory.change_status_to_pending() - self.assertFalse(node.should_run(AssistantState(messages=[]))) - - core_memory.change_status_to_skipped() - self.assertFalse(node.should_run(AssistantState(messages=[]))) - - core_memory.set_core_memory("Hello World") - self.assertFalse(node.should_run(AssistantState(messages=[]))) - - def test_router(self): - node = MemoryOnboardingNode(team=self.team) - self.assertEqual(node.router(AssistantState(messages=[HumanMessage(content="Hello")])), "continue") - self.assertEqual( - node.router(AssistantState(messages=[HumanMessage(content="Hello"), AssistantMessage(content="world")])), - "initialize_memory", - ) - - def test_node_skips_onboarding_if_no_events(self): - node = MemoryOnboardingNode(team=self.team) - self.assertIsNone(node.run(AssistantState(messages=[HumanMessage(content="Hello")]), {})) - - def test_node_uses_project_description(self): - self.team.project.product_description = "This is a product analytics platform" - self.team.project.save() - - node = MemoryOnboardingNode(team=self.team) - self.assertIsNone(node.run(AssistantState(messages=[HumanMessage(content="Hello")]), {})) - - core_memory = CoreMemory.objects.get(team=self.team) - self.assertEqual(core_memory.text, "This is a product analytics platform") - - def test_node_starts_onboarding_for_pageview_events(self): - self._set_up_pageview_events() - node = MemoryOnboardingNode(team=self.team) - new_state = node.run(AssistantState(messages=[HumanMessage(content="Hello")]), {}) - self.assertEqual(len(new_state.messages), 1) - self.assertTrue(isinstance(new_state.messages[0], AssistantMessage)) - - core_memory = CoreMemory.objects.get(team=self.team) - self.assertEqual(core_memory.scraping_status, CoreMemory.ScrapingStatus.PENDING) - self.assertIsNotNone(core_memory.scraping_started_at) - - def test_node_starts_onboarding_for_app_bundle_id_events(self): - self._set_up_app_bundle_id_events() - node = MemoryOnboardingNode(team=self.team) - new_state = node.run(AssistantState(messages=[HumanMessage(content="Hello")]), {}) - self.assertEqual(len(new_state.messages), 1) - self.assertTrue(isinstance(new_state.messages[0], AssistantMessage)) - - core_memory = CoreMemory.objects.get(team=self.team) - self.assertEqual(core_memory.scraping_status, CoreMemory.ScrapingStatus.PENDING) - self.assertIsNotNone(core_memory.scraping_started_at) - - -@override_settings(IN_UNIT_TESTING=True) -class TestMemoryInitializerNode(ClickhouseTestMixin, BaseTest): - def setUp(self): - super().setUp() - self.core_memory = CoreMemory.objects.create( - team=self.team, - scraping_status=CoreMemory.ScrapingStatus.PENDING, - scraping_started_at=timezone.now(), - ) - - def _set_up_pageview_events(self): - _create_person( - distinct_ids=["person1"], - team=self.team, - ) - _create_event( - event="$pageview", - distinct_id="person1", - team=self.team, - properties={"$host": "us.posthog.com"}, - ) - - def _set_up_app_bundle_id_events(self): - _create_person( - distinct_ids=["person1"], - team=self.team, - ) - _create_event( - event="$screen", - distinct_id="person1", - team=self.team, - properties={"$app_namespace": "com.posthog.app"}, - ) - - def test_router_with_failed_scraping_message(self): - node = MemoryInitializerNode(team=self.team) - state = AssistantState(messages=[AssistantMessage(content=FAILED_SCRAPING_MESSAGE)]) - self.assertEqual(node.router(state), "continue") - - def test_router_with_other_message(self): - node = MemoryInitializerNode(team=self.team) - state = AssistantState(messages=[AssistantMessage(content="Some other message")]) - self.assertEqual(node.router(state), "interrupt") - - def test_should_process_message_chunk_with_no_data_available(self): - from langchain_core.messages import AIMessageChunk - - chunk = AIMessageChunk(content="no data available.") - self.assertFalse(MemoryInitializerNode.should_process_message_chunk(chunk)) - - chunk = AIMessageChunk(content="NO DATA AVAILABLE for something") - self.assertFalse(MemoryInitializerNode.should_process_message_chunk(chunk)) - - def test_should_process_message_chunk_with_valid_data(self): - from langchain_core.messages import AIMessageChunk - - chunk = AIMessageChunk(content="PostHog is an open-source product analytics platform") - self.assertTrue(MemoryInitializerNode.should_process_message_chunk(chunk)) - - chunk = AIMessageChunk(content="This is a valid message that should be processed") - self.assertTrue(MemoryInitializerNode.should_process_message_chunk(chunk)) - - def test_format_message_removes_reference_tags(self): - message = "PostHog[1] is a product analytics platform[2]. It helps track user behavior[3]." - expected = "PostHog is a product analytics platform. It helps track user behavior." - self.assertEqual(MemoryInitializerNode.format_message(message), expected) - - def test_format_message_with_no_reference_tags(self): - message = "PostHog is a product analytics platform. It helps track user behavior." - self.assertEqual(MemoryInitializerNode.format_message(message), message) - - def test_run_with_url_based_initialization(self): - with patch.object(MemoryInitializerNode, "_model") as model_mock: - model_mock.return_value = RunnableLambda(lambda _: "PostHog is a product analytics platform.") - - self._set_up_pageview_events() - node = MemoryInitializerNode(team=self.team) - - new_state = node.run(AssistantState(messages=[HumanMessage(content="Hello")]), {}) - self.assertEqual(len(new_state.messages), 1) - self.assertIsInstance(new_state.messages[0], AssistantMessage) - self.assertEqual(new_state.messages[0].content, "PostHog is a product analytics platform.") - - core_memory = CoreMemory.objects.get(team=self.team) - self.assertEqual(core_memory.scraping_status, CoreMemory.ScrapingStatus.PENDING) - - flush_persons_and_events() - - def test_run_with_app_bundle_id_initialization(self): - with ( - patch.object(MemoryInitializerNode, "_model") as model_mock, - patch.object(MemoryInitializerNode, "_retrieve_context") as context_mock, - ): - context_mock.return_value = [ - EventTaxonomyItem(property="$app_namespace", sample_values=["com.posthog.app"], sample_count=1) - ] - model_mock.return_value = RunnableLambda(lambda _: "PostHog mobile app description.") - - self._set_up_app_bundle_id_events() - node = MemoryInitializerNode(team=self.team) - - new_state = node.run(AssistantState(messages=[HumanMessage(content="Hello")]), {}) - self.assertEqual(len(new_state.messages), 1) - self.assertIsInstance(new_state.messages[0], AssistantMessage) - self.assertEqual(new_state.messages[0].content, "PostHog mobile app description.") - - core_memory = CoreMemory.objects.get(team=self.team) - self.assertEqual(core_memory.scraping_status, CoreMemory.ScrapingStatus.PENDING) - - flush_persons_and_events() - - def test_run_with_no_data_available(self): - with ( - patch.object(MemoryInitializerNode, "_model") as model_mock, - patch.object(MemoryInitializerNode, "_retrieve_context") as context_mock, - ): - model_mock.return_value = RunnableLambda(lambda _: "no data available.") - context_mock.return_value = [] - - node = MemoryInitializerNode(team=self.team) - - with self.assertRaises(ValueError) as e: - node.run(AssistantState(messages=[HumanMessage(content="Hello")]), {}) - self.assertEqual(str(e.exception), "No host or app bundle ID found in the memory initializer.") - - -@override_settings(IN_UNIT_TESTING=True) -class TestMemoryInitializerInterruptNode(ClickhouseTestMixin, BaseTest): - def setUp(self): - super().setUp() - self.core_memory = CoreMemory.objects.create( - team=self.team, - scraping_status=CoreMemory.ScrapingStatus.PENDING, - scraping_started_at=timezone.now(), - ) - self.node = MemoryInitializerInterruptNode(team=self.team) - - def test_interrupt_when_not_resumed(self): - state = AssistantState(messages=[AssistantMessage(content="Product description")]) - - with self.assertRaises(NodeInterrupt) as e: - self.node.run(state, {}) - - interrupt_message = e.exception.args[0][0].value - self.assertIsInstance(interrupt_message, AssistantMessage) - self.assertEqual(interrupt_message.content, prompts.SCRAPING_VERIFICATION_MESSAGE) - self.assertIsNotNone(interrupt_message.meta) - self.assertEqual(len(interrupt_message.meta.form.options), 2) - self.assertEqual(interrupt_message.meta.form.options[0].value, prompts.SCRAPING_CONFIRMATION_MESSAGE) - self.assertEqual(interrupt_message.meta.form.options[1].value, prompts.SCRAPING_REJECTION_MESSAGE) - - def test_memory_accepted(self): - with patch.object(MemoryInitializerInterruptNode, "_model") as model_mock: - model_mock.return_value = RunnableLambda(lambda _: "Compressed memory") - - state = AssistantState( - messages=[ - AssistantMessage(content="Product description"), - HumanMessage(content=prompts.SCRAPING_CONFIRMATION_MESSAGE), - ], - resumed=True, - ) - - new_state = self.node.run(state, {}) - - self.assertEqual(len(new_state.messages), 1) - self.assertIsInstance(new_state.messages[0], AssistantMessage) - self.assertEqual( - new_state.messages[0].content, - prompts.SCRAPING_MEMORY_SAVED_MESSAGE, - ) - - core_memory = CoreMemory.objects.get(team=self.team) - self.assertEqual(core_memory.text, "Compressed memory") - self.assertEqual(core_memory.scraping_status, CoreMemory.ScrapingStatus.COMPLETED) - - def test_memory_rejected(self): - state = AssistantState( - messages=[ - AssistantMessage(content="Product description"), - HumanMessage(content=prompts.SCRAPING_REJECTION_MESSAGE), - ], - resumed=True, - ) - - new_state = self.node.run(state, {}) - - self.assertEqual(len(new_state.messages), 1) - self.assertIsInstance(new_state.messages[0], AssistantMessage) - self.assertEqual( - new_state.messages[0].content, - prompts.SCRAPING_TERMINATION_MESSAGE, - ) - - self.core_memory.refresh_from_db() - self.assertEqual(self.core_memory.scraping_status, CoreMemory.ScrapingStatus.SKIPPED) - - def test_error_when_last_message_not_human(self): - state = AssistantState( - messages=[AssistantMessage(content="Product description")], - resumed=True, - ) - - with self.assertRaises(ValueError) as e: - self.node.run(state, {}) - self.assertEqual(str(e.exception), "Last message is not a human message.") - - def test_error_when_no_core_memory(self): - self.core_memory.delete() - - state = AssistantState( - messages=[ - AssistantMessage(content="Product description"), - HumanMessage(content=prompts.SCRAPING_CONFIRMATION_MESSAGE), - ], - resumed=True, - ) - - with self.assertRaises(ValueError) as e: - self.node.run(state, {}) - self.assertEqual(str(e.exception), "No core memory found.") - - def test_error_when_no_memory_message(self): - state = AssistantState( - messages=[HumanMessage(content=prompts.SCRAPING_CONFIRMATION_MESSAGE)], - resumed=True, - ) - - with self.assertRaises(ValueError) as e: - self.node.run(state, {}) - self.assertEqual(str(e.exception), "No memory message found.") - - def test_format_memory(self): - markdown_text = "# Product Description\n\n- Feature 1\n- Feature 2\n\n**Bold text** and `code` [1]" - expected = "Product Description\n\nFeature 1\nFeature 2\n\nBold text and code [1]" - self.assertEqual(self.node._format_memory(markdown_text), expected) - - -@override_settings(IN_UNIT_TESTING=True) -class TestMemoryCollectorNode(ClickhouseTestMixin, BaseTest): - def setUp(self): - super().setUp() - self.core_memory = CoreMemory.objects.create(team=self.team) - self.core_memory.set_core_memory("Test product core memory") - self.node = MemoryCollectorNode(team=self.team) - - def test_router(self): - # Test with no memory collection messages - state = AssistantState(messages=[HumanMessage(content="Text")], memory_collection_messages=[]) - self.assertEqual(self.node.router(state), "next") - - # Test with memory collection messages - state = AssistantState( - messages=[HumanMessage(content="Text")], - memory_collection_messages=[LangchainAIMessage(content="Memory message")], - ) - self.assertEqual(self.node.router(state), "tools") - - def test_construct_messages(self): - # Test basic conversation reconstruction - state = AssistantState( - messages=[ - HumanMessage(content="Question 1", id="0"), - AssistantMessage(content="Answer 1", id="1"), - HumanMessage(content="Question 2", id="2"), - ], - start_id="2", - ) - history = self.node._construct_messages(state) - self.assertEqual(len(history), 3) - self.assertEqual(history[0].content, "Question 1") - self.assertEqual(history[1].content, "Answer 1") - self.assertEqual(history[2].content, "Question 2") - - # Test with memory collection messages - state = AssistantState( - messages=[HumanMessage(content="Question", id="0")], - memory_collection_messages=[ - LangchainAIMessage(content="Memory 1"), - LangchainToolMessage(content="Tool response", tool_call_id="1"), - ], - start_id="0", - ) - history = self.node._construct_messages(state) - self.assertEqual(len(history), 3) - self.assertEqual(history[0].content, "Question") - self.assertEqual(history[1].content, "Memory 1") - self.assertEqual(history[2].content, "Tool response") - - @freeze_time("2024-01-01") - def test_prompt_substitutions(self): - with patch.object(MemoryCollectorNode, "_model") as model_mock: - - def assert_prompt(prompt): - messages = prompt.to_messages() - - # Verify the structure of messages - self.assertEqual(len(messages), 3) - self.assertEqual(messages[0].type, "system") - self.assertEqual(messages[1].type, "human") - self.assertEqual(messages[2].type, "ai") - - # Verify system message content - system_message = messages[0].content - self.assertIn("Test product core memory", system_message) - self.assertIn("2024-01-01", system_message) - - # Verify conversation messages - self.assertEqual(messages[1].content, "We use a subscription model") - self.assertEqual(messages[2].content, "Memory message") - return LangchainAIMessage(content="[Done]") - - model_mock.return_value = RunnableLambda(assert_prompt) - - state = AssistantState( - messages=[ - HumanMessage(content="We use a subscription model", id="0"), - ], - memory_collection_messages=[ - LangchainAIMessage(content="Memory message"), - ], - start_id="0", - ) - - self.node.run(state, {}) - - def test_exits_on_done_message(self): - with patch.object(MemoryCollectorNode, "_model") as model_mock: - model_mock.return_value = RunnableLambda( - lambda _: LangchainAIMessage(content="Processing complete. [Done]") - ) - - state = AssistantState( - messages=[HumanMessage(content="Text")], - memory_collection_messages=[LangchainAIMessage(content="Previous memory")], - ) - - new_state = self.node.run(state, {}) - self.assertEqual(new_state.memory_updated, True) - self.assertEqual(new_state.memory_collection_messages, []) - - def test_appends_new_message(self): - with patch.object(MemoryCollectorNode, "_model") as model_mock: - model_mock.return_value = RunnableLambda( - lambda _: LangchainAIMessage( - content="New memory", - tool_calls=[ - { - "name": "core_memory_append", - "args": {"new_fragment": "New memory"}, - "id": "1", - }, - ], - ), - ) - - state = AssistantState( - messages=[HumanMessage(content="Text")], - memory_collection_messages=[LangchainAIMessage(content="Previous memory")], - ) - - new_state = self.node.run(state, {}) - self.assertEqual(len(new_state.memory_collection_messages), 2) - self.assertEqual(new_state.memory_collection_messages[0].content, "Previous memory") - self.assertEqual(new_state.memory_collection_messages[1].content, "New memory") - - def test_construct_messages_typical_conversation(self): - # Set up a typical conversation with multiple interactions - state = AssistantState( - messages=[ - HumanMessage(content="We use a subscription model", id="0"), - AssistantMessage(content="I'll note that down", id="1"), - HumanMessage(content="And we target enterprise customers", id="2"), - AssistantMessage(content="Let me process that information", id="3"), - HumanMessage(content="We also have a freemium tier", id="4"), - ], - memory_collection_messages=[ - LangchainAIMessage(content="Analyzing business model: subscription-based pricing."), - LangchainToolMessage(content="Memory appended.", tool_call_id="1"), - LangchainAIMessage(content="Analyzing target audience: enterprise customers."), - LangchainToolMessage(content="Memory appended.", tool_call_id="2"), - ], - start_id="0", - ) - - history = self.node._construct_messages(state) - - # Verify the complete conversation history is reconstructed correctly - self.assertEqual(len(history), 9) # 5 conversation messages + 4 memory messages - - # Check conversation messages - self.assertEqual(history[0].content, "We use a subscription model") - self.assertEqual(history[1].content, "I'll note that down") - self.assertEqual(history[2].content, "And we target enterprise customers") - self.assertEqual(history[3].content, "Let me process that information") - self.assertEqual(history[4].content, "We also have a freemium tier") - - # Check memory collection messages - self.assertEqual(history[5].content, "Analyzing business model: subscription-based pricing.") - self.assertEqual(history[6].content, "Memory appended.") - self.assertEqual(history[7].content, "Analyzing target audience: enterprise customers.") - self.assertEqual(history[8].content, "Memory appended.") - - -class TestMemoryCollectorToolsNode(BaseTest): - def setUp(self): - super().setUp() - self.core_memory = CoreMemory.objects.create(team=self.team) - self.core_memory.set_core_memory("Initial memory content") - self.node = MemoryCollectorToolsNode(team=self.team) - - def test_handles_correct_tools(self): - # Test handling a single append tool - state = AssistantState( - messages=[], - memory_collection_messages=[ - LangchainAIMessage( - content="Adding new memory", - tool_calls=[ - { - "name": "core_memory_append", - "args": {"memory_content": "New memory fragment."}, - "id": "1", - }, - { - "name": "core_memory_replace", - "args": { - "original_fragment": "Initial memory content", - "new_fragment": "New memory fragment 2.", - }, - "id": "2", - }, - ], - ) - ], - ) - - new_state = self.node.run(state, {}) - self.assertEqual(len(new_state.memory_collection_messages), 3) - self.assertEqual(new_state.memory_collection_messages[1].type, "tool") - self.assertEqual(new_state.memory_collection_messages[1].content, "Memory appended.") - self.assertEqual(new_state.memory_collection_messages[2].type, "tool") - self.assertEqual(new_state.memory_collection_messages[2].content, "Memory replaced.") - - def test_handles_validation_error(self): - # Test handling validation error with incorrect tool arguments - state = AssistantState( - messages=[], - memory_collection_messages=[ - LangchainAIMessage( - content="Invalid tool call", - tool_calls=[ - { - "name": "core_memory_append", - "args": {"invalid_arg": "This will fail"}, - "id": "1", - } - ], - ) - ], - ) - - new_state = self.node.run(state, {}) - self.assertEqual(len(new_state.memory_collection_messages), 2) - self.assertNotIn("{{validation_error_message}}", new_state.memory_collection_messages[1].content) - - def test_handles_multiple_tools(self): - # Test handling multiple tool calls in a single message - state = AssistantState( - messages=[], - memory_collection_messages=[ - LangchainAIMessage( - content="Multiple operations", - tool_calls=[ - { - "name": "core_memory_append", - "args": {"memory_content": "First memory"}, - "id": "1", - }, - { - "name": "core_memory_append", - "args": {"memory_content": "Second memory"}, - "id": "2", - }, - { - "name": "core_memory_replace", - "args": { - "original_fragment": "Initial memory content", - "new_fragment": "Third memory", - }, - "id": "3", - }, - ], - ) - ], - ) - - new_state = self.node.run(state, {}) - self.assertEqual(len(new_state.memory_collection_messages), 4) - self.assertEqual(new_state.memory_collection_messages[1].content, "Memory appended.") - self.assertEqual(new_state.memory_collection_messages[1].type, "tool") - self.assertEqual(new_state.memory_collection_messages[1].tool_call_id, "1") - self.assertEqual(new_state.memory_collection_messages[2].content, "Memory appended.") - self.assertEqual(new_state.memory_collection_messages[2].type, "tool") - self.assertEqual(new_state.memory_collection_messages[2].tool_call_id, "2") - self.assertEqual(new_state.memory_collection_messages[3].content, "Memory replaced.") - self.assertEqual(new_state.memory_collection_messages[3].type, "tool") - self.assertEqual(new_state.memory_collection_messages[3].tool_call_id, "3") - - self.core_memory.refresh_from_db() - self.assertEqual(self.core_memory.text, "Third memory\nFirst memory\nSecond memory") - - def test_handles_replacing_memory(self): - # Test replacing a memory fragment - state = AssistantState( - messages=[], - memory_collection_messages=[ - LangchainAIMessage( - content="Replacing memory", - tool_calls=[ - { - "name": "core_memory_replace", - "args": { - "original_fragment": "Initial memory", - "new_fragment": "Updated memory", - }, - "id": "1", - } - ], - ) - ], - ) - - new_state = self.node.run(state, {}) - self.assertEqual(len(new_state.memory_collection_messages), 2) - self.assertEqual(new_state.memory_collection_messages[1].content, "Memory replaced.") - self.assertEqual(new_state.memory_collection_messages[1].type, "tool") - self.assertEqual(new_state.memory_collection_messages[1].tool_call_id, "1") - self.core_memory.refresh_from_db() - self.assertEqual(self.core_memory.text, "Updated memory content") - - def test_handles_replace_memory_not_found(self): - # Test replacing a memory fragment that doesn't exist - state = AssistantState( - messages=[], - memory_collection_messages=[ - LangchainAIMessage( - content="Replacing non-existent memory", - tool_calls=[ - { - "name": "core_memory_replace", - "args": { - "original_fragment": "Non-existent memory", - "new_fragment": "New memory", - }, - "id": "1", - } - ], - ) - ], - ) - - new_state = self.node.run(state, {}) - self.assertEqual(len(new_state.memory_collection_messages), 2) - self.assertIn("not found", new_state.memory_collection_messages[1].content.lower()) - self.assertEqual(new_state.memory_collection_messages[1].type, "tool") - self.assertEqual(new_state.memory_collection_messages[1].tool_call_id, "1") - self.core_memory.refresh_from_db() - self.assertEqual(self.core_memory.text, "Initial memory content") - - def test_handles_appending_new_memory(self): - # Test appending a new memory fragment - state = AssistantState( - messages=[], - memory_collection_messages=[ - LangchainAIMessage( - content="Appending memory", - tool_calls=[ - { - "name": "core_memory_append", - "args": {"memory_content": "Additional memory"}, - "id": "1", - } - ], - ) - ], - ) - - new_state = self.node.run(state, {}) - self.assertEqual(len(new_state.memory_collection_messages), 2) - self.assertEqual(new_state.memory_collection_messages[1].content, "Memory appended.") - self.assertEqual(new_state.memory_collection_messages[1].type, "tool") - self.core_memory.refresh_from_db() - self.assertEqual(self.core_memory.text, "Initial memory content\nAdditional memory") - - def test_error_when_no_memory_collection_messages(self): - # Test error when no memory collection messages are present - state = AssistantState(messages=[], memory_collection_messages=[]) - - with self.assertRaises(ValueError) as e: - self.node.run(state, {}) - self.assertEqual(str(e.exception), "No memory collection messages found.") - - def test_error_when_last_message_not_ai(self): - # Test error when last message is not an AI message - state = AssistantState( - messages=[], - memory_collection_messages=[LangchainToolMessage(content="Not an AI message", tool_call_id="1")], - ) - - with self.assertRaises(ValueError) as e: - self.node.run(state, {}) - self.assertEqual(str(e.exception), "Last message must be an AI message.") - - def test_error_when_no_core_memory(self): - # Test error when core memory is not found - self.core_memory.delete() - state = AssistantState( - messages=[], - memory_collection_messages=[ - LangchainAIMessage( - content="Memory operation", - tool_calls=[ - { - "name": "core_memory_append", - "args": {"memory_content": "New memory"}, - "id": "1", - } - ], - ) - ], - ) - - with self.assertRaises(ValueError) as e: - self.node.run(state, {}) - self.assertEqual(str(e.exception), "No core memory found.") diff --git a/ee/hogai/memory/test/test_parsers.py b/ee/hogai/memory/test/test_parsers.py deleted file mode 100644 index 8f98d18815..0000000000 --- a/ee/hogai/memory/test/test_parsers.py +++ /dev/null @@ -1,22 +0,0 @@ -from langchain_core.messages import AIMessage - -from ee.hogai.memory.parsers import MemoryCollectionCompleted, compressed_memory_parser, raise_memory_updated -from posthog.test.base import BaseTest - - -class TestParsers(BaseTest): - def test_compressed_memory_parser(self): - memory = "Hello\n\nWorld " - self.assertEqual(compressed_memory_parser(memory), "Hello\nWorld ") - - def test_raise_memory_updated(self): - message = AIMessage(content="Hello World") - with self.assertRaises(MemoryCollectionCompleted): - raise_memory_updated(message) - - message = AIMessage(content="[Done]", tool_calls=[{"id": "1", "args": {}, "name": "function"}]) - with self.assertRaises(MemoryCollectionCompleted): - raise_memory_updated(message) - - message = AIMessage(content="Reasoning", tool_calls=[{"id": "1", "args": {}, "name": "function"}]) - self.assertEqual(raise_memory_updated(message), message) diff --git a/ee/hogai/retention/__init__.py b/ee/hogai/retention/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/retention/nodes.py b/ee/hogai/retention/nodes.py deleted file mode 100644 index 4a02854834..0000000000 --- a/ee/hogai/retention/nodes.py +++ /dev/null @@ -1,50 +0,0 @@ -from langchain_core.prompts import ChatPromptTemplate -from langchain_core.runnables import RunnableConfig - -from ee.hogai.retention.prompts import RETENTION_SYSTEM_PROMPT, REACT_SYSTEM_PROMPT -from ee.hogai.retention.toolkit import RETENTION_SCHEMA, RetentionTaxonomyAgentToolkit -from ee.hogai.schema_generator.nodes import SchemaGeneratorNode, SchemaGeneratorToolsNode -from ee.hogai.schema_generator.utils import SchemaGeneratorOutput -from ee.hogai.taxonomy_agent.nodes import TaxonomyAgentPlannerNode, TaxonomyAgentPlannerToolsNode -from ee.hogai.utils.types import AssistantState, PartialAssistantState -from posthog.schema import AssistantRetentionQuery - - -class RetentionPlannerNode(TaxonomyAgentPlannerNode): - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - toolkit = RetentionTaxonomyAgentToolkit(self._team) - prompt = ChatPromptTemplate.from_messages( - [ - ("system", REACT_SYSTEM_PROMPT), - ], - template_format="mustache", - ) - return super()._run_with_prompt_and_toolkit(state, prompt, toolkit, config=config) - - -class RetentionPlannerToolsNode(TaxonomyAgentPlannerToolsNode): - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - toolkit = RetentionTaxonomyAgentToolkit(self._team) - return super()._run_with_toolkit(state, toolkit, config=config) - - -RetentionSchemaGeneratorOutput = SchemaGeneratorOutput[AssistantRetentionQuery] - - -class RetentionGeneratorNode(SchemaGeneratorNode[AssistantRetentionQuery]): - INSIGHT_NAME = "Retention" - OUTPUT_MODEL = RetentionSchemaGeneratorOutput - OUTPUT_SCHEMA = RETENTION_SCHEMA - - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - prompt = ChatPromptTemplate.from_messages( - [ - ("system", RETENTION_SYSTEM_PROMPT), - ], - template_format="mustache", - ) - return super()._run_with_prompt(state, prompt, config=config) - - -class RetentionGeneratorToolsNode(SchemaGeneratorToolsNode): - pass diff --git a/ee/hogai/retention/prompts.py b/ee/hogai/retention/prompts.py deleted file mode 100644 index 39adcff50b..0000000000 --- a/ee/hogai/retention/prompts.py +++ /dev/null @@ -1,88 +0,0 @@ -REACT_SYSTEM_PROMPT = """ - -You are an expert product analyst agent specializing in data visualization and retention analysis. Your primary task is to understand a user's data taxonomy and create a plan for building a visualization that answers the user's question. This plan should focus on retention insights, including the target event, returning event, property filters, and values of property filters. - - -{{core_memory}} - - -{{react_format}} - - -{{react_human_in_the_loop}} - -Below you will find information on how to correctly discover the taxonomy of the user's data. - - -Retention is a type of insight that shows you how many users return during subsequent periods. - -They're useful for answering questions like: -- Are new sign ups coming back to use your product after trying it? -- Have recent changes improved retention? - - - -You'll be given a list of events in addition to the user's question. Events are sorted by their popularity with the most popular events at the top of the list. Prioritize popular events. You must always specify events to use. Events always have an associated user's profile. Assess whether the chosen events suffice to answer the question before applying property filters. Retention insights do not require filters by default. - -Plans of retention insights must always have two events: -- The activation event – an event that determines if the user is a part of a cohort. -- The retention event – an event that determines whether a user has been retained. - -For activation and retention events, use the `$pageview` event by default or the equivalent for mobile apps `$screen`. Avoid infrequent or inconsistent events like `signed in` unless asked explicitly, as they skew the data. - - -{{react_property_filters}} - - -- Ensure that any properties included are directly relevant to the context and objectives of the user's question. Avoid unnecessary or unrelated details. -- Avoid overcomplicating the response with excessive property filters. Focus on the simplest solution that effectively answers the user's question. - ---- - -{{react_format_reminder}} -""" - -RETENTION_SYSTEM_PROMPT = """ -Act as an expert product manager. Your task is to generate a JSON schema of retention insights. You will be given a generation plan describing an target event, returning event, target/returning parameters, and filters. Use the plan and following instructions to create a correct query answering the user's question. - -Below is the additional context. - -Follow this instruction to create a query: -* Build the insight according to the plan. Properties can be of multiple types: String, Numeric, Bool, and DateTime. A property can be an array of those types and only has a single type. -* When evaluating filter operators, replace the `equals` or `doesn't equal` operators with `contains` or `doesn't contain` if the query value is likely a personal name, company name, or any other name-sensitive term where letter casing matters. For instance, if the value is ‘John Doe' or ‘Acme Corp', replace `equals` with `contains` and change the value to lowercase from `John Doe` to `john doe` or `Acme Corp` to `acme corp`. -* Determine the activation type that will answer the user's question in the best way. Use the provided defaults. -* Determine the retention period and number of periods to look back. -* Determine if the user wants to filter out internal and test users. If the user didn't specify, filter out internal and test users by default. -* Determine if you need to apply a sampling factor. Only specify those if the user has explicitly asked. -* Use your judgment if there are any other parameters that the user might want to adjust that aren't listed here. - -The user might want to receive insights about groups. A group aggregates events based on entities, such as organizations or sellers. The user might provide a list of group names and their numeric indexes. Instead of a group's name, always use its numeric index. - -Retention can be aggregated by: -- Unique users (default, do not specify anything to use it). Use this option unless the user states otherwise. -- Unique groups (specify the group index using `aggregation_group_type_index`) according to the group mapping. - -## Schema Examples - -### Question: How do new users of insights retain? - -Plan: -``` -Target event: -insight created - -Returning event: -insight saved -``` - -Output: -``` -{"kind":"RetentionQuery","retentionFilter":{"period":"Week","totalIntervals":9,"targetEntity":{"id":"insight created","name":"insight created","type":"events","order":0},"returningEntity":{"id":"insight created","name":"insight created","type":"events","order":0},"retentionType":"retention_first_time","retentionReference":"total","cumulative":false},"filterTestAccounts":true} -``` - -Obey these rules: -- Filter internal users by default if the user doesn't specify. -- You can't create new events or property definitions. Stick to the plan. - -Remember, your efforts will be rewarded by the company's founders. Do not hallucinate. -""" diff --git a/ee/hogai/retention/test/test_nodes.py b/ee/hogai/retention/test/test_nodes.py deleted file mode 100644 index 5036dff215..0000000000 --- a/ee/hogai/retention/test/test_nodes.py +++ /dev/null @@ -1,50 +0,0 @@ -from unittest.mock import patch - -from django.test import override_settings -from langchain_core.runnables import RunnableLambda - -from ee.hogai.retention.nodes import RetentionGeneratorNode, RetentionSchemaGeneratorOutput -from ee.hogai.utils.types import AssistantState, PartialAssistantState -from posthog.schema import ( - AssistantRetentionQuery, - HumanMessage, - AssistantRetentionFilter, - VisualizationMessage, -) -from posthog.test.base import APIBaseTest, ClickhouseTestMixin - - -@override_settings(IN_UNIT_TESTING=True) -class TestRetentionGeneratorNode(ClickhouseTestMixin, APIBaseTest): - maxDiff = None - - def setUp(self): - super().setUp() - self.schema = AssistantRetentionQuery( - retentionFilter=AssistantRetentionFilter( - targetEntity={"id": "targetEntity", "type": "events", "name": "targetEntity"}, - returningEntity={"id": "returningEntity", "type": "events", "name": "returningEntity"}, - ) - ) - - def test_node_runs(self): - node = RetentionGeneratorNode(self.team) - with patch.object(RetentionGeneratorNode, "_model") as generator_model_mock: - generator_model_mock.return_value = RunnableLambda( - lambda _: RetentionSchemaGeneratorOutput(query=self.schema).model_dump() - ) - new_state = node.run( - AssistantState( - messages=[HumanMessage(content="Text")], - plan="Plan", - ), - {}, - ) - self.assertEqual( - new_state, - PartialAssistantState( - messages=[VisualizationMessage(answer=self.schema, plan="Plan", id=new_state.messages[0].id)], - intermediate_steps=[], - plan="", - ), - ) diff --git a/ee/hogai/retention/toolkit.py b/ee/hogai/retention/toolkit.py deleted file mode 100644 index 966d29c7f9..0000000000 --- a/ee/hogai/retention/toolkit.py +++ /dev/null @@ -1,57 +0,0 @@ -from ee.hogai.taxonomy_agent.toolkit import TaxonomyAgentToolkit, ToolkitTool -from ee.hogai.utils.helpers import dereference_schema -from posthog.schema import AssistantRetentionQuery - - -class RetentionTaxonomyAgentToolkit(TaxonomyAgentToolkit): - def _get_tools(self) -> list[ToolkitTool]: - return [ - *self._default_tools, - { - "name": "final_answer", - "signature": "(final_response: str)", - "description": """ -Use this tool to provide the final answer to the user's question. - -Answer in the following format: -``` -Activation event: -chosen event - -Retention event: -chosen event (can be the same as activation event, or different) - -(if filters are used) -Filters: - - property filter 1: - - entity - - property name - - property type - - operator - - property value - - property filter 2... Repeat for each property filter. -``` - -Args: - final_response: List all events and properties that you want to use to answer the question.""", - }, - ] - - -def generate_retention_schema() -> dict: - schema = AssistantRetentionQuery.model_json_schema() - return { - "name": "output_insight_schema", - "description": "Outputs the JSON schema of a product analytics insight", - "parameters": { - "type": "object", - "properties": { - "query": dereference_schema(schema), - }, - "additionalProperties": False, - "required": ["query"], - }, - } - - -RETENTION_SCHEMA = generate_retention_schema() diff --git a/ee/hogai/router/__init__.py b/ee/hogai/router/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/router/nodes.py b/ee/hogai/router/nodes.py deleted file mode 100644 index fac5029f14..0000000000 --- a/ee/hogai/router/nodes.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import Literal, cast -from uuid import uuid4 - -from langchain_core.messages import AIMessage as LangchainAIMessage, BaseMessage -from langchain_core.prompts import ChatPromptTemplate -from langchain_core.runnables import RunnableConfig -from langchain_openai import ChatOpenAI -from pydantic import BaseModel, Field - -from ee.hogai.router.prompts import ( - ROUTER_INSIGHT_DESCRIPTION_PROMPT, - ROUTER_SYSTEM_PROMPT, - ROUTER_USER_PROMPT, -) -from ee.hogai.utils.nodes import AssistantNode -from ee.hogai.utils.types import AssistantState, PartialAssistantState -from posthog.schema import HumanMessage, RouterMessage - -RouteName = Literal["trends", "funnel", "retention"] - - -class RouterOutput(BaseModel): - visualization_type: Literal["trends", "funnel", "retention"] = Field( - ..., description=ROUTER_INSIGHT_DESCRIPTION_PROMPT - ) - - -class RouterNode(AssistantNode): - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - prompt = ChatPromptTemplate.from_messages( - [ - ("system", ROUTER_SYSTEM_PROMPT), - ], - template_format="mustache", - ) + self._construct_messages(state) - chain = prompt | self._model - output: RouterOutput = chain.invoke({}, config) - return PartialAssistantState(messages=[RouterMessage(content=output.visualization_type, id=str(uuid4()))]) - - def router(self, state: AssistantState) -> RouteName: - last_message = state.messages[-1] - if isinstance(last_message, RouterMessage): - return cast(RouteName, last_message.content) - raise ValueError("Invalid route.") - - @property - def _model(self): - return ChatOpenAI(model="gpt-4o-mini", temperature=0, disable_streaming=True).with_structured_output( - RouterOutput - ) - - def _construct_messages(self, state: AssistantState): - history: list[BaseMessage] = [] - for message in state.messages: - if isinstance(message, HumanMessage): - history += ChatPromptTemplate.from_messages( - [("user", ROUTER_USER_PROMPT.strip())], template_format="mustache" - ).format_messages(question=message.content) - elif isinstance(message, RouterMessage): - history += [ - # AIMessage with the tool call - LangchainAIMessage(content=message.content), - ] - return history diff --git a/ee/hogai/router/prompts.py b/ee/hogai/router/prompts.py deleted file mode 100644 index d72c357061..0000000000 --- a/ee/hogai/router/prompts.py +++ /dev/null @@ -1,55 +0,0 @@ -ROUTER_SYSTEM_PROMPT = """ -Act as an expert product manager. Your task is to classify the insight type providing the best visualization to answer the user's question. - -Examples: - -Q: How many users signed up last week from the US? -A: The insight type is "trends". The request asks for an event count from unique users from a specific country. - -Q: What is the onboarding conversion rate? -A: The insight type is "funnels". The request explicitly asks for a conversion rate. Next steps should find at least two events to build this insight. - -Q: What is the ratio of $identify divided by page views? -A: The insight type is "trends". The request asks for a custom formula, which the trends visualization supports. - -Q: How many users returned to the product after signing up? -A: The insight type is "retention". The request asks for a retention analysis. -""" - -ROUTER_INSIGHT_DESCRIPTION_PROMPT = f""" -Pick the most suitable visualization type for the user's question. - -## `trends` - -A trends insight visualizes events over time using time series. They're useful for finding patterns in historical data. - -Examples of use cases include: -- How the product's most important metrics change over time. -- Long-term patterns, or cycles in product's usage. -- The usage of different features side-by-side. -- How the properties of events vary using aggregation (sum, average, etc). -- Users can also visualize the same data points in a variety of ways. - -## `funnel` - -A funnel insight visualizes a sequence of events that users go through in a product. They use percentages as the primary aggregation type. Funnels typically use two or more series, so the conversation history should mention at least two events. - -Examples of use cases include: -- Conversion rates. -- Drop off steps. -- Steps with the highest friction and time to convert. -- If product changes are improving their funnel over time. - -## `retention` - -A retention insight visualizes how many users return to the product after performing some action. They're useful for understanding user engagement and retention. - -Examples of use cases include: -- How many users come back and perform an action after their first visit. -- How many users come back to perform action X after performing action Y. -- How often users return to use a specific feature. -""" - -ROUTER_USER_PROMPT = """ -Question: {{question}} -""" diff --git a/ee/hogai/router/test/__init__.py b/ee/hogai/router/test/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/router/test/test_nodes.py b/ee/hogai/router/test/test_nodes.py deleted file mode 100644 index 53074a381b..0000000000 --- a/ee/hogai/router/test/test_nodes.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import Any -from unittest.mock import patch - -from django.test import override_settings -from langchain_core.messages import AIMessage as LangchainAIMessage, HumanMessage as LangchainHumanMessage -from langchain_core.runnables import RunnableLambda - -from ee.hogai.router.nodes import RouterNode, RouterOutput -from ee.hogai.utils.types import AssistantState, PartialAssistantState -from posthog.schema import ( - HumanMessage, - RouterMessage, - VisualizationMessage, -) -from posthog.test.base import APIBaseTest, ClickhouseTestMixin - - -@override_settings(IN_UNIT_TESTING=True) -class TestRouterNode(ClickhouseTestMixin, APIBaseTest): - def test_router(self): - node = RouterNode(self.team) - state: Any = AssistantState(messages=[RouterMessage(content="trends")]) - self.assertEqual(node.router(state), "trends") - - def test_node_runs(self): - with patch( - "ee.hogai.router.nodes.RouterNode._model", - return_value=RunnableLambda(lambda _: RouterOutput(visualization_type="funnel")), - ): - node = RouterNode(self.team) - state: Any = AssistantState(messages=[HumanMessage(content="generate trends")]) - next_state = node.run(state, {}) - self.assertEqual( - next_state, - PartialAssistantState(messages=[RouterMessage(content="funnel", id=next_state.messages[0].id)]), - ) - - with patch( - "ee.hogai.router.nodes.RouterNode._model", - return_value=RunnableLambda(lambda _: RouterOutput(visualization_type="trends")), - ): - node = RouterNode(self.team) - state: Any = AssistantState(messages=[HumanMessage(content="generate trends")]) - next_state = node.run(state, {}) - self.assertEqual( - next_state, - PartialAssistantState(messages=[RouterMessage(content="trends", id=next_state.messages[0].id)]), - ) - - def test_node_reconstructs_conversation(self): - node = RouterNode(self.team) - state: Any = AssistantState(messages=[HumanMessage(content="generate trends")]) - self.assertEqual(node._construct_messages(state), [LangchainHumanMessage(content="Question: generate trends")]) - state = AssistantState( - messages=[ - HumanMessage(content="generate trends"), - RouterMessage(content="trends"), - VisualizationMessage(), - ] - ) - self.assertEqual( - node._construct_messages(state), - [LangchainHumanMessage(content="Question: generate trends"), LangchainAIMessage(content="trends")], - ) diff --git a/ee/hogai/schema_generator/__init__.py b/ee/hogai/schema_generator/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/schema_generator/nodes.py b/ee/hogai/schema_generator/nodes.py deleted file mode 100644 index 9dd0980ee2..0000000000 --- a/ee/hogai/schema_generator/nodes.py +++ /dev/null @@ -1,255 +0,0 @@ -import xml.etree.ElementTree as ET -from collections.abc import Sequence -from functools import cached_property -from typing import Generic, Optional, TypeVar -from uuid import uuid4 - -from langchain_core.agents import AgentAction -from langchain_core.messages import ( - AIMessage as LangchainAssistantMessage, - BaseMessage, - HumanMessage as LangchainHumanMessage, - merge_message_runs, -) -from langchain_core.prompts import ChatPromptTemplate, HumanMessagePromptTemplate -from langchain_core.runnables import RunnableConfig -from langchain_openai import ChatOpenAI -from pydantic import BaseModel, ValidationError - -from ee.hogai.schema_generator.parsers import ( - PydanticOutputParserException, - parse_pydantic_structured_output, -) -from ee.hogai.schema_generator.prompts import ( - FAILOVER_OUTPUT_PROMPT, - FAILOVER_PROMPT, - GROUP_MAPPING_PROMPT, - NEW_PLAN_PROMPT, - PLAN_PROMPT, - QUESTION_PROMPT, -) -from ee.hogai.schema_generator.utils import SchemaGeneratorOutput -from ee.hogai.utils.helpers import find_last_message_of_type, slice_messages_to_conversation_start -from ee.hogai.utils.nodes import AssistantNode -from ee.hogai.utils.types import AssistantMessageUnion, AssistantState, PartialAssistantState -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.schema import ( - AssistantMessage, - FailureMessage, - HumanMessage, - VisualizationMessage, -) - -Q = TypeVar("Q", bound=BaseModel) - - -class SchemaGeneratorNode(AssistantNode, Generic[Q]): - INSIGHT_NAME: str - """ - Name of the insight type used in the exception messages. - """ - OUTPUT_MODEL: type[SchemaGeneratorOutput[Q]] - """Pydantic model of the output to be generated by the LLM.""" - OUTPUT_SCHEMA: dict - """JSON schema of OUTPUT_MODEL for LLM's use.""" - - @property - def _model(self): - return ChatOpenAI(model="gpt-4o", temperature=0, streaming=True, stream_usage=True).with_structured_output( - self.OUTPUT_SCHEMA, - method="function_calling", - include_raw=False, - ) - - @classmethod - def parse_output(cls, output: dict) -> Optional[SchemaGeneratorOutput[Q]]: - try: - return cls.OUTPUT_MODEL.model_validate(output) - except ValidationError: - return None - - def _run_with_prompt( - self, - state: AssistantState, - prompt: ChatPromptTemplate, - config: Optional[RunnableConfig] = None, - ) -> PartialAssistantState: - start_id = state.start_id - generated_plan = state.plan or "" - intermediate_steps = state.intermediate_steps or [] - validation_error_message = intermediate_steps[-1][1] if intermediate_steps else None - - generation_prompt = prompt + self._construct_messages(state, validation_error_message=validation_error_message) - merger = merge_message_runs() - parser = parse_pydantic_structured_output(self.OUTPUT_MODEL) - - chain = generation_prompt | merger | self._model | parser - - try: - message: SchemaGeneratorOutput[Q] = chain.invoke({}, config) - except PydanticOutputParserException as e: - # Generation step is expensive. After a second unsuccessful attempt, it's better to send a failure message. - if len(intermediate_steps) >= 2: - return PartialAssistantState( - messages=[ - FailureMessage( - content=f"Oops! It looks like I’m having trouble generating this {self.INSIGHT_NAME} insight. Could you please try again?" - ) - ], - intermediate_steps=[], - plan="", - ) - - return PartialAssistantState( - intermediate_steps=[ - *intermediate_steps, - (AgentAction("handle_incorrect_response", e.llm_output, e.validation_message), None), - ], - ) - - final_message = VisualizationMessage( - plan=generated_plan, - answer=message.query, - initiator=start_id, - id=str(uuid4()), - ) - - return PartialAssistantState( - messages=[final_message], - intermediate_steps=[], - plan="", - ) - - def router(self, state: AssistantState): - if state.intermediate_steps: - return "tools" - return "next" - - @cached_property - def _group_mapping_prompt(self) -> str: - groups = GroupTypeMapping.objects.filter(project_id=self._team.project_id).order_by("group_type_index") - if not groups: - return "The user has not defined any groups." - - root = ET.Element("list of defined groups") - root.text = ( - "\n" + "\n".join([f'name "{group.group_type}", index {group.group_type_index}' for group in groups]) + "\n" - ) - return ET.tostring(root, encoding="unicode") - - def _get_human_viz_message_mapping(self, messages: Sequence[AssistantMessageUnion]) -> dict[str, int]: - mapping: dict[str, int] = {} - for idx, msg in enumerate(messages): - if isinstance(msg, VisualizationMessage) and msg.initiator is not None: - mapping[msg.initiator] = idx - return mapping - - def _construct_messages( - self, state: AssistantState, validation_error_message: Optional[str] = None - ) -> list[BaseMessage]: - """ - Reconstruct the conversation for the generation. Take all previously generated questions, plans, and schemas, and return the history. - """ - messages = state.messages - generated_plan = state.plan - start_id = state.start_id - - if start_id is not None: - messages = slice_messages_to_conversation_start(messages, start_id) - if len(messages) == 0: - return [] - - conversation: list[BaseMessage] = [ - HumanMessagePromptTemplate.from_template(GROUP_MAPPING_PROMPT, template_format="mustache").format( - group_mapping=self._group_mapping_prompt - ) - ] - - msg_mapping = self._get_human_viz_message_mapping(messages) - initiator_message = messages[-1] - last_viz_message = find_last_message_of_type(messages, VisualizationMessage) - - for message in messages: - # The initial human message and the new plan are added to the end of the conversation. - if message == initiator_message: - continue - if isinstance(message, HumanMessage): - if message.id and (viz_message_idx := msg_mapping.get(message.id)): - # Plans go first. - viz_message = messages[viz_message_idx] - if isinstance(viz_message, VisualizationMessage): - conversation.append( - HumanMessagePromptTemplate.from_template(PLAN_PROMPT, template_format="mustache").format( - plan=viz_message.plan or "" - ) - ) - - # Augment with the prompt previous initiator messages. - conversation.append( - HumanMessagePromptTemplate.from_template(QUESTION_PROMPT, template_format="mustache").format( - question=message.content - ) - ) - # Otherwise, just append the human message. - else: - conversation.append(LangchainHumanMessage(content=message.content)) - # Summary, human-in-the-loop messages. - elif isinstance(message, AssistantMessage): - conversation.append(LangchainAssistantMessage(content=message.content)) - - # Include only last generated schema because it doesn't need more context. - if last_viz_message: - conversation.append( - LangchainAssistantMessage( - content=last_viz_message.answer.model_dump_json() if last_viz_message.answer else "" - ) - ) - # Add the initiator message and the generated plan to the end, so instructions are clear. - if isinstance(initiator_message, HumanMessage): - if generated_plan: - plan_prompt = PLAN_PROMPT if messages[0] == initiator_message else NEW_PLAN_PROMPT - conversation.append( - HumanMessagePromptTemplate.from_template(plan_prompt, template_format="mustache").format( - plan=generated_plan or "" - ) - ) - conversation.append( - HumanMessagePromptTemplate.from_template(QUESTION_PROMPT, template_format="mustache").format( - question=initiator_message.content - ) - ) - - # Retries must be added to the end of the conversation. - if validation_error_message: - conversation.append( - HumanMessagePromptTemplate.from_template(FAILOVER_PROMPT, template_format="mustache").format( - validation_error_message=validation_error_message - ) - ) - - return conversation - - -class SchemaGeneratorToolsNode(AssistantNode): - """ - Used for failover from generation errors. - """ - - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - intermediate_steps = state.intermediate_steps or [] - if not intermediate_steps: - return PartialAssistantState() - - action, _ = intermediate_steps[-1] - prompt = ( - ChatPromptTemplate.from_template(FAILOVER_OUTPUT_PROMPT, template_format="mustache") - .format_messages(output=action.tool_input, exception_message=action.log)[0] - .content - ) - - return PartialAssistantState( - intermediate_steps=[ - *intermediate_steps[:-1], - (action, str(prompt)), - ] - ) diff --git a/ee/hogai/schema_generator/parsers.py b/ee/hogai/schema_generator/parsers.py deleted file mode 100644 index 569a563968..0000000000 --- a/ee/hogai/schema_generator/parsers.py +++ /dev/null @@ -1,28 +0,0 @@ -import json -from collections.abc import Callable - -from pydantic import BaseModel, ValidationError - - -class PydanticOutputParserException(ValueError): - llm_output: str - """Serialized LLM output.""" - validation_message: str - """Pydantic validation error message.""" - - def __init__(self, llm_output: str, validation_message: str): - super().__init__(llm_output) - self.llm_output = llm_output - self.validation_message = validation_message - - -def parse_pydantic_structured_output(model: type[BaseModel]) -> Callable[[dict], BaseModel]: - def parser(output: dict) -> BaseModel: - try: - return model.model_validate(output) - except ValidationError as e: - raise PydanticOutputParserException( - llm_output=json.dumps(output), validation_message=e.json(include_url=False) - ) - - return parser diff --git a/ee/hogai/schema_generator/prompts.py b/ee/hogai/schema_generator/prompts.py deleted file mode 100644 index 20e4269d4f..0000000000 --- a/ee/hogai/schema_generator/prompts.py +++ /dev/null @@ -1,38 +0,0 @@ -GROUP_MAPPING_PROMPT = """ -Here is the group mapping: -{{group_mapping}} -""" - -PLAN_PROMPT = """ -Here is the plan: -{{plan}} -""" - -NEW_PLAN_PROMPT = """ -Here is the new plan: -{{plan}} -""" - -QUESTION_PROMPT = """ -Answer to this question: {{question}} -""" - -FAILOVER_OUTPUT_PROMPT = """ -Generation output: -``` -{{output}} -``` - -Exception message: -``` -{{exception_message}} -``` -""" - -FAILOVER_PROMPT = """ -The result of the previous generation raised the Pydantic validation exception. - -{{validation_error_message}} - -Fix the error and return the correct response. -""" diff --git a/ee/hogai/schema_generator/test/__init__.py b/ee/hogai/schema_generator/test/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/schema_generator/test/test_nodes.py b/ee/hogai/schema_generator/test/test_nodes.py deleted file mode 100644 index 3b2702b55b..0000000000 --- a/ee/hogai/schema_generator/test/test_nodes.py +++ /dev/null @@ -1,425 +0,0 @@ -import json -from unittest.mock import patch - -from django.test import override_settings -from langchain_core.agents import AgentAction -from langchain_core.prompts import ChatPromptTemplate -from langchain_core.runnables import RunnableConfig, RunnableLambda - -from ee.hogai.schema_generator.nodes import SchemaGeneratorNode, SchemaGeneratorToolsNode -from ee.hogai.schema_generator.utils import SchemaGeneratorOutput -from ee.hogai.utils.types import AssistantState, PartialAssistantState -from posthog.schema import ( - AssistantMessage, - AssistantTrendsQuery, - FailureMessage, - HumanMessage, - RouterMessage, - VisualizationMessage, -) -from posthog.test.base import BaseTest - -TestSchema = SchemaGeneratorOutput[AssistantTrendsQuery] - - -class DummyGeneratorNode(SchemaGeneratorNode[AssistantTrendsQuery]): - INSIGHT_NAME = "Test" - OUTPUT_MODEL = SchemaGeneratorOutput[AssistantTrendsQuery] - OUTPUT_SCHEMA = {} - - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - prompt = ChatPromptTemplate.from_messages( - [ - ("system", "system_prompt"), - ], - ) - return super()._run_with_prompt(state, prompt, config=config) - - -@override_settings(IN_UNIT_TESTING=True) -class TestSchemaGeneratorNode(BaseTest): - def setUp(self): - super().setUp() - self.schema = AssistantTrendsQuery(series=[]) - - def test_node_runs(self): - node = DummyGeneratorNode(self.team) - with patch.object(DummyGeneratorNode, "_model") as generator_model_mock: - generator_model_mock.return_value = RunnableLambda(lambda _: TestSchema(query=self.schema).model_dump()) - new_state = node.run( - AssistantState( - messages=[HumanMessage(content="Text", id="0")], - plan="Plan", - start_id="0", - ), - {}, - ) - self.assertEqual(new_state.intermediate_steps, []) - self.assertEqual(new_state.plan, "") - self.assertEqual(len(new_state.messages), 1) - self.assertEqual(new_state.messages[0].type, "ai/viz") - self.assertEqual(new_state.messages[0].answer, self.schema) - - def test_agent_reconstructs_conversation_and_does_not_add_an_empty_plan(self): - node = DummyGeneratorNode(self.team) - history = node._construct_messages( - AssistantState(messages=[HumanMessage(content="Text", id="0")], start_id="0") - ) - self.assertEqual(len(history), 2) - self.assertEqual(history[0].type, "human") - self.assertIn("mapping", history[0].content) - self.assertEqual(history[1].type, "human") - self.assertIn("Answer to this question:", history[1].content) - self.assertNotIn("{{question}}", history[1].content) - - def test_agent_reconstructs_conversation_adds_plan(self): - node = DummyGeneratorNode(self.team) - history = node._construct_messages( - AssistantState(messages=[HumanMessage(content="Text", id="0")], plan="randomplan", start_id="0") - ) - self.assertEqual(len(history), 3) - self.assertEqual(history[0].type, "human") - self.assertIn("mapping", history[0].content) - self.assertEqual(history[1].type, "human") - self.assertIn("the plan", history[1].content) - self.assertNotIn("{{plan}}", history[1].content) - self.assertIn("randomplan", history[1].content) - self.assertEqual(history[2].type, "human") - self.assertIn("Answer to this question:", history[2].content) - self.assertNotIn("{{question}}", history[2].content) - self.assertIn("Text", history[2].content) - - def test_agent_reconstructs_conversation_can_handle_follow_ups(self): - node = DummyGeneratorNode(self.team) - history = node._construct_messages( - AssistantState( - messages=[ - HumanMessage(content="Text", id="0"), - VisualizationMessage(answer=self.schema, plan="randomplan", id="1", initiator="0"), - HumanMessage(content="Follow Up", id="2"), - ], - plan="newrandomplan", - start_id="2", - ) - ) - - self.assertEqual(len(history), 6) - self.assertEqual(history[0].type, "human") - self.assertIn("mapping", history[0].content) - self.assertEqual(history[1].type, "human") - self.assertIn("the plan", history[1].content) - self.assertNotIn("{{plan}}", history[1].content) - self.assertIn("randomplan", history[1].content) - self.assertEqual(history[2].type, "human") - self.assertIn("Answer to this question:", history[2].content) - self.assertNotIn("{{question}}", history[2].content) - self.assertIn("Text", history[2].content) - self.assertEqual(history[3].type, "ai") - self.assertEqual(history[3].content, self.schema.model_dump_json()) - self.assertEqual(history[4].type, "human") - self.assertIn("the new plan", history[4].content) - self.assertNotIn("{{plan}}", history[4].content) - self.assertIn("newrandomplan", history[4].content) - self.assertEqual(history[5].type, "human") - self.assertIn("Answer to this question:", history[5].content) - self.assertNotIn("{{question}}", history[5].content) - self.assertIn("Follow Up", history[5].content) - - def test_agent_reconstructs_conversation_and_does_not_merge_messages(self): - node = DummyGeneratorNode(self.team) - history = node._construct_messages( - AssistantState( - messages=[HumanMessage(content="Te", id="0"), HumanMessage(content="xt", id="1")], - plan="randomplan", - start_id="1", - ) - ) - self.assertEqual(len(history), 4) - self.assertEqual(history[0].type, "human") - self.assertIn("mapping", history[0].content) - self.assertIn("Te", history[1].content) - self.assertEqual(history[2].type, "human") - self.assertNotIn("{{plan}}", history[2].content) - self.assertIn("randomplan", history[2].content) - self.assertEqual(history[3].type, "human") - self.assertIn("Answer to this question:", history[3].content) - self.assertNotIn("{{question}}", history[3].content) - self.assertEqual(history[3].type, "human") - self.assertIn("xt", history[3].content) - - def test_filters_out_human_in_the_loop_after_initiator(self): - node = DummyGeneratorNode(self.team) - history = node._construct_messages( - AssistantState( - messages=[ - HumanMessage(content="Text", id="0"), - VisualizationMessage(answer=self.schema, plan="randomplan", initiator="0", id="1"), - HumanMessage(content="Follow", id="2"), - HumanMessage(content="Up", id="3"), - ], - plan="newrandomplan", - start_id="0", - ) - ) - self.assertEqual(len(history), 3) - self.assertEqual(history[0].type, "human") - self.assertIn("mapping", history[0].content) - self.assertEqual(history[1].type, "human") - self.assertIn("the plan", history[1].content) - self.assertNotIn("{{plan}}", history[1].content) - self.assertIn("randomplan", history[1].content) - self.assertEqual(history[2].type, "human") - self.assertIn("Answer to this question:", history[2].content) - self.assertNotIn("{{question}}", history[2].content) - self.assertIn("Text", history[2].content) - - def test_preserves_human_in_the_loop_before_initiator(self): - node = DummyGeneratorNode(self.team) - history = node._construct_messages( - AssistantState( - messages=[ - HumanMessage(content="Question 1", id="0"), - AssistantMessage(content="Loop", id="1"), - HumanMessage(content="Answer", id="2"), - VisualizationMessage(answer=self.schema, plan="randomplan", initiator="0", id="3"), - HumanMessage(content="Question 2", id="4"), - ], - plan="newrandomplan", - start_id="4", - ) - ) - self.assertEqual(len(history), 8) - self.assertEqual(history[0].type, "human") - self.assertIn("mapping", history[0].content) - self.assertEqual(history[1].type, "human") - self.assertIn("the plan", history[1].content) - self.assertNotIn("{{plan}}", history[1].content) - self.assertIn("randomplan", history[1].content) - self.assertNotIn("{{question}}", history[2].content) - self.assertIn("Question 1", history[2].content) - self.assertEqual(history[3].type, "ai") - self.assertEqual("Loop", history[3].content) - self.assertEqual(history[4].type, "human") - self.assertEqual("Answer", history[4].content) - self.assertEqual(history[5].type, "ai") - self.assertEqual(history[6].type, "human") - self.assertIn("the new plan", history[6].content) - self.assertIn("newrandomplan", history[6].content) - self.assertEqual(history[7].type, "human") - self.assertNotIn("{{question}}", history[7].content) - self.assertIn("Question 2", history[7].content) - - def test_agent_reconstructs_typical_conversation(self): - node = DummyGeneratorNode(self.team) - history = node._construct_messages( - AssistantState( - messages=[ - HumanMessage(content="Question 1", id="0"), - RouterMessage(content="trends", id="1"), - VisualizationMessage(answer=AssistantTrendsQuery(series=[]), plan="Plan 1", initiator="0", id="2"), - AssistantMessage(content="Summary 1", id="3"), - HumanMessage(content="Question 2", id="4"), - RouterMessage(content="funnel", id="5"), - VisualizationMessage(answer=AssistantTrendsQuery(series=[]), plan="Plan 2", initiator="4", id="6"), - AssistantMessage(content="Summary 2", id="7"), - HumanMessage(content="Question 3", id="8"), - RouterMessage(content="funnel", id="9"), - ], - plan="Plan 3", - start_id="8", - ) - ) - - self.assertEqual(len(history), 10) - self.assertEqual(history[0].type, "human") - self.assertIn("mapping", history[0].content) - self.assertEqual(history[1].type, "human") - self.assertIn("Plan 1", history[1].content) - self.assertEqual(history[2].type, "human") - self.assertIn("Question 1", history[2].content) - self.assertEqual(history[3].type, "ai") - self.assertEqual(history[3].content, "Summary 1") - self.assertEqual(history[4].type, "human") - self.assertIn("Plan 2", history[4].content) - self.assertEqual(history[5].type, "human") - self.assertIn("Question 2", history[5].content) - self.assertEqual(history[6].type, "ai") - self.assertEqual(history[6].content, "Summary 2") - self.assertEqual(history[7].type, "ai") - self.assertEqual(history[8].type, "human") - self.assertIn("Plan 3", history[8].content) - self.assertEqual(history[9].type, "human") - self.assertIn("Question 3", history[9].content) - - def test_prompt_messages_merged(self): - node = DummyGeneratorNode(self.team) - state = AssistantState( - messages=[ - HumanMessage(content="Question 1", id="0"), - RouterMessage(content="trends", id="1"), - VisualizationMessage(answer=AssistantTrendsQuery(series=[]), plan="Plan 1", initiator="0", id="2"), - AssistantMessage(content="Summary 1", id="3"), - HumanMessage(content="Question 2", id="4"), - RouterMessage(content="funnel", id="5"), - VisualizationMessage(answer=AssistantTrendsQuery(series=[]), plan="Plan 2", initiator="4", id="6"), - AssistantMessage(content="Summary 2", id="7"), - HumanMessage(content="Question 3", id="8"), - RouterMessage(content="funnel", id="9"), - ], - plan="Plan 3", - start_id="8", - ) - with patch.object(DummyGeneratorNode, "_model") as generator_model_mock: - - def assert_prompt(prompt): - self.assertEqual(len(prompt), 6) - self.assertEqual(prompt[0].type, "system") - self.assertEqual(prompt[1].type, "human") - self.assertEqual(prompt[2].type, "ai") - self.assertEqual(prompt[3].type, "human") - self.assertEqual(prompt[4].type, "ai") - self.assertEqual(prompt[5].type, "human") - - generator_model_mock.return_value = RunnableLambda(assert_prompt) - node.run(state, {}) - - def test_failover_with_incorrect_schema(self): - node = DummyGeneratorNode(self.team) - with patch.object(DummyGeneratorNode, "_model") as generator_model_mock: - schema = TestSchema(query=None).model_dump() - # Emulate an incorrect JSON. It should be an object. - schema["query"] = [] - generator_model_mock.return_value = RunnableLambda(lambda _: json.dumps(schema)) - - new_state = node.run(AssistantState(messages=[HumanMessage(content="Text")]), {}) - self.assertEqual(len(new_state.intermediate_steps), 1) - - new_state = node.run( - AssistantState( - messages=[HumanMessage(content="Text")], - intermediate_steps=[(AgentAction(tool="", tool_input="", log="exception"), "exception")], - ), - {}, - ) - self.assertEqual(len(new_state.intermediate_steps), 2) - - def test_node_leaves_failover(self): - node = DummyGeneratorNode(self.team) - with patch.object( - DummyGeneratorNode, - "_model", - return_value=RunnableLambda(lambda _: TestSchema(query=self.schema).model_dump()), - ): - new_state = node.run( - AssistantState( - messages=[HumanMessage(content="Text")], - intermediate_steps=[(AgentAction(tool="", tool_input="", log="exception"), "exception")], - ), - {}, - ) - self.assertEqual(new_state.intermediate_steps, []) - - new_state = node.run( - AssistantState( - messages=[HumanMessage(content="Text")], - intermediate_steps=[ - (AgentAction(tool="", tool_input="", log="exception"), "exception"), - (AgentAction(tool="", tool_input="", log="exception"), "exception"), - ], - ), - {}, - ) - self.assertEqual(new_state.intermediate_steps, []) - - def test_node_leaves_failover_after_second_unsuccessful_attempt(self): - node = DummyGeneratorNode(self.team) - with patch.object(DummyGeneratorNode, "_model") as generator_model_mock: - schema = TestSchema(query=None).model_dump() - # Emulate an incorrect JSON. It should be an object. - schema["query"] = [] - generator_model_mock.return_value = RunnableLambda(lambda _: json.dumps(schema)) - - new_state = node.run( - AssistantState( - messages=[HumanMessage(content="Text")], - intermediate_steps=[ - (AgentAction(tool="", tool_input="", log="exception"), "exception"), - (AgentAction(tool="", tool_input="", log="exception"), "exception"), - ], - ), - {}, - ) - self.assertEqual(new_state.intermediate_steps, []) - self.assertEqual(len(new_state.messages), 1) - self.assertIsInstance(new_state.messages[0], FailureMessage) - self.assertEqual(new_state.plan, "") - - def test_agent_reconstructs_conversation_with_failover(self): - action = AgentAction(tool="fix", tool_input="validation error", log="exception") - node = DummyGeneratorNode(self.team) - history = node._construct_messages( - AssistantState( - messages=[HumanMessage(content="Text", id="0")], - plan="randomplan", - intermediate_steps=[(action, "uniqexception")], - start_id="0", - ), - validation_error_message="uniqexception", - ) - self.assertEqual(len(history), 4) - self.assertEqual(history[0].type, "human") - self.assertIn("mapping", history[0].content) - self.assertEqual(history[1].type, "human") - self.assertIn("the plan", history[1].content) - self.assertNotIn("{{plan}}", history[1].content) - self.assertIn("randomplan", history[1].content) - self.assertEqual(history[2].type, "human") - self.assertIn("Answer to this question:", history[2].content) - self.assertNotIn("{{question}}", history[2].content) - self.assertIn("Text", history[2].content) - self.assertEqual(history[3].type, "human") - self.assertIn("Pydantic", history[3].content) - self.assertIn("uniqexception", history[3].content) - - def test_agent_reconstructs_conversation_with_failed_messages(self): - node = DummyGeneratorNode(self.team) - history = node._construct_messages( - AssistantState( - messages=[ - HumanMessage(content="Text"), - FailureMessage(content="Error"), - HumanMessage(content="Text"), - ], - plan="randomplan", - ), - ) - self.assertEqual(len(history), 3) - self.assertEqual(history[0].type, "human") - self.assertIn("mapping", history[0].content) - self.assertEqual(history[1].type, "human") - self.assertIn("the plan", history[1].content) - self.assertNotIn("{{plan}}", history[1].content) - self.assertIn("randomplan", history[1].content) - self.assertEqual(history[2].type, "human") - self.assertIn("Answer to this question:", history[2].content) - self.assertNotIn("{{question}}", history[2].content) - self.assertIn("Text", history[2].content) - - def test_router(self): - node = DummyGeneratorNode(self.team) - state = node.router(AssistantState(messages=[], intermediate_steps=None)) - self.assertEqual(state, "next") - state = node.router( - AssistantState(messages=[], intermediate_steps=[(AgentAction(tool="", tool_input="", log=""), None)]) - ) - self.assertEqual(state, "tools") - - -class TestSchemaGeneratorToolsNode(BaseTest): - def test_tools_node(self): - node = SchemaGeneratorToolsNode(self.team) - action = AgentAction(tool="fix", tool_input="validationerror", log="pydanticexception") - state = node.run(AssistantState(messages=[], intermediate_steps=[(action, None)]), {}) - self.assertIsNotNone("validationerror", state.intermediate_steps[0][1]) - self.assertIn("validationerror", state.intermediate_steps[0][1]) - self.assertIn("pydanticexception", state.intermediate_steps[0][1]) diff --git a/ee/hogai/schema_generator/utils.py b/ee/hogai/schema_generator/utils.py deleted file mode 100644 index 8d0f8db4de..0000000000 --- a/ee/hogai/schema_generator/utils.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import Generic, Optional, TypeVar - -from pydantic import BaseModel - -T = TypeVar("T", bound=BaseModel) - - -class SchemaGeneratorOutput(BaseModel, Generic[T]): - query: Optional[T] = None diff --git a/ee/hogai/summarizer/__init__.py b/ee/hogai/summarizer/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/summarizer/nodes.py b/ee/hogai/summarizer/nodes.py deleted file mode 100644 index 394f14a02b..0000000000 --- a/ee/hogai/summarizer/nodes.py +++ /dev/null @@ -1,114 +0,0 @@ -import datetime -import json -from time import sleep -from uuid import uuid4 - -from django.conf import settings -from django.core.serializers.json import DjangoJSONEncoder -from django.utils import timezone -from langchain_core.prompts import ChatPromptTemplate -from langchain_core.runnables import RunnableConfig -from langchain_openai import ChatOpenAI -from rest_framework.exceptions import APIException -from sentry_sdk import capture_exception - -from ee.hogai.summarizer.prompts import SUMMARIZER_INSTRUCTION_PROMPT, SUMMARIZER_SYSTEM_PROMPT -from ee.hogai.utils.nodes import AssistantNode -from ee.hogai.utils.types import AssistantNodeName, AssistantState, PartialAssistantState -from posthog.api.services.query import process_query_dict -from posthog.clickhouse.client.execute_async import get_query_status -from posthog.errors import ExposedCHQueryError -from posthog.hogql.errors import ExposedHogQLError -from posthog.hogql_queries.query_runner import ExecutionMode -from posthog.schema import AssistantMessage, FailureMessage, HumanMessage, VisualizationMessage - - -class SummarizerNode(AssistantNode): - name = AssistantNodeName.SUMMARIZER - - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - viz_message = state.messages[-1] - if not isinstance(viz_message, VisualizationMessage): - raise ValueError("Can only run summarization with a visualization message as the last one in the state") - if viz_message.answer is None: - raise ValueError("Did not found query in the visualization message") - - try: - results_response = process_query_dict( # type: ignore - self._team, # TODO: Add user - viz_message.answer.model_dump(mode="json"), # We need mode="json" so that - # Celery doesn't run in tests, so there we use force_blocking instead - # This does mean that the waiting logic is not tested - execution_mode=ExecutionMode.RECENT_CACHE_CALCULATE_ASYNC_IF_STALE - if not settings.TEST - else ExecutionMode.CALCULATE_BLOCKING_ALWAYS, - ).model_dump(mode="json") - if results_response.get("query_status") and not results_response["query_status"]["complete"]: - query_id = results_response["query_status"]["id"] - for i in range(0, 999): - sleep(i / 2) # We start at 0.5s and every iteration we wait 0.5s more - query_status = get_query_status(team_id=self._team.pk, query_id=query_id) - if query_status.error: - if query_status.error_message: - raise APIException(query_status.error_message) - else: - raise ValueError("Query failed") - if query_status.complete: - results_response = query_status.results - break - except (APIException, ExposedHogQLError, ExposedCHQueryError) as err: - err_message = str(err) - if isinstance(err, APIException): - if isinstance(err.detail, dict): - err_message = ", ".join(f"{key}: {value}" for key, value in err.detail.items()) - elif isinstance(err.detail, list): - err_message = ", ".join(map(str, err.detail)) - return PartialAssistantState( - messages=[ - FailureMessage(content=f"There was an error running this query: {err_message}", id=str(uuid4())) - ] - ) - except Exception as err: - capture_exception(err) - return PartialAssistantState( - messages=[FailureMessage(content="There was an unknown error running this query.", id=str(uuid4()))] - ) - - summarization_prompt = ChatPromptTemplate(self._construct_messages(state), template_format="mustache") - - chain = summarization_prompt | self._model - - utc_now = timezone.now().astimezone(datetime.UTC) - project_now = utc_now.astimezone(self._team.timezone_info) - - message = chain.invoke( - { - "query_kind": viz_message.answer.kind, - "core_memory": self.core_memory_text, - "results": json.dumps(results_response["results"], cls=DjangoJSONEncoder), - "utc_datetime_display": utc_now.strftime("%Y-%m-%d %H:%M:%S"), - "project_datetime_display": project_now.strftime("%Y-%m-%d %H:%M:%S"), - "project_timezone": self._team.timezone_info.tzname(utc_now), - }, - config, - ) - - return PartialAssistantState(messages=[AssistantMessage(content=str(message.content), id=str(uuid4()))]) - - @property - def _model(self): - return ChatOpenAI( - model="gpt-4o", temperature=0.5, streaming=True, stream_usage=True - ) # Slightly higher temp than earlier steps - - def _construct_messages(self, state: AssistantState) -> list[tuple[str, str]]: - conversation: list[tuple[str, str]] = [("system", SUMMARIZER_SYSTEM_PROMPT)] - - for message in state.messages: - if isinstance(message, HumanMessage): - conversation.append(("human", message.content)) - elif isinstance(message, AssistantMessage): - conversation.append(("assistant", message.content)) - - conversation.append(("human", SUMMARIZER_INSTRUCTION_PROMPT)) - return conversation diff --git a/ee/hogai/summarizer/prompts.py b/ee/hogai/summarizer/prompts.py deleted file mode 100644 index 6d5d98eef5..0000000000 --- a/ee/hogai/summarizer/prompts.py +++ /dev/null @@ -1,31 +0,0 @@ -SUMMARIZER_SYSTEM_PROMPT = """ -Act as an expert product manager. Your task is to help the user build a successful product and business. -Also, you're a hedeghog named Max. - -Offer actionable feedback if possible. Only provide suggestions you're certain will be useful for this team. -Acknowledge when more information would be needed. When query results are provided, note that the user can already see the chart. - -Use Silicon Valley lingo. Be informal but get to the point immediately, without fluff - e.g. don't start with "alright, …". -NEVER use "Title Case", even in headings. Our style is "Sentence case" EVERYWHERE. -You can use Markdown for emphasis. Bullets can improve clarity of action points. - - -{{core_memory}} - -""" - -SUMMARIZER_INSTRUCTION_PROMPT = """ -Here are results of the {{query_kind}} you created to answer my latest question: - -```json -{{results}} -``` - -The current date and time is {{utc_datetime_display}} UTC, which is {{project_datetime_display}} in this project's timezone ({{project_timezone}}). -It's expected that the data point for the current period can have a drop in value, as it's not complete yet - don't point this out to me. - -Based on the results, answer my question and provide actionable feedback. Avoid generic advice. Take into account what you know about the product. -The answer needs to be high-impact, no more than a few sentences. - -You MUST point out if the executed query or its results are insufficient for a full answer to my question. -""" diff --git a/ee/hogai/summarizer/test/__init__.py b/ee/hogai/summarizer/test/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/summarizer/test/test_nodes.py b/ee/hogai/summarizer/test/test_nodes.py deleted file mode 100644 index 0dc6703427..0000000000 --- a/ee/hogai/summarizer/test/test_nodes.py +++ /dev/null @@ -1,181 +0,0 @@ -from unittest.mock import patch - -from django.test import override_settings -from langchain_core.messages import ( - HumanMessage as LangchainHumanMessage, -) -from langchain_core.runnables import RunnableLambda -from rest_framework.exceptions import ValidationError - -from ee.hogai.summarizer.nodes import SummarizerNode -from ee.hogai.summarizer.prompts import SUMMARIZER_INSTRUCTION_PROMPT, SUMMARIZER_SYSTEM_PROMPT -from ee.hogai.utils.types import AssistantState -from posthog.api.services.query import process_query_dict -from posthog.schema import ( - AssistantTrendsEventsNode, - AssistantTrendsQuery, - HumanMessage, - VisualizationMessage, -) -from posthog.test.base import APIBaseTest, ClickhouseTestMixin - - -@override_settings(IN_UNIT_TESTING=True) -class TestSummarizerNode(ClickhouseTestMixin, APIBaseTest): - maxDiff = None - - @patch("ee.hogai.summarizer.nodes.process_query_dict", side_effect=process_query_dict) - def test_node_runs(self, mock_process_query_dict): - node = SummarizerNode(self.team) - with patch.object(SummarizerNode, "_model") as generator_model_mock: - generator_model_mock.return_value = RunnableLambda( - lambda _: LangchainHumanMessage(content="The results indicate foobar.") - ) - new_state = node.run( - AssistantState( - messages=[ - HumanMessage(content="Text", id="test"), - VisualizationMessage( - answer=AssistantTrendsQuery(series=[AssistantTrendsEventsNode()]), - plan="Plan", - id="test2", - initiator="test", - ), - ], - plan="Plan", - start_id="test", - ), - {}, - ) - mock_process_query_dict.assert_called_once() # Query processing started - msg = new_state.messages[0] - self.assertEqual(msg.content, "The results indicate foobar.") - self.assertEqual(msg.type, "ai") - self.assertIsNotNone(msg.id) - - @patch( - "ee.hogai.summarizer.nodes.process_query_dict", - side_effect=ValueError("You have not glibbled the glorp before running this."), - ) - def test_node_handles_internal_error(self, mock_process_query_dict): - node = SummarizerNode(self.team) - with patch.object(SummarizerNode, "_model") as generator_model_mock: - generator_model_mock.return_value = RunnableLambda( - lambda _: LangchainHumanMessage(content="The results indicate foobar.") - ) - new_state = node.run( - AssistantState( - messages=[ - HumanMessage(content="Text", id="test"), - VisualizationMessage( - answer=AssistantTrendsQuery(series=[AssistantTrendsEventsNode()]), - plan="Plan", - id="test2", - initiator="test", - ), - ], - plan="Plan", - start_id="test", - ), - {}, - ) - mock_process_query_dict.assert_called_once() # Query processing started - msg = new_state.messages[0] - self.assertEqual(msg.content, "There was an unknown error running this query.") - self.assertEqual(msg.type, "ai/failure") - self.assertIsNotNone(msg.id) - - @patch( - "ee.hogai.summarizer.nodes.process_query_dict", - side_effect=ValidationError( - "This query exceeds the capabilities of our picolator. Try de-brolling its flim-flam." - ), - ) - def test_node_handles_exposed_error(self, mock_process_query_dict): - node = SummarizerNode(self.team) - with patch.object(SummarizerNode, "_model") as generator_model_mock: - generator_model_mock.return_value = RunnableLambda( - lambda _: LangchainHumanMessage(content="The results indicate foobar.") - ) - new_state = node.run( - AssistantState( - messages=[ - HumanMessage(content="Text", id="test"), - VisualizationMessage( - answer=AssistantTrendsQuery(series=[AssistantTrendsEventsNode()]), - plan="Plan", - id="test2", - initiator="test", - ), - ], - plan="Plan", - start_id="test", - ), - {}, - ) - mock_process_query_dict.assert_called_once() # Query processing started - msg = new_state.messages[0] - self.assertEqual( - msg.content, - "There was an error running this query: This query exceeds the capabilities of our picolator. Try de-brolling its flim-flam.", - ) - self.assertEqual(msg.type, "ai/failure") - self.assertIsNotNone(msg.id) - - def test_node_requires_a_viz_message_in_state(self): - node = SummarizerNode(self.team) - - with self.assertRaisesMessage( - ValueError, "Can only run summarization with a visualization message as the last one in the state" - ): - node.run( - AssistantState( - messages=[ - HumanMessage(content="Text"), - ], - plan="Plan", - start_id="test", - ), - {}, - ) - - def test_node_requires_viz_message_in_state_to_have_query(self): - node = SummarizerNode(self.team) - - with self.assertRaisesMessage(ValueError, "Did not found query in the visualization message"): - node.run( - AssistantState( - messages=[ - VisualizationMessage(answer=None, plan="Plan", id="test"), - ], - plan="Plan", - start_id="test", - ), - {}, - ) - - def test_agent_reconstructs_conversation(self): - node = SummarizerNode(self.team) - - history = node._construct_messages( - AssistantState( - messages=[ - HumanMessage(content="What's the trends in signups?", id="test"), - VisualizationMessage( - answer=AssistantTrendsQuery(series=[AssistantTrendsEventsNode()]), - plan="Plan", - id="test2", - initiator="test", - ), - ], - start_id="test", - ) - ) - self.assertEqual( - history, - [ - ("system", SUMMARIZER_SYSTEM_PROMPT), - ("human", "What's the trends in signups?"), - ("human", SUMMARIZER_INSTRUCTION_PROMPT), - ], - ) diff --git a/ee/hogai/taxonomy_agent/__init__.py b/ee/hogai/taxonomy_agent/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/taxonomy_agent/nodes.py b/ee/hogai/taxonomy_agent/nodes.py deleted file mode 100644 index 74724b1d5d..0000000000 --- a/ee/hogai/taxonomy_agent/nodes.py +++ /dev/null @@ -1,308 +0,0 @@ -import xml.etree.ElementTree as ET -from abc import ABC -from functools import cached_property -from typing import cast - -from git import Optional -from langchain.agents.format_scratchpad import format_log_to_str -from langchain_core.agents import AgentAction -from langchain_core.messages import ( - AIMessage as LangchainAssistantMessage, - BaseMessage, - HumanMessage as LangchainHumanMessage, - merge_message_runs, -) -from langchain_core.prompts import ChatPromptTemplate, HumanMessagePromptTemplate -from langchain_core.runnables import RunnableConfig -from langchain_openai import ChatOpenAI -from langgraph.errors import NodeInterrupt -from pydantic import ValidationError - -from posthog.taxonomy.taxonomy import CORE_FILTER_DEFINITIONS_BY_GROUP -from ee.hogai.taxonomy_agent.parsers import ( - ReActParserException, - ReActParserMissingActionException, - parse_react_agent_output, -) -from ee.hogai.taxonomy_agent.prompts import ( - CORE_MEMORY_INSTRUCTIONS, - REACT_DEFINITIONS_PROMPT, - REACT_FOLLOW_UP_PROMPT, - REACT_FORMAT_PROMPT, - REACT_FORMAT_REMINDER_PROMPT, - REACT_HUMAN_IN_THE_LOOP_PROMPT, - REACT_MALFORMED_JSON_PROMPT, - REACT_MISSING_ACTION_CORRECTION_PROMPT, - REACT_MISSING_ACTION_PROMPT, - REACT_PROPERTY_FILTERS_PROMPT, - REACT_PYDANTIC_VALIDATION_EXCEPTION_PROMPT, - REACT_SCRATCHPAD_PROMPT, - REACT_USER_PROMPT, -) -from ee.hogai.taxonomy_agent.toolkit import TaxonomyAgentTool, TaxonomyAgentToolkit -from ee.hogai.utils.helpers import filter_messages, remove_line_breaks, slice_messages_to_conversation_start -from ee.hogai.utils.nodes import AssistantNode -from ee.hogai.utils.types import AssistantState, PartialAssistantState -from posthog.hogql_queries.ai.team_taxonomy_query_runner import TeamTaxonomyQueryRunner -from posthog.hogql_queries.query_runner import ExecutionMode -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.schema import ( - AssistantMessage, - CachedTeamTaxonomyQueryResponse, - HumanMessage, - TeamTaxonomyQuery, - VisualizationMessage, -) - - -class TaxonomyAgentPlannerNode(AssistantNode): - def _run_with_prompt_and_toolkit( - self, - state: AssistantState, - prompt: ChatPromptTemplate, - toolkit: TaxonomyAgentToolkit, - config: Optional[RunnableConfig] = None, - ) -> PartialAssistantState: - intermediate_steps = state.intermediate_steps or [] - conversation = ( - prompt - + ChatPromptTemplate.from_messages( - [ - ("user", REACT_DEFINITIONS_PROMPT), - ], - template_format="mustache", - ) - + self._construct_messages(state) - + ChatPromptTemplate.from_messages( - [ - ("user", REACT_SCRATCHPAD_PROMPT), - ], - template_format="mustache", - ) - ) - - agent = conversation | merge_message_runs() | self._model | parse_react_agent_output - - try: - result = cast( - AgentAction, - agent.invoke( - { - "react_format": self._get_react_format_prompt(toolkit), - "core_memory": self.core_memory.text if self.core_memory else "", - "react_format_reminder": REACT_FORMAT_REMINDER_PROMPT, - "react_property_filters": self._get_react_property_filters_prompt(), - "react_human_in_the_loop": REACT_HUMAN_IN_THE_LOOP_PROMPT, - "groups": self._team_group_types, - "events": self._events_prompt, - "agent_scratchpad": self._get_agent_scratchpad(intermediate_steps), - "core_memory_instructions": CORE_MEMORY_INSTRUCTIONS, - }, - config, - ), - ) - except ReActParserException as e: - if isinstance(e, ReActParserMissingActionException): - # When the agent doesn't output the "Action:" block, we need to correct the log and append the action block, - # so that it has a higher chance to recover. - corrected_log = str( - ChatPromptTemplate.from_template(REACT_MISSING_ACTION_CORRECTION_PROMPT, template_format="mustache") - .format_messages(output=e.llm_output)[0] - .content - ) - result = AgentAction( - "handle_incorrect_response", - REACT_MISSING_ACTION_PROMPT, - corrected_log, - ) - else: - result = AgentAction( - "handle_incorrect_response", - REACT_MALFORMED_JSON_PROMPT, - e.llm_output, - ) - - return PartialAssistantState( - intermediate_steps=[*intermediate_steps, (result, None)], - ) - - def router(self, state: AssistantState): - if state.intermediate_steps: - return "tools" - raise ValueError("Invalid state.") - - @property - def _model(self) -> ChatOpenAI: - return ChatOpenAI(model="gpt-4o", temperature=0, streaming=True, stream_usage=True) - - def _get_react_format_prompt(self, toolkit: TaxonomyAgentToolkit) -> str: - return cast( - str, - ChatPromptTemplate.from_template(REACT_FORMAT_PROMPT, template_format="mustache") - .format_messages( - tools=toolkit.render_text_description(), - tool_names=", ".join([t["name"] for t in toolkit.tools]), - )[0] - .content, - ) - - def _get_react_property_filters_prompt(self) -> str: - return cast( - str, - ChatPromptTemplate.from_template(REACT_PROPERTY_FILTERS_PROMPT, template_format="mustache") - .format_messages(groups=self._team_group_types)[0] - .content, - ) - - @cached_property - def _events_prompt(self) -> str: - response = TeamTaxonomyQueryRunner(TeamTaxonomyQuery(), self._team).run( - ExecutionMode.RECENT_CACHE_CALCULATE_ASYNC_IF_STALE_AND_BLOCKING_ON_MISS - ) - - if not isinstance(response, CachedTeamTaxonomyQueryResponse): - raise ValueError("Failed to generate events prompt.") - - events: list[str] = [ - # Add "All Events" to the mapping - "All Events", - ] - for item in response.results: - if len(response.results) > 25 and item.count <= 3: - continue - events.append(item.event) - - root = ET.Element("defined_events") - for event_name in events: - event_tag = ET.SubElement(root, "event") - name_tag = ET.SubElement(event_tag, "name") - name_tag.text = event_name - - if event_core_definition := CORE_FILTER_DEFINITIONS_BY_GROUP["events"].get(event_name): - if event_core_definition.get("system") or event_core_definition.get("ignored_in_assistant"): - continue # Skip irrelevant events - if description := event_core_definition.get("description"): - desc_tag = ET.SubElement(event_tag, "description") - if label := event_core_definition.get("label"): - desc_tag.text = f"{label}. {description}" - else: - desc_tag.text = description - desc_tag.text = remove_line_breaks(desc_tag.text) - return ET.tostring(root, encoding="unicode") - - @cached_property - def _team_group_types(self) -> list[str]: - return list( - GroupTypeMapping.objects.filter(project_id=self._team.project_id) - .order_by("group_type_index") - .values_list("group_type", flat=True) - ) - - def _construct_messages(self, state: AssistantState) -> list[BaseMessage]: - """ - Reconstruct the conversation for the agent. On this step we only care about previously asked questions and generated plans. All other messages are filtered out. - """ - start_id = state.start_id - filtered_messages = filter_messages(slice_messages_to_conversation_start(state.messages, start_id)) - conversation = [] - - for idx, message in enumerate(filtered_messages): - if isinstance(message, HumanMessage): - # Add initial instructions. - if idx == 0: - conversation.append( - HumanMessagePromptTemplate.from_template(REACT_USER_PROMPT, template_format="mustache").format( - question=message.content - ) - ) - # Add follow-up instructions only for the human message that initiated a generation. - elif message.id == start_id: - conversation.append( - HumanMessagePromptTemplate.from_template( - REACT_FOLLOW_UP_PROMPT, - template_format="mustache", - ).format(feedback=message.content) - ) - # Everything else leave as is. - else: - conversation.append(LangchainHumanMessage(content=message.content)) - elif isinstance(message, VisualizationMessage): - conversation.append(LangchainAssistantMessage(content=message.plan or "")) - elif isinstance(message, AssistantMessage) and ( - # Filter out summarizer messages (which always follow viz), but leave clarification questions in - idx < 1 or not isinstance(filtered_messages[idx - 1], VisualizationMessage) - ): - conversation.append(LangchainAssistantMessage(content=message.content)) - - return conversation - - def _get_agent_scratchpad(self, scratchpad: list[tuple[AgentAction, str | None]]) -> str: - actions = [] - for action, observation in scratchpad: - if observation is None: - continue - actions.append((action, observation)) - return format_log_to_str(actions) - - -class TaxonomyAgentPlannerToolsNode(AssistantNode, ABC): - def _run_with_toolkit( - self, state: AssistantState, toolkit: TaxonomyAgentToolkit, config: Optional[RunnableConfig] = None - ) -> PartialAssistantState: - intermediate_steps = state.intermediate_steps or [] - action, observation = intermediate_steps[-1] - - try: - input = TaxonomyAgentTool.model_validate({"name": action.tool, "arguments": action.tool_input}).root - except ValidationError as e: - observation = str( - ChatPromptTemplate.from_template(REACT_PYDANTIC_VALIDATION_EXCEPTION_PROMPT, template_format="mustache") - .format_messages(exception=e.errors(include_url=False))[0] - .content - ) - return PartialAssistantState( - intermediate_steps=[*intermediate_steps[:-1], (action, str(observation))], - ) - - # The plan has been found. Move to the generation. - if input.name == "final_answer": - return PartialAssistantState( - plan=input.arguments, - intermediate_steps=[], - ) - if input.name == "ask_user_for_help": - # The agent has requested help, so we interrupt the graph. - if not state.resumed: - raise NodeInterrupt(input.arguments) - - # Feedback was provided. - last_message = state.messages[-1] - response = "" - if isinstance(last_message, HumanMessage): - response = last_message.content - - return PartialAssistantState( - resumed=False, - intermediate_steps=[*intermediate_steps[:-1], (action, response)], - ) - - output = "" - if input.name == "retrieve_event_properties": - output = toolkit.retrieve_event_properties(input.arguments) - elif input.name == "retrieve_event_property_values": - output = toolkit.retrieve_event_property_values(input.arguments.event_name, input.arguments.property_name) - elif input.name == "retrieve_entity_properties": - output = toolkit.retrieve_entity_properties(input.arguments) - elif input.name == "retrieve_entity_property_values": - output = toolkit.retrieve_entity_property_values(input.arguments.entity, input.arguments.property_name) - else: - output = toolkit.handle_incorrect_response(input.arguments) - - return PartialAssistantState( - intermediate_steps=[*intermediate_steps[:-1], (action, output)], - ) - - def router(self, state: AssistantState): - if state.plan: - return "plan_found" - return "continue" diff --git a/ee/hogai/taxonomy_agent/parsers.py b/ee/hogai/taxonomy_agent/parsers.py deleted file mode 100644 index 9233b57479..0000000000 --- a/ee/hogai/taxonomy_agent/parsers.py +++ /dev/null @@ -1,70 +0,0 @@ -import json -import re - -from langchain_core.agents import AgentAction -from langchain_core.messages import AIMessage as LangchainAIMessage - - -class ReActParserException(ValueError): - llm_output: str - - def __init__(self, llm_output: str): - super().__init__(llm_output) - self.llm_output = llm_output - - -class ReActParserMalformedJsonException(ReActParserException): - pass - - -class ReActParserMissingActionException(ReActParserException): - """ - The ReAct agent didn't output the "Action:" block. - """ - - pass - - -ACTION_LOG_PREFIX = "Action:" - - -def parse_react_agent_output(message: LangchainAIMessage) -> AgentAction: - """ - A ReAct agent must output in this format: - - Some thoughts... - Action: - ```json - {"action": "action_name", "action_input": "action_input"} - ``` - """ - text = str(message.content) - if ACTION_LOG_PREFIX not in text: - raise ReActParserMissingActionException(text) - found = re.compile(r"^.*?`{3}(?:json)?\n?(.*?)`{3}.*?$", re.DOTALL).search(text) - if not found: - # JSON not found. - raise ReActParserMalformedJsonException(text) - try: - action = found.group(1).strip() - response = json.loads(action) - is_complete = "action" in response and "action_input" in response - except Exception: - # JSON is malformed or has a wrong type. - raise ReActParserMalformedJsonException(text) - if not is_complete: - # JSON does not contain an action. - raise ReActParserMalformedJsonException(text) - return AgentAction(response["action"], response.get("action_input", {}), text) - - -class PydanticOutputParserException(ValueError): - llm_output: str - """Serialized LLM output.""" - validation_message: str - """Pydantic validation error message.""" - - def __init__(self, llm_output: str, validation_message: str): - super().__init__(llm_output) - self.llm_output = llm_output - self.validation_message = validation_message diff --git a/ee/hogai/taxonomy_agent/prompts.py b/ee/hogai/taxonomy_agent/prompts.py deleted file mode 100644 index 4779da4e7f..0000000000 --- a/ee/hogai/taxonomy_agent/prompts.py +++ /dev/null @@ -1,140 +0,0 @@ -REACT_FORMAT_PROMPT = """ -You have access to the following tools: -{{tools}} - -Use a JSON blob to specify a tool by providing an action key (tool name) and an action_input key (tool input). - -Valid "action" values: {{tool_names}} - -Provide only ONE action per $JSON_BLOB, as shown: - -``` -{ - "action": $TOOL_NAME, - "action_input": $INPUT -} -``` - -Follow this format: - -Question: input question to answer -Thought: consider previous and subsequent steps -Action: -``` -$JSON_BLOB -``` -Observation: action result -... (repeat Thought/Action/Observation N times) -Thought: I know what to respond -Action: -``` -{ - "action": "final_answer", - "action_input": "Final response to human" -} -``` -""".strip() - -REACT_PROPERTY_FILTERS_PROMPT = """ - -Use property filters to provide a narrowed results. Only include property filters when they are essential to directly answer the user’s question. Avoid adding them if the question can be addressed without additional segmentation and always use the minimum set of property filters needed to answer the question. Properties have one of the four types: String, Numeric, Boolean, and DateTime. - -IMPORTANT: Do not check if a property is set unless the user explicitly asks for it. - -When using a property filter, you must: -- **Prioritize properties directly related to the context or objective of the user's query.** Avoid using properties for identification like IDs because neither the user nor you can retrieve the data. Instead, prioritize filtering based on general properties like `paidCustomer` or `icp_score`. -- **Ensure that you find both the property group and name.** Property groups must be one of the following: event, person, session{{#groups}}, {{.}}{{/groups}}. -- After selecting a property, **validate that the property value accurately reflects the intended criteria**. -- **Find the suitable operator for type** (e.g., `contains`, `is set`). The operators are listed below. -- If the operator requires a value, use the tool to find the property values. Verify that you can answer the question with given property values. If you can't, try to find a different property or event. -- You set logical operators to combine multiple properties of a single series: AND or OR. - -Infer the property groups from the user's request. If your first guess doesn't yield any results, try to adjust the property group. You must make sure that the property name matches the lookup value, e.g. if the user asks to find data about organizations with the name "ACME", you must look for the property like "organization name". - -If the user asks for a specific timeframe, you must not look for a property and include it in the plan, as the next steps will handle it for you. - -Supported operators for the String or Numeric types are: -- equals -- doesn't equal -- contains -- doesn't contain -- matches regex -- doesn't match regex -- is set -- is not set - -Supported operators for the DateTime type are: -- equals -- doesn't equal -- greater than -- less than -- is set -- is not set - -Supported operators for the Boolean type are: -- equals -- doesn't equal -- is set -- is not set - -All operators take a single value except for `equals` and `doesn't equal which can take one or more values. - -""".strip() - -REACT_HUMAN_IN_THE_LOOP_PROMPT = """ - -Ask the user for clarification if: -- The user's question is ambiguous. -- You can't find matching events or properties. -- You're unable to build a plan that effectively answers the user's question. - -""".strip() - -REACT_FORMAT_REMINDER_PROMPT = """ -Begin! Reminder that you must ALWAYS respond with a valid JSON blob of a single action. Use tools if necessary. Respond directly if appropriate. Format is Action:```$JSON_BLOB``` then Observation. -""".strip() - -REACT_DEFINITIONS_PROMPT = """ -Here are the event names. -{{events}} -""" - -REACT_SCRATCHPAD_PROMPT = """ -Thought: {{agent_scratchpad}} -""" - -REACT_USER_PROMPT = """ -Answer the following question as best you can. -Question: What events, properties and/or property values should I use to answer this question "{{question}}"? -""" - -REACT_FOLLOW_UP_PROMPT = """ -Improve the previously generated plan based on the feedback: {{feedback}} -""" - -REACT_MISSING_ACTION_PROMPT = """ -Your previous answer didn't output the `Action:` block. You must always follow the format described in the system prompt. -""" - -REACT_MISSING_ACTION_CORRECTION_PROMPT = """ -{{output}} -Action: I didn't output the `Action:` block. -""" - -REACT_MALFORMED_JSON_PROMPT = """ -Your previous answer had a malformed JSON. You must return a correct JSON response containing the `action` and `action_input` fields. -""" - -REACT_PYDANTIC_VALIDATION_EXCEPTION_PROMPT = """ -The action input you previously provided didn't pass the validation and raised a Pydantic validation exception. - - -{{exception}} - - -You must fix the exception and try again. -""" - -CORE_MEMORY_INSTRUCTIONS = """ -You have access to the core memory in the tag, which stores information about the user's company and product. -""".strip() diff --git a/ee/hogai/taxonomy_agent/test/__init__.py b/ee/hogai/taxonomy_agent/test/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/taxonomy_agent/test/test_nodes.py b/ee/hogai/taxonomy_agent/test/test_nodes.py deleted file mode 100644 index dfb5561881..0000000000 --- a/ee/hogai/taxonomy_agent/test/test_nodes.py +++ /dev/null @@ -1,301 +0,0 @@ -from unittest.mock import patch - -from django.test import override_settings -from langchain_core.agents import AgentAction -from langchain_core.messages import AIMessage as LangchainAIMessage -from langchain_core.runnables import RunnableConfig, RunnableLambda - -from ee.hogai.taxonomy_agent.nodes import ( - ChatPromptTemplate, - TaxonomyAgentPlannerNode, - TaxonomyAgentPlannerToolsNode, -) -from ee.hogai.taxonomy_agent.toolkit import TaxonomyAgentToolkit, ToolkitTool -from ee.hogai.utils.types import AssistantState, PartialAssistantState -from posthog.models import GroupTypeMapping -from posthog.schema import ( - AssistantMessage, - AssistantTrendsQuery, - FailureMessage, - HumanMessage, - RouterMessage, - VisualizationMessage, -) -from posthog.test.base import APIBaseTest, ClickhouseTestMixin, _create_event, _create_person - - -class DummyToolkit(TaxonomyAgentToolkit): - def _get_tools(self) -> list[ToolkitTool]: - return self._default_tools - - -@override_settings(IN_UNIT_TESTING=True) -class TestTaxonomyAgentPlannerNode(ClickhouseTestMixin, APIBaseTest): - def setUp(self): - super().setUp() - self.schema = AssistantTrendsQuery(series=[]) - - def _get_node(self): - class Node(TaxonomyAgentPlannerNode): - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - prompt: ChatPromptTemplate = ChatPromptTemplate.from_messages([("user", "test")]) - toolkit = DummyToolkit(self._team) - return super()._run_with_prompt_and_toolkit(state, prompt, toolkit, config=config) - - return Node(self.team) - - def test_agent_reconstructs_conversation(self): - node = self._get_node() - history = node._construct_messages(AssistantState(messages=[HumanMessage(content="Text")])) - self.assertEqual(len(history), 1) - self.assertEqual(history[0].type, "human") - self.assertIn("Text", history[0].content) - self.assertNotIn(f"{{question}}", history[0].content) - - history = node._construct_messages( - AssistantState( - messages=[ - HumanMessage(content="Text", id="0"), - VisualizationMessage(answer=self.schema, plan="randomplan", id="1", initiator="0"), - ], - start_id="1", - ) - ) - self.assertEqual(len(history), 2) - self.assertEqual(history[0].type, "human") - self.assertIn("Text", history[0].content) - self.assertNotIn("{{question}}", history[0].content) - self.assertEqual(history[1].type, "ai") - self.assertEqual(history[1].content, "randomplan") - - history = node._construct_messages( - AssistantState( - messages=[ - HumanMessage(content="Text", id="0"), - VisualizationMessage(answer=self.schema, plan="randomplan", id="1", initiator="0"), - HumanMessage(content="Text", id="2"), - ], - start_id="2", - ) - ) - self.assertEqual(len(history), 3) - self.assertEqual(history[0].type, "human") - self.assertIn("Text", history[0].content) - self.assertNotIn("{{question}}", history[0].content) - self.assertEqual(history[1].type, "ai") - self.assertEqual(history[1].content, "randomplan") - self.assertEqual(history[2].type, "human") - self.assertIn("Text", history[2].content) - self.assertNotIn("{{question}}", history[2].content) - - def test_agent_reconstructs_conversation_and_omits_unknown_messages(self): - node = self._get_node() - history = node._construct_messages( - AssistantState( - messages=[ - HumanMessage(content="Text", id="0"), - RouterMessage(content="trends", id="1"), - AssistantMessage(content="test", id="2"), - ], - start_id="0", - ) - ) - self.assertEqual(len(history), 1) - self.assertEqual(history[0].type, "human") - self.assertIn("Text", history[0].content) - self.assertNotIn("{{question}}", history[0].content) - - def test_agent_reconstructs_conversation_with_failures(self): - node = self._get_node() - history = node._construct_messages( - AssistantState( - messages=[ - HumanMessage(content="Text"), - FailureMessage(content="Error"), - HumanMessage(content="Text"), - ], - ) - ) - self.assertEqual(len(history), 1) - self.assertEqual(history[0].type, "human") - self.assertIn("Text", history[0].content) - self.assertNotIn("{{question}}", history[0].content) - - def test_agent_reconstructs_typical_conversation(self): - node = self._get_node() - history = node._construct_messages( - AssistantState( - messages=[ - HumanMessage(content="Question 1", id="0"), - RouterMessage(content="trends", id="1"), - VisualizationMessage(answer=AssistantTrendsQuery(series=[]), plan="Plan 1", id="2", initiator="0"), - AssistantMessage(content="Summary 1", id="3"), - HumanMessage(content="Question 2", id="4"), - RouterMessage(content="funnel", id="5"), - AssistantMessage(content="Loop 1", id="6"), - HumanMessage(content="Loop Answer 1", id="7"), - VisualizationMessage(answer=AssistantTrendsQuery(series=[]), plan="Plan 2", id="8", initiator="4"), - AssistantMessage(content="Summary 2", id="9"), - HumanMessage(content="Question 3", id="10"), - RouterMessage(content="funnel", id="11"), - ], - start_id="10", - ) - ) - self.assertEqual(len(history), 7) - self.assertEqual(history[0].type, "human") - self.assertIn("Question 1", history[0].content) - self.assertEqual(history[1].type, "ai") - self.assertEqual(history[1].content, "Plan 1") - self.assertEqual(history[2].type, "human") - self.assertIn("Question 2", history[2].content) - self.assertEqual(history[3].type, "ai") - self.assertEqual(history[3].content, "Loop 1") - self.assertEqual(history[4].type, "human") - self.assertEqual(history[4].content, "Loop Answer 1") - self.assertEqual(history[5].type, "ai") - self.assertEqual(history[5].content, "Plan 2") - self.assertEqual(history[6].type, "human") - self.assertIn("Question 3", history[6].content) - - def test_agent_reconstructs_conversation_without_messages_after_parent(self): - node = self._get_node() - history = node._construct_messages( - AssistantState( - messages=[ - HumanMessage(content="Question 1", id="0"), - RouterMessage(content="trends", id="1"), - AssistantMessage(content="Loop 1", id="2"), - HumanMessage(content="Loop Answer 1", id="3"), - ], - start_id="0", - ) - ) - self.assertEqual(len(history), 1) - self.assertEqual(history[0].type, "human") - self.assertIn("Question 1", history[0].content) - - def test_agent_filters_out_low_count_events(self): - _create_person(distinct_ids=["test"], team=self.team) - for i in range(26): - _create_event(event=f"event{i}", distinct_id="test", team=self.team) - _create_event(event="distinctevent", distinct_id="test", team=self.team) - node = self._get_node() - self.assertEqual( - node._events_prompt, - "All EventsAll events. This is a wildcard that matches all events.distinctevent", - ) - - def test_agent_preserves_low_count_events_for_smaller_teams(self): - _create_person(distinct_ids=["test"], team=self.team) - _create_event(event="distinctevent", distinct_id="test", team=self.team) - node = self._get_node() - self.assertIn("distinctevent", node._events_prompt) - self.assertIn("all events", node._events_prompt) - - def test_agent_scratchpad(self): - node = self._get_node() - scratchpad = [ - (AgentAction(tool="test1", tool_input="input1", log="log1"), "test"), - (AgentAction(tool="test2", tool_input="input2", log="log2"), None), - (AgentAction(tool="test3", tool_input="input3", log="log3"), ""), - ] - prompt = node._get_agent_scratchpad(scratchpad) - self.assertIn("log1", prompt) - self.assertIn("log3", prompt) - - def test_agent_handles_output_without_action_block(self): - with patch( - "ee.hogai.taxonomy_agent.nodes.TaxonomyAgentPlannerNode._model", - return_value=RunnableLambda(lambda _: LangchainAIMessage(content="I don't want to output an action.")), - ): - node = self._get_node() - state_update = node.run(AssistantState(messages=[HumanMessage(content="Question")]), {}) - self.assertEqual(len(state_update.intermediate_steps), 1) - action, obs = state_update.intermediate_steps[0] - self.assertIsNone(obs) - self.assertIn("I don't want to output an action.", action.log) - self.assertIn("Action:", action.log) - self.assertIn("Action:", action.tool_input) - - def test_agent_handles_output_with_malformed_json(self): - with patch( - "ee.hogai.taxonomy_agent.nodes.TaxonomyAgentPlannerNode._model", - return_value=RunnableLambda(lambda _: LangchainAIMessage(content="Thought.\nAction: abc")), - ): - node = self._get_node() - state_update = node.run(AssistantState(messages=[HumanMessage(content="Question")]), {}) - self.assertEqual(len(state_update.intermediate_steps), 1) - action, obs = state_update.intermediate_steps[0] - self.assertIsNone(obs) - self.assertIn("Thought.\nAction: abc", action.log) - self.assertIn("action", action.tool_input) - self.assertIn("action_input", action.tool_input) - - def test_node_outputs_all_events_prompt(self): - node = self._get_node() - self.assertIn("All Events", node._events_prompt) - self.assertIn( - "All EventsAll events. This is a wildcard that matches all events.", - node._events_prompt, - ) - - def test_format_prompt(self): - node = self._get_node() - self.assertNotIn("Human:", node._get_react_format_prompt(DummyToolkit(self.team))) - self.assertIn("retrieve_event_properties,", node._get_react_format_prompt(DummyToolkit(self.team))) - self.assertIn( - "retrieve_event_properties(event_name: str)", node._get_react_format_prompt(DummyToolkit(self.team)) - ) - - def test_property_filters_prompt(self): - GroupTypeMapping.objects.create(team=self.team, project=self.project, group_type="org", group_type_index=0) - GroupTypeMapping.objects.create(team=self.team, project=self.project, group_type="account", group_type_index=1) - node = self._get_node() - prompt = node._get_react_property_filters_prompt() - self.assertIn("org, account.", prompt) - - -@override_settings(IN_UNIT_TESTING=True) -class TestTaxonomyAgentPlannerToolsNode(ClickhouseTestMixin, APIBaseTest): - def _get_node(self): - class Node(TaxonomyAgentPlannerToolsNode): - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - toolkit = DummyToolkit(self._team) - return super()._run_with_toolkit(state, toolkit, config=config) - - return Node(self.team) - - def test_node_handles_action_name_validation_error(self): - state = AssistantState( - intermediate_steps=[(AgentAction(tool="does not exist", tool_input="input", log="log"), "test")], - messages=[], - ) - node = self._get_node() - state_update = node.run(state, {}) - self.assertEqual(len(state_update.intermediate_steps), 1) - action, observation = state_update.intermediate_steps[0] - self.assertIsNotNone(observation) - self.assertIn("", observation) - - def test_node_handles_action_input_validation_error(self): - state = AssistantState( - intermediate_steps=[ - (AgentAction(tool="retrieve_entity_property_values", tool_input="input", log="log"), "test") - ], - messages=[], - ) - node = self._get_node() - state_update = node.run(state, {}) - self.assertEqual(len(state_update.intermediate_steps), 1) - action, observation = state_update.intermediate_steps[0] - self.assertIsNotNone(observation) - self.assertIn("", observation) - - def test_router(self): - node = self._get_node() - self.assertEqual(node.router(AssistantState(messages=[HumanMessage(content="Question")])), "continue") - self.assertEqual(node.router(AssistantState(messages=[HumanMessage(content="Question")], plan="")), "continue") - self.assertEqual( - node.router(AssistantState(messages=[HumanMessage(content="Question")], plan="plan")), "plan_found" - ) diff --git a/ee/hogai/taxonomy_agent/test/test_parsers.py b/ee/hogai/taxonomy_agent/test/test_parsers.py deleted file mode 100644 index d8e5ed61e6..0000000000 --- a/ee/hogai/taxonomy_agent/test/test_parsers.py +++ /dev/null @@ -1,78 +0,0 @@ -from langchain_core.messages import AIMessage as LangchainAIMessage - -from ee.hogai.taxonomy_agent.parsers import ( - ReActParserMalformedJsonException, - ReActParserMissingActionException, - parse_react_agent_output, -) -from posthog.test.base import BaseTest - - -class TestTaxonomyAgentParsers(BaseTest): - def test_parse_react_agent_output(self): - res = parse_react_agent_output( - LangchainAIMessage( - content=""" - Some thoughts... - Action: - ```json - {"action": "action_name", "action_input": "action_input"} - ``` - """ - ) - ) - self.assertEqual(res.tool, "action_name") - self.assertEqual(res.tool_input, "action_input") - - res = parse_react_agent_output( - LangchainAIMessage( - content=""" - Some thoughts... - Action: - ``` - {"action": "tool", "action_input": {"key": "value"}} - ``` - """ - ) - ) - self.assertEqual(res.tool, "tool") - self.assertEqual(res.tool_input, {"key": "value"}) - - self.assertRaises( - ReActParserMissingActionException, parse_react_agent_output, LangchainAIMessage(content="Some thoughts...") - ) - self.assertRaises( - ReActParserMalformedJsonException, - parse_react_agent_output, - LangchainAIMessage(content="Some thoughts...\nAction: abc"), - ) - self.assertRaises( - ReActParserMalformedJsonException, - parse_react_agent_output, - LangchainAIMessage(content="Some thoughts...\nAction:"), - ) - self.assertRaises( - ReActParserMalformedJsonException, - parse_react_agent_output, - LangchainAIMessage(content="Some thoughts...\nAction: {}"), - ) - self.assertRaises( - ReActParserMalformedJsonException, - parse_react_agent_output, - LangchainAIMessage(content="Some thoughts...\nAction:\n```\n{}\n```"), - ) - self.assertRaises( - ReActParserMalformedJsonException, - parse_react_agent_output, - LangchainAIMessage(content="Some thoughts...\nAction:\n```\n{not a json}\n```"), - ) - self.assertRaises( - ReActParserMalformedJsonException, - parse_react_agent_output, - LangchainAIMessage(content='Some thoughts...\nAction:\n```\n{"action":"tool"}\n```'), - ) - self.assertRaises( - ReActParserMalformedJsonException, - parse_react_agent_output, - LangchainAIMessage(content='Some thoughts...\nAction:\n```\n{"action_input":"input"}\n```'), - ) diff --git a/ee/hogai/taxonomy_agent/test/test_toolkit.py b/ee/hogai/taxonomy_agent/test/test_toolkit.py deleted file mode 100644 index 32967d0916..0000000000 --- a/ee/hogai/taxonomy_agent/test/test_toolkit.py +++ /dev/null @@ -1,273 +0,0 @@ -from datetime import datetime - -from django.test import override_settings -from freezegun import freeze_time - -from ee.hogai.taxonomy_agent.toolkit import TaxonomyAgentToolkit, ToolkitTool -from posthog.models.group.util import create_group -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.models.property_definition import PropertyDefinition, PropertyType -from posthog.test.base import APIBaseTest, ClickhouseTestMixin, _create_event, _create_person - - -class DummyToolkit(TaxonomyAgentToolkit): - def _get_tools(self) -> list[ToolkitTool]: - return self._default_tools - - -@override_settings(IN_UNIT_TESTING=True) -class TestTaxonomyAgentToolkit(ClickhouseTestMixin, APIBaseTest): - def _create_taxonomy(self): - PropertyDefinition.objects.create( - team=self.team, type=PropertyDefinition.Type.EVENT, name="$browser", property_type=PropertyType.String - ) - PropertyDefinition.objects.create( - team=self.team, type=PropertyDefinition.Type.EVENT, name="id", property_type=PropertyType.Numeric - ) - PropertyDefinition.objects.create( - team=self.team, type=PropertyDefinition.Type.EVENT, name="bool", property_type=PropertyType.Boolean - ) - PropertyDefinition.objects.create( - team=self.team, type=PropertyDefinition.Type.EVENT, name="date", property_type=PropertyType.Datetime - ) - - _create_person( - distinct_ids=["person1"], - team=self.team, - properties={"email": "person1@example.com"}, - ) - _create_event( - event="event1", - distinct_id="person1", - properties={ - "$browser": "Chrome", - "date": datetime(2024, 1, 1).isoformat(), - }, - team=self.team, - ) - _create_event( - event="event1", - distinct_id="person1", - properties={ - "$browser": "Firefox", - "bool": True, - }, - team=self.team, - ) - - _create_person( - distinct_ids=["person2"], - properties={"email": "person2@example.com"}, - team=self.team, - ) - for i in range(10): - _create_event( - event="event1", - distinct_id=f"person2", - properties={"id": i}, - team=self.team, - ) - - def test_retrieve_entity_properties(self): - toolkit = DummyToolkit(self.team) - - PropertyDefinition.objects.create( - team=self.team, type=PropertyDefinition.Type.PERSON, name="test", property_type="String" - ) - self.assertEqual( - toolkit.retrieve_entity_properties("person"), - "test", - ) - - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, group_type_index=0, group_type="group" - ) - PropertyDefinition.objects.create( - team=self.team, type=PropertyDefinition.Type.GROUP, group_type_index=0, name="test", property_type="Numeric" - ) - self.assertEqual( - toolkit.retrieve_entity_properties("group"), - "test", - ) - - self.assertNotEqual( - toolkit.retrieve_entity_properties("session"), - "", - ) - self.assertIn( - "$session_duration", - toolkit.retrieve_entity_properties("session"), - ) - - def test_retrieve_entity_properties_returns_descriptive_feedback_without_properties(self): - toolkit = DummyToolkit(self.team) - self.assertEqual( - toolkit.retrieve_entity_properties("person"), - "Properties do not exist in the taxonomy for the entity person.", - ) - - def test_retrieve_entity_property_values(self): - toolkit = DummyToolkit(self.team) - self.assertEqual( - toolkit.retrieve_entity_property_values("session", "$session_duration"), - "30, 146, 2 and many more distinct values.", - ) - self.assertEqual( - toolkit.retrieve_entity_property_values("session", "nonsense"), - "The property nonsense does not exist in the taxonomy.", - ) - - PropertyDefinition.objects.create( - team=self.team, type=PropertyDefinition.Type.PERSON, name="email", property_type=PropertyType.String - ) - PropertyDefinition.objects.create( - team=self.team, type=PropertyDefinition.Type.PERSON, name="id", property_type=PropertyType.Numeric - ) - - for i in range(5): - id = f"person{i}" - with freeze_time(f"2024-01-01T{i}:00:00Z"): - _create_person( - distinct_ids=[id], - properties={"email": f"{id}@example.com", "id": i}, - team=self.team, - ) - with freeze_time(f"2024-01-02T00:00:00Z"): - _create_person( - distinct_ids=["person5"], - properties={"email": "person5@example.com", "id": 5}, - team=self.team, - ) - - self.assertEqual( - toolkit.retrieve_entity_property_values("person", "email"), - '"person5@example.com", "person4@example.com", "person3@example.com", "person2@example.com", "person1@example.com" and 1 more distinct value.', - ) - self.assertEqual( - toolkit.retrieve_entity_property_values("person", "id"), - "5, 4, 3, 2, 1 and 1 more distinct value.", - ) - - toolkit = DummyToolkit(self.team) - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, group_type_index=0, group_type="proj" - ) - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, group_type_index=1, group_type="org" - ) - PropertyDefinition.objects.create( - team=self.team, type=PropertyDefinition.Type.GROUP, group_type_index=0, name="test", property_type="Numeric" - ) - PropertyDefinition.objects.create( - team=self.team, type=PropertyDefinition.Type.GROUP, group_type_index=1, name="test", property_type="String" - ) - - for i in range(7): - id = f"group{i}" - with freeze_time(f"2024-01-01T{i}:00:00Z"): - create_group( - group_type_index=0, - group_key=id, - properties={"test": i}, - team_id=self.team.pk, - ) - with freeze_time(f"2024-01-02T00:00:00Z"): - create_group( - group_type_index=1, - group_key="org", - properties={"test": "7"}, - team_id=self.team.pk, - ) - - self.assertEqual( - toolkit.retrieve_entity_property_values("proj", "test"), - "6, 5, 4, 3, 2 and 2 more distinct values.", - ) - self.assertEqual(toolkit.retrieve_entity_property_values("org", "test"), '"7"') - - def test_group_names(self): - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, group_type_index=0, group_type="proj" - ) - GroupTypeMapping.objects.create( - team=self.team, project_id=self.team.project_id, group_type_index=1, group_type="org" - ) - toolkit = DummyToolkit(self.team) - self.assertEqual(toolkit._entity_names, ["person", "session", "proj", "org"]) - - def test_retrieve_event_properties_returns_descriptive_feedback_without_properties(self): - toolkit = DummyToolkit(self.team) - self.assertEqual( - toolkit.retrieve_event_properties("pageview"), - "Properties do not exist in the taxonomy for the event pageview.", - ) - - def test_empty_events(self): - toolkit = DummyToolkit(self.team) - self.assertEqual( - toolkit.retrieve_event_properties("test"), "Properties do not exist in the taxonomy for the event test." - ) - - _create_person( - distinct_ids=["person1"], - team=self.team, - properties={}, - ) - _create_event( - event="event1", - distinct_id="person1", - properties={}, - team=self.team, - ) - - toolkit = DummyToolkit(self.team) - self.assertEqual( - toolkit.retrieve_event_properties("event1"), - "Properties do not exist in the taxonomy for the event event1.", - ) - - def test_retrieve_event_properties(self): - self._create_taxonomy() - toolkit = DummyToolkit(self.team) - prompt = toolkit.retrieve_event_properties("event1") - - self.assertIn( - "id", - prompt, - ) - self.assertIn( - "$browserName of the browser the user has used.", - prompt, - ) - self.assertIn( - "date", - prompt, - ) - self.assertIn( - "bool", - prompt, - ) - - def test_retrieve_event_property_values(self): - self._create_taxonomy() - toolkit = DummyToolkit(self.team) - - self.assertIn('"Chrome"', toolkit.retrieve_event_property_values("event1", "$browser")) - self.assertIn('"Firefox"', toolkit.retrieve_event_property_values("event1", "$browser")) - self.assertEqual(toolkit.retrieve_event_property_values("event1", "bool"), "true") - self.assertEqual( - toolkit.retrieve_event_property_values("event1", "id"), - "9, 8, 7, 6, 5 and 5 more distinct values.", - ) - self.assertEqual( - toolkit.retrieve_event_property_values("event1", "date"), f'"{datetime(2024, 1, 1).isoformat()}"' - ) - - def test_enrich_props_with_descriptions(self): - toolkit = DummyToolkit(self.team) - res = toolkit._enrich_props_with_descriptions("event", [("$geoip_city_name", "String")]) - self.assertEqual(len(res), 1) - prop, type, description = res[0] - self.assertEqual(prop, "$geoip_city_name") - self.assertEqual(type, "String") - self.assertIsNotNone(description) diff --git a/ee/hogai/taxonomy_agent/toolkit.py b/ee/hogai/taxonomy_agent/toolkit.py deleted file mode 100644 index 00b91de772..0000000000 --- a/ee/hogai/taxonomy_agent/toolkit.py +++ /dev/null @@ -1,437 +0,0 @@ -import xml.etree.ElementTree as ET -from abc import ABC, abstractmethod -from collections.abc import Iterable -from functools import cached_property -from textwrap import dedent -from typing import Literal, Optional, TypedDict, Union, cast - -from pydantic import BaseModel, Field, RootModel - -from posthog.taxonomy.taxonomy import CORE_FILTER_DEFINITIONS_BY_GROUP -from posthog.hogql.database.schema.channel_type import DEFAULT_CHANNEL_TYPES -from posthog.hogql_queries.ai.actors_property_taxonomy_query_runner import ActorsPropertyTaxonomyQueryRunner -from posthog.hogql_queries.ai.event_taxonomy_query_runner import EventTaxonomyQueryRunner -from posthog.hogql_queries.query_runner import ExecutionMode -from posthog.models.group_type_mapping import GroupTypeMapping -from posthog.models.property_definition import PropertyDefinition, PropertyType -from posthog.models.team.team import Team -from posthog.schema import ( - ActorsPropertyTaxonomyQuery, - CachedActorsPropertyTaxonomyQueryResponse, - CachedEventTaxonomyQueryResponse, - EventTaxonomyQuery, -) - - -class ToolkitTool(TypedDict): - name: str - signature: str - description: str - - -class RetrieveEntityPropertiesValuesArgs(BaseModel): - entity: str - property_name: str - - -class RetrieveEntityPropertiesValuesTool(BaseModel): - name: Literal["retrieve_entity_property_values"] - arguments: RetrieveEntityPropertiesValuesArgs - - -class RetrieveEventPropertiesValuesArgs(BaseModel): - event_name: str - property_name: str - - -class RetrieveEventPropertiesValuesTool(BaseModel): - name: Literal["retrieve_event_property_values"] - arguments: RetrieveEventPropertiesValuesArgs - - -class SingleArgumentTaxonomyAgentTool(BaseModel): - name: Literal[ - "retrieve_entity_properties", - "retrieve_event_properties", - "final_answer", - "handle_incorrect_response", - "ask_user_for_help", - ] - arguments: str - - -class TaxonomyAgentTool( - RootModel[ - Union[SingleArgumentTaxonomyAgentTool, RetrieveEntityPropertiesValuesTool, RetrieveEventPropertiesValuesTool] - ] -): - root: Union[ - SingleArgumentTaxonomyAgentTool, RetrieveEntityPropertiesValuesTool, RetrieveEventPropertiesValuesTool - ] = Field(..., discriminator="name") - - -class TaxonomyAgentToolkit(ABC): - _team: Team - - def __init__(self, team: Team): - self._team = team - - @cached_property - def tools(self) -> list[ToolkitTool]: - return [ - { - "name": tool["name"], - "signature": tool["signature"], - "description": dedent(tool["description"]), - } - for tool in self._get_tools() - ] - - @abstractmethod - def _get_tools(self) -> list[ToolkitTool]: - raise NotImplementedError - - @property - def _default_tools(self) -> list[ToolkitTool]: - stringified_entities = ", ".join([f"'{entity}'" for entity in self._entity_names]) - return [ - { - "name": "retrieve_event_properties", - "signature": "(event_name: str)", - "description": """ - Use this tool to retrieve the property names of an event that the user has in their taxonomy. You will receive a list of properties containing their name, value type, and description, or a message that properties have not been found. - - - **Try other events** if the tool doesn't return any properties. - - **Prioritize properties that are directly related to the context or objective of the user's query.** - - **Avoid using ambiguous properties** unless their relevance is explicitly confirmed. - - Args: - event_name: The name of the event that you want to retrieve properties for. - """, - }, - { - "name": "retrieve_event_property_values", - "signature": "(event_name: str, property_name: str)", - "description": """ - Use this tool to retrieve the property values for an event that the user has in their taxonomy. Adjust filters to these values. You will receive a list of property values or a message that property values have not been found. Some properties can have many values, so the output will be truncated. Use your judgment to find a proper value. - - Args: - event_name: The name of the event that you want to retrieve values for. - property_name: The name of the property that you want to retrieve values for. - """, - }, - { - "name": f"retrieve_entity_properties", - "signature": f"(entity: Literal[{stringified_entities}])", - "description": """ - Use this tool to retrieve property names for a property group (entity) that the user has in their taxonomy. You will receive a list of properties containing their name, value type, and description, or a message that properties have not been found. - - - **Infer the property groups from the user's request.** - - **Try other entities** if the tool doesn't return any properties. - - **Prioritize properties that are directly related to the context or objective of the user's query.** - - **Avoid using ambiguous properties** unless their relevance is explicitly confirmed. - - Args: - entity: The type of the entity that you want to retrieve properties for. - """, - }, - { - "name": "retrieve_entity_property_values", - "signature": f"(entity: Literal[{stringified_entities}], property_name: str)", - "description": """ - Use this tool to retrieve property values for a property name that the user has in their taxonomy. Adjust filters to these values. You will receive a list of property values or a message that property values have not been found. Some properties can have many values, so the output will be truncated. Use your judgment to find a proper value. - - Args: - entity: The type of the entity that you want to retrieve properties for. - property_name: The name of the property that you want to retrieve values for. - """, - }, - { - "name": "ask_user_for_help", - "signature": "(question: str)", - "description": """ - Use this tool to ask a question to the user. Your question must be concise and clear. - - Args: - question: The question you want to ask. - """, - }, - ] - - def render_text_description(self) -> str: - """ - Render the tool name and description in plain text. - - Returns: - The rendered text. - - Output will be in the format of: - - .. code-block:: markdown - - search: This tool is used for search - calculator: This tool is used for math - """ - descriptions = [] - for tool in self.tools: - description = f"{tool['name']}{tool['signature']} - {tool['description']}" - descriptions.append(description) - return "\n".join(descriptions) - - @property - def _groups(self): - return GroupTypeMapping.objects.filter(project_id=self._team.project_id).order_by("group_type_index") - - @cached_property - def _entity_names(self) -> list[str]: - """ - The schemas use `group_type_index` for groups complicating things for the agent. Instead, we use groups' names, - so the generation step will handle their indexes. Tools would need to support multiple arguments, or we would need - to create various tools for different group types. Since we don't use function calling here, we want to limit the - number of tools because non-function calling models can't handle many tools. - """ - entities = [ - "person", - "session", - *[group.group_type for group in self._groups], - ] - return entities - - def _generate_properties_xml(self, children: list[tuple[str, str | None, str | None]]): - root = ET.Element("properties") - property_type_to_tag = {} - - for name, property_type, description in children: - # Do not include properties that are ambiguous. - if property_type is None: - continue - if property_type not in property_type_to_tag: - property_type_to_tag[property_type] = ET.SubElement(root, property_type) - - type_tag = property_type_to_tag[property_type] - prop = ET.SubElement(type_tag, "prop") - ET.SubElement(prop, "name").text = name - if description: - ET.SubElement(prop, "description").text = description - - return ET.tostring(root, encoding="unicode") - - def _enrich_props_with_descriptions(self, entity: str, props: Iterable[tuple[str, str | None]]): - enriched_props = [] - mapping = { - "session": CORE_FILTER_DEFINITIONS_BY_GROUP["session_properties"], - "person": CORE_FILTER_DEFINITIONS_BY_GROUP["person_properties"], - "event": CORE_FILTER_DEFINITIONS_BY_GROUP["event_properties"], - } - for prop_name, prop_type in props: - description = None - if entity_definition := mapping.get(entity, {}).get(prop_name): - if entity_definition.get("system") or entity_definition.get("ignored_in_assistant"): - continue - description = entity_definition.get("description") - enriched_props.append((prop_name, prop_type, description)) - return enriched_props - - def retrieve_entity_properties(self, entity: str) -> str: - """ - Retrieve properties for an entitiy like person, session, or one of the groups. - """ - if entity not in ("person", "session", *[group.group_type for group in self._groups]): - return f"Entity {entity} does not exist in the taxonomy." - - if entity == "person": - qs = PropertyDefinition.objects.filter(team=self._team, type=PropertyDefinition.Type.PERSON).values_list( - "name", "property_type" - ) - props = self._enrich_props_with_descriptions("person", qs) - elif entity == "session": - # Session properties are not in the DB. - props = self._enrich_props_with_descriptions( - "session", - [ - (prop_name, prop["type"]) - for prop_name, prop in CORE_FILTER_DEFINITIONS_BY_GROUP["session_properties"].items() - if prop.get("type") is not None - ], - ) - else: - group_type_index = next( - (group.group_type_index for group in self._groups if group.group_type == entity), None - ) - if group_type_index is None: - return f"Group {entity} does not exist in the taxonomy." - qs = PropertyDefinition.objects.filter( - team=self._team, type=PropertyDefinition.Type.GROUP, group_type_index=group_type_index - ).values_list("name", "property_type") - props = self._enrich_props_with_descriptions(entity, qs) - - if not props: - return f"Properties do not exist in the taxonomy for the entity {entity}." - - return self._generate_properties_xml(props) - - def retrieve_event_properties(self, event_name: str) -> str: - """ - Retrieve properties for an event. - """ - runner = EventTaxonomyQueryRunner(EventTaxonomyQuery(event=event_name), self._team) - response = runner.run(ExecutionMode.RECENT_CACHE_CALCULATE_ASYNC_IF_STALE_AND_BLOCKING_ON_MISS) - - if not isinstance(response, CachedEventTaxonomyQueryResponse): - return "Properties have not been found." - - if not response.results: - return f"Properties do not exist in the taxonomy for the event {event_name}." - - # Intersect properties with their types. - qs = PropertyDefinition.objects.filter( - team=self._team, type=PropertyDefinition.Type.EVENT, name__in=[item.property for item in response.results] - ) - property_to_type = {property_definition.name: property_definition.property_type for property_definition in qs} - props = [ - (item.property, property_to_type.get(item.property)) - for item in response.results - # Exclude properties that exist in the taxonomy, but don't have a type. - if item.property in property_to_type - ] - - if not props: - return f"Properties do not exist in the taxonomy for the event {event_name}." - - return self._generate_properties_xml(self._enrich_props_with_descriptions("event", props)) - - def _format_property_values( - self, sample_values: list, sample_count: Optional[int] = 0, format_as_string: bool = False - ) -> str: - if len(sample_values) == 0 or sample_count == 0: - return f"The property does not have any values in the taxonomy." - - # Add quotes to the String type, so the LLM can easily infer a type. - # Strings like "true" or "10" are interpreted as booleans or numbers without quotes, so the schema generation fails. - # Remove the floating point the value is an integer. - formatted_sample_values: list[str] = [] - for value in sample_values: - if format_as_string: - formatted_sample_values.append(f'"{value}"') - elif isinstance(value, float) and value.is_integer(): - formatted_sample_values.append(str(int(value))) - else: - formatted_sample_values.append(str(value)) - prop_values = ", ".join(formatted_sample_values) - - # If there wasn't an exact match with the user's search, we provide a hint that LLM can use an arbitrary value. - if sample_count is None: - return f"{prop_values} and many more distinct values." - elif sample_count > len(sample_values): - diff = sample_count - len(sample_values) - return f"{prop_values} and {diff} more distinct value{'' if diff == 1 else 's'}." - - return prop_values - - def retrieve_event_property_values(self, event_name: str, property_name: str) -> str: - try: - property_definition = PropertyDefinition.objects.get( - team=self._team, name=property_name, type=PropertyDefinition.Type.EVENT - ) - except PropertyDefinition.DoesNotExist: - return f"The property {property_name} does not exist in the taxonomy." - - runner = EventTaxonomyQueryRunner(EventTaxonomyQuery(event=event_name), self._team) - response = runner.run(ExecutionMode.RECENT_CACHE_CALCULATE_ASYNC_IF_STALE_AND_BLOCKING_ON_MISS) - - if not isinstance(response, CachedEventTaxonomyQueryResponse): - return f"The event {event_name} does not exist in the taxonomy." - - if not response.results: - return f"Property values for {property_name} do not exist in the taxonomy for the event {event_name}." - - prop = next((item for item in response.results if item.property == property_name), None) - if not prop: - return f"The property {property_name} does not exist in the taxonomy for the event {event_name}." - - return self._format_property_values( - prop.sample_values, - prop.sample_count, - format_as_string=property_definition.property_type in (PropertyType.String, PropertyType.Datetime), - ) - - def _retrieve_session_properties(self, property_name: str) -> str: - """ - Sessions properties example property values are hardcoded. - """ - if property_name not in CORE_FILTER_DEFINITIONS_BY_GROUP["session_properties"]: - return f"The property {property_name} does not exist in the taxonomy." - - sample_values: list[str | int | float] - if property_name == "$channel_type": - sample_values = cast(list[str | int | float], DEFAULT_CHANNEL_TYPES.copy()) - sample_count = len(sample_values) - is_str = True - elif ( - property_name in CORE_FILTER_DEFINITIONS_BY_GROUP["session_properties"] - and "examples" in CORE_FILTER_DEFINITIONS_BY_GROUP["session_properties"][property_name] - ): - sample_values = CORE_FILTER_DEFINITIONS_BY_GROUP["session_properties"][property_name]["examples"] - sample_count = None - is_str = ( - CORE_FILTER_DEFINITIONS_BY_GROUP["session_properties"][property_name]["type"] == PropertyType.String - ) - else: - return f"Property values for {property_name} do not exist in the taxonomy for the session entity." - - return self._format_property_values(sample_values, sample_count, format_as_string=is_str) - - def retrieve_entity_property_values(self, entity: str, property_name: str) -> str: - if entity not in self._entity_names: - return f"The entity {entity} does not exist in the taxonomy. You must use one of the following: {', '.join(self._entity_names)}." - - if entity == "session": - return self._retrieve_session_properties(property_name) - - if entity == "person": - query = ActorsPropertyTaxonomyQuery(property=property_name) - else: - group_index = next((group.group_type_index for group in self._groups if group.group_type == entity), None) - if group_index is None: - return f"The entity {entity} does not exist in the taxonomy." - query = ActorsPropertyTaxonomyQuery(group_type_index=group_index, property=property_name) - - try: - if query.group_type_index is not None: - prop_type = PropertyDefinition.Type.GROUP - group_type_index = query.group_type_index - else: - prop_type = PropertyDefinition.Type.PERSON - group_type_index = None - - property_definition = PropertyDefinition.objects.get( - team=self._team, - name=property_name, - type=prop_type, - group_type_index=group_type_index, - ) - except PropertyDefinition.DoesNotExist: - return f"The property {property_name} does not exist in the taxonomy for the entity {entity}." - - response = ActorsPropertyTaxonomyQueryRunner(query, self._team).run( - ExecutionMode.RECENT_CACHE_CALCULATE_ASYNC_IF_STALE_AND_BLOCKING_ON_MISS - ) - - if not isinstance(response, CachedActorsPropertyTaxonomyQueryResponse): - return f"The entity {entity} does not exist in the taxonomy." - - if not response.results: - return f"Property values for {property_name} do not exist in the taxonomy for the entity {entity}." - - return self._format_property_values( - response.results.sample_values, - response.results.sample_count, - format_as_string=property_definition.property_type in (PropertyType.String, PropertyType.Datetime), - ) - - def handle_incorrect_response(self, response: str) -> str: - """ - No-op tool. Take a parsing error and return a response that the LLM can use to correct itself. - Used to control a number of retries. - """ - return response diff --git a/ee/hogai/test/__init__.py b/ee/hogai/test/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/test/test_assistant.py b/ee/hogai/test/test_assistant.py deleted file mode 100644 index 525c4ff134..0000000000 --- a/ee/hogai/test/test_assistant.py +++ /dev/null @@ -1,692 +0,0 @@ -import json -from typing import Any, Optional, cast -from unittest.mock import patch - -import pytest -from langchain_core import messages -from langchain_core.agents import AgentAction -from langchain_core.runnables import RunnableConfig, RunnableLambda -from langgraph.graph.state import CompiledStateGraph -from langgraph.types import StateSnapshot -from pydantic import BaseModel - -from ee.hogai.funnels.nodes import FunnelsSchemaGeneratorOutput -from ee.hogai.memory import prompts as memory_prompts -from ee.hogai.router.nodes import RouterOutput -from ee.hogai.trends.nodes import TrendsSchemaGeneratorOutput -from ee.models.assistant import Conversation, CoreMemory -from posthog.schema import ( - AssistantFunnelsEventsNode, - AssistantFunnelsQuery, - AssistantMessage, - AssistantTrendsQuery, - FailureMessage, - HumanMessage, - ReasoningMessage, - RouterMessage, - VisualizationMessage, -) -from posthog.test.base import ClickhouseTestMixin, NonAtomicBaseTest, _create_event, _create_person - -from ..assistant import Assistant -from ..graph import AssistantGraph, AssistantNodeName - - -class TestAssistant(ClickhouseTestMixin, NonAtomicBaseTest): - CLASS_DATA_LEVEL_SETUP = False - - def setUp(self): - super().setUp() - self.conversation = Conversation.objects.create(team=self.team, user=self.user) - self.core_memory = CoreMemory.objects.create( - team=self.team, - text="Initial memory.", - initial_text="Initial memory.", - scraping_status=CoreMemory.ScrapingStatus.COMPLETED, - ) - - def _set_up_onboarding_tests(self): - self.core_memory.delete() - _create_person( - distinct_ids=["person1"], - team=self.team, - ) - _create_event( - event="$pageview", - distinct_id="person1", - team=self.team, - properties={"$host": "us.posthog.com"}, - ) - - def _parse_stringified_message(self, message: str) -> tuple[str, Any]: - event_line, data_line, *_ = cast(str, message).split("\n") - return (event_line.removeprefix("event: "), json.loads(data_line.removeprefix("data: "))) - - def _run_assistant_graph( - self, - test_graph: Optional[CompiledStateGraph] = None, - message: Optional[str] = "Hello", - conversation: Optional[Conversation] = None, - is_new_conversation: bool = False, - ) -> list[tuple[str, Any]]: - # Create assistant instance with our test graph - assistant = Assistant( - self.team, - conversation or self.conversation, - HumanMessage(content=message), - self.user, - is_new_conversation=is_new_conversation, - ) - if test_graph: - assistant._graph = test_graph - # Capture and parse output of assistant.stream() - output: list[tuple[str, Any]] = [] - for message in assistant.stream(): - output.append(self._parse_stringified_message(message)) - return output - - def assertConversationEqual(self, output: list[tuple[str, Any]], expected_output: list[tuple[str, Any]]): - for i, ((output_msg_type, output_msg), (expected_msg_type, expected_msg)) in enumerate( - zip(output, expected_output) - ): - self.assertEqual(output_msg_type, expected_msg_type, f"Message type mismatch at index {i}") - msg_dict = ( - expected_msg.model_dump(exclude_none=True) if isinstance(expected_msg, BaseModel) else expected_msg - ) - self.assertDictContainsSubset(msg_dict, output_msg, f"Message content mismatch at index {i}") - - @patch( - "ee.hogai.trends.nodes.TrendsPlannerNode.run", - return_value={"intermediate_steps": [(AgentAction(tool="final_answer", tool_input="Plan", log=""), None)]}, - ) - @patch( - "ee.hogai.summarizer.nodes.SummarizerNode.run", return_value={"messages": [AssistantMessage(content="Foobar")]} - ) - def test_reasoning_messages_added(self, _mock_summarizer_run, _mock_funnel_planner_run): - output = self._run_assistant_graph( - AssistantGraph(self.team) - .add_edge(AssistantNodeName.START, AssistantNodeName.TRENDS_PLANNER) - .add_trends_planner(AssistantNodeName.SUMMARIZER) - .add_summarizer(AssistantNodeName.END) - .compile(), - conversation=self.conversation, - ) - - # Assert that ReasoningMessages are added - expected_output = [ - ( - "message", - HumanMessage(content="Hello").model_dump(exclude_none=True), - ), - ( - "message", - { - "type": "ai/reasoning", - "content": "Picking relevant events and properties", # For TrendsPlannerNode - "substeps": [], - }, - ), - ( - "message", - { - "type": "ai/reasoning", - "content": "Picking relevant events and properties", # For TrendsPlannerToolsNode - "substeps": [], - }, - ), - ( - "message", - { - "type": "ai", - "content": "Foobar", # Summarizer merits no ReasoningMessage, we output its results outright - }, - ), - ] - self.assertConversationEqual(output, expected_output) - - @patch( - "ee.hogai.trends.nodes.TrendsPlannerNode.run", - return_value={ - "intermediate_steps": [ - # Compare with toolkit.py to see supported AgentAction shapes. The list below is supposed to include ALL - (AgentAction(tool="retrieve_entity_properties", tool_input="session", log=""), None), - (AgentAction(tool="retrieve_event_properties", tool_input="$pageview", log=""), None), - ( - AgentAction( - tool="retrieve_event_property_values", - tool_input={"event_name": "purchase", "property_name": "currency"}, - log="", - ), - None, - ), - ( - AgentAction( - tool="retrieve_entity_property_values", - tool_input={"entity": "person", "property_name": "country_of_birth"}, - log="", - ), - None, - ), - (AgentAction(tool="handle_incorrect_response", tool_input="", log=""), None), - (AgentAction(tool="final_answer", tool_input="Plan", log=""), None), - ] - }, - ) - def test_reasoning_messages_with_substeps_added(self, _mock_funnel_planner_run): - output = self._run_assistant_graph( - AssistantGraph(self.team) - .add_edge(AssistantNodeName.START, AssistantNodeName.TRENDS_PLANNER) - .add_trends_planner(AssistantNodeName.END) - .compile(), - conversation=self.conversation, - ) - - # Assert that ReasoningMessages are added - expected_output = [ - ( - "message", - HumanMessage(content="Hello").model_dump(exclude_none=True), - ), - ( - "message", - { - "type": "ai/reasoning", - "content": "Picking relevant events and properties", # For TrendsPlannerNode - "substeps": [], - }, - ), - ( - "message", - { - "type": "ai/reasoning", - "content": "Picking relevant events and properties", # For TrendsPlannerToolsNode - "substeps": [ - "Exploring session properties", - "Exploring `$pageview` event's properties", - "Analyzing `currency` event's property `purchase`", - "Analyzing person property `country_of_birth`", - ], - }, - ), - ] - self.assertConversationEqual(output, expected_output) - - def _test_human_in_the_loop(self, graph: CompiledStateGraph): - with patch("ee.hogai.taxonomy_agent.nodes.TaxonomyAgentPlannerNode._model") as mock: - config: RunnableConfig = { - "configurable": { - "thread_id": self.conversation.id, - } - } - - # Interrupt the graph - message = """ - Thought: Let's ask for help. - Action: - ``` - { - "action": "ask_user_for_help", - "action_input": "Need help with this query" - } - ``` - """ - mock.return_value = RunnableLambda(lambda _: messages.AIMessage(content=message)) - output = self._run_assistant_graph(graph, conversation=self.conversation) - expected_output = [ - ("message", HumanMessage(content="Hello")), - ("message", ReasoningMessage(content="Picking relevant events and properties", substeps=[])), - ("message", ReasoningMessage(content="Picking relevant events and properties", substeps=[])), - ("message", AssistantMessage(content="Need help with this query")), - ] - self.assertConversationEqual(output, expected_output) - snapshot: StateSnapshot = graph.get_state(config) - self.assertTrue(snapshot.next) - self.assertIn("intermediate_steps", snapshot.values) - - # Resume the graph from the interruption point. - message = """ - Thought: Finish. - Action: - ``` - { - "action": "final_answer", - "action_input": "Plan" - } - ``` - """ - mock.return_value = RunnableLambda(lambda _: messages.AIMessage(content=message)) - output = self._run_assistant_graph(graph, conversation=self.conversation, message="It's straightforward") - expected_output = [ - ("message", HumanMessage(content="It's straightforward")), - ("message", ReasoningMessage(content="Picking relevant events and properties", substeps=[])), - ("message", ReasoningMessage(content="Picking relevant events and properties", substeps=[])), - ] - self.assertConversationEqual(output, expected_output) - snapshot: StateSnapshot = graph.get_state(config) - self.assertFalse(snapshot.next) - self.assertEqual(snapshot.values.get("intermediate_steps"), []) - self.assertEqual(snapshot.values["plan"], "Plan") - - def test_trends_interrupt_when_asking_for_help(self): - graph = ( - AssistantGraph(self.team) - .add_edge(AssistantNodeName.START, AssistantNodeName.TRENDS_PLANNER) - .add_trends_planner(AssistantNodeName.END) - .compile() - ) - self._test_human_in_the_loop(graph) - - def test_funnels_interrupt_when_asking_for_help(self): - graph = ( - AssistantGraph(self.team) - .add_edge(AssistantNodeName.START, AssistantNodeName.FUNNEL_PLANNER) - .add_funnel_planner(AssistantNodeName.END) - .compile() - ) - self._test_human_in_the_loop(graph) - - def test_messages_are_updated_after_feedback(self): - with patch("ee.hogai.taxonomy_agent.nodes.TaxonomyAgentPlannerNode._model") as mock: - graph = ( - AssistantGraph(self.team) - .add_edge(AssistantNodeName.START, AssistantNodeName.TRENDS_PLANNER) - .add_trends_planner(AssistantNodeName.END) - .compile() - ) - config: RunnableConfig = { - "configurable": { - "thread_id": self.conversation.id, - } - } - - # Interrupt the graph - message = """ - Thought: Let's ask for help. - Action: - ``` - { - "action": "ask_user_for_help", - "action_input": "Need help with this query" - } - ``` - """ - mock.return_value = RunnableLambda(lambda _: messages.AIMessage(content=message)) - self._run_assistant_graph(graph, conversation=self.conversation) - snapshot: StateSnapshot = graph.get_state(config) - self.assertTrue(snapshot.next) - self.assertIn("intermediate_steps", snapshot.values) - self.assertEqual(len(snapshot.values["intermediate_steps"]), 1) - action, observation = snapshot.values["intermediate_steps"][0] - self.assertEqual(action.tool, "ask_user_for_help") - self.assertIsNone(observation) - self.assertNotIn("resumed", snapshot.values) - - self._run_assistant_graph(graph, conversation=self.conversation, message="It's straightforward") - snapshot: StateSnapshot = graph.get_state(config) - self.assertTrue(snapshot.next) - self.assertIn("intermediate_steps", snapshot.values) - self.assertEqual(len(snapshot.values["intermediate_steps"]), 2) - action, observation = snapshot.values["intermediate_steps"][0] - self.assertEqual(action.tool, "ask_user_for_help") - self.assertEqual(observation, "It's straightforward") - action, observation = snapshot.values["intermediate_steps"][1] - self.assertEqual(action.tool, "ask_user_for_help") - self.assertIsNone(observation) - self.assertFalse(snapshot.values["resumed"]) - - def test_resuming_uses_saved_state(self): - with patch("ee.hogai.taxonomy_agent.nodes.TaxonomyAgentPlannerNode._model") as mock: - graph = ( - AssistantGraph(self.team) - .add_edge(AssistantNodeName.START, AssistantNodeName.FUNNEL_PLANNER) - .add_funnel_planner(AssistantNodeName.END) - .compile() - ) - config: RunnableConfig = { - "configurable": { - "thread_id": self.conversation.id, - } - } - - # Interrupt the graph - message = """ - Thought: Let's ask for help. - Action: - ``` - { - "action": "ask_user_for_help", - "action_input": "Need help with this query" - } - ``` - """ - mock.return_value = RunnableLambda(lambda _: messages.AIMessage(content=message)) - - self._run_assistant_graph(graph, conversation=self.conversation) - state: StateSnapshot = graph.get_state(config).values - self.assertIn("start_id", state) - self.assertIsNotNone(state["start_id"]) - - self._run_assistant_graph(graph, conversation=self.conversation, message="It's straightforward") - state: StateSnapshot = graph.get_state(config).values - self.assertIn("start_id", state) - self.assertIsNotNone(state["start_id"]) - - def test_new_conversation_handles_serialized_conversation(self): - graph = ( - AssistantGraph(self.team) - .add_node(AssistantNodeName.ROUTER, lambda _: {"messages": [AssistantMessage(content="Hello")]}) - .add_edge(AssistantNodeName.START, AssistantNodeName.ROUTER) - .add_edge(AssistantNodeName.ROUTER, AssistantNodeName.END) - .compile() - ) - output = self._run_assistant_graph( - graph, - conversation=self.conversation, - is_new_conversation=True, - ) - expected_output = [ - ("conversation", {"id": str(self.conversation.id)}), - ] - self.assertConversationEqual(output[:1], expected_output) - - output = self._run_assistant_graph( - graph, - conversation=self.conversation, - is_new_conversation=False, - ) - self.assertNotEqual(output[0][0], "conversation") - - @pytest.mark.asyncio - async def test_async_stream(self): - graph = ( - AssistantGraph(self.team) - .add_node(AssistantNodeName.ROUTER, lambda _: {"messages": [AssistantMessage(content="bar")]}) - .add_edge(AssistantNodeName.START, AssistantNodeName.ROUTER) - .add_edge(AssistantNodeName.ROUTER, AssistantNodeName.END) - .compile() - ) - assistant = Assistant(self.team, self.conversation, HumanMessage(content="foo")) - assistant._graph = graph - - expected_output = [ - ("message", HumanMessage(content="foo")), - ("message", ReasoningMessage(content="Identifying type of analysis")), - ("message", AssistantMessage(content="bar")), - ] - actual_output = [self._parse_stringified_message(message) async for message in assistant._astream()] - self.assertConversationEqual(actual_output, expected_output) - - @pytest.mark.asyncio - async def test_async_stream_handles_exceptions(self): - def node_handler(state): - raise ValueError() - - graph = ( - AssistantGraph(self.team) - .add_node(AssistantNodeName.ROUTER, node_handler) - .add_edge(AssistantNodeName.START, AssistantNodeName.ROUTER) - .add_edge(AssistantNodeName.ROUTER, AssistantNodeName.END) - .compile() - ) - assistant = Assistant(self.team, self.conversation, HumanMessage(content="foo")) - assistant._graph = graph - - expected_output = [ - ("message", HumanMessage(content="foo")), - ("message", ReasoningMessage(content="Identifying type of analysis")), - ("message", FailureMessage()), - ] - actual_output = [] - with self.assertRaises(ValueError): - async for message in assistant._astream(): - actual_output.append(self._parse_stringified_message(message)) - self.assertConversationEqual(actual_output, expected_output) - - @patch("ee.hogai.summarizer.nodes.SummarizerNode._model") - @patch("ee.hogai.schema_generator.nodes.SchemaGeneratorNode._model") - @patch("ee.hogai.taxonomy_agent.nodes.TaxonomyAgentPlannerNode._model") - @patch("ee.hogai.router.nodes.RouterNode._model") - @patch("ee.hogai.memory.nodes.MemoryCollectorNode._model", return_value=messages.AIMessage(content="[Done]")) - def test_full_trends_flow(self, memory_collector_mock, router_mock, planner_mock, generator_mock, summarizer_mock): - router_mock.return_value = RunnableLambda(lambda _: RouterOutput(visualization_type="trends")) - planner_mock.return_value = RunnableLambda( - lambda _: messages.AIMessage( - content=""" - Thought: Done. - Action: - ``` - { - "action": "final_answer", - "action_input": "Plan" - } - ``` - """ - ) - ) - query = AssistantTrendsQuery(series=[]) - generator_mock.return_value = RunnableLambda(lambda _: TrendsSchemaGeneratorOutput(query=query)) - summarizer_mock.return_value = RunnableLambda(lambda _: AssistantMessage(content="Summary")) - - # First run - actual_output = self._run_assistant_graph(is_new_conversation=True) - expected_output = [ - ("conversation", {"id": str(self.conversation.id)}), - ("message", HumanMessage(content="Hello")), - ("message", ReasoningMessage(content="Identifying type of analysis")), - ("message", RouterMessage(content="trends")), - ("message", ReasoningMessage(content="Picking relevant events and properties", substeps=[])), - ("message", ReasoningMessage(content="Picking relevant events and properties", substeps=[])), - ("message", ReasoningMessage(content="Creating trends query")), - ("message", VisualizationMessage(answer=query, plan="Plan")), - ("message", AssistantMessage(content="Summary")), - ] - self.assertConversationEqual(actual_output, expected_output) - self.assertEqual(actual_output[1][1]["id"], actual_output[7][1]["initiator"]) - - # Second run - actual_output = self._run_assistant_graph(is_new_conversation=False) - self.assertConversationEqual(actual_output, expected_output[1:]) - self.assertEqual(actual_output[0][1]["id"], actual_output[6][1]["initiator"]) - - # Third run - actual_output = self._run_assistant_graph(is_new_conversation=False) - self.assertConversationEqual(actual_output, expected_output[1:]) - self.assertEqual(actual_output[0][1]["id"], actual_output[6][1]["initiator"]) - - @patch("ee.hogai.summarizer.nodes.SummarizerNode._model") - @patch("ee.hogai.schema_generator.nodes.SchemaGeneratorNode._model") - @patch("ee.hogai.taxonomy_agent.nodes.TaxonomyAgentPlannerNode._model") - @patch("ee.hogai.router.nodes.RouterNode._model") - @patch("ee.hogai.memory.nodes.MemoryCollectorNode._model", return_value=messages.AIMessage(content="[Done]")) - def test_full_funnel_flow(self, memory_collector_mock, router_mock, planner_mock, generator_mock, summarizer_mock): - router_mock.return_value = RunnableLambda(lambda _: RouterOutput(visualization_type="funnel")) - planner_mock.return_value = RunnableLambda( - lambda _: messages.AIMessage( - content=""" - Thought: Done. - Action: - ``` - { - "action": "final_answer", - "action_input": "Plan" - } - ``` - """ - ) - ) - query = AssistantFunnelsQuery( - series=[ - AssistantFunnelsEventsNode(event="$pageview"), - AssistantFunnelsEventsNode(event="$pageleave"), - ] - ) - generator_mock.return_value = RunnableLambda(lambda _: FunnelsSchemaGeneratorOutput(query=query)) - summarizer_mock.return_value = RunnableLambda(lambda _: AssistantMessage(content="Summary")) - - # First run - actual_output = self._run_assistant_graph(is_new_conversation=True) - expected_output = [ - ("conversation", {"id": str(self.conversation.id)}), - ("message", HumanMessage(content="Hello")), - ("message", ReasoningMessage(content="Identifying type of analysis")), - ("message", RouterMessage(content="funnel")), - ("message", ReasoningMessage(content="Picking relevant events and properties", substeps=[])), - ("message", ReasoningMessage(content="Picking relevant events and properties", substeps=[])), - ("message", ReasoningMessage(content="Creating funnel query")), - ("message", VisualizationMessage(answer=query, plan="Plan")), - ("message", AssistantMessage(content="Summary")), - ] - self.assertConversationEqual(actual_output, expected_output) - self.assertEqual(actual_output[1][1]["id"], actual_output[7][1]["initiator"]) - - # Second run - actual_output = self._run_assistant_graph(is_new_conversation=False) - self.assertConversationEqual(actual_output, expected_output[1:]) - self.assertEqual(actual_output[0][1]["id"], actual_output[6][1]["initiator"]) - - # Third run - actual_output = self._run_assistant_graph(is_new_conversation=False) - self.assertConversationEqual(actual_output, expected_output[1:]) - self.assertEqual(actual_output[0][1]["id"], actual_output[6][1]["initiator"]) - - @patch("ee.hogai.memory.nodes.MemoryInitializerInterruptNode._model") - @patch("ee.hogai.memory.nodes.MemoryInitializerNode._model") - def test_onboarding_flow_accepts_memory(self, model_mock, interruption_model_mock): - self._set_up_onboarding_tests() - - # Mock the memory initializer to return a product description - model_mock.return_value = RunnableLambda(lambda _: "PostHog is a product analytics platform.") - interruption_model_mock.return_value = RunnableLambda(lambda _: "PostHog is a product analytics platform.") - - # Create a graph with memory initialization flow - graph = AssistantGraph(self.team).add_memory_initializer(AssistantNodeName.END).compile() - - # First run - get the product description - output = self._run_assistant_graph(graph, is_new_conversation=True) - expected_output = [ - ("conversation", {"id": str(self.conversation.id)}), - ("message", HumanMessage(content="Hello")), - ( - "message", - AssistantMessage( - content=memory_prompts.SCRAPING_INITIAL_MESSAGE, - ), - ), - ("message", AssistantMessage(content="PostHog is a product analytics platform.")), - ("message", AssistantMessage(content=memory_prompts.SCRAPING_VERIFICATION_MESSAGE)), - ] - self.assertConversationEqual(output, expected_output) - - # Second run - accept the memory - output = self._run_assistant_graph( - graph, - message=memory_prompts.SCRAPING_CONFIRMATION_MESSAGE, - is_new_conversation=False, - ) - expected_output = [ - ("message", HumanMessage(content=memory_prompts.SCRAPING_CONFIRMATION_MESSAGE)), - ( - "message", - AssistantMessage(content=memory_prompts.SCRAPING_MEMORY_SAVED_MESSAGE), - ), - ("message", ReasoningMessage(content="Identifying type of analysis")), - ] - self.assertConversationEqual(output, expected_output) - - # Verify the memory was saved - core_memory = CoreMemory.objects.get(team=self.team) - self.assertEqual(core_memory.scraping_status, CoreMemory.ScrapingStatus.COMPLETED) - self.assertIsNotNone(core_memory.text) - - @patch("ee.hogai.memory.nodes.MemoryInitializerNode._model") - def test_onboarding_flow_rejects_memory(self, model_mock): - self._set_up_onboarding_tests() - - # Mock the memory initializer to return a product description - model_mock.return_value = RunnableLambda(lambda _: "PostHog is a product analytics platform.") - - # Create a graph with memory initialization flow - graph = AssistantGraph(self.team).add_memory_initializer(AssistantNodeName.END).compile() - - # First run - get the product description - output = self._run_assistant_graph(graph, is_new_conversation=True) - expected_output = [ - ("conversation", {"id": str(self.conversation.id)}), - ("message", HumanMessage(content="Hello")), - ( - "message", - AssistantMessage( - content=memory_prompts.SCRAPING_INITIAL_MESSAGE, - ), - ), - ("message", AssistantMessage(content="PostHog is a product analytics platform.")), - ("message", AssistantMessage(content=memory_prompts.SCRAPING_VERIFICATION_MESSAGE)), - ] - self.assertConversationEqual(output, expected_output) - - # Second run - reject the memory - output = self._run_assistant_graph( - graph, - message=memory_prompts.SCRAPING_REJECTION_MESSAGE, - is_new_conversation=False, - ) - expected_output = [ - ("message", HumanMessage(content=memory_prompts.SCRAPING_REJECTION_MESSAGE)), - ( - "message", - AssistantMessage( - content=memory_prompts.SCRAPING_TERMINATION_MESSAGE, - ), - ), - ("message", ReasoningMessage(content="Identifying type of analysis")), - ] - self.assertConversationEqual(output, expected_output) - - # Verify the memory was skipped - core_memory = CoreMemory.objects.get(team=self.team) - self.assertEqual(core_memory.scraping_status, CoreMemory.ScrapingStatus.SKIPPED) - self.assertEqual(core_memory.text, "") - - @patch("ee.hogai.memory.nodes.MemoryCollectorNode._model") - def test_memory_collector_flow(self, model_mock): - # Create a graph with just memory collection - graph = ( - AssistantGraph(self.team).add_memory_collector(AssistantNodeName.END).add_memory_collector_tools().compile() - ) - - # Mock the memory collector to first analyze and then append memory - def memory_collector_side_effect(prompt): - prompt_messages = prompt.to_messages() - if len(prompt_messages) == 2: # First run - return messages.AIMessage( - content="Let me analyze that.", - tool_calls=[ - { - "id": "1", - "name": "core_memory_append", - "args": {"memory_content": "The product uses a subscription model."}, - } - ], - ) - else: # Second run - return messages.AIMessage(content="Processing complete. [Done]") - - model_mock.return_value = RunnableLambda(memory_collector_side_effect) - - # First run - analyze and append memory - output = self._run_assistant_graph( - graph, - message="We use a subscription model", - is_new_conversation=True, - ) - expected_output = [ - ("conversation", {"id": str(self.conversation.id)}), - ("message", HumanMessage(content="We use a subscription model")), - ("message", AssistantMessage(content="Let me analyze that.")), - ("message", AssistantMessage(content="Memory appended.")), - ] - self.assertConversationEqual(output, expected_output) - - # Verify memory was appended - self.core_memory.refresh_from_db() - self.assertIn("The product uses a subscription model.", self.core_memory.text) diff --git a/ee/hogai/test/test_utils.py b/ee/hogai/test/test_utils.py deleted file mode 100644 index 8c32471c88..0000000000 --- a/ee/hogai/test/test_utils.py +++ /dev/null @@ -1,74 +0,0 @@ -from ee.hogai.utils.helpers import filter_messages -from posthog.schema import ( - AssistantMessage, - AssistantTrendsQuery, - FailureMessage, - HumanMessage, - RouterMessage, - VisualizationMessage, -) -from posthog.test.base import BaseTest - - -class TestTrendsUtils(BaseTest): - def test_filters_and_merges_human_messages(self): - conversation = [ - HumanMessage(content="Text"), - FailureMessage(content="Error"), - HumanMessage(content="Text"), - VisualizationMessage(answer=AssistantTrendsQuery(series=[]), plan="plan"), - HumanMessage(content="Text2"), - VisualizationMessage(answer=None, plan="plan"), - ] - messages = filter_messages(conversation) - self.assertEqual(len(messages), 4) - self.assertEqual( - [ - HumanMessage(content="Text\nText"), - VisualizationMessage(answer=AssistantTrendsQuery(series=[]), plan="plan"), - HumanMessage(content="Text2"), - VisualizationMessage(answer=None, plan="plan"), - ], - messages, - ) - - def test_filters_typical_conversation(self): - messages = filter_messages( - [ - HumanMessage(content="Question 1"), - RouterMessage(content="trends"), - VisualizationMessage(answer=AssistantTrendsQuery(series=[]), plan="Plan 1"), - AssistantMessage(content="Summary 1"), - HumanMessage(content="Question 2"), - RouterMessage(content="funnel"), - VisualizationMessage(answer=AssistantTrendsQuery(series=[]), plan="Plan 2"), - AssistantMessage(content="Summary 2"), - ] - ) - self.assertEqual(len(messages), 6) - self.assertEqual( - messages, - [ - HumanMessage(content="Question 1"), - VisualizationMessage(answer=AssistantTrendsQuery(series=[]), plan="Plan 1"), - AssistantMessage(content="Summary 1"), - HumanMessage(content="Question 2"), - VisualizationMessage(answer=AssistantTrendsQuery(series=[]), plan="Plan 2"), - AssistantMessage(content="Summary 2"), - ], - ) - - def test_joins_human_messages(self): - messages = filter_messages( - [ - HumanMessage(content="Question 1"), - HumanMessage(content="Question 2"), - ] - ) - self.assertEqual(len(messages), 1) - self.assertEqual( - messages, - [ - HumanMessage(content="Question 1\nQuestion 2"), - ], - ) diff --git a/ee/hogai/trends/__init__.py b/ee/hogai/trends/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/trends/nodes.py b/ee/hogai/trends/nodes.py deleted file mode 100644 index e430b4036e..0000000000 --- a/ee/hogai/trends/nodes.py +++ /dev/null @@ -1,50 +0,0 @@ -from langchain_core.prompts import ChatPromptTemplate -from langchain_core.runnables import RunnableConfig - -from ee.hogai.schema_generator.nodes import SchemaGeneratorNode, SchemaGeneratorToolsNode -from ee.hogai.schema_generator.utils import SchemaGeneratorOutput -from ee.hogai.taxonomy_agent.nodes import TaxonomyAgentPlannerNode, TaxonomyAgentPlannerToolsNode -from ee.hogai.trends.prompts import REACT_SYSTEM_PROMPT, TRENDS_SYSTEM_PROMPT -from ee.hogai.trends.toolkit import TRENDS_SCHEMA, TrendsTaxonomyAgentToolkit -from ee.hogai.utils.types import AssistantState, PartialAssistantState -from posthog.schema import AssistantTrendsQuery - - -class TrendsPlannerNode(TaxonomyAgentPlannerNode): - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - toolkit = TrendsTaxonomyAgentToolkit(self._team) - prompt = ChatPromptTemplate.from_messages( - [ - ("system", REACT_SYSTEM_PROMPT), - ], - template_format="mustache", - ) - return super()._run_with_prompt_and_toolkit(state, prompt, toolkit, config=config) - - -class TrendsPlannerToolsNode(TaxonomyAgentPlannerToolsNode): - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - toolkit = TrendsTaxonomyAgentToolkit(self._team) - return super()._run_with_toolkit(state, toolkit, config=config) - - -TrendsSchemaGeneratorOutput = SchemaGeneratorOutput[AssistantTrendsQuery] - - -class TrendsGeneratorNode(SchemaGeneratorNode[AssistantTrendsQuery]): - INSIGHT_NAME = "Trends" - OUTPUT_MODEL = TrendsSchemaGeneratorOutput - OUTPUT_SCHEMA = TRENDS_SCHEMA - - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: - prompt = ChatPromptTemplate.from_messages( - [ - ("system", TRENDS_SYSTEM_PROMPT), - ], - template_format="mustache", - ) - return super()._run_with_prompt(state, prompt, config=config) - - -class TrendsGeneratorToolsNode(SchemaGeneratorToolsNode): - pass diff --git a/ee/hogai/trends/prompts.py b/ee/hogai/trends/prompts.py deleted file mode 100644 index b04f00c15b..0000000000 --- a/ee/hogai/trends/prompts.py +++ /dev/null @@ -1,193 +0,0 @@ -REACT_SYSTEM_PROMPT = """ - -You are an expert product analyst agent specializing in data visualization and trends analysis. Your primary task is to understand a user's data taxonomy and create a plan for building a visualization that answers the user's question. This plan should focus on trends insights, including a series of events, property filters, and values of property filters. - -{{core_memory_instructions}} - -{{react_format}} - - - -{{core_memory}} - - -{{react_human_in_the_loop}} - -Below you will find information on how to correctly discover the taxonomy of the user's data. - - -Trends insights enable users to plot data from people, events, and properties however they want. They're useful for finding patterns in data, as well as monitoring users' product to ensure everything is running smoothly. Users can use multiple independent series in a single query to see trends. They can also use a formula to calculate a metric. Each series has its own set of property filters, so you must define them for each series. Trends insights do not require breakdowns or filters by default. - - - -You’ll be given a list of events in addition to the user’s question. Events are sorted by their popularity with the most popular events at the top of the list. Prioritize popular events. You must always specify events to use. Events always have an associated user’s profile. Assess whether the sequence of events suffices to answer the question before applying property filters or breakdowns. - - - -**Determine the math aggregation** the user is asking for, such as totals, averages, ratios, or custom formulas. If not specified, choose a reasonable default based on the event type (e.g., total count). By default, the total count should be used. You can aggregate data by events, event's property values,{{#groups}} {{.}}s,{{/groups}} or users. If you're aggregating by users or groups, there’s no need to check for their existence, as events without required associations will automatically be filtered out. - -Available math aggregations types for the event count are: -- total count -- average -- minimum -- maximum -- median -- 90th percentile -- 95th percentile -- 99th percentile -- unique users -- weekly active users -- daily active users -- first time for a user -{{#groups}} -- unique {{.}}s -{{/groups}} - -Available math aggregation types for event's property values are: -- average -- sum -- minimum -- maximum -- median -- 90th percentile -- 95th percentile -- 99th percentile - -Available math aggregation types counting number of events completed per user (intensity of usage) are: -- average -- minimum -- maximum -- median -- 90th percentile -- 95th percentile -- 99th percentile - -Examples of using aggregation types: -- `unique users` to find how many distinct users have logged the event per a day. -- `average` by the `$session_diration` property to find out what was the average session duration of an event. -- `99th percentile by users` to find out what was the 99th percentile of the event count by users. - - - -If the math aggregation is more complex or not listed above, use custom formulas to perform mathematical operations like calculating percentages or metrics. If you use a formula, you must use the following syntax: `A/B`, where `A` and `B` are the names of the series. You can combine math aggregations and formulas. - -When using a formula, you must: -- Identify and specify **all** events needed to solve the formula. -- Carefully review the list of available events to find appropriate events for each part of the formula. -- Ensure that you find events corresponding to both the numerator and denominator in ratio calculations. - -Examples of using math formulas: -- If you want to calculate the percentage of users who have completed onboarding, you need to find and use events similar to `$identify` and `onboarding complete`, so the formula will be `A / B`, where `A` is `onboarding complete` (unique users) and `B` is `$identify` (unique users). - - -{{react_property_filters}} - - -Breakdowns are used to segment data by property values of maximum three properties. They divide all defined trends series to multiple subseries based on the values of the property. Include breakdowns **only when they are essential to directly answer the user’s question**. You must not add breakdowns if the question can be addressed without additional segmentation. Always use the minimum set of breakdowns needed to answer the question. - -When using breakdowns, you must: -- **Identify the property group** and name for each breakdown. -- **Provide the property name** for each breakdown. -- **Validate that the property value accurately reflects the intended criteria**. - -Examples of using breakdowns: -- page views trend by country: you need to find a property such as `$geoip_country_code` and set it as a breakdown. -- number of users who have completed onboarding by an organization: you need to find a property such as `organization name` and set it as a breakdown. - - - -- Ensure that any properties or breakdowns included are directly relevant to the context and objectives of the user’s question. Avoid unnecessary or unrelated details. -- Avoid overcomplicating the response with excessive property filters or breakdowns. Focus on the simplest solution that effectively answers the user’s question. - ---- - -{{react_format_reminder}} -""" - -TRENDS_SYSTEM_PROMPT = """ -Act as an expert product manager. Your task is to generate a JSON schema of trends insights. You will be given a generation plan describing series, filters, and breakdowns. Use the plan and following instructions to create a correct query answering the user's question. - -Below is the additional context. - -Follow this instruction to create a query: -* Build series according to the plan. The plan includes event, math types, property filters, and breakdowns. Properties can be of multiple types: String, Numeric, Bool, and DateTime. A property can be an array of those types and only has a single type. -* When evaluating filter operators, replace the `equals` or `doesn't equal` operators with `contains` or `doesn't contain` if the query value is likely a personal name, company name, or any other name-sensitive term where letter casing matters. For instance, if the value is ‘John Doe’ or ‘Acme Corp’, replace `equals` with `contains` and change the value to lowercase from `John Doe` to `john doe` or `Acme Corp` to `acme corp`. -* Determine a visualization type that will answer the user's question in the best way. -* Determine if the user wants to name the series or use the default names. -* Choose the date range and the interval the user wants to analyze. -* Determine if the user wants to compare the results to a previous period or use smoothing. -* Determine if the user wants to filter out internal and test users. If the user didn't specify, filter out internal and test users by default. -* Determine if the user wants to use a sampling factor. -* Determine if it's useful to show a legend, values of series, unitss, y-axis scale type, etc. -* Use your judgment if there are any other parameters that the user might want to adjust that aren't listed here. - -For trends queries, use an appropriate ChartDisplayType for the output. For example: -- if the user wants to see dynamics in time like a line graph, use `ActionsLineGraph`. -- if the user wants to see cumulative dynamics across time, use `ActionsLineGraphCumulative`. -- if the user asks a question where you can answer with a single number, use `BoldNumber`. -- if the user wants a table, use `ActionsTable`. -- if the data is categorical, use `ActionsBar`. -- if the data is easy to understand in a pie chart, use `ActionsPie`. -- if the user has only one series and wants to see data from particular countries, use `WorldMap`. - -The user might want to get insights for groups. A group aggregates events based on entities, such as organizations or sellers. The user might provide a list of group names and their numeric indexes. Instead of a group's name, always use its numeric index. - -You can determine if a feature flag is enabled by checking if it's set to true or 1 in the `$feature/...` property. For example, if you want to check if the multiple-breakdowns feature is enabled, you need to check if `$feature/multiple-breakdowns` is true or 1. - -## Schema Examples - -### How many users do I have? - -``` -{"dateRange":{"date_from":"all"},"interval":"month","kind":"TrendsQuery","series":[{"event":"user signed up","kind":"EventsNode","math":"total"}],"trendsFilter":{"display":"BoldNumber"}} -``` - -### Show a bar chart of the organic search traffic for the last month grouped by week. - -``` -{"dateRange":{"date_from":"-30d","date_to":null,"explicitDate":false},"interval":"week","kind":"TrendsQuery","series":[{"event":"$pageview","kind":"EventsNode","math":"dau","properties":[{"key":"$referring_domain","operator":"icontains","type":"event","value":"google"},{"key":"utm_source","operator":"is_not_set","type":"event","value":"is_not_set"}]}],"trendsFilter":{"display":"ActionsBar"}} -``` - -### insight created unique users & first-time users for the last 12m) - -``` -{"dateRange":{"date_from":"-12m","date_to":""},"filterTestAccounts":true,"interval":"month","kind":"TrendsQuery","series":[{"event":"insight created","kind":"EventsNode","math":"dau","custom_name":"insight created"},{"event":"insight created","kind":"EventsNode","math":"first_time_for_user","custom_name":"insight created"}],"trendsFilter":{"display":"ActionsLineGraph"}} -``` - -### What are the top 10 referring domains for the last month? - -``` -{"breakdownFilter":{"breakdown_type":"event","breakdowns":[{"group_type_index":null,"histogram_bin_count":null,"normalize_url":null,"property":"$referring_domain","type":"event"}]},"dateRange":{"date_from":"-30d"},"interval":"day","kind":"TrendsQuery","series":[{"event":"$pageview","kind":"EventsNode","math":"total","custom_name":"$pageview"}]} -``` - -### What is the DAU to MAU ratio of users from the US and Australia that viewed a page in the last 7 days? Compare it to the previous period. - -``` -{"compareFilter":{"compare":true,"compare_to":null},"dateRange":{"date_from":"-7d"},"interval":"day","kind":"TrendsQuery","properties":{"type":"AND","values":[{"type":"AND","values":[{"key":"$geoip_country_name","operator":"exact","type":"event","value":["United States","Australia"]}]}]},"series":[{"event":"$pageview","kind":"EventsNode","math":"dau","custom_name":"$pageview"},{"event":"$pageview","kind":"EventsNode","math":"monthly_active","custom_name":"$pageview"}],"trendsFilter":{"aggregationAxisFormat":"percentage_scaled","display":"ActionsLineGraph","formula":"A/B"}} -``` - -### I want to understand how old are dashboard results when viewed from the beginning of this year grouped by a month. Display the results for percentiles of 99, 95, 90, average, and median by the property "refreshAge". - -``` -{"dateRange":{"date_from":"yStart","date_to":null,"explicitDate":false},"filterTestAccounts":true,"interval":"month","kind":"TrendsQuery","series":[{"event":"viewed dashboard","kind":"EventsNode","math":"p99","math_property":"refreshAge","custom_name":"viewed dashboard"},{"event":"viewed dashboard","kind":"EventsNode","math":"p95","math_property":"refreshAge","custom_name":"viewed dashboard"},{"event":"viewed dashboard","kind":"EventsNode","math":"p90","math_property":"refreshAge","custom_name":"viewed dashboard"},{"event":"viewed dashboard","kind":"EventsNode","math":"avg","math_property":"refreshAge","custom_name":"viewed dashboard"},{"event":"viewed dashboard","kind":"EventsNode","math":"median","math_property":"refreshAge","custom_name":"viewed dashboard"}],"trendsFilter":{"aggregationAxisFormat":"duration","display":"ActionsLineGraph"}} -``` - -### organizations joined in the last 30 days by day from the google search - -``` -{"dateRange":{"date_from":"-30d"},"filterTestAccounts":false,"interval":"day","kind":"TrendsQuery","properties":{"type":"AND","values":[{"type":"OR","values":[{"key":"$initial_utm_source","operator":"exact","type":"person","value":["google"]}]}]},"series":[{"event":"user signed up","kind":"EventsNode","math":"unique_group","math_group_type_index":0,"name":"user signed up","properties":[{"key":"is_organization_first_user","operator":"exact","type":"person","value":["true"]}]}],"trendsFilter":{"display":"ActionsLineGraph"}} -``` - -### trends for the last two weeks of the onboarding completed event by unique projects with a session duration more than 5 minutes and the insight analyzed event by unique projects with a breakdown by event's Country Name. exclude the US. - -``` -{"kind":"TrendsQuery","series":[{"kind":"EventsNode","event":"onboarding completed","name":"onboarding completed","properties":[{"key":"$session_duration","value":300,"operator":"gt","type":"session"}],"math":"unique_group","math_group_type_index":2},{"kind":"EventsNode","event":"insight analyzed","name":"insight analyzed","math":"unique_group","math_group_type_index":2}],"trendsFilter":{"display":"ActionsBar","showValuesOnSeries":true,"showPercentStackView":false,"showLegend":false},"breakdownFilter":{"breakdowns":[{"property":"$geoip_country_name","type":"event"}],"breakdown_limit":5},"properties":{"type":"AND","values":[{"type":"AND","values":[{"key":"$geoip_country_code","value":["US"],"operator":"is_not","type":"event"}]}]},"dateRange":{"date_from":"-14d","date_to":null},"interval":"day"} -``` - -Obey these rules: -- if the date range is not specified, use the best judgment to select a reasonable date range. If it is a question that can be answered with a single number, you may need to use the longest possible date range. -- Filter internal users by default if the user doesn't specify. -- Only use events and properties defined by the user. You can't create new events or property definitions. - -Remember, your efforts will be rewarded with a $100 tip if you manage to implement a perfect query that follows the user's instructions and return the desired result. Do not hallucinate. -""" diff --git a/ee/hogai/trends/test/__init__.py b/ee/hogai/trends/test/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/trends/test/test_nodes.py b/ee/hogai/trends/test/test_nodes.py deleted file mode 100644 index 004ab58408..0000000000 --- a/ee/hogai/trends/test/test_nodes.py +++ /dev/null @@ -1,44 +0,0 @@ -from unittest.mock import patch - -from django.test import override_settings -from langchain_core.runnables import RunnableLambda - -from ee.hogai.trends.nodes import TrendsGeneratorNode, TrendsSchemaGeneratorOutput -from ee.hogai.utils.types import AssistantState, PartialAssistantState -from posthog.schema import ( - AssistantTrendsQuery, - HumanMessage, - VisualizationMessage, -) -from posthog.test.base import APIBaseTest, ClickhouseTestMixin - - -@override_settings(IN_UNIT_TESTING=True) -class TestTrendsGeneratorNode(ClickhouseTestMixin, APIBaseTest): - maxDiff = None - - def setUp(self): - super().setUp() - self.schema = AssistantTrendsQuery(series=[]) - - def test_node_runs(self): - node = TrendsGeneratorNode(self.team) - with patch.object(TrendsGeneratorNode, "_model") as generator_model_mock: - generator_model_mock.return_value = RunnableLambda( - lambda _: TrendsSchemaGeneratorOutput(query=self.schema).model_dump() - ) - new_state = node.run( - AssistantState( - messages=[HumanMessage(content="Text")], - plan="Plan", - ), - {}, - ) - self.assertEqual( - new_state, - PartialAssistantState( - messages=[VisualizationMessage(answer=self.schema, plan="Plan", id=new_state.messages[0].id)], - intermediate_steps=[], - plan="", - ), - ) diff --git a/ee/hogai/trends/test/test_prompt.py b/ee/hogai/trends/test/test_prompt.py deleted file mode 100644 index f44fd46553..0000000000 --- a/ee/hogai/trends/test/test_prompt.py +++ /dev/null @@ -1,21 +0,0 @@ -from langchain_core.prompts import ChatPromptTemplate - -from ee.hogai.trends.prompts import REACT_SYSTEM_PROMPT -from posthog.test.base import BaseTest - - -class TestTrendsPrompts(BaseTest): - def test_planner_prompt_has_groups(self): - prompt = ChatPromptTemplate.from_messages( - [ - ("system", REACT_SYSTEM_PROMPT), - ], - template_format="mustache", - ).format( - groups=["org", "account"], - react_format="", - react_format_reminder="", - ) - self.assertIn("orgs, accounts,", prompt) - self.assertIn("unique orgs", prompt) - self.assertIn("unique accounts", prompt) diff --git a/ee/hogai/trends/toolkit.py b/ee/hogai/trends/toolkit.py deleted file mode 100644 index 5fd7a35f0f..0000000000 --- a/ee/hogai/trends/toolkit.py +++ /dev/null @@ -1,74 +0,0 @@ -from ee.hogai.taxonomy_agent.toolkit import TaxonomyAgentToolkit, ToolkitTool -from ee.hogai.utils.helpers import dereference_schema -from posthog.schema import AssistantTrendsQuery - - -class TrendsTaxonomyAgentToolkit(TaxonomyAgentToolkit): - def _get_tools(self) -> list[ToolkitTool]: - return [ - *self._default_tools, - { - "name": "final_answer", - "signature": "(final_response: str)", - "description": """ - Use this tool to provide the final answer to the user's question. - - Answer in the following format: - ``` - Events: - - event 1 - - math operation: total - - property filter 1: - - entity - - property name - - property type - - operator - - property value - - property filter 2... Repeat for each property filter. - - event 2 - - math operation: average by `property name`. - - property filter 1: - - entity - - property name - - property type - - operator - - property value - - property filter 2... Repeat for each property filter. - - Repeat for each event. - - (if a formula is used) - Formula: - `A/B`, where `A` is the first event and `B` is the second event. - - (if a breakdown is used) - Breakdown by: - - breakdown 1: - - entity - - property name - - Repeat for each breakdown. - ``` - - Args: - final_response: List all events and properties that you want to use to answer the question. - """, - }, - ] - - -def generate_trends_schema() -> dict: - schema = AssistantTrendsQuery.model_json_schema() - return { - "name": "output_insight_schema", - "description": "Outputs the JSON schema of a trends insight", - "parameters": { - "type": "object", - "properties": { - "query": dereference_schema(schema), - }, - "additionalProperties": False, - "required": ["query"], - }, - } - - -TRENDS_SCHEMA = generate_trends_schema() diff --git a/ee/hogai/utils/__init__.py b/ee/hogai/utils/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/hogai/utils/asgi.py b/ee/hogai/utils/asgi.py deleted file mode 100644 index a613ac8bb1..0000000000 --- a/ee/hogai/utils/asgi.py +++ /dev/null @@ -1,34 +0,0 @@ -from collections.abc import AsyncIterator, Callable, Iterable, Iterator -from typing import TypeVar - -from asgiref.sync import sync_to_async - -T = TypeVar("T") - - -class SyncIterableToAsync(AsyncIterator[T]): - def __init__(self, iterable: Iterable[T]) -> None: - self._iterable: Iterable[T] = iterable - # async versions of the `next` and `iter` functions - self.next_async: Callable = sync_to_async(self.next, thread_sensitive=False) - self.iter_async: Callable = sync_to_async(iter, thread_sensitive=False) - self.sync_iterator: Iterator[T] | None = None - - def __aiter__(self) -> AsyncIterator[T]: - return self - - async def __anext__(self) -> T: - if self.sync_iterator is None: - self.sync_iterator = await self.iter_async(self._iterable) - return await self.next_async(self.sync_iterator) - - @staticmethod - def next(it: Iterator[T]) -> T: - """ - asyncio expects `StopAsyncIteration` in place of `StopIteration`, - so here's a modified in-built `next` function that can handle this. - """ - try: - return next(it) - except StopIteration: - raise StopAsyncIteration diff --git a/ee/hogai/utils/helpers.py b/ee/hogai/utils/helpers.py deleted file mode 100644 index b09e439c6a..0000000000 --- a/ee/hogai/utils/helpers.py +++ /dev/null @@ -1,79 +0,0 @@ -from collections.abc import Sequence -from typing import Optional, TypeVar, Union - -from jsonref import replace_refs -from langchain_core.messages import ( - HumanMessage as LangchainHumanMessage, - merge_message_runs, -) - -from posthog.schema import ( - AssistantMessage, - HumanMessage, - VisualizationMessage, -) - -from .types import AssistantMessageUnion - - -def remove_line_breaks(line: str) -> str: - return line.replace("\n", " ") - - -def filter_messages( - messages: Sequence[AssistantMessageUnion], - entity_filter: Union[tuple[type[AssistantMessageUnion], ...], type[AssistantMessageUnion]] = ( - AssistantMessage, - VisualizationMessage, - ), -) -> list[AssistantMessageUnion]: - """ - Filters and merges the message history to be consumable by agents. Returns human and AI messages. - """ - stack: list[LangchainHumanMessage] = [] - filtered_messages: list[AssistantMessageUnion] = [] - - def _merge_stack(stack: list[LangchainHumanMessage]) -> list[HumanMessage]: - return [ - HumanMessage(content=langchain_message.content, id=langchain_message.id) - for langchain_message in merge_message_runs(stack) - ] - - for message in messages: - if isinstance(message, HumanMessage): - stack.append(LangchainHumanMessage(content=message.content, id=message.id)) - elif isinstance(message, entity_filter): - if stack: - filtered_messages += _merge_stack(stack) - stack = [] - filtered_messages.append(message) - - if stack: - filtered_messages += _merge_stack(stack) - - return filtered_messages - - -T = TypeVar("T", bound=AssistantMessageUnion) - - -def find_last_message_of_type(messages: Sequence[AssistantMessageUnion], message_type: type[T]) -> Optional[T]: - return next((msg for msg in reversed(messages) if isinstance(msg, message_type)), None) - - -def slice_messages_to_conversation_start( - messages: Sequence[AssistantMessageUnion], start_id: Optional[str] = None -) -> Sequence[AssistantMessageUnion]: - result = [] - for msg in messages: - result.append(msg) - if msg.id == start_id: - break - return result - - -def dereference_schema(schema: dict) -> dict: - new_schema: dict = replace_refs(schema, proxies=False, lazy_load=False) - if "$defs" in new_schema: - new_schema.pop("$defs") - return new_schema diff --git a/ee/hogai/utils/markdown.py b/ee/hogai/utils/markdown.py deleted file mode 100644 index 279fc17ffb..0000000000 --- a/ee/hogai/utils/markdown.py +++ /dev/null @@ -1,111 +0,0 @@ -from collections.abc import Sequence -from html.parser import HTMLParser -from inspect import getmembers, ismethod - -from markdown_it import MarkdownIt -from markdown_it.renderer import RendererProtocol -from markdown_it.token import Token -from markdown_it.utils import EnvType, OptionsDict - - -# Taken from https://github.com/elespike/mdit_plain/blob/main/src/mdit_plain/renderer.py -class HTMLTextRenderer(HTMLParser): - def __init__(self): - super().__init__() - self._handled_data = [] - - def handle_data(self, data): - self._handled_data.append(data) - - def reset(self): - self._handled_data = [] - super().reset() - - def render(self, html): - self.feed(html) - rendered_data = "".join(self._handled_data) - self.reset() - return rendered_data - - -class RendererPlain(RendererProtocol): - __output__ = "plain" - - def __init__(self, parser=None): - self.parser = parser - self.htmlparser = HTMLTextRenderer() - self.rules = { - func_name.replace("render_", ""): func - for func_name, func in getmembers(self, predicate=ismethod) - if func_name.startswith("render_") - } - - def render(self, tokens: Sequence[Token], options: OptionsDict, env: EnvType): - result = "" - for i, token in enumerate(tokens): - rule = self.rules.get(token.type, self.render_default) - result += rule(tokens, i, options, env) - if token.children is not None: - result += self.render(token.children, options, env) - return result.strip() - - def render_default(self, tokens, i, options, env): - return "" - - def render_bullet_list_close(self, tokens, i, options, env): - if (i + 1) == len(tokens) or "list" in tokens[i + 1].type: - return "" - return "\n" - - def render_code_block(self, tokens, i, options, env): - return f"\n{tokens[i].content}\n" - - def render_code_inline(self, tokens, i, options, env): - return tokens[i].content - - def render_fence(self, tokens, i, options, env): - return f"\n{tokens[i].content}\n" - - def render_hardbreak(self, tokens, i, options, env): - return "\n" - - def render_heading_close(self, tokens, i, options, env): - return "\n" - - def render_heading_open(self, tokens, i, options, env): - return "\n" - - def render_html_block(self, tokens, i, options, env): - return self.htmlparser.render(tokens[i].content) - - def render_list_item_open(self, tokens, i, options, env): - next_token = tokens[i + 1] - if hasattr(next_token, "hidden") and not next_token.hidden: - return "" - return "\n" - - def render_ordered_list_close(self, tokens, i, options, env): - if (i + 1) == len(tokens) or "list" in tokens[i + 1].type: - return "" - return "\n" - - def render_paragraph_close(self, tokens, i, options, env): - if tokens[i].hidden: - return "" - return "\n" - - def render_paragraph_open(self, tokens, i, options, env): - if tokens[i].hidden: - return "" - return "\n" - - def render_softbreak(self, tokens, i, options, env): - return "\n" - - def render_text(self, tokens, i, options, env): - return tokens[i].content - - -def remove_markdown(text: str) -> str: - parser = MarkdownIt(renderer_cls=RendererPlain) - return parser.render(text) diff --git a/ee/hogai/utils/nodes.py b/ee/hogai/utils/nodes.py deleted file mode 100644 index b727e643e3..0000000000 --- a/ee/hogai/utils/nodes.py +++ /dev/null @@ -1,32 +0,0 @@ -from abc import ABC, abstractmethod - -from langchain_core.runnables import RunnableConfig - -from ee.models.assistant import CoreMemory -from posthog.models.team.team import Team - -from .types import AssistantState, PartialAssistantState - - -class AssistantNode(ABC): - _team: Team - - def __init__(self, team: Team): - self._team = team - - @abstractmethod - def run(cls, state: AssistantState, config: RunnableConfig) -> PartialAssistantState | None: - raise NotImplementedError - - @property - def core_memory(self) -> CoreMemory | None: - try: - return CoreMemory.objects.get(team=self._team) - except CoreMemory.DoesNotExist: - return None - - @property - def core_memory_text(self) -> str: - if not self.core_memory: - return "" - return self.core_memory.formatted_text diff --git a/ee/hogai/utils/state.py b/ee/hogai/utils/state.py deleted file mode 100644 index 3392f3362a..0000000000 --- a/ee/hogai/utils/state.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import Any, Literal, TypedDict, TypeGuard, Union - -from langchain_core.messages import AIMessageChunk - -from ee.hogai.utils.types import AssistantNodeName, AssistantState, PartialAssistantState - -# A state update can have a partial state or a LangGraph's reserved dataclasses like Interrupt. -GraphValueUpdate = dict[AssistantNodeName, dict[Any, Any] | Any] - -GraphValueUpdateTuple = tuple[Literal["values"], GraphValueUpdate] - - -def is_value_update(update: list[Any]) -> TypeGuard[GraphValueUpdateTuple]: - """ - Transition between nodes. - - Returns: - PartialAssistantState, Interrupt, or other LangGraph reserved dataclasses. - """ - return len(update) == 2 and update[0] == "updates" - - -def validate_value_update(update: GraphValueUpdate) -> dict[AssistantNodeName, PartialAssistantState | Any]: - validated_update = {} - for node_name, value in update.items(): - if isinstance(value, dict): - validated_update[node_name] = PartialAssistantState.model_validate(value) - else: - validated_update[node_name] = value - return validated_update - - -class LangGraphState(TypedDict): - langgraph_node: AssistantNodeName - - -GraphMessageUpdateTuple = tuple[Literal["messages"], tuple[Union[AIMessageChunk, Any], LangGraphState]] - - -def is_message_update(update: list[Any]) -> TypeGuard[GraphMessageUpdateTuple]: - """ - Streaming of messages. - """ - return len(update) == 2 and update[0] == "messages" - - -GraphStateUpdateTuple = tuple[Literal["updates"], dict[Any, Any]] - - -def is_state_update(update: list[Any]) -> TypeGuard[GraphStateUpdateTuple]: - """ - Update of the state. Returns a full state. - """ - return len(update) == 2 and update[0] == "values" - - -def validate_state_update(state_update: dict[Any, Any]) -> AssistantState: - return AssistantState.model_validate(state_update) - - -GraphTaskStartedUpdateTuple = tuple[Literal["debug"], tuple[Union[AIMessageChunk, Any], LangGraphState]] - - -def is_task_started_update( - update: list[Any], -) -> TypeGuard[GraphTaskStartedUpdateTuple]: - """ - Streaming of messages. - """ - return len(update) == 2 and update[0] == "debug" and update[1]["type"] == "task" diff --git a/ee/hogai/utils/test/test_assistant_node.py b/ee/hogai/utils/test/test_assistant_node.py deleted file mode 100644 index 16946db36c..0000000000 --- a/ee/hogai/utils/test/test_assistant_node.py +++ /dev/null @@ -1,31 +0,0 @@ -from langchain_core.runnables import RunnableConfig - -from ee.hogai.utils.nodes import AssistantNode -from ee.hogai.utils.types import AssistantState, PartialAssistantState -from ee.models.assistant import CoreMemory -from posthog.test.base import BaseTest - - -class TestAssistantNode(BaseTest): - def setUp(self): - super().setUp() - - class Node(AssistantNode): - def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState | None: - raise NotImplementedError - - self.node = Node(self.team) - - def test_core_memory_when_exists(self): - core_memory = CoreMemory.objects.create(team=self.team, text="Test memory") - self.assertEqual(self.node.core_memory, core_memory) - - def test_core_memory_when_does_not_exist(self): - self.assertIsNone(self.node.core_memory) - - def test_product_core_memory_when_exists(self): - CoreMemory.objects.create(team=self.team, text="Test memory") - self.assertEqual(self.node.core_memory_text, "Test memory") - - def test_product_core_memory_when_does_not_exist(self): - self.assertEqual(self.node.core_memory_text, "") diff --git a/ee/hogai/utils/types.py b/ee/hogai/utils/types.py deleted file mode 100644 index 2b92ecdedc..0000000000 --- a/ee/hogai/utils/types.py +++ /dev/null @@ -1,74 +0,0 @@ -import operator -from collections.abc import Sequence -from enum import StrEnum -from typing import Annotated, Optional, Union - -from langchain_core.agents import AgentAction -from langchain_core.messages import BaseMessage as LangchainBaseMessage -from langgraph.graph import END, START -from pydantic import BaseModel, Field - -from posthog.schema import ( - AssistantMessage, - FailureMessage, - HumanMessage, - ReasoningMessage, - RouterMessage, - VisualizationMessage, -) - -AIMessageUnion = Union[AssistantMessage, VisualizationMessage, FailureMessage, RouterMessage, ReasoningMessage] -AssistantMessageUnion = Union[HumanMessage, AIMessageUnion] - - -class _SharedAssistantState(BaseModel): - intermediate_steps: Optional[list[tuple[AgentAction, Optional[str]]]] = Field(default=None) - start_id: Optional[str] = Field(default=None) - """ - The ID of the message from which the conversation started. - """ - plan: Optional[str] = Field(default=None) - resumed: Optional[bool] = Field(default=None) - """ - Whether the agent was resumed after interruption, such as a human in the loop. - """ - memory_updated: Optional[bool] = Field(default=None) - """ - Whether the memory was updated in the `MemoryCollectorNode`. - """ - memory_collection_messages: Optional[Sequence[LangchainBaseMessage]] = Field(default=None) - """ - The messages with tool calls to collect memory in the `MemoryCollectorToolsNode`. - """ - - -class AssistantState(_SharedAssistantState): - messages: Annotated[Sequence[AssistantMessageUnion], operator.add] - - -class PartialAssistantState(_SharedAssistantState): - messages: Optional[Sequence[AssistantMessageUnion]] = Field(default=None) - - -class AssistantNodeName(StrEnum): - START = START - END = END - MEMORY_ONBOARDING = "memory_onboarding" - MEMORY_INITIALIZER = "memory_initializer" - MEMORY_INITIALIZER_INTERRUPT = "memory_initializer_interrupt" - ROUTER = "router" - TRENDS_PLANNER = "trends_planner" - TRENDS_PLANNER_TOOLS = "trends_planner_tools" - TRENDS_GENERATOR = "trends_generator" - TRENDS_GENERATOR_TOOLS = "trends_generator_tools" - FUNNEL_PLANNER = "funnel_planner" - FUNNEL_PLANNER_TOOLS = "funnel_planner_tools" - FUNNEL_GENERATOR = "funnel_generator" - FUNNEL_GENERATOR_TOOLS = "funnel_generator_tools" - RETENTION_PLANNER = "retention_planner" - RETENTION_PLANNER_TOOLS = "retention_planner_tools" - RETENTION_GENERATOR = "retention_generator" - RETENTION_GENERATOR_TOOLS = "retention_generator_tools" - SUMMARIZER = "summarizer" - MEMORY_COLLECTOR = "memory_collector" - MEMORY_COLLECTOR_TOOLS = "memory_collector_tools" diff --git a/ee/management/commands/materialize_columns.py b/ee/management/commands/materialize_columns.py deleted file mode 100644 index 6d54f8362f..0000000000 --- a/ee/management/commands/materialize_columns.py +++ /dev/null @@ -1,111 +0,0 @@ -import argparse -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.", - ) - parser.add_argument( - "--nullable", - action=argparse.BooleanOptionalAction, - default=True, - dest="is_nullable", - ) - - def handle(self, *, is_nullable: bool, **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( - properties_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"], - is_nullable=is_nullable, - ) - 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"], - is_nullable=is_nullable, - ) diff --git a/ee/management/commands/update_materialized_column.py b/ee/management/commands/update_materialized_column.py deleted file mode 100644 index bb55a61545..0000000000 --- a/ee/management/commands/update_materialized_column.py +++ /dev/null @@ -1,31 +0,0 @@ -import logging - -from typing import Any -from collections.abc import Callable, Iterable -from django.core.management.base import BaseCommand, CommandParser - -from posthog.clickhouse.materialized_columns import ColumnName, TablesWithMaterializedColumns -from ee.clickhouse.materialized_columns.columns import update_column_is_disabled, drop_column - -logger = logging.getLogger(__name__) - -COLUMN_OPERATIONS: dict[str, Callable[[TablesWithMaterializedColumns, Iterable[ColumnName]], Any]] = { - "enable": lambda table, column_names: update_column_is_disabled(table, column_names, is_disabled=False), - "disable": lambda table, column_names: update_column_is_disabled(table, column_names, is_disabled=True), - "drop": drop_column, -} - - -class Command(BaseCommand): - def add_arguments(self, parser: CommandParser) -> None: - parser.add_argument("operation", choices=COLUMN_OPERATIONS.keys()) - parser.add_argument("table") - parser.add_argument("column_names", nargs="+", metavar="column") - - def handle( - self, operation: str, table: TablesWithMaterializedColumns, column_names: Iterable[ColumnName], **options - ): - logger.info("Running %r on %r for %r...", operation, table, column_names) - fn = COLUMN_OPERATIONS[operation] - fn(table, column_names) - logger.info("Success!") diff --git a/ee/migrations/0001_initial.py b/ee/migrations/0001_initial.py deleted file mode 100644 index 5b668bc772..0000000000 --- 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 36516d048a..0000000000 --- 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 6760baca0c..0000000000 --- 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 cd0d2b6b58..0000000000 --- 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 d785637d17..0000000000 --- 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 c86f415d3f..0000000000 --- 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 015498bfca..0000000000 --- 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 1172813b25..0000000000 --- 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 c01f76cfd6..0000000000 --- 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 687d746044..0000000000 --- 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 0f5d2ff4f2..0000000000 --- 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 540cd28133..0000000000 --- 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 c27f29ef35..0000000000 --- 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 dd5b0a7468..0000000000 --- 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 c61c980ba4..0000000000 --- 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 d366581f31..0000000000 --- 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/0017_accesscontrol_and_more.py b/ee/migrations/0017_accesscontrol_and_more.py deleted file mode 100644 index 1c870d3389..0000000000 --- a/ee/migrations/0017_accesscontrol_and_more.py +++ /dev/null @@ -1,75 +0,0 @@ -# Generated by Django 4.2.15 on 2024-11-07 17:05 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import posthog.models.utils - - -class Migration(migrations.Migration): - dependencies = [ - ("posthog", "0512_errortrackingissue_errortrackingissuefingerprintv2_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("ee", "0016_rolemembership_organization_member"), - ] - - operations = [ - migrations.CreateModel( - name="AccessControl", - fields=[ - ( - "id", - models.UUIDField( - default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False - ), - ), - ("access_level", models.CharField(max_length=32)), - ("resource", models.CharField(max_length=32)), - ("resource_id", models.CharField(max_length=36, null=True)), - ("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_member", - models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="access_controls", - related_query_name="access_controls", - to="posthog.organizationmembership", - ), - ), - ( - "role", - models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="access_controls", - related_query_name="access_controls", - to="ee.role", - ), - ), - ( - "team", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="access_controls", - related_query_name="access_controls", - to="posthog.team", - ), - ), - ], - ), - migrations.AddConstraint( - model_name="accesscontrol", - constraint=models.UniqueConstraint( - fields=("resource", "resource_id", "team", "organization_member", "role"), - name="unique resource per target", - ), - ), - ] diff --git a/ee/migrations/0018_conversation_conversationcheckpoint_and_more.py b/ee/migrations/0018_conversation_conversationcheckpoint_and_more.py deleted file mode 100644 index ec48cc780a..0000000000 --- a/ee/migrations/0018_conversation_conversationcheckpoint_and_more.py +++ /dev/null @@ -1,147 +0,0 @@ -# Generated by Django 4.2.15 on 2024-12-11 15:51 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import posthog.models.utils - - -class Migration(migrations.Migration): - dependencies = [ - ("posthog", "0528_project_field_in_taxonomy"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("ee", "0017_accesscontrol_and_more"), - ] - - operations = [ - migrations.CreateModel( - name="Conversation", - fields=[ - ( - "id", - models.UUIDField( - default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False - ), - ), - ("team", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="posthog.team")), - ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - "abstract": False, - }, - ), - migrations.CreateModel( - name="ConversationCheckpoint", - fields=[ - ( - "id", - models.UUIDField( - default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False - ), - ), - ( - "checkpoint_ns", - models.TextField( - default="", - help_text='Checkpoint namespace. Denotes the path to the subgraph node the checkpoint originates from, separated by `|` character, e.g. `"child|grandchild"`. Defaults to "" (root graph).', - ), - ), - ("checkpoint", models.JSONField(help_text="Serialized checkpoint data.", null=True)), - ("metadata", models.JSONField(help_text="Serialized checkpoint metadata.", null=True)), - ( - "parent_checkpoint", - models.ForeignKey( - help_text="Parent checkpoint ID.", - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="children", - to="ee.conversationcheckpoint", - ), - ), - ( - "thread", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="checkpoints", to="ee.conversation" - ), - ), - ], - ), - migrations.CreateModel( - name="ConversationCheckpointWrite", - fields=[ - ( - "id", - models.UUIDField( - default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False - ), - ), - ("task_id", models.UUIDField(help_text="Identifier for the task creating the checkpoint write.")), - ( - "idx", - models.IntegerField( - help_text="Index of the checkpoint write. It is an integer value where negative numbers are reserved for special cases, such as node interruption." - ), - ), - ( - "channel", - models.TextField( - help_text="An arbitrary string defining the channel name. For example, it can be a node name or a reserved LangGraph's enum." - ), - ), - ("type", models.TextField(help_text="Type of the serialized blob. For example, `json`.", null=True)), - ("blob", models.BinaryField(null=True)), - ( - "checkpoint", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="writes", - to="ee.conversationcheckpoint", - ), - ), - ], - ), - migrations.CreateModel( - name="ConversationCheckpointBlob", - fields=[ - ( - "id", - models.UUIDField( - default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False - ), - ), - ( - "channel", - models.TextField( - help_text="An arbitrary string defining the channel name. For example, it can be a node name or a reserved LangGraph's enum." - ), - ), - ("version", models.TextField(help_text="Monotonically increasing version of the channel.")), - ("type", models.TextField(help_text="Type of the serialized blob. For example, `json`.", null=True)), - ("blob", models.BinaryField(null=True)), - ( - "checkpoint", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="blobs", - to="ee.conversationcheckpoint", - ), - ), - ], - ), - migrations.AddConstraint( - model_name="conversationcheckpointwrite", - constraint=models.UniqueConstraint( - fields=("checkpoint_id", "task_id", "idx"), name="unique_checkpoint_write" - ), - ), - migrations.AddConstraint( - model_name="conversationcheckpointblob", - constraint=models.UniqueConstraint( - fields=("checkpoint_id", "channel", "version"), name="unique_checkpoint_blob" - ), - ), - migrations.AddConstraint( - model_name="conversationcheckpoint", - constraint=models.UniqueConstraint(fields=("id", "checkpoint_ns", "thread"), name="unique_checkpoint"), - ), - ] diff --git a/ee/migrations/0019_remove_conversationcheckpointblob_unique_checkpoint_blob_and_more.py b/ee/migrations/0019_remove_conversationcheckpointblob_unique_checkpoint_blob_and_more.py deleted file mode 100644 index 377f85b3d2..0000000000 --- a/ee/migrations/0019_remove_conversationcheckpointblob_unique_checkpoint_blob_and_more.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 4.2.15 on 2024-12-19 11:00 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - dependencies = [ - ("ee", "0018_conversation_conversationcheckpoint_and_more"), - ] - - operations = [ - migrations.RemoveConstraint( - model_name="conversationcheckpointblob", - name="unique_checkpoint_blob", - ), - migrations.AddField( - model_name="conversationcheckpointblob", - name="checkpoint_ns", - field=models.TextField( - default="", - help_text='Checkpoint namespace. Denotes the path to the subgraph node the checkpoint originates from, separated by `|` character, e.g. `"child|grandchild"`. Defaults to "" (root graph).', - ), - ), - migrations.AddField( - model_name="conversationcheckpointblob", - name="thread", - field=models.ForeignKey( - null=True, on_delete=django.db.models.deletion.CASCADE, related_name="blobs", to="ee.conversation" - ), - ), - migrations.AddConstraint( - model_name="conversationcheckpointblob", - constraint=models.UniqueConstraint( - fields=("thread_id", "checkpoint_ns", "channel", "version"), name="unique_checkpoint_blob" - ), - ), - ] diff --git a/ee/migrations/0020_corememory.py b/ee/migrations/0020_corememory.py deleted file mode 100644 index a66baec6e5..0000000000 --- a/ee/migrations/0020_corememory.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 4.2.15 on 2024-12-20 15:14 - -from django.db import migrations, models -import django.db.models.deletion -import posthog.models.utils - - -class Migration(migrations.Migration): - dependencies = [ - ("posthog", "0535_alter_hogfunction_type"), - ("ee", "0019_remove_conversationcheckpointblob_unique_checkpoint_blob_and_more"), - ] - - operations = [ - migrations.CreateModel( - name="CoreMemory", - fields=[ - ( - "id", - models.UUIDField( - default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False - ), - ), - ( - "text", - models.TextField(default="", help_text="Dumped core memory where facts are separated by newlines."), - ), - ("initial_text", models.TextField(default="", help_text="Scraped memory about the business.")), - ( - "scraping_status", - models.CharField( - blank=True, - choices=[("pending", "Pending"), ("completed", "Completed"), ("skipped", "Skipped")], - max_length=20, - null=True, - ), - ), - ("scraping_started_at", models.DateTimeField(null=True)), - ("team", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to="posthog.team")), - ], - options={ - "abstract": False, - }, - ), - ] diff --git a/ee/migrations/__init__.py b/ee/migrations/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/migrations/max_migration.txt b/ee/migrations/max_migration.txt deleted file mode 100644 index cd0433c401..0000000000 --- a/ee/migrations/max_migration.txt +++ /dev/null @@ -1 +0,0 @@ -0020_corememory diff --git a/ee/models/__init__.py b/ee/models/__init__.py deleted file mode 100644 index d1dfa7e8dc..0000000000 --- a/ee/models/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -from .assistant import ( - Conversation, - ConversationCheckpoint, - ConversationCheckpointBlob, - ConversationCheckpointWrite, - CoreMemory, -) -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 .rbac.access_control import AccessControl -from .rbac.role import Role, RoleMembership - -__all__ = [ - "AccessControl", - "ConversationCheckpoint", - "ConversationCheckpointBlob", - "ConversationCheckpointWrite", - "CoreMemory", - "DashboardPrivilege", - "Conversation", - "EnterpriseEventDefinition", - "EnterprisePropertyDefinition", - "ExplicitTeamMembership", - "FeatureFlagRoleAccess", - "Hook", - "License", - "Role", - "RoleMembership", -] diff --git a/ee/models/assistant.py b/ee/models/assistant.py deleted file mode 100644 index 90ac31e339..0000000000 --- a/ee/models/assistant.py +++ /dev/null @@ -1,145 +0,0 @@ -from collections.abc import Iterable -from datetime import timedelta - -from django.db import models -from django.utils import timezone -from langgraph.checkpoint.serde.types import TASKS - -from posthog.models.team.team import Team -from posthog.models.user import User -from posthog.models.utils import UUIDModel - - -class Conversation(UUIDModel): - user = models.ForeignKey(User, on_delete=models.CASCADE) - team = models.ForeignKey(Team, on_delete=models.CASCADE) - - -class ConversationCheckpoint(UUIDModel): - thread = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name="checkpoints") - checkpoint_ns = models.TextField( - default="", - help_text='Checkpoint namespace. Denotes the path to the subgraph node the checkpoint originates from, separated by `|` character, e.g. `"child|grandchild"`. Defaults to "" (root graph).', - ) - parent_checkpoint = models.ForeignKey( - "self", null=True, on_delete=models.CASCADE, related_name="children", help_text="Parent checkpoint ID." - ) - checkpoint = models.JSONField(null=True, help_text="Serialized checkpoint data.") - metadata = models.JSONField(null=True, help_text="Serialized checkpoint metadata.") - - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["id", "checkpoint_ns", "thread"], - name="unique_checkpoint", - ) - ] - - @property - def pending_sends(self) -> Iterable["ConversationCheckpointWrite"]: - if self.parent_checkpoint is None: - return [] - return self.parent_checkpoint.writes.filter(channel=TASKS).order_by("task_id", "idx") - - @property - def pending_writes(self) -> Iterable["ConversationCheckpointWrite"]: - return self.writes.order_by("idx", "task_id") - - -class ConversationCheckpointBlob(UUIDModel): - checkpoint = models.ForeignKey(ConversationCheckpoint, on_delete=models.CASCADE, related_name="blobs") - """ - The checkpoint that created the blob. Do not use this field to query blobs. - """ - thread = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name="blobs", null=True) - checkpoint_ns = models.TextField( - default="", - help_text='Checkpoint namespace. Denotes the path to the subgraph node the checkpoint originates from, separated by `|` character, e.g. `"child|grandchild"`. Defaults to "" (root graph).', - ) - channel = models.TextField( - help_text="An arbitrary string defining the channel name. For example, it can be a node name or a reserved LangGraph's enum." - ) - version = models.TextField(help_text="Monotonically increasing version of the channel.") - type = models.TextField(null=True, help_text="Type of the serialized blob. For example, `json`.") - blob = models.BinaryField(null=True) - - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["thread_id", "checkpoint_ns", "channel", "version"], - name="unique_checkpoint_blob", - ) - ] - - -class ConversationCheckpointWrite(UUIDModel): - checkpoint = models.ForeignKey(ConversationCheckpoint, on_delete=models.CASCADE, related_name="writes") - task_id = models.UUIDField(help_text="Identifier for the task creating the checkpoint write.") - idx = models.IntegerField( - help_text="Index of the checkpoint write. It is an integer value where negative numbers are reserved for special cases, such as node interruption." - ) - channel = models.TextField( - help_text="An arbitrary string defining the channel name. For example, it can be a node name or a reserved LangGraph's enum." - ) - type = models.TextField(null=True, help_text="Type of the serialized blob. For example, `json`.") - blob = models.BinaryField(null=True) - - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["checkpoint_id", "task_id", "idx"], - name="unique_checkpoint_write", - ) - ] - - -class CoreMemory(UUIDModel): - class ScrapingStatus(models.TextChoices): - PENDING = "pending", "Pending" - COMPLETED = "completed", "Completed" - SKIPPED = "skipped", "Skipped" - - team = models.OneToOneField(Team, on_delete=models.CASCADE) - text = models.TextField(default="", help_text="Dumped core memory where facts are separated by newlines.") - initial_text = models.TextField(default="", help_text="Scraped memory about the business.") - scraping_status = models.CharField(max_length=20, choices=ScrapingStatus.choices, blank=True, null=True) - scraping_started_at = models.DateTimeField(null=True) - - def change_status_to_pending(self): - self.scraping_started_at = timezone.now() - self.scraping_status = CoreMemory.ScrapingStatus.PENDING - self.save() - - def change_status_to_skipped(self): - self.scraping_status = CoreMemory.ScrapingStatus.SKIPPED - self.save() - - @property - def is_scraping_pending(self) -> bool: - return self.scraping_status == CoreMemory.ScrapingStatus.PENDING and ( - self.scraping_started_at is None or (self.scraping_started_at + timedelta(minutes=5)) > timezone.now() - ) - - @property - def is_scraping_finished(self) -> bool: - return self.scraping_status in [CoreMemory.ScrapingStatus.COMPLETED, CoreMemory.ScrapingStatus.SKIPPED] - - def set_core_memory(self, text: str): - self.text = text - self.initial_text = text - self.scraping_status = CoreMemory.ScrapingStatus.COMPLETED - self.save() - - def append_core_memory(self, text: str): - self.text = self.text + "\n" + text - self.save() - - def replace_core_memory(self, original_fragment: str, new_fragment: str): - if original_fragment not in self.text: - raise ValueError(f"Original fragment {original_fragment} not found in core memory") - self.text = self.text.replace(original_fragment, new_fragment) - self.save() - - @property - def formatted_text(self) -> str: - return self.text[0:5000] diff --git a/ee/models/dashboard_privilege.py b/ee/models/dashboard_privilege.py deleted file mode 100644 index 4dde1f4d13..0000000000 --- a/ee/models/dashboard_privilege.py +++ /dev/null @@ -1,30 +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( - "posthog.Dashboard", - on_delete=models.CASCADE, - related_name="privileges", - related_query_name="privilege", - ) - user = models.ForeignKey( - "posthog.User", - on_delete=models.CASCADE, - related_name="explicit_dashboard_privileges", - related_query_name="explicit_dashboard_privilege", - ) - level = models.PositiveSmallIntegerField(choices=Dashboard.RestrictionLevel.choices) - added_at = models.DateTimeField(auto_now_add=True) - updated_at = 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 fc172c4ac3..0000000000 --- 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(blank=True, null=True, default="") - updated_at = models.DateTimeField(auto_now=True) - updated_by = models.ForeignKey("posthog.User", null=True, on_delete=models.SET_NULL, blank=True) - verified = models.BooleanField(default=False, blank=True) - verified_at = 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 35330a11bb..0000000000 --- a/ee/models/explicit_team_membership.py +++ /dev/null @@ -1,47 +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( - "posthog.Team", - on_delete=models.CASCADE, - related_name="explicit_memberships", - related_query_name="explicit_membership", - ) - parent_membership = models.ForeignKey( - "posthog.OrganizationMembership", - on_delete=models.CASCADE, - related_name="explicit_team_memberships", - related_query_name="explicit_team_membership", - ) - level = models.PositiveSmallIntegerField(default=Level.MEMBER, choices=Level.choices) - joined_at = models.DateTimeField(auto_now_add=True) - updated_at = 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 867f2d562b..0000000000 --- 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( - "posthog.FeatureFlag", - on_delete=models.CASCADE, - related_name="access", - related_query_name="access", - ) - role = models.ForeignKey( - "Role", - on_delete=models.CASCADE, - related_name="feature_flag_access", - related_query_name="feature_flag_access", - ) - added_at = models.DateTimeField(auto_now_add=True) - updated_at = 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 7cfaf22b9f..0000000000 --- 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 5a18d8f8c5..0000000000 --- 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(auto_now_add=True) - plan = models.CharField(max_length=200) - valid_until = models.DateTimeField() - key = models.CharField(max_length=200) - # DEPRECATED: This is no longer used - max_users = 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/property_definition.py b/ee/models/property_definition.py deleted file mode 100644 index 3354afacb4..0000000000 --- a/ee/models/property_definition.py +++ /dev/null @@ -1,31 +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(blank=True, null=True, default="") - updated_at = models.DateTimeField(auto_now=True) - updated_by = models.ForeignKey("posthog.User", null=True, on_delete=models.SET_NULL, blank=True) - - verified = models.BooleanField(default=False, blank=True) - verified_at = 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/rbac/access_control.py b/ee/models/rbac/access_control.py deleted file mode 100644 index 9566b4adab..0000000000 --- a/ee/models/rbac/access_control.py +++ /dev/null @@ -1,53 +0,0 @@ -from django.db import models - -from posthog.models.utils import UUIDModel - - -class AccessControl(UUIDModel): - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["resource", "resource_id", "team", "organization_member", "role"], - name="unique resource per target", - ) - ] - - team = models.ForeignKey( - "posthog.Team", - on_delete=models.CASCADE, - related_name="access_controls", - related_query_name="access_controls", - ) - - # Configuration of what we are accessing - access_level: models.CharField = models.CharField(max_length=32) - resource: models.CharField = models.CharField(max_length=32) - resource_id: models.CharField = models.CharField(max_length=36, null=True) - - # Optional scope it to a specific member - organization_member = models.ForeignKey( - "posthog.OrganizationMembership", - on_delete=models.CASCADE, - related_name="access_controls", - related_query_name="access_controls", - null=True, - ) - - # Optional scope it to a specific role - role = models.ForeignKey( - "Role", - on_delete=models.CASCADE, - related_name="access_controls", - related_query_name="access_controls", - null=True, - ) - - created_by = 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) - - # TODO: add model validation for access_level and resource diff --git a/ee/models/rbac/organization_resource_access.py b/ee/models/rbac/organization_resource_access.py deleted file mode 100644 index de4c86d95a..0000000000 --- a/ee/models/rbac/organization_resource_access.py +++ /dev/null @@ -1,41 +0,0 @@ -from django.db import models - -from posthog.models.organization import Organization - -# NOTE: This will be deprecated in favour of the AccessControl model - - -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(max_length=32, choices=Resources.choices) - access_level = models.PositiveSmallIntegerField(default=AccessLevel.CAN_ALWAYS_EDIT, choices=AccessLevel.choices) - organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="resource_access") - created_by = models.ForeignKey( - "posthog.User", - on_delete=models.SET_NULL, - null=True, - ) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["organization", "resource"], - name="unique resource per organization", - ) - ] diff --git a/ee/models/rbac/role.py b/ee/models/rbac/role.py deleted file mode 100644 index cb35294da2..0000000000 --- a/ee/models/rbac/role.py +++ /dev/null @@ -1,61 +0,0 @@ -from django.db import models - -from ee.models.rbac.organization_resource_access import OrganizationResourceAccess -from posthog.models.utils import UUIDModel - - -class Role(UUIDModel): - class Meta: - constraints = [models.UniqueConstraint(fields=["organization", "name"], name="unique_role_name")] - - name = models.CharField(max_length=200) - organization = models.ForeignKey( - "posthog.Organization", - on_delete=models.CASCADE, - related_name="roles", - related_query_name="role", - ) - - created_at = models.DateTimeField(auto_now_add=True) - created_by = models.ForeignKey( - "posthog.User", - on_delete=models.SET_NULL, - related_name="roles", - related_query_name="role", - null=True, - ) - - # TODO: Deprecate this field - feature_flags_access_level = models.PositiveSmallIntegerField( - default=OrganizationResourceAccess.AccessLevel.CAN_ALWAYS_EDIT, - choices=OrganizationResourceAccess.AccessLevel.choices, - ) - - -class RoleMembership(UUIDModel): - class Meta: - constraints = [models.UniqueConstraint(fields=["role", "user"], name="unique_user_and_role")] - - role = 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( - "posthog.User", - on_delete=models.CASCADE, - related_name="role_memberships", - related_query_name="role_membership", - ) - - organization_member = models.ForeignKey( - "posthog.OrganizationMembership", - on_delete=models.CASCADE, - related_name="role_memberships", - related_query_name="role_membership", - null=True, - ) - joined_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) diff --git a/ee/models/test/__init__.py b/ee/models/test/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/models/test/test_assistant.py b/ee/models/test/test_assistant.py deleted file mode 100644 index daf00499bb..0000000000 --- a/ee/models/test/test_assistant.py +++ /dev/null @@ -1,95 +0,0 @@ -from datetime import timedelta - -from django.utils import timezone -from freezegun import freeze_time - -from ee.models.assistant import CoreMemory -from posthog.test.base import BaseTest - - -class TestCoreMemory(BaseTest): - def setUp(self): - super().setUp() - self.core_memory = CoreMemory.objects.create(team=self.team) - - def test_status_changes(self): - # Test pending status - self.core_memory.change_status_to_pending() - self.assertEqual(self.core_memory.scraping_status, CoreMemory.ScrapingStatus.PENDING) - self.assertIsNotNone(self.core_memory.scraping_started_at) - - # Test skipped status - self.core_memory.change_status_to_skipped() - self.assertEqual(self.core_memory.scraping_status, CoreMemory.ScrapingStatus.SKIPPED) - - def test_scraping_status_properties(self): - # Test pending status within time window - self.core_memory.change_status_to_pending() - self.assertTrue(self.core_memory.is_scraping_pending) - - # Test pending status outside time window - self.core_memory.scraping_started_at = timezone.now() - timedelta(minutes=6) - self.core_memory.save() - self.assertFalse(self.core_memory.is_scraping_pending) - - # Test finished status - self.core_memory.scraping_status = CoreMemory.ScrapingStatus.COMPLETED - self.core_memory.save() - self.assertTrue(self.core_memory.is_scraping_finished) - - self.core_memory.scraping_status = CoreMemory.ScrapingStatus.SKIPPED - self.core_memory.save() - self.assertTrue(self.core_memory.is_scraping_finished) - - @freeze_time("2023-01-01 12:00:00") - def test_is_scraping_pending_timing(self): - # Set initial pending status - self.core_memory.change_status_to_pending() - initial_time = timezone.now() - - # Test 3 minutes after (should be true) - with freeze_time(initial_time + timedelta(minutes=3)): - self.assertTrue(self.core_memory.is_scraping_pending) - - # Test exactly 5 minutes after (should be false) - with freeze_time(initial_time + timedelta(minutes=5)): - self.assertFalse(self.core_memory.is_scraping_pending) - - # Test 6 minutes after (should be false) - with freeze_time(initial_time + timedelta(minutes=6)): - self.assertFalse(self.core_memory.is_scraping_pending) - - def test_core_memory_operations(self): - # Test setting core memory - test_text = "Test memory content" - self.core_memory.set_core_memory(test_text) - self.assertEqual(self.core_memory.text, test_text) - self.assertEqual(self.core_memory.initial_text, test_text) - self.assertEqual(self.core_memory.scraping_status, CoreMemory.ScrapingStatus.COMPLETED) - - # Test appending core memory - append_text = "Additional content" - self.core_memory.append_core_memory(append_text) - self.assertEqual(self.core_memory.text, f"{test_text}\n{append_text}") - - # Test replacing core memory - original = "content" - new = "memory" - self.core_memory.replace_core_memory(original, new) - self.assertIn(new, self.core_memory.text) - self.assertNotIn(original, self.core_memory.text) - - # Test replacing non-existent content - with self.assertRaises(ValueError): - self.core_memory.replace_core_memory("nonexistent", "new") - - def test_formatted_text(self): - # Test formatted text with short content - short_text = "Short text" - self.core_memory.set_core_memory(short_text) - self.assertEqual(self.core_memory.formatted_text, short_text) - - # Test formatted text with long content - long_text = "x" * 6000 - self.core_memory.set_core_memory(long_text) - self.assertEqual(len(self.core_memory.formatted_text), 5000) 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 253de5d9c1..0000000000 --- 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 25ede95c04..0000000000 --- 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 4af882084e..0000000000 --- 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 e69de29bb2..0000000000 diff --git a/ee/session_recordings/ai/utils.py b/ee/session_recordings/ai/utils.py deleted file mode 100644 index 38ef49cfdb..0000000000 --- a/ee/session_recordings/ai/utils.py +++ /dev/null @@ -1,179 +0,0 @@ -import dataclasses -from datetime import datetime - -from typing import Any - - -@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) - - # one for each result in results - processed_elements_chain: list[dict] = dataclasses.field(default_factory=list) - - 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 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 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 b9181e361b..0000000000 --- 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 e69de29bb2..0000000000 diff --git a/ee/session_recordings/queries/test/__init__.py b/ee/session_recordings/queries/test/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ee/session_recordings/queries/test/__snapshots__/test_session_recording_list_from_query.ambr b/ee/session_recordings/queries/test/__snapshots__/test_session_recording_list_from_query.ambr deleted file mode 100644 index e3c36bc703..0000000000 --- a/ee/session_recordings/queries/test/__snapshots__/test_session_recording_list_from_query.ambr +++ /dev/null @@ -1,1649 +0,0 @@ -# serializer version: 1 -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, %(hogql_val_3)s)), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff(%(hogql_val_4)s, start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_5)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_6)s), toDateTime64('2020-12-29 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_7)s), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), globalIn(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 99999), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_8)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, %(hogql_val_9)s), now64(6, %(hogql_val_10)s)), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_11)s), toDateTime64('2020-12-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(events.mat_pp_rgInternal, ''), 'null'), %(hogql_val_12)s), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 50000 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.test_effect_of_poe_settings_on_query_generated_1_test_poe_being_unavailable_we_fall_back_to_person_id_overrides - ''' - 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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, %(hogql_val_3)s)), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff(%(hogql_val_4)s, start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_5)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_6)s), toDateTime64('2020-12-29 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_7)s), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), globalIn(s.session_id, - (SELECT 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, 99999) - 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) - LEFT JOIN - (SELECT argMax(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, %(hogql_val_8)s), ''), 'null'), '^"|"$', ''), person.version) AS properties___rgInternal, person.id AS id - FROM person - WHERE equals(person.team_id, 99999) - 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_9)s), person.version), plus(now64(6, %(hogql_val_10)s), toIntervalDay(1))), 0)) SETTINGS optimize_aggregation_in_order=1) AS events__person ON equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), events__person.id) - WHERE and(equals(events.team_id, 99999), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_11)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, %(hogql_val_12)s), now64(6, %(hogql_val_13)s)), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_14)s), toDateTime64('2020-12-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(events__person.properties___rgInternal, %(hogql_val_15)s), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 50000 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, %(hogql_val_3)s)), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff(%(hogql_val_4)s, start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_5)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_6)s), toDateTime64('2020-12-29 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_7)s), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), globalIn(s.session_id, - (SELECT 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, 99999) - 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) - LEFT JOIN - (SELECT argMax(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, %(hogql_val_8)s), ''), 'null'), '^"|"$', ''), person.version) AS properties___rgInternal, person.id AS id - FROM person - WHERE equals(person.team_id, 99999) - 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_9)s), person.version), plus(now64(6, %(hogql_val_10)s), toIntervalDay(1))), 0)) SETTINGS optimize_aggregation_in_order=1) AS events__person ON equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), events__person.id) - WHERE and(equals(events.team_id, 99999), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_11)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, %(hogql_val_12)s), now64(6, %(hogql_val_13)s)), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_14)s), toDateTime64('2020-12-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(events__person.properties___rgInternal, %(hogql_val_15)s), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 50000 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, %(hogql_val_3)s)), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff(%(hogql_val_4)s, start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_5)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_6)s), toDateTime64('2020-12-29 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_7)s), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), globalIn(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 99999), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_8)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, %(hogql_val_9)s), now64(6, %(hogql_val_10)s)), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_11)s), toDateTime64('2020-12-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(events.mat_pp_rgInternal, ''), 'null'), %(hogql_val_12)s), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 50000 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, %(hogql_val_3)s)), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff(%(hogql_val_4)s, start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_5)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_6)s), toDateTime64('2020-12-29 00:00:00.000000', 6, 'UTC')), 0), ifNull(lessOrEquals(toTimeZone(s.min_first_timestamp, %(hogql_val_7)s), toDateTime64('2021-01-01 13:46:23.000000', 6, 'UTC')), 0), globalIn(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 99999), notEmpty(events.`$session_id`), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_8)s), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, %(hogql_val_9)s), now64(6, %(hogql_val_10)s)), greaterOrEquals(toTimeZone(events.timestamp, %(hogql_val_11)s), toDateTime64('2020-12-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(events.mat_pp_rgInternal, ''), 'null'), %(hogql_val_12)s), 0)) - GROUP BY events.`$session_id` - HAVING 1))) - GROUP BY s.session_id - HAVING 1 - ORDER BY start_time DESC - LIMIT 50000 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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), globalIn(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 99999), 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-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(events.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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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), globalIn(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 99999), 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-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(events.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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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), globalIn(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 99999), 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-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(events.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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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), globalIn(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 99999), 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-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(events.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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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), globalIn(s.session_id, - (SELECT 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, 99999) - 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) - LEFT JOIN - (SELECT person.id AS id, nullIf(nullIf(person.pmat_email, ''), 'null') AS properties___email - FROM person - WHERE and(equals(person.team_id, 99999), ifNull(in(tuple(person.id, person.version), - (SELECT person.id AS id, max(person.version) AS version - FROM person - WHERE equals(person.team_id, 99999) - 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 events__person ON equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), events__person.id) - WHERE and(equals(events.team_id, 99999), 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-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(events__person.properties___email, '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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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), globalIn(s.session_id, - (SELECT 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, 99999) - 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) - LEFT JOIN - (SELECT person.id AS id, nullIf(nullIf(person.pmat_email, ''), 'null') AS properties___email - FROM person - WHERE and(equals(person.team_id, 99999), ifNull(in(tuple(person.id, person.version), - (SELECT person.id AS id, max(person.version) AS version - FROM person - WHERE equals(person.team_id, 99999) - 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 events__person ON equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), events__person.id) - WHERE and(equals(events.team_id, 99999), 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-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(events__person.properties___email, '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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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), globalIn(s.session_id, - (SELECT 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, 99999) - 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) - LEFT JOIN - (SELECT person.id AS id, nullIf(nullIf(person.pmat_email, ''), 'null') AS properties___email - FROM person - WHERE and(equals(person.team_id, 99999), ifNull(in(tuple(person.id, person.version), - (SELECT person.id AS id, max(person.version) AS version - FROM person - WHERE equals(person.team_id, 99999) - 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 events__person ON equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), events__person.id) - WHERE and(equals(events.team_id, 99999), 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-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(events__person.properties___email, '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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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), globalIn(s.session_id, - (SELECT 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, 99999) - 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) - LEFT JOIN - (SELECT person.id AS id, nullIf(nullIf(person.pmat_email, ''), 'null') AS properties___email - FROM person - WHERE and(equals(person.team_id, 99999), ifNull(in(tuple(person.id, person.version), - (SELECT person.id AS id, max(person.version) AS version - FROM person - WHERE equals(person.team_id, 99999) - 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 events__person ON equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), events__person.id) - WHERE and(equals(events.team_id, 99999), 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-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(events__person.properties___email, '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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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), globalIn(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 99999), 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-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(events.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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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), globalIn(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 99999), 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-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(events.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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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), globalIn(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 99999), 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-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(events.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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), 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-29 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), globalIn(s.session_id, - (SELECT events.`$session_id` AS session_id - FROM events - WHERE and(equals(events.team_id, 99999), 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-28 23:58:00.000000', 6, 'UTC')), ifNull(equals(nullIf(nullIf(events.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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), globalIn(s.session_id, - (SELECT DISTINCT events.`$session_id` AS `$session_id` - FROM events - WHERE and(equals(events.team_id, 99999), 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-29 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), globalIn(s.session_id, - (SELECT DISTINCT events.`$session_id` AS `$session_id` - FROM events - WHERE and(equals(events.team_id, 99999), 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-29 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), globalIn(s.session_id, - (SELECT DISTINCT events.`$session_id` AS `$session_id` - FROM events - WHERE and(equals(events.team_id, 99999), 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-29 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), globalIn(s.session_id, - (SELECT DISTINCT events.`$session_id` AS `$session_id` - FROM events - WHERE and(equals(events.team_id, 99999), 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-29 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), globalIn(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, 99999) - 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, 99999), 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-29 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), globalIn(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, 99999) - 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, 99999), 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-29 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), globalIn(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, 99999) - 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, 99999), 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-29 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), globalIn(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, 99999) - 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, 99999), 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-29 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), globalIn(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, 99999) - 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, 99999), 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-29 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), globalIn(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, 99999) - 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, 99999), 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-29 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), globalIn(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, 99999) - 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, 99999), 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-29 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- -# name: TestClickhouseSessionRecordingsListFromQuery.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) AS click_count, - sum(s.keypress_count) AS keypress_count, - sum(s.mouse_activity_count) AS 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, - ifNull(greaterOrEquals(max(toTimeZone(s._timestamp, 'UTC')), toDateTime64('2021-01-01 13:41:23.000000', 6, 'UTC')), 0) AS ongoing, - round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score - FROM session_replay_events AS s - WHERE and(equals(s.team_id, 99999), ifNull(greaterOrEquals(toTimeZone(s.min_first_timestamp, 'UTC'), toDateTime64('2020-12-11 13:46:23.000000', 6, 'UTC')), 0), globalIn(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, 99999) - 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, 99999), 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-29 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-29 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, - allow_experimental_analyzer=0 - ''' -# --- diff --git a/ee/session_recordings/queries/test/test_session_recording_list_from_query.py b/ee/session_recordings/queries/test/test_session_recording_list_from_query.py deleted file mode 100644 index 3893af827e..0000000000 --- a/ee/session_recordings/queries/test/test_session_recording_list_from_query.py +++ /dev/null @@ -1,347 +0,0 @@ -import re -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.schema import PersonsOnEventsMode, RecordingsQuery -from posthog.session_recordings.queries.session_recording_list_from_query import SessionRecordingListFromQuery -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 TestClickhouseSessionRecordingsListFromQuery(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, - ], - [ - "test_poe_being_unavailable_we_fall_back_to_person_id_overrides", - False, - False, - False, - PersonsOnEventsMode.PERSON_ID_OVERRIDE_PROPERTIES_JOINED, - ], - [ - "test_poe_being_unavailable_we_fall_back_to_person_subquery_but_still_use_mat_props", - False, - False, - False, - PersonsOnEventsMode.PERSON_ID_OVERRIDE_PROPERTIES_JOINED, - ], - [ - "test_allow_denormalised_props_fix_does_not_stop_all_poe_processing", - False, - True, - False, - PersonsOnEventsMode.PERSON_ID_OVERRIDE_PROPERTIES_ON_EVENTS, - ], - [ - "test_poe_v2_available_person_properties_are_used_in_replay_listing", - False, - True, - True, - PersonsOnEventsMode.PERSON_ID_OVERRIDE_PROPERTIES_ON_EVENTS, - ], - ] - ) - 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, - ) -> 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") - - query = RecordingsQuery.model_validate( - { - "properties": [ - { - "key": "rgInternal", - "value": ["false"], - "operator": "exact", - "type": "person", - } - ] - }, - ) - session_recording_list_instance = SessionRecordingListFromQuery( - query=query, 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) - - self._assert_is_events_person_filter(person_filtering_expr) - - if poe_v1 or poe_v2: - # Property used directly from event (from materialized column) - assert "ifNull(equals(nullIf(nullIf(events.mat_pp_rgInternal, ''), 'null')" in printed_query - else: - # We get the person property value from the persons JOIN - assert re.search( - r"argMax\(replaceRegexpAll\(nullIf\(nullIf\(JSONExtractRaw\(person\.properties, %\(hogql_val_\d+\)s\), ''\), 'null'\), '^\"|\"\$', ''\), person\.version\) AS properties___rgInternal", - printed_query, - ) - # Then we actually filter on that property value - assert re.search( - r"ifNull\(equals\(events__person\.properties___rgInternal, %\(hogql_val_\d+\)s\), 0\)", - printed_query, - ) - 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 = RecordingsQuery.model_validate( - {"properties": []}, - ) - - session_recording_list_instance = SessionRecordingListFromQuery( - query=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 = RecordingsQuery.model_validate( - { - "properties": [ - { - "key": "email", - "value": ["bla"], - "operator": "exact", - "type": "person", - } - ] - }, - ) - - session_recording_list_instance = SessionRecordingListFromQuery( - query=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]) - - query = RecordingsQuery.model_validate({"person_uuid": str(p.uuid)}) - session_recording_list_instance = SessionRecordingListFromQuery( - query=query, 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 b14397717f..0000000000 --- a/ee/session_recordings/session_recording_extensions.py +++ /dev/null @@ -1,97 +0,0 @@ -# EE extended functions for SessionRecording model -from datetime import timedelta - -import structlog -from django.utils import timezone -from prometheus_client import Histogram, Counter - -from posthog import settings -from posthog.session_recordings.models.session_recording import SessionRecording -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", -) - -RECORDING_PERSIST_START_COUNTER = Counter( - "recording_persist_started", - "Count of session recordings that were persisted", -) - -MINIMUM_AGE_FOR_RECORDING = timedelta(hours=24) - - -class InvalidRecordingForPersisting(Exception): - pass - - -def persist_recording(recording_id: str, team_id: int) -> None: - """Persist a recording to the S3""" - - 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 - - RECORDING_PERSIST_START_COUNTER.inc() - - 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. - SNAPSHOT_PERSIST_TOO_YOUNG_COUNTER.inc() - recording.save() - return - - target_prefix = recording.build_blob_lts_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() - 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) diff --git a/ee/session_recordings/session_recording_playlist.py b/ee/session_recordings/session_recording_playlist.py deleted file mode 100644 index c95c274e89..0000000000 --- a/ee/session_recordings/session_recording_playlist.py +++ /dev/null @@ -1,261 +0,0 @@ -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 posthog.api.utils import action - -from posthog.api.forbid_destroy_model import ForbidDestroyModel -from posthog.api.routing import TeamAndOrgViewSetMixin -from posthog.api.shared import UserBasicSerializer -from posthog.models import ( - SessionRecording, - SessionRecordingPlaylist, - SessionRecordingPlaylistItem, - User, -) -from posthog.models.activity_logging.activity_log import ( - Change, - Detail, - changes_between, - log_activity, -) -from posthog.models.utils import UUIDT -from posthog.rate_limit import ( - ClickHouseBurstRateThrottle, - ClickHouseSustainedRateThrottle, -) -from posthog.schema import RecordingsQuery -from posthog.session_recordings.session_recording_api import ( - list_recordings_response, - query_as_params_to_dict, - list_recordings_from_query, -) -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"]() - - 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 - - -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) - ) - - data_dict = query_as_params_to_dict(request.GET.dict()) - query = RecordingsQuery.model_validate(data_dict) - query.session_ids = playlist_items - return list_recordings_response( - list_recordings_from_query(query, request, context=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) - - if playlist_item: - playlist_item.delete() - - return response.Response({"success": True}) - - raise NotImplementedError() 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 536eb03477..0000000000 --- a/ee/session_recordings/session_summary/summarize_session.py +++ /dev/null @@ -1,144 +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, - 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( - 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-4o-mini", # 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 always try to make the summary actionable. E.g. mentioning what someone clicked on, or summarizing errors they experienced. - 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}. You never refer to URLs by their placeholder. Always refer to the URL with the simplest version e.g. posthog.com or posthog.com/replay - """, - }, - { - "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_texts, elements_chain_elements, and elements_chain_href extracted from the html can aid in understanding what a user interacted with - but should not be directly used in your response""", - }, - { - "role": "user", - "content": """ - generate a two or three sentence summary of the session. - only summarize, don't offer advice. - use as concise and simple language as is possible. - Dont' refer to the session length unless it is notable for some reason. - 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/test_summarize_session.py b/ee/session_recordings/session_summary/test/test_summarize_session.py deleted file mode 100644 index 3cc69df02b..0000000000 --- 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 e69de29bb2..0000000000 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 04dccb2faa..0000000000 --- a/ee/session_recordings/test/test_session_recording_extensions.py +++ /dev/null @@ -1,134 +0,0 @@ -from datetime import timedelta, datetime, UTC -from secrets import token_urlsafe -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 ( - persist_recording, -) -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, object_storage_client -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_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_blob_lts_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 - blob_path = f"{TEST_BUCKET}/team_id/{self.team.pk}/session_id/{session_id}/data" - for file in ["a", "b", "c"]: - file_name = f"{blob_path}/{file}" - write(file_name, f"my content-{file}".encode()) - - assert object_storage_client().list_objects(OBJECT_STORAGE_BUCKET, blob_path) == [ - f"{blob_path}/a", - f"{blob_path}/b", - f"{blob_path}/c", - ] - - recording: SessionRecording = SessionRecording.objects.create(team=self.team, session_id=session_id) - - assert recording.created_at == two_minutes_ago - assert recording.storage_version is None - - 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" - - stored_objects = list_objects(recording.build_blob_lts_storage_path("2023-08-01")) - assert stored_objects == [ - f"{recording.build_blob_lts_storage_path('2023-08-01')}/a", - f"{recording.build_blob_lts_storage_path('2023-08-01')}/b", - f"{recording.build_blob_lts_storage_path('2023-08-01')}/c", - ] 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 2d26d96aab..0000000000 --- a/ee/session_recordings/test/test_session_recording_playlist.py +++ /dev/null @@ -1,351 +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 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_can_create_many_playlists(self): - for i in range(100): - response = self.client.post( - f"/api/projects/{self.team.id}/session_recording_playlists", - data={"name": f"test-{i}"}, - ) - assert response.status_code == status.HTTP_201_CREATED - - 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 0a2be3cb50..0000000000 --- a/ee/settings.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -Django settings for PostHog Enterprise Edition. -""" - -import os - -from posthog.settings import AUTHENTICATION_BACKENDS, DEBUG, DEMO, SITE_URL -from posthog.settings.utils import get_from_env -from posthog.utils import str_to_bool - -# SSO -AUTHENTICATION_BACKENDS = [ - *AUTHENTICATION_BACKENDS, - "ee.api.authentication.MultitenantSAMLAuth", - "ee.api.authentication.CustomGoogleOAuth2", -] - -# 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) - -# Assistant -LANGFUSE_PUBLIC_KEY = get_from_env("LANGFUSE_PUBLIC_KEY", "", type_cast=str) -LANGFUSE_SECRET_KEY = get_from_env("LANGFUSE_SECRET_KEY", "", type_cast=str) -LANGFUSE_HOST = get_from_env("LANGFUSE_HOST", "https://us.cloud.langfuse.com", type_cast=str) diff --git a/ee/surveys/summaries/summarize_surveys.py b/ee/surveys/summaries/summarize_surveys.py deleted file mode 100644 index 1b74ca04d6..0000000000 --- a/ee/surveys/summaries/summarize_surveys.py +++ /dev/null @@ -1,137 +0,0 @@ -import json - -import openai - -from datetime import datetime -from typing import Optional, cast - -from posthog.hogql import ast -from posthog.hogql.parser import parse_select -from posthog.hogql_queries.insights.paginators import HogQLHasMorePaginator -from posthog.schema import HogQLQueryResponse -from posthog.utils import get_instance_region - -from prometheus_client import Histogram - -from posthog.api.activity_log import ServerTimingsGathered -from posthog.models import Team, User - -import structlog - -logger = structlog.get_logger(__name__) - -TOKENS_IN_PROMPT_HISTOGRAM = Histogram( - "posthog_survey_summary_tokens_in_prompt_histogram", - "histogram of the number of tokens in the prompt used to generate a survey 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 prepare_data(query_response: HogQLQueryResponse) -> list[str]: - response_values = [] - properties_list: list[dict] = [json.loads(x[1]) for x in query_response.results] - for props in properties_list: - response_values.extend([value for key, value in props.items() if key.startswith("$survey_response") and value]) - return response_values - - -def summarize_survey_responses( - survey_id: str, question_index: Optional[int], survey_start: datetime, survey_end: datetime, team: Team, user: User -): - timer = ServerTimingsGathered() - - with timer("prepare_query"): - paginator = HogQLHasMorePaginator(limit=100, offset=0) - q = parse_select( - """ - SELECT distinct_id, properties - FROM events - WHERE event == 'survey sent' - AND properties.$survey_id = {survey_id} - -- e.g. `$survey_response` or `$survey_response_2` - AND trim(JSONExtractString(properties, {survey_response_property})) != '' - AND timestamp >= {start_date} - AND timestamp <= {end_date} - """, - { - "survey_id": ast.Constant(value=survey_id), - "survey_response_property": ast.Constant( - value=f"$survey_response_{question_index}" if question_index else "$survey_response" - ), - "start_date": ast.Constant(value=survey_start), - "end_date": ast.Constant(value=survey_end), - }, - ) - - with timer("run_query"): - query_response = paginator.execute_hogql_query( - team=team, - query_type="survey_response_list_query", - query=cast(ast.SelectQuery, q), - ) - - with timer("llm_api_prep"): - instance_region = get_instance_region() or "HOBBY" - prepared_data = prepare_data(query_response) - - with timer("openai_completion"): - result = openai.chat.completions.create( - model="gpt-4o-mini", # allows 128k tokens - temperature=0.7, - messages=[ - { - "role": "system", - "content": """ - You are a product manager's assistant. You summarise survey responses from users for the product manager. - You don't do any other tasks. - """, - }, - { - "role": "user", - "content": f"""the survey responses are {prepared_data}.""", - }, - { - "role": "user", - "content": """ - generate a one or two paragraph summary of the survey response. - only summarize, the goal is to identify real user pain points and needs -use bullet points to identify the themes, and highlights of quotes to bring them to life -we're trying to identify what to work on - use as concise and simple language as is possible. - generate no text other than the summary. - the aim is to let people see themes in the responses received. return the text in markdown format without using any paragraph formatting""", - }, - ], - user=f"{instance_region}/{user.pk}", - ) - - usage = result.usage.prompt_tokens if result.usage else None - if usage: - TOKENS_IN_PROMPT_HISTOGRAM.observe(usage) - - logger.info("survey_summary_response", result=result) - - content: str = result.choices[0].message.content or "" - return {"content": content, "timings": timer.get_all_timings()} diff --git a/ee/tasks/__init__.py b/ee/tasks/__init__.py deleted file mode 100644 index 4bc7933994..0000000000 --- a/ee/tasks/__init__.py +++ /dev/null @@ -1,19 +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, -) - -# 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", -] diff --git a/ee/tasks/auto_rollback_feature_flag.py b/ee/tasks/auto_rollback_feature_flag.py deleted file mode 100644 index f676f91d0c..0000000000 --- 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 98091c3b1d..0000000000 --- a/ee/tasks/materialized_columns.py +++ /dev/null @@ -1,60 +0,0 @@ -from collections.abc import Iterator -from dataclasses import dataclass -from celery.utils.log import get_task_logger -from clickhouse_driver import Client - -from ee.clickhouse.materialized_columns.columns import MaterializedColumn, get_cluster, tables as table_infos -from posthog.client import sync_execute -from posthog.settings import CLICKHOUSE_DATABASE -from posthog.clickhouse.materialized_columns import ColumnName, TablesWithMaterializedColumns - -logger = get_task_logger(__name__) - - -@dataclass -class MarkMaterializedTask: - table: str - column: MaterializedColumn - - def execute(self, client: Client) -> None: - expression, parameters = self.column.get_expression_and_parameters() - client.execute( - f"ALTER TABLE {self.table} MODIFY COLUMN {self.column.name} {self.column.type} MATERIALIZED {expression}", - parameters, - ) - - -def mark_all_materialized() -> None: - cluster = get_cluster() - - for table_name, column in get_materialized_columns_with_default_expression(): - table_info = table_infos[table_name] - table_info.map_data_nodes( - cluster, - MarkMaterializedTask( - table_info.data_table, - column, - ).execute, - ).result() - - -def get_materialized_columns_with_default_expression() -> Iterator[tuple[str, MaterializedColumn]]: - table_names: list[TablesWithMaterializedColumns] = ["events", "person"] - for table_name in table_names: - for column in MaterializedColumn.get_all(table_name): - if is_default_expression(table_name, column.name): - yield table_name, column - - -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/send_license_usage.py b/ee/tasks/send_license_usage.py deleted file mode 100644 index 72ad3f171c..0000000000 --- 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 c3e2a41422..0000000000 --- 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 7ca0e06e6d..0000000000 --- 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 39e342bcec..0000000000 --- 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 73643c7a97..0000000000 --- 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 eb8afed13c..0000000000 --- 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 e69de29bb2..0000000000 diff --git a/ee/tasks/test/subscriptions/__init__.py b/ee/tasks/test/subscriptions/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 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 cbe268b76e..0000000000 --- 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 dbb6bca116..0000000000 --- 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 b340843549..0000000000 --- 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:8010/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:8010/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:8010/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:8010/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 5f6db8011b..0000000000 --- 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 edab23bbfb..0000000000 --- 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 c9afe25850..0000000000 --- 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 ed0dd3e429..0000000000 --- a/ee/tasks/test/test_calculate_cohort.py +++ /dev/null @@ -1,541 +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", - }, - self.team.pk, - ) - - 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", - }, - self.team.pk, - ) - 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"}]', - }, - self.team.pk, - ) - 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", - }, - self.team.pk, - ) - - 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", - }, - self.team.pk, - ) - - 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.team.pk, - ) - 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 441179c2c3..0000000000 --- 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 64b227d7d1..0000000000 --- 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 54723fd5d5..0000000000 --- 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 91b58e0fcb..0000000000 --- a/ee/urls.py +++ /dev/null @@ -1,113 +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 ee.api import integration - -from .api import ( - authentication, - billing, - conversation, - dashboard_collaborator, - explicit_team_member, - feature_flag_role_access, - hooks, - license, - sentry_stats, - subscription, -) -from .api.rbac import organization_resource_access, role -from .session_recordings import session_recording_playlist - - -def extend_api_router() -> None: - from posthog.api import ( - environment_dashboards_router, - environments_router, - legacy_project_dashboards_router, - organizations_router, - project_feature_flags_router, - projects_router, - register_grandfathered_environment_nested_viewset, - router as root_router, - ) - - 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"], - ) - # Start: routes to be deprecated - project_feature_flags_router.register( - r"role_access", - feature_flag_role_access.FeatureFlagRoleAccessViewSet, - "project_feature_flag_role_access", - ["project_id", "feature_flag_id"], - ) - organizations_router.register( - r"resource_access", - organization_resource_access.OrganizationResourceAccessViewSet, - "organization_resource_access", - ["organization_id"], - ) - # End: routes to be deprecated - register_grandfathered_environment_nested_viewset(r"hooks", hooks.HookViewSet, "environment_hooks", ["team_id"]) - register_grandfathered_environment_nested_viewset( - r"explicit_members", - explicit_team_member.ExplicitTeamMemberViewSet, - "environment_explicit_members", - ["team_id"], - ) - - environment_dashboards_router.register( - r"collaborators", - dashboard_collaborator.DashboardCollaboratorViewSet, - "environment_dashboard_collaborators", - ["project_id", "dashboard_id"], - ) - legacy_project_dashboards_router.register( - r"collaborators", - dashboard_collaborator.DashboardCollaboratorViewSet, - "project_dashboard_collaborators", - ["project_id", "dashboard_id"], - ) - - register_grandfathered_environment_nested_viewset( - r"subscriptions", subscription.SubscriptionViewSet, "environment_subscriptions", ["team_id"] - ) - projects_router.register( - r"session_recording_playlists", - session_recording_playlist.SessionRecordingPlaylistViewSet, - "project_session_recording_playlists", - ["project_id"], - ) - - environments_router.register( - r"conversations", conversation.ConversationViewSet, "environment_conversations", ["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 21a4ec036c9a02fb232ed075e4e8557e18ab5c7f Mon Sep 17 00:00:00 2001 From: Vini murafa <06.poems_races@icloud.com> Date: Thu, 16 Jan 2025 22:49:35 +0100 Subject: [PATCH 2/2] Typo fix Lemon UI.stories.mdx --- frontend/src/stories/Lemon UI.stories.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/stories/Lemon UI.stories.mdx b/frontend/src/stories/Lemon UI.stories.mdx index 37d8bc7c3b..a599b661e1 100644 --- a/frontend/src/stories/Lemon UI.stories.mdx +++ b/frontend/src/stories/Lemon UI.stories.mdx @@ -14,7 +14,7 @@ Lemon UI has grown as a replacement for [Ant Design](https://ant.design/), from after onboarding our first product designer, Chris. The quality of our UI has been steadily going up since then, but the transition process is not complete yet. -**Your** awareness is needed for us to transition sucessfully. 💪 Please **DO NOT** use the following Ant components when building new UI: +**Your** awareness is needed for us to transition successfully. 💪 Please **DO NOT** use the following Ant components when building new UI: - `Button` – instead go for `LemonButton` - `Select` – instead go for `LemonSelect`

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#