diff --git a/docs/SUPERSET_DASHBOARD.md b/docs/SUPERSET_DASHBOARD.md index edfed2e..9693835 100644 --- a/docs/SUPERSET_DASHBOARD.md +++ b/docs/SUPERSET_DASHBOARD.md @@ -67,7 +67,18 @@ KPI разложены в одну строку по 12-колоночной с - **📱 Traffic by Device** — pie chart распределения по устройствам #### География -- **🌍 Geography Map** — world map с распределением по странам +- **🌍 Top Countries by Events** — top-15 стран по количеству событий + (`COUNT(*)`, единицы — события, штуки). Столбцы заменили legacy world map: + на текущем разреженном распределении так видны страна, значение, порядок и + tooltip. Перекос стран приходит из гео-фактуры статического сида + `geo_by_click_id`; своя генерация гео описана как отдельный будущий шаг в + ADR-0006 и не лечится настройкой чарта. + +> **Что проверили по Superset.** Через MCP Context7 проверили `/apache/superset`: +> legacy world map описан как отдельный legacy-плагин, а ECharts bar chart имеет +> штатные параметры `show_legend`, `rich_tooltip`, подписи осей и формат чисел. +> Поэтому для разреженной географии выбран top-N bar chart +> (`viz_type: echarts_timeseries_bar`), а не донастройка `world_map`. #### Маркетинг - **🔗 UTM Effectiveness Table** — таблица эффективности UTM-меток diff --git a/docs/course/lessons/06_superset_bi.md b/docs/course/lessons/06_superset_bi.md index df5b0ba..ca1f53b 100644 --- a/docs/course/lessons/06_superset_bi.md +++ b/docs/course/lessons/06_superset_bi.md @@ -119,7 +119,7 @@ http://localhost:8088/superset/dashboard/ecommerce-analytics/ - KPI сверху: `Total Events`, `Unique Users`, `Avg Events/Visit`, `Conversion to /confirmation`; - динамика: `Events over Time`, `Traffic by Device`; -- география: `Geography Map`; +- география: `Top Countries by Events`; - маркетинг: `UTM Effectiveness Table`, `Page Funnel`; - прохождение строк по слоям: `Rows by Layer (event)`. diff --git a/superset/create_dashboard.py b/superset/create_dashboard.py index 43bef21..1638f5c 100644 --- a/superset/create_dashboard.py +++ b/superset/create_dashboard.py @@ -147,18 +147,40 @@ CHARTS_CONFIG = [ }, # География { - "slice_name": "🌍 Geography Map", - "viz_type": "world_map", + "slice_name": "🌍 Top Countries by Events", + "previous_slice_names": ["🌍 Geography Map"], + # Legacy world_map показывает разреженную географию плохо: нет явной + # легенды, подписи единиц и стабильного tooltip. Для текущего сида + # читаемее top-N стран столбцами: сразу видны страна, значение и порядок. + # Перекос стран — свойство geo-фактуры из сида, а не настройка чарта. + "viz_type": "echarts_timeseries_bar", "dataset_name": "v_events_enriched", "params": { - "entity": "geo_country", - "metric": { - "expressionType": "SQL", - "sqlExpression": "COUNT(*)", - "label": "Events" - }, - "row_limit": 500, - "linear_color_scheme": "blue_white_yellow", + "x_axis": "geo_country", + "metrics": [ + { + "expressionType": "SQL", + "sqlExpression": "COUNT(*)", + "label": "Events, pcs", + } + ], + "row_limit": 15, + "order_desc": True, + "sort_series_type": "sum", + "orientation": "vertical", + "color_scheme": "supersetColors", + "show_legend": True, + "legendOrientation": "top", + "legendType": "scroll", + "rich_tooltip": True, + "tooltipTimeFormat": "smart_date", + "x_axis_title": "Country", + "x_axis_title_margin": 15, + "truncateXAxis": True, + "y_axis_title": "Events, pcs", + "y_axis_title_margin": 15, + "y_axis_title_position": "Left", + "y_axis_format": ",d", "time_range": "No filter" } }, @@ -290,7 +312,7 @@ DASHBOARD_ROWS = [ # Динамика во времени + разрез по устройствам [("📅 Events over Time", 8), ("📱 Traffic by Device", 4)], # География + эффективность маркетинговых каналов - [("🌍 Geography Map", 6), ("🔗 UTM Effectiveness Table", 6)], + [("🌍 Top Countries by Events", 6), ("🔗 UTM Effectiveness Table", 6)], # Популярные страницы + прохождение строк по слоям [("🪜 Page Funnel", 6), ("🧱 Rows by Layer (event)", 6)], ] @@ -360,6 +382,19 @@ def sync_query_context(chart, params: dict, dataset_id: int) -> None: chart.query_context = json.dumps(query_context) +def choose_chart_to_sync(existing_charts: list, current_name: str): + """Выбирает один chart для синхронизации и отдаёт лишние дубли на удаление.""" + if not existing_charts: + return None, [] + + selected = next( + (chart for chart in existing_charts if chart.slice_name == current_name), + existing_charts[0], + ) + duplicates = [chart for chart in existing_charts if chart is not selected] + return selected, duplicates + + def build_dashboard_metadata(filter_dataset_id: int | None) -> str: """Формирует json_metadata с валидными datasetId для native filters.""" native_filters = [] @@ -467,11 +502,23 @@ def main() -> bool: # идемпотентного rename без дублей в списке Charts. chart_names = [chart_config["slice_name"]] chart_names.extend(chart_config.get("previous_slice_names", [])) - existing = db.session.query(Slice).filter( + existing_charts = db.session.query(Slice).filter( Slice.slice_name.in_(chart_names) - ).order_by(Slice.id.asc()).first() + ).order_by(Slice.id.asc()).all() + existing, duplicate_charts = choose_chart_to_sync( + existing_charts, + chart_config["slice_name"], + ) if existing: + for duplicate in duplicate_charts: + db.session.delete(duplicate) + logger.info( + "Deleted duplicate chart after rename: %s (ID: %s)", + duplicate.slice_name, + duplicate.id, + ) + # Синхронизируем параметры существующего чарта с конфигом. existing.slice_name = chart_config["slice_name"] existing.viz_type = chart_config["viz_type"] diff --git a/superset/dashboards/ecommerce_analytics.zip.json b/superset/dashboards/ecommerce_analytics.zip.json index ca643c0..7814a13 100644 --- a/superset/dashboards/ecommerce_analytics.zip.json +++ b/superset/dashboards/ecommerce_analytics.zip.json @@ -3,12 +3,23 @@ { "__Dashboard__": { "dashboard_title": "🛒 E-commerce Analytics Dashboard", - "description": "Аналитический дашборд для e-commerce кликстрима: трафик, конверсии, география и качество данных.", + "description": "Аналитический дашборд для e-commerce кликстрима: трафик, конверсии, география и прохождение строк по слоям.", "slug": "ecommerce-analytics", "published": true, "json_metadata": "{\"native_filter_configuration\": [{\"id\": \"date_filter\", \"name\": \"📅 Date Range\", \"filterType\": \"filter_time\", \"targets\": [{\"datasetId\": null, \"column\": {\"name\": \"event_date\"}}], \"defaultValue\": \"Last week\", \"scope\": {\"root\": [\"ROOT_ID\"], \"excluded\": []}, \"cascadeParentIds\": [], \"isInstant\": true}, {\"id\": \"country_filter\", \"name\": \"🌍 Country\", \"filterType\": \"filter_select\", \"targets\": [{\"datasetId\": null, \"column\": {\"name\": \"geo_country\"}}], \"scope\": {\"root\": [\"ROOT_ID\"], \"excluded\": []}, \"isInstant\": true, \"allowsMultipleValues\": true, \"isRequired\": false}, {\"id\": \"device_filter\", \"name\": \"📱 Device Type\", \"filterType\": \"filter_select\", \"targets\": [{\"datasetId\": null, \"column\": {\"name\": \"device_type\"}}], \"scope\": {\"root\": [\"ROOT_ID\"], \"excluded\": []}, \"isInstant\": true, \"allowsMultipleValues\": true, \"isRequired\": false}, {\"id\": \"browser_filter\", \"name\": \"🌐 Browser\", \"filterType\": \"filter_select\", \"targets\": [{\"datasetId\": null, \"column\": {\"name\": \"browser_name\"}}], \"scope\": {\"root\": [\"ROOT_ID\"], \"excluded\": []}, \"isInstant\": true, \"allowsMultipleValues\": true, \"isRequired\": false}], \"color_scheme\": \"supersetColors\", \"label_colors\": {}}", - "position_json": "{\"DASHBOARD_VERSION_KEY\": \"v2\", \"CHART-1\": {\"id\": \"CHART-1\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 1, \"sliceName\": \"📊 Total Events\", \"height\": 50, \"width\": 4, \"x\": 0, \"y\": 0}}, \"CHART-2\": {\"id\": \"CHART-2\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 2, \"sliceName\": \"👤 Unique Users\", \"height\": 50, \"width\": 4, \"x\": 4, \"y\": 0}}, \"CHART-3\": {\"id\": \"CHART-3\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 3, \"sliceName\": \"🎯 Unique Sessions\", \"height\": 50, \"width\": 4, \"x\": 8, \"y\": 0}}, \"CHART-4\": {\"id\": \"CHART-4\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 4, \"sliceName\": \"📈 Avg Events/Session\", \"height\": 50, \"width\": 4, \"x\": 0, \"y\": 50}}, \"CHART-5\": {\"id\": \"CHART-5\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 5, \"sliceName\": \"📅 Events by Hour\", \"height\": 50, \"width\": 8, \"x\": 0, \"y\": 100}}, \"CHART-6\": {\"id\": \"CHART-6\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 6, \"sliceName\": \"📱 Traffic by Device\", \"height\": 50, \"width\": 4, \"x\": 8, \"y\": 100}}, \"CHART-7\": {\"id\": \"CHART-7\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 7, \"sliceName\": \"🌍 Geography Map\", \"height\": 50, \"width\": 6, \"x\": 0, \"y\": 150}}, \"CHART-8\": {\"id\": \"CHART-8\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 8, \"sliceName\": \"🔗 UTM Effectiveness Table\", \"height\": 50, \"width\": 6, \"x\": 6, \"y\": 150}}, \"CHART-9\": {\"id\": \"CHART-9\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 9, \"sliceName\": \"📄 Top Pages\", \"height\": 50, \"width\": 6, \"x\": 0, \"y\": 200}}, \"CHART-10\": {\"id\": \"CHART-10\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 10, \"sliceName\": \"🔍 Data Quality Summary\", \"height\": 50, \"width\": 6, \"x\": 6, \"y\": 200}}}", - "slices": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + "position_json": "{\"DASHBOARD_VERSION_KEY\": \"v2\", \"ROOT_ID\": {\"id\": \"ROOT_ID\", \"type\": \"ROOT\", \"children\": [\"GRID_ID\"]}, \"GRID_ID\": {\"id\": \"GRID_ID\", \"type\": \"GRID\", \"children\": [\"ROW-0\", \"ROW-1\", \"ROW-2\", \"ROW-3\"], \"parents\": [\"ROOT_ID\"], \"meta\": {\"background\": \"BACKGROUND_TRANSPARENT\"}}, \"CHART-1\": {\"id\": \"CHART-1\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-0\"], \"meta\": {\"chartId\": 1, \"sliceName\": \"📊 Total Events\", \"width\": 3, \"height\": 30}}, \"CHART-2\": {\"id\": \"CHART-2\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-0\"], \"meta\": {\"chartId\": 2, \"sliceName\": \"👤 Unique Users\", \"width\": 3, \"height\": 30}}, \"CHART-3\": {\"id\": \"CHART-3\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-0\"], \"meta\": {\"chartId\": 3, \"sliceName\": \"📈 Avg Events/Visit\", \"width\": 3, \"height\": 30}}, \"CHART-4\": {\"id\": \"CHART-4\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-0\"], \"meta\": {\"chartId\": 4, \"sliceName\": \"🎯 Conversion to /confirmation\", \"width\": 3, \"height\": 30}}, \"ROW-0\": {\"id\": \"ROW-0\", \"type\": \"ROW\", \"children\": [\"CHART-1\", \"CHART-2\", \"CHART-3\", \"CHART-4\"], \"parents\": [\"ROOT_ID\", \"GRID_ID\"], \"meta\": {\"background\": \"BACKGROUND_TRANSPARENT\"}}, \"CHART-5\": {\"id\": \"CHART-5\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-1\"], \"meta\": {\"chartId\": 5, \"sliceName\": \"📅 Events over Time\", \"width\": 8, \"height\": 60}}, \"CHART-6\": {\"id\": \"CHART-6\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-1\"], \"meta\": {\"chartId\": 6, \"sliceName\": \"📱 Traffic by Device\", \"width\": 4, \"height\": 60}}, \"ROW-1\": {\"id\": \"ROW-1\", \"type\": \"ROW\", \"children\": [\"CHART-5\", \"CHART-6\"], \"parents\": [\"ROOT_ID\", \"GRID_ID\"], \"meta\": {\"background\": \"BACKGROUND_TRANSPARENT\"}}, \"CHART-7\": {\"id\": \"CHART-7\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-2\"], \"meta\": {\"chartId\": 7, \"sliceName\": \"🌍 Top Countries by Events\", \"width\": 6, \"height\": 60}}, \"CHART-8\": {\"id\": \"CHART-8\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-2\"], \"meta\": {\"chartId\": 8, \"sliceName\": \"🔗 UTM Effectiveness Table\", \"width\": 6, \"height\": 60}}, \"ROW-2\": {\"id\": \"ROW-2\", \"type\": \"ROW\", \"children\": [\"CHART-7\", \"CHART-8\"], \"parents\": [\"ROOT_ID\", \"GRID_ID\"], \"meta\": {\"background\": \"BACKGROUND_TRANSPARENT\"}}, \"CHART-9\": {\"id\": \"CHART-9\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-3\"], \"meta\": {\"chartId\": 9, \"sliceName\": \"🪜 Page Funnel\", \"width\": 6, \"height\": 60}}, \"CHART-10\": {\"id\": \"CHART-10\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-3\"], \"meta\": {\"chartId\": 10, \"sliceName\": \"🧱 Rows by Layer (event)\", \"width\": 6, \"height\": 60}}, \"ROW-3\": {\"id\": \"ROW-3\", \"type\": \"ROW\", \"children\": [\"CHART-9\", \"CHART-10\"], \"parents\": [\"ROOT_ID\", \"GRID_ID\"], \"meta\": {\"background\": \"BACKGROUND_TRANSPARENT\"}}}", + "slices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ] } } ], @@ -16,51 +27,51 @@ { "__Slice__": { "slice_name": "📊 Total Events", - "viz_type": "big_number", + "viz_type": "big_number_total", "datasource_type": "table", "datasource_name": "dm.v_events_enriched", - "params": "{\"datasource\": \"1__table\", \"viz_type\": \"big_number\", \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"column\": null, \"aggregate\": null, \"label\": \"Total Events\", \"optionName\": \"metric_1\"}, \"y_axis_format\": \",d\", \"show_trend_line\": false, \"time_range\": \"No filter\"}", - "description": "Общее количество событий" + "params": "{\"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"column\": null, \"aggregate\": null, \"label\": \"Total Events\", \"optionName\": \"metric_1\"}, \"y_axis_format\": \",d\", \"time_range\": \"No filter\", \"datasource\": \"1__table\", \"viz_type\": \"big_number_total\"}", + "description": "Chart created automatically for v_events_enriched" } }, { "__Slice__": { "slice_name": "👤 Unique Users", - "viz_type": "big_number", + "viz_type": "big_number_total", "datasource_type": "table", "datasource_name": "dm.v_events_enriched", - "params": "{\"datasource\": \"1__table\", \"viz_type\": \"big_number\", \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(DISTINCT user_domain_id)\", \"label\": \"Unique Users\", \"optionName\": \"metric_2\"}, \"y_axis_format\": \",d\", \"show_trend_line\": false, \"time_range\": \"No filter\"}", - "description": "Уникальные пользователи" + "params": "{\"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(DISTINCT user_domain_id)\", \"label\": \"Unique Users\", \"optionName\": \"metric_2\"}, \"y_axis_format\": \",d\", \"time_range\": \"No filter\", \"datasource\": \"1__table\", \"viz_type\": \"big_number_total\"}", + "description": "Chart created automatically for v_events_enriched" } }, { "__Slice__": { - "slice_name": "🎯 Unique Sessions", - "viz_type": "big_number", + "slice_name": "📈 Avg Events/Visit", + "viz_type": "big_number_total", "datasource_type": "table", "datasource_name": "dm.v_events_enriched", - "params": "{\"datasource\": \"1__table\", \"viz_type\": \"big_number\", \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(DISTINCT click_id)\", \"label\": \"Unique Sessions\", \"optionName\": \"metric_3\"}, \"y_axis_format\": \",d\", \"show_trend_line\": false, \"time_range\": \"No filter\"}", - "description": "Уникальные сессии" + "params": "{\"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*) / COUNT(DISTINCT click_id)\", \"label\": \"Avg Events/Visit\", \"optionName\": \"metric_4\"}, \"y_axis_format\": \".1f\", \"time_range\": \"No filter\", \"datasource\": \"1__table\", \"viz_type\": \"big_number_total\"}", + "description": "Chart created automatically for v_events_enriched" } }, { "__Slice__": { - "slice_name": "📈 Avg Events/Session", - "viz_type": "big_number", + "slice_name": "🎯 Conversion to /confirmation", + "viz_type": "big_number_total", "datasource_type": "table", "datasource_name": "dm.v_events_enriched", - "params": "{\"datasource\": \"1__table\", \"viz_type\": \"big_number\", \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*) / COUNT(DISTINCT click_id)\", \"label\": \"Avg Events/Session\", \"optionName\": \"metric_4\"}, \"y_axis_format\": \".2f\", \"show_trend_line\": false, \"time_range\": \"No filter\"}", - "description": "Среднее количество событий на сессию" + "params": "{\"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"if(countIf(page_url_path = '/home') = 0, 0, countIf(page_url_path = '/confirmation') / countIf(page_url_path = '/home'))\", \"label\": \"Conversion to /confirmation\", \"optionName\": \"metric_5\"}, \"y_axis_format\": \".1%\", \"time_range\": \"No filter\", \"datasource\": \"1__table\", \"viz_type\": \"big_number_total\"}", + "description": "Chart created automatically for v_events_enriched" } }, { "__Slice__": { - "slice_name": "📅 Events by Hour", + "slice_name": "📅 Events over Time", "viz_type": "echarts_timeseries_line", "datasource_type": "table", "datasource_name": "dm.v_events_enriched", - "params": "{\"datasource\": \"1__table\", \"viz_type\": \"echarts_timeseries_line\", \"granularity_sqla\": \"event_ts\", \"time_grain_sqla\": \"PT1H\", \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"label\": \"Events\"}], \"groupby\": [], \"time_range\": \"Last week\", \"adhoc_filters\": [], \"row_limit\": 10000}", - "description": "События по часам" + "params": "{\"granularity_sqla\": \"event_ts\", \"time_grain_sqla\": \"PT5M\", \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"label\": \"Events\"}], \"groupby\": [], \"time_range\": \"No filter\", \"adhoc_filters\": [], \"row_limit\": 10000, \"datasource\": \"1__table\", \"viz_type\": \"echarts_timeseries_line\"}", + "description": "Chart created automatically for v_events_enriched" } }, { @@ -69,18 +80,18 @@ "viz_type": "pie", "datasource_type": "table", "datasource_name": "dm.v_events_enriched", - "params": "{\"datasource\": \"1__table\", \"viz_type\": \"pie\", \"groupby\": [\"device_type\"], \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"label\": \"Count\"}, \"row_limit\": 100, \"donut\": true, \"show_legend\": true, \"labels_outside\": true, \"time_range\": \"No filter\"}", - "description": "Распределение трафика по устройствам" + "params": "{\"groupby\": [\"device_type\"], \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"label\": \"Count\"}, \"row_limit\": 100, \"donut\": true, \"show_legend\": true, \"labels_outside\": true, \"time_range\": \"No filter\", \"datasource\": \"1__table\", \"viz_type\": \"pie\"}", + "description": "Chart created automatically for v_events_enriched" } }, { "__Slice__": { - "slice_name": "🌍 Geography Map", - "viz_type": "world_map", + "slice_name": "🌍 Top Countries by Events", + "viz_type": "echarts_timeseries_bar", "datasource_type": "table", "datasource_name": "dm.v_events_enriched", - "params": "{\"datasource\": \"1__table\", \"viz_type\": \"world_map\", \"entity\": \"geo_country\", \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"label\": \"Events\"}, \"row_limit\": 500, \"linear_color_scheme\": \"blue_white_yellow\", \"time_range\": \"No filter\"}", - "description": "География посетителей" + "params": "{\"x_axis\": \"geo_country\", \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"label\": \"Events, pcs\"}], \"row_limit\": 15, \"order_desc\": true, \"sort_series_type\": \"sum\", \"orientation\": \"vertical\", \"color_scheme\": \"supersetColors\", \"show_legend\": true, \"legendOrientation\": \"top\", \"legendType\": \"scroll\", \"rich_tooltip\": true, \"tooltipTimeFormat\": \"smart_date\", \"x_axis_title\": \"Country\", \"x_axis_title_margin\": 15, \"truncateXAxis\": true, \"y_axis_title\": \"Events, pcs\", \"y_axis_title_margin\": 15, \"y_axis_title_position\": \"Left\", \"y_axis_format\": \",d\", \"time_range\": \"No filter\", \"datasource\": \"1__table\", \"viz_type\": \"echarts_timeseries_bar\"}", + "description": "Chart created automatically for v_events_enriched" } }, { @@ -89,28 +100,28 @@ "viz_type": "table", "datasource_type": "table", "datasource_name": "dm.v_utm_effectiveness", - "params": "{\"datasource\": \"2__table\", \"viz_type\": \"table\", \"groupby\": [\"utm_source\", \"utm_medium\", \"utm_campaign\"], \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(clicks)\", \"label\": \"Clicks\"}, {\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(uniq_users)\", \"label\": \"Users\"}, {\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(uniq_sessions)\", \"label\": \"Sessions\"}], \"row_limit\": 100, \"time_range\": \"No filter\", \"adhoc_filters\": [{\"clause\": \"WHERE\", \"expressionType\": \"SQL\", \"sqlExpression\": \"utm_source IS NOT NULL\", \"subject\": null, \"operator\": null, \"comparator\": null}]}\n", - "description": "Эффективность UTM-кампаний" + "params": "{\"groupby\": [\"utm_source\", \"utm_medium\", \"utm_campaign\"], \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(clicks)\", \"label\": \"Clicks\"}, {\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(uniq_users)\", \"label\": \"Users\"}, {\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(uniq_sessions)\", \"label\": \"Sessions\"}], \"row_limit\": 100, \"time_range\": \"No filter\", \"adhoc_filters\": [{\"clause\": \"WHERE\", \"expressionType\": \"SQL\", \"sqlExpression\": \"utm_source IS NOT NULL\", \"subject\": null, \"operator\": null, \"comparator\": null}], \"datasource\": \"2__table\", \"viz_type\": \"table\"}", + "description": "Chart created automatically for v_utm_effectiveness" } }, { "__Slice__": { - "slice_name": "📄 Top Pages", - "viz_type": "dist_bar", + "slice_name": "🪜 Page Funnel", + "viz_type": "funnel", "datasource_type": "table", "datasource_name": "dm.v_top_pages_daily", - "params": "{\"datasource\": \"3__table\", \"viz_type\": \"dist_bar\", \"groupby\": [\"page_url_path\"], \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(pageviews)\", \"label\": \"Pageviews\"}], \"row_limit\": 20, \"order_by_cols\": [[\"SUM(pageviews)\", false]], \"time_range\": \"No filter\", \"orientation\": \"vertical\", \"show_legend\": false}", - "description": "Топ страниц по просмотрам" + "params": "{\"groupby\": [\"page_url_path\"], \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(pageviews)\", \"label\": \"Pageviews\"}, \"row_limit\": 20, \"time_range\": \"No filter\", \"sort_by_metric\": true, \"percent_calculation_type\": \"first_step\", \"color_scheme\": \"supersetColors\", \"show_legend\": true, \"legendOrientation\": \"top\", \"legendMargin\": 50, \"tooltip_label_type\": 5, \"number_format\": \"SMART_NUMBER\", \"show_labels\": true, \"show_tooltip_labels\": true, \"datasource\": \"3__table\", \"viz_type\": \"funnel\"}", + "description": "Chart created automatically for v_top_pages_daily" } }, { "__Slice__": { - "slice_name": "🔍 Data Quality Summary", + "slice_name": "🧱 Rows by Layer (event)", "viz_type": "dist_bar", "datasource_type": "table", "datasource_name": "dm.dq_summary", - "params": "{\"datasource\": \"4__table\", \"viz_type\": \"dist_bar\", \"groupby\": [\"layer\"], \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(check_value)\", \"label\": \"Row Count\"}], \"adhoc_filters\": [{\"clause\": \"WHERE\", \"expressionType\": \"SQL\", \"sqlExpression\": \"check_name = 'total_rows'\", \"subject\": null, \"operator\": null, \"comparator\": null}], \"row_limit\": 100, \"time_range\": \"No filter\", \"show_legend\": false}", - "description": "Сводка по качеству данных" + "params": "{\"groupby\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"multiIf(layer = 'stg', '1 · stg', layer = 'ods', '2 · ods', layer = 'dds', '3 · dds', '4 · dm')\", \"label\": \"Layer\"}], \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(check_value)\", \"label\": \"Rows\"}], \"adhoc_filters\": [{\"clause\": \"WHERE\", \"expressionType\": \"SQL\", \"sqlExpression\": \"check_name = 'total_rows' AND table_name IN ('browser_raw', 'browser_event', 'event', 'v_events_enriched')\", \"subject\": null, \"operator\": null, \"comparator\": null}], \"order_bars\": true, \"row_limit\": 100, \"time_range\": \"No filter\", \"y_axis_format\": \",d\", \"show_legend\": false, \"datasource\": \"4__table\", \"viz_type\": \"dist_bar\"}", + "description": "Chart created automatically for dq_summary" } } ], @@ -122,18 +133,66 @@ "database": "clickhouse_dwh", "description": "Полная обогащённая витрина событий (event + click)", "columns": [ - {"column_name": "event_id", "type": "UUID", "description": "UUID события"}, - {"column_name": "event_ts", "type": "DateTime64(6)", "description": "Время события"}, - {"column_name": "event_date", "type": "Date", "description": "Дата события"}, - {"column_name": "event_type", "type": "String", "description": "Тип события"}, - {"column_name": "click_id", "type": "UUID", "description": "ID сессии"}, - {"column_name": "user_domain_id", "type": "UUID", "description": "ID пользователя"}, - {"column_name": "device_type", "type": "String", "description": "Тип устройства"}, - {"column_name": "geo_country", "type": "String", "description": "Страна"}, - {"column_name": "browser_name", "type": "String", "description": "Браузер"}, - {"column_name": "utm_source", "type": "String", "description": "UTM Source"}, - {"column_name": "utm_medium", "type": "String", "description": "UTM Medium"}, - {"column_name": "page_url_path", "type": "String", "description": "Путь URL"} + { + "column_name": "event_id", + "type": "UUID", + "description": "UUID события" + }, + { + "column_name": "event_ts", + "type": "DateTime64(6)", + "description": "Время события" + }, + { + "column_name": "event_date", + "type": "Date", + "description": "Дата события" + }, + { + "column_name": "event_type", + "type": "String", + "description": "Тип события" + }, + { + "column_name": "click_id", + "type": "UUID", + "description": "ID сессии" + }, + { + "column_name": "user_domain_id", + "type": "UUID", + "description": "ID пользователя" + }, + { + "column_name": "device_type", + "type": "String", + "description": "Тип устройства" + }, + { + "column_name": "geo_country", + "type": "String", + "description": "Страна" + }, + { + "column_name": "browser_name", + "type": "String", + "description": "Браузер" + }, + { + "column_name": "utm_source", + "type": "String", + "description": "UTM Source" + }, + { + "column_name": "utm_medium", + "type": "String", + "description": "UTM Medium" + }, + { + "column_name": "page_url_path", + "type": "String", + "description": "Путь URL" + } ] } }, @@ -144,13 +203,41 @@ "database": "clickhouse_dwh", "description": "Эффективность UTM-кампаний", "columns": [ - {"column_name": "event_date", "type": "Date", "description": "Дата"}, - {"column_name": "utm_source", "type": "String", "description": "UTM Source"}, - {"column_name": "utm_medium", "type": "String", "description": "UTM Medium"}, - {"column_name": "utm_campaign", "type": "String", "description": "UTM Campaign"}, - {"column_name": "clicks", "type": "UInt64", "description": "Клики"}, - {"column_name": "uniq_users", "type": "UInt64", "description": "Уникальные пользователи"}, - {"column_name": "uniq_sessions", "type": "UInt64", "description": "Уникальные сессии"} + { + "column_name": "event_date", + "type": "Date", + "description": "Дата" + }, + { + "column_name": "utm_source", + "type": "String", + "description": "UTM Source" + }, + { + "column_name": "utm_medium", + "type": "String", + "description": "UTM Medium" + }, + { + "column_name": "utm_campaign", + "type": "String", + "description": "UTM Campaign" + }, + { + "column_name": "clicks", + "type": "UInt64", + "description": "Клики" + }, + { + "column_name": "uniq_users", + "type": "UInt64", + "description": "Уникальные пользователи" + }, + { + "column_name": "uniq_sessions", + "type": "UInt64", + "description": "Уникальные сессии" + } ] } }, @@ -161,10 +248,26 @@ "database": "clickhouse_dwh", "description": "Популярность страниц по дням", "columns": [ - {"column_name": "event_date", "type": "Date", "description": "Дата"}, - {"column_name": "page_url_path", "type": "String", "description": "Путь URL"}, - {"column_name": "pageviews", "type": "UInt64", "description": "Просмотры"}, - {"column_name": "uniq_clicks", "type": "UInt64", "description": "Уникальные клики"} + { + "column_name": "event_date", + "type": "Date", + "description": "Дата" + }, + { + "column_name": "page_url_path", + "type": "String", + "description": "Путь URL" + }, + { + "column_name": "pageviews", + "type": "UInt64", + "description": "Просмотры" + }, + { + "column_name": "uniq_clicks", + "type": "UInt64", + "description": "Уникальные клики" + } ] } }, @@ -175,11 +278,31 @@ "database": "clickhouse_dwh", "description": "Сводка по качеству данных", "columns": [ - {"column_name": "check_date", "type": "Date", "description": "Дата проверки"}, - {"column_name": "layer", "type": "String", "description": "Слой (stg/ods/dds)"}, - {"column_name": "table_name", "type": "String", "description": "Имя таблицы"}, - {"column_name": "check_name", "type": "String", "description": "Тип проверки"}, - {"column_name": "check_value", "type": "UInt64", "description": "Значение"} + { + "column_name": "check_date", + "type": "Date", + "description": "Дата проверки" + }, + { + "column_name": "layer", + "type": "String", + "description": "Слой (stg/ods/dds)" + }, + { + "column_name": "table_name", + "type": "String", + "description": "Имя таблицы" + }, + { + "column_name": "check_name", + "type": "String", + "description": "Тип проверки" + }, + { + "column_name": "check_value", + "type": "UInt64", + "description": "Значение" + } ] } } diff --git a/tests/test_superset_dashboard_config.py b/tests/test_superset_dashboard_config.py new file mode 100644 index 0000000..d494c69 --- /dev/null +++ b/tests/test_superset_dashboard_config.py @@ -0,0 +1,130 @@ +import importlib.util +import json +from pathlib import Path + + +def load_dashboard_module(): + module_path = Path(__file__).resolve().parents[1] / "superset" / "create_dashboard.py" + spec = importlib.util.spec_from_file_location("create_dashboard", module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def chart_config(module, slice_name): + return next(chart for chart in module.CHARTS_CONFIG if chart["slice_name"] == slice_name) + + +def exported_chart(exported_dashboard, slice_name): + return next( + chart["__Slice__"] + for chart in exported_dashboard["charts"] + if chart["__Slice__"]["slice_name"] == slice_name + ) + + +def load_exported_dashboard(): + export_path = Path(__file__).resolve().parents[1] / "superset" / "dashboards" / "ecommerce_analytics.zip.json" + return json.loads(export_path.read_text()) + + +class FakeChart: + def __init__(self, chart_id, slice_name): + self.id = chart_id + self.slice_name = slice_name + + +def test_geo_chart_uses_readable_top_countries_bar_config(): + module = load_dashboard_module() + + geo_chart = chart_config(module, "🌍 Top Countries by Events") + + assert geo_chart["viz_type"] == "echarts_timeseries_bar" + assert geo_chart["previous_slice_names"] == ["🌍 Geography Map"] + + params = geo_chart["params"] + assert params["x_axis"] == "geo_country" + assert params["metrics"] == [ + { + "expressionType": "SQL", + "sqlExpression": "COUNT(*)", + "label": "Events, pcs", + } + ] + assert params["row_limit"] == 15 + assert params["order_desc"] is True + assert params["sort_series_type"] == "sum" + assert params["show_legend"] is True + assert params["rich_tooltip"] is True + assert params["y_axis_title"] == "Events, pcs" + assert params["x_axis_title"] == "Country" + assert params["y_axis_format"] == ",d" + + +def test_exported_dashboard_uses_same_readable_geo_chart(): + exported_dashboard = load_exported_dashboard() + + geo_chart = exported_chart(exported_dashboard, "🌍 Top Countries by Events") + + assert geo_chart["viz_type"] == "echarts_timeseries_bar" + params = json.loads(geo_chart["params"]) + assert params["viz_type"] == "echarts_timeseries_bar" + assert params["x_axis"] == "geo_country" + assert params["metrics"][0]["label"] == "Events, pcs" + assert params["show_legend"] is True + assert params["rich_tooltip"] is True + assert params["y_axis_title"] == "Events, pcs" + + position_json = json.loads(exported_dashboard["dashboards"][0]["__Dashboard__"]["position_json"]) + assert position_json["CHART-7"]["meta"]["sliceName"] == "🌍 Top Countries by Events" + + +def test_exported_dashboard_chart_names_match_current_config(): + module = load_dashboard_module() + exported_dashboard = load_exported_dashboard() + + expected_names = [chart["slice_name"] for chart in module.CHARTS_CONFIG] + actual_names = [chart["__Slice__"]["slice_name"] for chart in exported_dashboard["charts"]] + + assert actual_names == expected_names + + +def test_exported_dashboard_layout_matches_current_rows(): + module = load_dashboard_module() + exported_dashboard = load_exported_dashboard() + + position_json = json.loads(exported_dashboard["dashboards"][0]["__Dashboard__"]["position_json"]) + chart_titles_by_id = { + component_id: component["meta"]["sliceName"] + for component_id, component in position_json.items() + if isinstance(component, dict) and component.get("type") == "CHART" + } + + actual_rows = [] + for row_id in position_json["GRID_ID"]["children"]: + row = [] + for chart_component_id in position_json[row_id]["children"]: + chart_component = position_json[chart_component_id] + row.append( + ( + chart_titles_by_id[chart_component_id], + chart_component["meta"]["width"], + ) + ) + actual_rows.append(row) + + assert actual_rows == module.DASHBOARD_ROWS + + +def test_choose_chart_to_sync_prefers_current_name_and_marks_old_name_duplicate(): + module = load_dashboard_module() + old_chart = FakeChart(7, "🌍 Geography Map") + current_chart = FakeChart(11, "🌍 Top Countries by Events") + + selected, duplicates = module.choose_chart_to_sync( + [old_chart, current_chart], + "🌍 Top Countries by Events", + ) + + assert selected is current_chart + assert duplicates == [old_chart]