feat(native-filters): Add legacy (filter-box) to native filter migration script (#23269)

This commit is contained in:
John Bodley 2023-04-21 15:12:33 +12:00 committed by GitHub
parent 5bec1a65ae
commit d0fda60c85
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 798 additions and 12 deletions

View File

@ -0,0 +1,103 @@
---
title: Migrating from Legacy to Native Filters
sidebar_position: 5
version: 1
---
##
The `superset native-filters` CLI command group—somewhat akin to an Alembic migration—
comprises of a number of sub-commands which allows administrators to upgrade/downgrade
existing dashboards which use the legacy filter-box charts—in combination with the
filter scopes/filter mapping—to use the native filter dashboard component.
Even though both legacy and native filters can coexist the overall user experience (UX)
is substandard as the already convoluted filter space becomes overly complex. After
enabling the `DASHBOARD_NATIVE_FILTERS` it is strongly advised to run the migration ASAP to
ensure users are not exposed to the hybrid state.
### Upgrading
The
```
superset native-filters upgrade
```
command—which provides the option to target either specific dashboard(s) or all
dashboards—migrates the legacy filters to native filters.
Specifically, the command performs the following:
- Replaces every filter-box chart within the dashboard with a markdown element which
provides a link to the deprecated chart. This preserves the layout whilst simultaneously
providing context to help owners review/verify said change.
- Migrates the filter scopes/filter mappings to the native filter configuration.
#### Quality Control
Dashboard owners should:
- Verify that the filter behavior is correct.
- Consolidate any conflicting/redundant filters—this previously may not have been
obvious given the embedded nature of the legacy filters and/or the non-optimal UX of the
legacy filter mapping (scopes and immunity).
- Rename the filters—which may not be uniquely named—to provide the necessary context
which previously was likely provided by both the location of the filter-box and the
corresponding filter-box title.
Dashboard owners may:
- Remove† the markdown elements from their dashboards and adjust the layout accordingly.
† Note removing the markdown elements—which contain metadata relating to the replaced
chart—prevents the dashboard from being fully restored and thus this operation should
only be performed if it is evident that a downgrade is not necessary.
### Downgrading
Similarly the
```
superset native-filters downgrade
```
command reverses said migration, i.e., restores the dashboard to the previous state.
### Cleanup
The ability to downgrade/reverse the migration requires temporary storage of the
dashboard metadata—relating to both positional composition and filter configuration.
Once the upgrade has been verified it is recommended to run the
```
superset native-filters cleanup
```
command—which provides the option to target either specific dashboard(s) or all
dashboards. Note this operation is irreversible.
Specifically, the command performs the following:
- Removes the temporary dashboard metadata.
- Deletes the filter-box charts associated with the dashboard†.
† Note the markdown elements will still remain however the link to the referenced filter-box
chart will no longer be valid.
Finally, the
```
superset native-filers cleanup --all
```
command will additionally delete all filter-box charts, irrespective of whether they
were ever associated with a dashboard.
#### Quality Control
Dashboard owners should:
- Remove the markdown elements from their dashboards and adjust the layout accordingly.

View File

@ -40,12 +40,15 @@ click==8.0.4
# apache-superset
# celery
# click-didyoumean
# click-option-group
# click-plugins
# click-repl
# flask
# flask-appbuilder
click-didyoumean==0.3.0
# via celery
click-option-group==0.5.5
# via apache-superset
click-plugins==1.1.1
# via celery
click-repl==0.2.0
@ -64,6 +67,8 @@ cryptography==39.0.1
# via
# apache-superset
# paramiko
deprecated==1.2.13
# via limits
deprecation==2.1.0
# via apache-superset
dnspython==2.1.0
@ -78,6 +83,7 @@ flask==2.1.3
# flask-caching
# flask-compress
# flask-jwt-extended
# flask-limiter
# flask-login
# flask-migrate
# flask-sqlalchemy
@ -92,6 +98,8 @@ flask-compress==1.13
# via apache-superset
flask-jwt-extended==4.3.1
# via flask-appbuilder
flask-limiter==3.3.0
# via flask-appbuilder
flask-login==0.6.0
# via
# apache-superset
@ -128,6 +136,8 @@ humanize==3.11.0
# via apache-superset
idna==3.2
# via email-validator
importlib-metadata==6.0.0
# via flask
isodate==0.6.0
# via apache-superset
itsdangerous==2.1.1
@ -144,10 +154,14 @@ kombu==5.2.4
# via celery
korean-lunar-calendar==0.2.1
# via holidays
limits==3.2.0
# via flask-limiter
mako==1.1.4
# via alembic
markdown==3.3.4
# via apache-superset
markdown-it-py==2.2.0
# via rich
markupsafe==2.1.1
# via
# jinja2
@ -162,6 +176,8 @@ marshmallow-enum==1.5.1
# via flask-appbuilder
marshmallow-sqlalchemy==0.23.1
# via flask-appbuilder
mdurl==0.1.2
# via markdown-it-py
msgpack==1.0.2
# via apache-superset
numpy==1.23.5
@ -169,10 +185,13 @@ numpy==1.23.5
# apache-superset
# pandas
# pyarrow
ordered-set==4.1.0
# via flask-limiter
packaging==21.3
# via
# bleach
# deprecation
# limits
pandas==1.5.3
# via apache-superset
paramiko==2.11.0
@ -191,6 +210,8 @@ pyarrow==10.0.1
# via apache-superset
pycparser==2.20
# via cffi
pygments==2.14.0
# via rich
pyjwt==2.4.0
# via
# apache-superset
@ -232,8 +253,12 @@ pyyaml==5.4.1
# apispec
redis==3.5.3
# via apache-superset
rich==13.3.1
# via flask-limiter
selenium==3.141.0
# via apache-superset
shortid==0.1.2
# via apache-superset
simplejson==3.17.3
# via apache-superset
six==1.16.0
@ -269,7 +294,11 @@ sshtunnel==0.4.0
tabulate==0.8.9
# via apache-superset
typing-extensions==4.4.0
# via apache-superset
# via
# apache-superset
# flask-limiter
# limits
# rich
urllib3==1.26.6
# via selenium
vine==5.0.0
@ -286,6 +315,8 @@ werkzeug==2.1.2
# flask
# flask-jwt-extended
# flask-login
wrapt==1.12.1
# via deprecated
wtforms==2.3.3
# via
# apache-superset
@ -296,6 +327,8 @@ wtforms-json==0.3.3
# via apache-superset
xlsxwriter==3.0.7
# via apache-superset
zipp==3.15.0
# via importlib-metadata
# The following packages are considered to be unsafe in a requirements file:
# setuptools

View File

@ -80,8 +80,6 @@ pure-sasl==0.6.2
# via thrift-sasl
pydruid==0.6.5
# via apache-superset
pygments==2.12.0
# via ipython
pyhive[hive]==0.6.5
# via apache-superset
pyinstrument==4.0.2

View File

@ -30,7 +30,7 @@ packaging==21.3
pep517==0.11.0
# via build
pip-compile-multi==2.6.2
# via -r integration.in
# via -r requirements/integration.in
pip-tools==6.8.0
# via pip-compile-multi
platformdirs==2.6.2
@ -38,7 +38,7 @@ platformdirs==2.6.2
pluggy==0.13.1
# via tox
pre-commit==3.2.2
# via -r integration.in
# via -r requirements/integration.in
py==1.10.0
# via tox
pyparsing==3.0.6
@ -50,11 +50,11 @@ six==1.16.0
toml==0.10.2
# via tox
tomli==1.2.1
# via pep517
# via build
toposort==1.6
# via pip-compile-multi
tox==3.25.1
# via -r integration.in
# via -r requirements/integration.in
virtualenv==20.17.1
# via
# pre-commit

View File

@ -77,6 +77,7 @@ setup(
"cachelib>=0.4.1,<0.5",
"celery>=5.2.2, <6.0.0",
"click>=8.0.3",
"click-option-group",
"colorama",
"croniter>=0.3.28",
"cron-descriptor",
@ -114,6 +115,7 @@ setup(
"PyJWT>=2.4.0, <3.0",
"redis",
"selenium>=3.141.0",
"shortid",
"sshtunnel>=0.4.0, <0.5",
"simplejson>=3.15.0",
"slack_sdk>=3.1.1, <4",

View File

@ -50,9 +50,12 @@ for load, module_name, is_pkg in pkgutil.walk_packages(
):
module = importlib.import_module(module_name)
for attribute in module.__dict__.values():
if isinstance(attribute, click.core.Command):
if isinstance(attribute, (click.core.Command, click.core.Group)):
superset.add_command(attribute)
if isinstance(attribute, click.core.Group):
break
@superset.command()
@with_appcontext

View File

@ -0,0 +1,398 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import json
from copy import deepcopy
from textwrap import dedent
from typing import Set, Tuple
import click
from click_option_group import optgroup, RequiredMutuallyExclusiveOptionGroup
from flask.cli import with_appcontext
from sqlalchemy import Column, ForeignKey, Integer, String, Table, Text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from superset import db, is_feature_enabled
Base = declarative_base()
dashboard_slices = Table(
"dashboard_slices",
Base.metadata,
Column("id", Integer, primary_key=True),
Column("dashboard_id", Integer, ForeignKey("dashboards.id")),
Column("slice_id", Integer, ForeignKey("slices.id")),
)
slice_user = Table(
"slice_user",
Base.metadata,
Column("id", Integer, primary_key=True),
Column("slice_id", Integer, ForeignKey("slices.id")),
)
class Dashboard(Base): # type: ignore # pylint: disable=too-few-public-methods
__tablename__ = "dashboards"
id = Column(Integer, primary_key=True)
json_metadata = Column(Text)
slices = relationship("Slice", secondary=dashboard_slices, backref="dashboards")
position_json = Column()
def __repr__(self) -> str:
return f"Dashboard<{self.id}>"
class Slice(Base): # type: ignore # pylint: disable=too-few-public-methods
__tablename__ = "slices"
id = Column(Integer, primary_key=True)
datasource_id = Column(Integer)
params = Column(Text)
slice_name = Column(String(250))
viz_type = Column(String(250))
def __repr__(self) -> str:
return f"Slice<{self.id}>"
@click.group()
def native_filters() -> None:
"""
Perform native filter operations.
"""
@native_filters.command()
@with_appcontext
@optgroup.group(
"Grouped options",
cls=RequiredMutuallyExclusiveOptionGroup,
)
@optgroup.option(
"--all",
"all_",
default=False,
help="Upgrade all dashboards",
is_flag=True,
)
@optgroup.option(
"--id",
"dashboard_ids",
help="Upgrade the specific dashboard. Can be supplied multiple times.",
multiple=True,
type=int,
)
def upgrade(
all_: bool, # pylint: disable=unused-argument
dashboard_ids: Tuple[int, ...],
) -> None:
"""
Upgrade legacy filter-box charts to native dashboard filters.
"""
# pylint: disable=import-outside-toplevel
from superset.utils.dashboard_filter_scopes_converter import (
convert_filter_scopes_to_native_filters,
)
if not is_feature_enabled("DASHBOARD_NATIVE_FILTERS"):
click.echo("The 'DASHBOARD_NATIVE_FILTERS' feature needs to be enabled.")
return
# Mapping between the CHART- and MARKDOWN- IDs.
mapping = {}
for dashboard in ( # pylint: disable=too-many-nested-blocks
db.session.query(Dashboard)
.filter(*[Dashboard.id.in_(dashboard_ids)] if dashboard_ids else [])
.all()
):
click.echo(f"Upgrading {str(dashboard)}")
try:
json_metadata = json.loads(dashboard.json_metadata or "{}")
position_json = json.loads(dashboard.position_json or "{}")
if "native_filter_migration" in json_metadata:
click.echo(f"{dashboard} has already been upgraded")
continue
# Save the native and legacy filter configurations for recovery purposes.
json_metadata["native_filter_migration"] = {
key: deepcopy(json_metadata[key])
for key in (
"default_filters",
"filter_scopes",
"native_filter_configuration",
)
if key in json_metadata
}
filter_boxes_by_id = {
slc.id: slc for slc in dashboard.slices if slc.viz_type == "filter_box"
}
# Convert the legacy filter configurations to native filters.
native_filter_configuration = json_metadata.setdefault(
"native_filter_configuration",
[],
)
native_filter_configuration.extend(
convert_filter_scopes_to_native_filters(
json_metadata,
position_json,
filter_boxes=list(filter_boxes_by_id.values()),
),
)
# Remove the legacy filter configuration.
for key in ["default_filters", "filter_scopes"]:
json_metadata.pop(key, None)
# Replace the filter-box charts with markdown elements.
for key, value in list(position_json.items()): # Immutable iteration
if (
isinstance(value, dict)
and value["type"] == "CHART"
and value["meta"]["chartId"] in filter_boxes_by_id
):
slc = filter_boxes_by_id[value["meta"]["chartId"]]
mapping[key] = key.replace("CHART-", "MARKDOWN-")
value["id"] = mapping[key]
value["type"] = "MARKDOWN"
value["meta"]["code"] = dedent(
f"""
&#9888; The <a href="/superset/slice/{slc.id}/">{slc.slice_name}
</a> filter-box chart has been migrated to a native filter.
This placeholder markdown element can be safely removed after
verifiying that the native filter have been correct applied,
otherwise ask an admin to revert the migration.
"""
)
# Save the filter-box info for recovery purposes.
value["meta"]["native_filter_migration"] = {
key: value["meta"].pop(key)
for key in (
"chartId",
"sliceName",
"sliceNameOverride",
)
if key in value["meta"]
}
position_json[mapping[key]] = value
del position_json[key]
# Replace the relevant CHART- references.
for value in position_json.values():
if isinstance(value, dict):
for relation in ["children", "parents"]:
if relation in value:
for idx, key in enumerate(value[relation]):
if key in mapping:
value[relation][idx] = mapping[key]
# Remove the filter-box charts from the dashboard/slice mapping
dashboard.slices = [
slc for slc in dashboard.slices if slc.viz_type != "filter_box"
]
dashboard.json_metadata = json.dumps(json_metadata)
dashboard.position_json = json.dumps(position_json)
except Exception: # pylint: disable=broad-except
click.echo(f"Unable to upgrade {str(dashboard)}")
db.session.commit()
db.session.close()
@native_filters.command()
@with_appcontext
@optgroup.group(
"Grouped options",
cls=RequiredMutuallyExclusiveOptionGroup,
)
@optgroup.option(
"--all",
"all_",
default=False,
help="Downgrade all dashboards",
is_flag=True,
)
@optgroup.option(
"--id",
"dashboard_ids",
help="Downgrade the specific dashboard. Can be supplied multiple times.",
multiple=True,
type=int,
)
def downgrade(
all_: bool, # pylint: disable=unused-argument
dashboard_ids: Tuple[int, ...],
) -> None:
"""
Downgrade native dashboard filters to legacy filter-box charts (where applicable).
"""
# Mapping between the MARKDOWN- and CHART- IDs.
mapping = {}
for dashboard in ( # pylint: disable=too-many-nested-blocks
db.session.query(Dashboard)
.filter(*[Dashboard.id.in_(dashboard_ids)] if dashboard_ids else [])
.all()
):
click.echo(f"Downgrading {str(dashboard)}")
try:
json_metadata = json.loads(dashboard.json_metadata or "{}")
position_json = json.loads(dashboard.position_json or "{}")
if "native_filter_migration" not in json_metadata:
click.echo(f"{str(dashboard)} has not been upgraded")
continue
# Restore the native and legacy filter configurations.
for key in (
"default_filters",
"filter_scopes",
"native_filter_configuration",
):
json_metadata.pop(key, None)
json_metadata.update(json_metadata.pop("native_filter_migration"))
# Replace the relevant markdown elements with filter-box charts.
slice_ids = set()
for key, value in list(position_json.items()): # Immutable iteration
if (
isinstance(value, dict)
and value["type"] == "MARKDOWN"
and "native_filter_migration" in value["meta"]
):
value["meta"].update(value["meta"].pop("native_filter_migration"))
slice_ids.add(value["meta"]["chartId"])
mapping[key] = key.replace("MARKDOWN-", "CHART-")
value["id"] = mapping[key]
del value["meta"]["code"]
value["type"] = "CHART"
position_json[mapping[key]] = value
del position_json[key]
# Replace the relevant CHART- references.
for value in position_json.values():
if isinstance(value, dict):
for relation in ["children", "parents"]:
if relation in value:
for idx, key in enumerate(value[relation]):
if key in mapping:
value[relation][idx] = mapping[key]
# Restore the filter-box charts to the dashboard/slice mapping.
for slc in db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all():
dashboard.slices.append(slc)
dashboard.json_metadata = json.dumps(json_metadata)
dashboard.position_json = json.dumps(position_json)
except Exception: # pylint: disable=broad-except
click.echo(f"Unable to downgrade {str(dashboard)}")
db.session.commit()
db.session.close()
@native_filters.command()
@with_appcontext
@optgroup.group(
"Grouped options",
cls=RequiredMutuallyExclusiveOptionGroup,
)
@optgroup.option(
"--all",
"all_",
default=False,
help="Cleanup all dashboards",
is_flag=True,
)
@optgroup.option(
"--id",
"dashboard_ids",
help="Cleanup the specific dashboard. Can be supplied multiple times.",
multiple=True,
type=int,
)
def cleanup(
all_: bool, # pylint: disable=unused-argument
dashboard_ids: Tuple[int, ...],
) -> None:
"""
Cleanup obsolete legacy filter-box charts and interim metadata.
Note this operation is irreversible.
"""
slice_ids: Set[int] = set()
# Cleanup the dashboard which contains legacy fields used for downgrading.
for dashboard in (
db.session.query(Dashboard)
.filter(*[Dashboard.id.in_(dashboard_ids)] if dashboard_ids else [])
.all()
):
click.echo(f"Cleaning up {str(dashboard)}")
try:
json_metadata = json.loads(dashboard.json_metadata or "{}")
position_json = json.loads(dashboard.position_json or "{}")
if "native_filter_migration" not in json_metadata:
click.echo(f"{str(dashboard)} has not been upgraded")
continue
# Remove the saved filter configurations.
del json_metadata["native_filter_migration"]
dashboard.json_metadata = json.dumps(json_metadata)
for value in position_json.values():
if (
isinstance(value, dict)
and "native_filter_migration" in value["meta"]
):
slice_ids.add(value["meta"]["native_filter_migration"]["chartId"])
del value["meta"]["native_filter_migration"]
dashboard.json_metadata = json.dumps(json_metadata)
dashboard.position_json = json.dumps(position_json)
except Exception: # pylint: disable=broad-except
click.echo(f"Unable to cleanup {str(dashboard)}")
# Delete the obsolete filter-box charts associated with the dashboards.
db.session.query(slice_user).filter(slice_user.c.slice_id.in_(slice_ids)).delete()
db.session.query(Slice).filter(Slice.id.in_(slice_ids)).delete()
db.session.commit()
db.session.close()

View File

@ -134,6 +134,7 @@ class DashboardJSONMetadataSchema(Schema):
import_time = fields.Integer()
remote_id = fields.Integer()
filter_bar_orientation = fields.Str(allow_none=True)
native_filter_migration = fields.Dict()
@pre_load
def remove_show_native_filters( # pylint: disable=unused-argument, no-self-use

View File

@ -19,13 +19,15 @@ import logging
from collections import defaultdict
from typing import Any, Dict, List
from shortid import ShortId
from superset.models.slice import Slice
logger = logging.getLogger(__name__)
def convert_filter_scopes(
json_metadata: Dict[Any, Any], filters: List[Slice]
json_metadata: Dict[Any, Any], filter_boxes: List[Slice]
) -> Dict[int, Dict[str, Dict[str, Any]]]:
filter_scopes = {}
immuned_by_id: List[int] = json_metadata.get("filter_immune_slices") or []
@ -51,10 +53,10 @@ def convert_filter_scopes(
else:
logging.info("slice [%i] has invalid field: %s", filter_id, filter_field)
for filter_slice in filters:
for filter_box in filter_boxes:
filter_fields: Dict[str, Dict[str, Any]] = {}
filter_id = filter_slice.id
slice_params = json.loads(filter_slice.params or "{}")
filter_id = filter_box.id
slice_params = json.loads(filter_box.params or "{}")
configs = slice_params.get("filter_configs") or []
if slice_params.get("date_filter"):
@ -88,3 +90,249 @@ def copy_filter_scopes(
if int(slice_id) in old_to_new_slc_id_dict
]
return new_filter_scopes
def convert_filter_scopes_to_native_filters( # pylint: disable=invalid-name,too-many-branches,too-many-locals,too-many-nested-blocks,too-many-statements
json_metadata: Dict[str, Any],
position_json: Dict[str, Any],
filter_boxes: List[Slice],
) -> List[Dict[str, Any]]:
"""
Convert the legacy filter scopes et al. to the native filter configuration.
Dashboard filter scopes are implicitly defined where an undefined scope implies
no immunity, i.e., they apply to all applicable charts. The `convert_filter_scopes`
method provides an explicit definition by extracting the underlying filter-box
configurations.
Hierarchical legacy filters are defined via non-exclusion of peer or children
filter-box charts whereas native hierarchical filters are defined via explicit
parental relationships, i.e., the inverse.
:param json_metata: The dashboard metadata
:param position_json: The dashboard layout
:param filter_boxes: The filter-box charts associated with the dashboard
:returns: The native filter configuration
:see: convert_filter_scopes
"""
shortid = ShortId()
default_filters = json.loads(json_metadata.get("default_filters") or "{}")
filter_scopes = json_metadata.get("filter_scopes", {})
filter_box_ids = {filter_box.id for filter_box in filter_boxes}
filter_scope_by_key_and_field: Dict[str, Dict[str, Dict[str, Any]]] = defaultdict(
dict
)
filter_by_key_and_field: Dict[str, Dict[str, Dict[str, Any]]] = defaultdict(dict)
# Dense representation of filter scopes, falling back to chart level filter configs
# if the respective filter scope is not defined at the dashboard level.
for filter_box in filter_boxes:
key = str(filter_box.id)
filter_scope_by_key_and_field[key] = {
**(
convert_filter_scopes(
json_metadata,
filter_boxes=[filter_box],
).get(filter_box.id, {})
),
**(filter_scopes.get(key, {})),
}
# Contruct the native filters.
for filter_box in filter_boxes:
key = str(filter_box.id)
params = json.loads(filter_box.params or "{}")
for field, filter_scope in filter_scope_by_key_and_field[key].items():
default = default_filters.get(key, {}).get(field)
fltr: Dict[str, Any] = {
"cascadeParentIds": [],
"id": f"NATIVE_FILTER-{shortid.generate()}",
"scope": {
"rootPath": filter_scope["scope"],
"excluded": [
id_
for id_ in filter_scope["immune"]
if id_ not in filter_box_ids
],
},
"type": "NATIVE_FILTER",
}
if field == "__time_col" and params.get("show_sqla_time_column"):
fltr.update(
{
"filterType": "filter_timecolumn",
"name": "Time Column",
"targets": [{"datasetId": filter_box.datasource_id}],
}
)
if not default:
default = params.get("granularity_sqla")
if default:
fltr["defaultDataMask"] = {
"extraFormData": {"granularity_sqla": default},
"filterState": {"value": [default]},
}
elif field == "__time_grain" and params.get("show_sqla_time_granularity"):
fltr.update(
{
"filterType": "filter_timegrain",
"name": "Time Grain",
"targets": [{"datasetId": filter_box.datasource_id}],
}
)
if not default:
default = params.get("time_grain_sqla")
if default:
fltr["defaultDataMask"] = {
"extraFormData": {"time_grain_sqla": default},
"filterState": {"value": [default]},
}
elif field == "__time_range" and params.get("date_filter"):
fltr.update(
{
"filterType": "filter_time",
"name": "Time Range",
"targets": [{}],
}
)
if not default:
default = params.get("time_range")
if default and default != "No filter":
fltr["defaultDataMask"] = {
"extraFormData": {"time_range": default},
"filterState": {"value": default},
}
else:
for config in params.get("filter_configs") or []:
if config["column"] == field:
fltr.update(
{
"controlValues": {
"defaultToFirstItem": False,
"enableEmptyFilter": not config.get(
"clearable",
True,
),
"inverseSelection": False,
"multiSelect": config.get(
"multiple",
False,
),
"searchAllOptions": config.get(
"searchAllOptions",
False,
),
},
"filterType": "filter_select",
"name": config.get("label") or field,
"targets": [
{
"column": {"name": field},
"datasetId": filter_box.datasource_id,
},
],
}
)
if "metric" in config:
fltr["sortMetric"] = config["metric"]
fltr["controlValues"]["sortAscending"] = config["asc"]
if params.get("adhoc_filters"):
fltr["adhoc_filters"] = params["adhoc_filters"]
# Pre-filter available values based on time range/column.
time_range = params.get("time_range")
if time_range and time_range != "No filter":
fltr.update(
{
"time_range": time_range,
"granularity_sqla": params.get("granularity_sqla"),
}
)
if not default:
default = config.get("defaultValue")
if default:
if config["multiple"]:
default = default.split(";")
else:
default = [default]
if default:
fltr["defaultDataMask"] = {
"extraFormData": {
"filters": [
{
"col": field,
"op": "IN",
"val": default,
}
],
},
"filterState": {"value": default},
}
break
if "filterType" in fltr:
filter_by_key_and_field[key][field] = fltr
# Ancestors of filter-box charts.
ancestors_by_id = defaultdict(set)
for filter_box in filter_boxes:
for value in position_json.values():
if (
isinstance(value, dict)
and value["type"] == "CHART"
and value["meta"]["chartId"] == filter_box.id
and value["parents"] # Misnomer as this the the complete ancestry.
):
ancestors_by_id[filter_box.id] = set(value["parents"])
# Wire up the hierarchical filters.
for this in filter_boxes:
for other in filter_boxes:
if (
this != other
and any( # Immunity is at the chart rather than field level.
this.id not in filter_scope["immune"]
and set(filter_scope["scope"]) <= ancestors_by_id[this.id]
for filter_scope in filter_scope_by_key_and_field[
str(other.id)
].values()
)
):
for child in filter_by_key_and_field[str(this.id)].values():
if child["filterType"] == "filter_select":
for parent in filter_by_key_and_field[str(other.id)].values():
if (
parent["filterType"] in {"filter_select", "filter_time"}
and parent["id"] not in child["cascadeParentIds"]
):
child["cascadeParentIds"].append(parent["id"])
return sorted(
[
fltr
for key in filter_by_key_and_field
for fltr in filter_by_key_and_field[key].values()
],
key=lambda fltr: fltr["filterType"],
)