EnxDev commented on code in PR #43232:
URL: https://github.com/apache/superset/pull/43232#discussion_r4163271390
##########
tests/integration_tests/charts/commands_tests.py:
##########
@@ -942,3 +950,1509 @@ def test_fave_unfave_chart_command_non_owner(self,
mock_find_by_id):
finally:
if example_chart.datasource:
self.revoke_role_access_to_table("Gamma",
example_chart.datasource)
+
+
+def _create_chart_annotation_layer(name, descr=None):
+ layer = AnnotationLayer(name=name, descr=descr)
+ db.session.add(layer)
+ db.session.commit()
+ return layer
+
+
+def _create_chart_annotation(
+ layer,
+ short_descr,
+ long_descr=None,
+ json_metadata=None,
+):
+ annotation = Annotation(
+ layer=layer,
+ short_descr=short_descr,
+ long_descr=long_descr,
+ json_metadata=json_metadata,
+ )
+ db.session.add(annotation)
+ db.session.commit()
+ return annotation
+
+
+def _delete_chart_annotation_layer(layer):
+ db.session.query(Annotation).filter(Annotation.layer_id ==
layer.id).delete()
+ db.session.delete(layer)
+ db.session.commit()
+
+
+def _create_chart_dependency(source_chart, slice_name):
+ chart = Slice(
+ slice_name=slice_name,
+ viz_type=source_chart.viz_type,
+ datasource_id=source_chart.datasource_id,
+ datasource_type=source_chart.datasource_type,
+ params=source_chart.params,
+ query_context=source_chart.query_context,
+ cache_timeout=source_chart.cache_timeout,
+ )
+ db.session.add(chart)
+ db.session.commit()
+ return chart
+
+
+def _delete_chart_dependency(chart):
+ db.session.delete(chart)
+ db.session.commit()
+
+
+def _annotation_layer_import_config(layer_uuid, name, annotations, descr=None):
+ return {
+ "name": name,
+ "descr": descr,
+ "uuid": layer_uuid,
+ "version": "1.0.0",
+ "annotation": annotations,
+ }
+
+
+def _chart_import_config(chart_uuid, slice_name):
+ config = deepcopy(chart_config)
+ config["uuid"] = chart_uuid
+ config["slice_name"] = slice_name
+ return config
+
+
+def _chart_using_source(chart_uuid: str, source_chart_uuid: str) -> dict[str,
Any]:
+ """Chart config with a table annotation sourced from another chart."""
+ config = _chart_import_config(chart_uuid, "Uses Source")
+ config["params"]["annotation_layers"] = [
+ {
+ "name": "Source",
+ "annotationType": "EVENT",
+ "sourceType": "table",
+ "value": source_chart_uuid,
+ }
+ ]
+ return config
+
+
+def _source_chart_bundle(
+ source_chart: dict[str, Any],
+ main_chart: dict[str, Any] | None = None,
+ selected: list[str] | None = None,
+) -> dict[str, str]:
+ """Chart bundle with a source chart, an optional user of it and
selection."""
+ contents = {
+ "metadata.yaml": yaml.safe_dump(chart_metadata_config),
+ "databases/imported_database.yaml": yaml.safe_dump(database_config),
+ "datasets/imported_dataset.yaml": yaml.safe_dump(dataset_config),
+ "charts/source_chart.yaml": yaml.safe_dump(source_chart),
+ }
+ if main_chart is not None:
+ contents["charts/main_chart.yaml"] = yaml.safe_dump(main_chart)
+ if selected is not None:
+ contents[SELECTED_CHARTS_FILE_NAME] = yaml.safe_dump({"chart_uuids":
selected})
+ return contents
+
+
+def _cleanup_imported_chart_bundle(chart_uuids, layer_uuids):
+ for chart_uuid in chart_uuids:
+ chart =
db.session.query(Slice).filter_by(uuid=chart_uuid).one_or_none()
+ if chart:
+ db.session.delete(chart)
+ for layer_uuid in layer_uuids:
+ layer = (
+
db.session.query(AnnotationLayer).filter_by(uuid=layer_uuid).one_or_none()
+ )
+ if layer:
+ db.session.query(Annotation).filter(
+ Annotation.layer_id == layer.id
+ ).delete()
+ db.session.delete(layer)
+ dataset = (
+
db.session.query(SqlaTable).filter_by(uuid=dataset_config["uuid"]).one_or_none()
+ )
+ if dataset:
+ db.session.delete(dataset)
+ database = (
+
db.session.query(Database).filter_by(uuid=database_config["uuid"]).one_or_none()
+ )
+ if database:
+ db.session.delete(database)
+ db.session.commit()
+
+
+class TestExportChartsAnnotationLayers(SupersetTestCase):
+ """Tests for annotation layer handling in chart export."""
+
+ @patch("superset.security.manager.g")
+ @pytest.mark.usefixtures("load_energy_table_with_slice")
+ def
test_export_chart_multiple_native_annotation_layers_with_children(self, mock_g):
+ """Export each referenced native layer once and preserve child
annotations."""
+ mock_g.user = security_manager.find_user("admin")
+ chart = db.session.query(Slice).filter_by(slice_name="Energy
Sankey").one()
+ original_params = json.loads(chart.params or "{}")
+ layer_one = _create_chart_annotation_layer(
+ name=f"Layer One {uuid4()}", descr="first layer"
+ )
+ layer_two = _create_chart_annotation_layer(
+ name=f"Layer Two {uuid4()}", descr="second layer"
+ )
+ unrelated_layer = _create_chart_annotation_layer(name=f"Unrelated
{uuid4()}")
+ try:
+ _create_chart_annotation(
+ layer_one,
+ short_descr="layer-one-annotation",
+ long_descr="layer-one-long",
+ json_metadata='{"scope": "one"}',
+ )
+ _create_chart_annotation(
+ layer_two,
+ short_descr="layer-two-annotation",
+ long_descr="layer-two-long",
+ json_metadata='{"scope": "two"}',
+ )
+ chart.params = json.dumps(
+ {
+ **original_params,
+ "annotation_layers": [
+ {
+ "name": "Native One",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": layer_one.id,
+ "show": True,
+ "style": "solid",
+ },
+ {
+ "name": "Native Two",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": layer_two.id,
+ "show": False,
+ "style": "dashed",
+ },
+ ],
+ }
+ )
+ db.session.commit()
+
+ contents = dict(ExportChartsCommand([chart.id]).run())
+ chart_yaml = yaml.safe_load(
+ contents[f"charts/Energy_Sankey_{chart.id}.yaml"]()
+ )
+ exported_layers = chart_yaml["params"]["annotation_layers"]
+
+ assert [layer["value"] for layer in exported_layers] == [
+ str(layer_one.uuid),
+ str(layer_two.uuid),
+ ]
+ assert [layer["sourceType"] for layer in exported_layers] == [
+ "NATIVE",
+ "NATIVE",
+ ]
+ assert [layer["style"] for layer in exported_layers] == ["solid",
"dashed"]
+ assert [layer["show"] for layer in exported_layers] == [True,
False]
+
+ layer_payloads = {
+ payload["uuid"]: payload
+ for path, factory in contents.items()
+ if path.startswith("annotation_layers/")
+ for payload in [yaml.safe_load(factory())]
+ }
+ assert set(layer_payloads) == {str(layer_one.uuid),
str(layer_two.uuid)}
+ assert layer_payloads[str(layer_one.uuid)]["annotation"][0][
+ "short_descr"
+ ] == ("layer-one-annotation")
+ assert layer_payloads[str(layer_two.uuid)]["annotation"][0][
+ "short_descr"
+ ] == ("layer-two-annotation")
+ assert str(unrelated_layer.uuid) not in layer_payloads
+ finally:
+ chart.params = json.dumps(original_params)
+ db.session.commit()
+ _delete_chart_annotation_layer(unrelated_layer)
+ _delete_chart_annotation_layer(layer_two)
+ _delete_chart_annotation_layer(layer_one)
+
+ @patch("superset.security.manager.g")
+ @pytest.mark.usefixtures("load_energy_table_with_slice")
+ def
test_export_chart_duplicate_native_annotation_reference_deduplicates_files(
+ self, mock_g
+ ):
+ """Export a native layer referenced twice in one chart exactly once."""
+ mock_g.user = security_manager.find_user("admin")
+ chart = db.session.query(Slice).filter_by(slice_name="Energy
Sankey").one()
+ original_params = json.loads(chart.params or "{}")
+ layer = _create_chart_annotation_layer(name=f"Duplicate {uuid4()}")
+ try:
+ chart.params = json.dumps(
+ {
+ **original_params,
+ "annotation_layers": [
+ {
+ "name": "Native One",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": layer.id,
+ },
+ {
+ "name": "Native Two",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": layer.id,
+ },
+ ],
+ }
+ )
+ db.session.commit()
+
+ contents = dict(ExportChartsCommand([chart.id]).run())
+ chart_yaml = yaml.safe_load(
+ contents[f"charts/Energy_Sankey_{chart.id}.yaml"]()
+ )
+ assert [
+ layer["value"] for layer in
chart_yaml["params"]["annotation_layers"]
+ ] == [str(layer.uuid), str(layer.uuid)]
+ layer_paths = [
+ path for path in contents if
path.startswith("annotation_layers/")
+ ]
+ assert len(layer_paths) == 1
+ finally:
+ chart.params = json.dumps(original_params)
+ db.session.commit()
+ _delete_chart_annotation_layer(layer)
+
+ @patch("superset.security.manager.g")
+ @pytest.mark.usefixtures("load_energy_table_with_slice")
+ def test_export_chart_missing_native_annotation_reference_drops_reference(
+ self, mock_g
+ ):
+ """Drop a native annotation whose layer was deleted instead of
failing."""
+ mock_g.user = security_manager.find_user("admin")
+ chart = db.session.query(Slice).filter_by(slice_name="Energy
Sankey").one()
+ original_params = json.loads(chart.params or "{}")
+ missing_layer_id = 987654321
+ try:
+ chart.params = json.dumps(
+ {
+ **original_params,
+ "annotation_layers": [
+ {
+ "name": "Missing Native",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": missing_layer_id,
+ }
+ ],
+ }
+ )
+ db.session.commit()
+
+ contents = dict(ExportChartsCommand([chart.id]).run())
+ chart_yaml = yaml.safe_load(
+ contents[f"charts/Energy_Sankey_{chart.id}.yaml"]()
+ )
+ assert chart_yaml["params"]["annotation_layers"] == []
+ assert not [
+ path for path in contents if
path.startswith("annotation_layers/")
+ ]
+ finally:
+ chart.params = json.dumps(original_params)
+ db.session.commit()
+
+ @patch("superset.security.manager.g")
+ @pytest.mark.usefixtures("load_energy_table_with_slice")
+ def test_export_chart_multiple_chart_annotation_references(self, mock_g):
+ """Export each referenced table or line annotation chart exactly
once."""
+ mock_g.user = security_manager.find_user("admin")
+ main_chart = db.session.query(Slice).filter_by(slice_name="Energy
Sankey").one()
+ original_params = json.loads(main_chart.params or "{}")
+ ref_table_chart = _create_chart_dependency(
+ main_chart,
+ slice_name=f"Reference Table {uuid4()}",
+ )
+ ref_line_chart = _create_chart_dependency(
+ main_chart,
+ slice_name=f"Reference Line {uuid4()}",
+ )
+ try:
+ main_chart.params = json.dumps(
+ {
+ **original_params,
+ "annotation_layers": [
+ {
+ "name": "Table Ref",
+ "annotationType": "EVENT",
+ "sourceType": "table",
+ "value": ref_table_chart.id,
+ "show": True,
+ },
+ {
+ "name": "Line Ref",
+ "annotationType": "TIME_SERIES",
+ "sourceType": "line",
+ "value": ref_line_chart.id,
+ "show": False,
+ },
+ ],
+ }
+ )
+ db.session.commit()
+
+ contents = dict(ExportChartsCommand([main_chart.id]).run())
+ chart_yaml = yaml.safe_load(
+ contents[f"charts/Energy_Sankey_{main_chart.id}.yaml"]()
+ )
+ exported_layers = chart_yaml["params"]["annotation_layers"]
+ assert exported_layers[0]["sourceType"] == "table"
+ assert exported_layers[0]["value"] == str(ref_table_chart.uuid)
+ assert exported_layers[1]["sourceType"] == "line"
+ assert exported_layers[1]["value"] == str(ref_line_chart.uuid)
+ ref_table_chart_path = (
+ "charts/"
+ f"{ref_table_chart.slice_name.replace(' ', '_')}"
+ f"_{ref_table_chart.id}.yaml"
+ )
+ assert ref_table_chart_path in contents
+ ref_line_chart_path = (
+ "charts/"
+ f"{ref_line_chart.slice_name.replace(' ', '_')}"
+ f"_{ref_line_chart.id}.yaml"
+ )
+ assert ref_line_chart_path in contents
+ assert not [
+ path for path in contents if
path.startswith("annotation_layers/")
+ ]
+ finally:
+ main_chart.params = json.dumps(original_params)
+ db.session.commit()
+ _delete_chart_dependency(ref_line_chart)
+ _delete_chart_dependency(ref_table_chart)
+
+ @patch("superset.security.manager.g")
+ @pytest.mark.usefixtures("load_energy_table_with_slice")
+ def test_export_chart_missing_chart_annotation_reference_drops_reference(
+ self, mock_g
+ ):
+ """Drop a table/line annotation whose source chart is missing."""
+ mock_g.user = security_manager.find_user("admin")
+ chart = db.session.query(Slice).filter_by(slice_name="Energy
Sankey").one()
+ original_params = json.loads(chart.params or "{}")
+ missing_chart_id = 987654321
+ try:
+ chart.params = json.dumps(
+ {
+ **original_params,
+ "annotation_layers": [
+ {
+ "name": "Missing Table Ref",
+ "annotationType": "EVENT",
+ "sourceType": "table",
+ "value": missing_chart_id,
+ }
+ ],
+ }
+ )
+ db.session.commit()
+
+ contents = dict(ExportChartsCommand([chart.id]).run())
+ chart_yaml = yaml.safe_load(
+ contents[f"charts/Energy_Sankey_{chart.id}.yaml"]()
+ )
+ assert chart_yaml["params"]["annotation_layers"] == []
+ finally:
+ chart.params = json.dumps(original_params)
+ db.session.commit()
+
+ @patch("superset.security.manager.g")
+ @pytest.mark.usefixtures("load_energy_table_with_slice")
+ def
test_export_chart_annotation_references_consistent_in_params_and_query_context(
+ self, mock_g
+ ):
+ """Resolve same annotation refs in params and query context."""
+ mock_g.user = security_manager.find_user("admin")
+ main_chart = db.session.query(Slice).filter_by(slice_name="Energy
Sankey").one()
+ original_params = json.loads(main_chart.params or "{}")
+ original_query_context = main_chart.query_context
+ native_layer = _create_chart_annotation_layer(
+ name=f"Native Query {uuid4()}", descr="query layer"
+ )
+ ref_chart = _create_chart_dependency(
+ main_chart, slice_name=f"Query Ref {uuid4()}"
+ )
+ try:
+ _create_chart_annotation(
+ native_layer,
+ short_descr="query-child",
+ long_descr="query-child-long",
+ json_metadata='{"color": "blue"}',
+ )
+ annotations = [
+ {
+ "name": "Native",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": native_layer.id,
+ "show": True,
+ "style": "solid",
+ },
+ {
+ "name": "Table",
+ "annotationType": "EVENT",
+ "sourceType": "table",
+ "value": ref_chart.id,
+ "show": True,
+ "style": "solid",
+ },
+ {
+ "name": "Formula",
+ "annotationType": "FORMULA",
+ "sourceType": "FORMULA",
+ "value": "cos(x)",
+ "show": False,
+ "style": "dashed",
+ },
+ ]
+ main_chart.params = json.dumps(
+ {
+ **original_params,
+ "annotation_layers": deepcopy(annotations),
+ }
+ )
+ main_chart.query_context = json.dumps(
+ {
+ "datasource": {"id": main_chart.datasource_id, "type":
"table"},
+ "queries": [{"annotation_layers": deepcopy(annotations)}],
+ "form_data": {"annotation_layers": deepcopy(annotations)},
+ }
+ )
+ db.session.commit()
+
+ contents = dict(ExportChartsCommand([main_chart.id]).run())
+ chart_yaml = yaml.safe_load(
+ contents[f"charts/Energy_Sankey_{main_chart.id}.yaml"]()
+ )
+ params_layers = chart_yaml["params"]["annotation_layers"]
+ query_context = json.loads(chart_yaml["query_context"])
+ query_layers = query_context["queries"][0]["annotation_layers"]
+ form_layers = query_context["form_data"]["annotation_layers"]
+
+ expected_values = [str(native_layer.uuid), str(ref_chart.uuid),
"cos(x)"]
+ assert [layer["value"] for layer in params_layers] ==
expected_values
+ assert [layer["value"] for layer in query_layers] ==
expected_values
+ assert [layer["value"] for layer in form_layers] == expected_values
+
+ layer_payloads = [
+ yaml.safe_load(factory())
+ for path, factory in contents.items()
+ if path.startswith("annotation_layers/")
+ ]
+ assert len(layer_payloads) == 1
+ assert layer_payloads[0]["uuid"] == str(native_layer.uuid)
+ assert layer_payloads[0]["annotation"][0]["short_descr"] ==
"query-child"
+ assert layer_payloads[0]["annotation"][0]["json_metadata"] == {
+ "color": "blue"
+ }
+ finally:
+ main_chart.params = json.dumps(original_params)
+ main_chart.query_context = original_query_context
+ db.session.commit()
+ _delete_chart_dependency(ref_chart)
+ _delete_chart_annotation_layer(native_layer)
+
+ @patch("superset.security.manager.g")
+ @pytest.mark.usefixtures("load_energy_table_with_slice")
+ def
test_export_chart_soft_deleted_chart_annotation_reference_drops_reference(
+ self, mock_g
+ ) -> None:
+ """Drop a table/line annotation whose source chart is not visible."""
+ mock_g.user = security_manager.find_user("admin")
+ chart = db.session.query(Slice).filter_by(slice_name="Energy
Sankey").one()
+ original_params = json.loads(chart.params or "{}")
+ ref_chart = _create_chart_dependency(chart, slice_name=f"Deleted Ref
{uuid4()}")
+ try:
+ chart.params = json.dumps(
+ {
+ **original_params,
+ "annotation_layers": [
+ {
+ "name": "Deleted Table Ref",
+ "annotationType": "EVENT",
+ "sourceType": "table",
+ "value": ref_chart.id,
+ }
+ ],
+ }
+ )
+ ref_chart.soft_delete()
+ db.session.commit()
+
+ contents = dict(ExportChartsCommand([chart.id]).run())
+ chart_yaml = yaml.safe_load(
+ contents[f"charts/Energy_Sankey_{chart.id}.yaml"]()
+ )
+ assert chart_yaml["params"]["annotation_layers"] == []
+ assert [path for path in contents if path.startswith("charts/")]
== [
+ f"charts/Energy_Sankey_{chart.id}.yaml"
+ ]
+ finally:
+ chart.params = json.dumps(original_params)
+ ref_chart.restore()
+ db.session.commit()
+ _delete_chart_dependency(ref_chart)
+
+ @patch("superset.security.manager.g")
+ @pytest.mark.usefixtures("load_energy_table_with_slice")
+ def test_export_chart_query_context_only_annotation_references_are_bundled(
+ self, mock_g
+ ) -> None:
+ """Bundle dependencies referenced only from query_context."""
+ mock_g.user = security_manager.find_user("admin")
+ main_chart = db.session.query(Slice).filter_by(slice_name="Energy
Sankey").one()
+ original_params = main_chart.params
+ original_query_context = main_chart.query_context
+ native_layer = _create_chart_annotation_layer(name=f"QC Only
{uuid4()}")
+ ref_chart = _create_chart_dependency(
+ main_chart, slice_name=f"QC Only Ref {uuid4()}"
+ )
+ try:
+ annotations = [
+ {
+ "name": "Native",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": native_layer.id,
+ },
+ {
+ "name": "Table",
+ "annotationType": "EVENT",
+ "sourceType": "table",
+ "value": ref_chart.id,
+ },
+ ]
+ main_chart.params = json.dumps(
+ {**json.loads(original_params or "{}"), "annotation_layers":
[]}
+ )
+ main_chart.query_context = json.dumps(
+ {
+ "datasource": {"id": main_chart.datasource_id, "type":
"table"},
+ "queries": [{"annotation_layers": deepcopy(annotations)}],
+ }
+ )
+ db.session.commit()
+
+ contents = dict(ExportChartsCommand([main_chart.id]).run())
+ chart_yaml = yaml.safe_load(
+ contents[f"charts/Energy_Sankey_{main_chart.id}.yaml"]()
+ )
+ query_layers =
json.loads(chart_yaml["query_context"])["queries"][0][
+ "annotation_layers"
+ ]
+ assert [layer["value"] for layer in query_layers] == [
+ str(native_layer.uuid),
+ str(ref_chart.uuid),
+ ]
+ assert (
+ len(
+ [path for path in contents if
path.startswith("annotation_layers/")]
+ )
+ == 1
+ )
+ ref_chart_path = (
+ f"charts/{ref_chart.slice_name.replace(' ',
'_')}_{ref_chart.id}.yaml"
+ )
+ assert ref_chart_path in contents
+ finally:
+ main_chart.params = original_params
+ main_chart.query_context = original_query_context
+ db.session.commit()
+ _delete_chart_dependency(ref_chart)
+ _delete_chart_annotation_layer(native_layer)
+
+ @patch("superset.security.manager.g")
+ @pytest.mark.usefixtures("load_energy_table_with_slice")
+ def test_export_chart_native_annotations_require_annotation_read(
+ self, mock_g
+ ) -> None:
+ """Leave native layers out of the export without can_read on
Annotation."""
+ mock_g.user = security_manager.find_user("admin")
+ chart = db.session.query(Slice).filter_by(slice_name="Energy
Sankey").one()
+ original_params = json.loads(chart.params or "{}")
+ layer = _create_chart_annotation_layer(name=f"Unreadable {uuid4()}")
+ _create_chart_annotation(layer, short_descr="secret")
+ can_access = security_manager.can_access
+
+ def can_access_without_annotation_read(permission: str, view: str) ->
bool:
+ if (permission, view) == ("can_read", "Annotation"):
+ return False
+ return can_access(permission, view)
+
+ try:
+ chart.params = json.dumps(
+ {
+ **original_params,
+ "annotation_layers": [
+ {
+ "name": "Native",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": layer.id,
+ },
+ {
+ "name": "Formula",
+ "annotationType": "FORMULA",
+ "sourceType": "",
+ "value": "x",
+ },
+ ],
+ }
+ )
+ db.session.commit()
+
+ with patch.object(
+ security_manager,
+ "can_access",
+ side_effect=can_access_without_annotation_read,
+ ):
+ contents = dict(ExportChartsCommand([chart.id]).run())
+ chart_yaml = yaml.safe_load(
+ contents[f"charts/Energy_Sankey_{chart.id}.yaml"]()
+ )
+ with pytest.raises(AnnotationLayerNotFoundError):
+ list(ExportAnnotationLayersCommand([layer.id]).run())
+
+ assert [
+ annotation["name"]
+ for annotation in chart_yaml["params"]["annotation_layers"]
+ ] == ["Formula"]
+ assert not [
+ path for path in contents if
path.startswith("annotation_layers/")
+ ]
+ finally:
+ chart.params = json.dumps(original_params)
+ db.session.commit()
+ _delete_chart_annotation_layer(layer)
+
+ @patch("superset.security.manager.g")
+ @pytest.mark.usefixtures("load_energy_table_with_slice")
+ def test_export_chart_tags_include_annotation_source_charts(self, mock_g)
-> None:
+ """tags.yaml carries the tags of charts exported as annotation
sources."""
+ mock_g.user = security_manager.find_user("admin")
+ main_chart = db.session.query(Slice).filter_by(slice_name="Energy
Sankey").one()
+ original_params = main_chart.params
+ ref_chart = _create_chart_dependency(
+ main_chart, slice_name=f"Tagged Ref {uuid4()}"
+ )
+ tag = Tag(
+ name=f"source-tag-{uuid4().hex[:8]}",
+ type=TagType.custom,
+ description="tag on the source chart",
+ )
+ db.session.add(tag)
+ db.session.flush()
+ db.session.add(
+ TaggedObject(
+ tag_id=tag.id, object_id=ref_chart.id,
object_type=ObjectType.chart
+ )
+ )
+ try:
+ main_chart.params = json.dumps(
+ {
+ **json.loads(original_params or "{}"),
+ "annotation_layers": [
+ {
+ "name": "Table",
+ "annotationType": "EVENT",
+ "sourceType": "table",
+ "value": ref_chart.id,
+ }
+ ],
+ }
+ )
+ db.session.commit()
+
+ contents = dict(ExportChartsCommand([main_chart.id]).run())
+ tags = yaml.safe_load(contents["tags.yaml"]())["tags"]
+ assert {"tag_name": tag.name, "description": tag.description} in
tags
+ assert yaml.safe_load(contents[SELECTED_CHARTS_FILE_NAME]()) == {
+ "chart_uuids": [str(main_chart.uuid)]
+ }
+ finally:
+ main_chart.params = original_params
+ db.session.query(TaggedObject).filter_by(tag_id=tag.id).delete()
+ db.session.delete(tag)
+ db.session.commit()
+ _delete_chart_dependency(ref_chart)
+
+ @patch("superset.security.manager.g")
+ @pytest.mark.usefixtures("load_energy_table_with_slice")
+ def
test_export_chart_without_annotation_layers_adds_no_annotation_dependencies(
+ self, mock_g
+ ):
+ """Export charts without annotation layers without adding layer
artifacts."""
+ mock_g.user = security_manager.find_user("admin")
+ chart = db.session.query(Slice).filter_by(slice_name="Energy
Sankey").one()
+ original_params = json.loads(chart.params or "{}")
+ original_query_context = chart.query_context
+ try:
+ params_without_annotations = deepcopy(original_params)
+ params_without_annotations.pop("annotation_layers", None)
+ chart.params = json.dumps(params_without_annotations)
+ query_context = json.loads(chart.query_context or "{}")
+ for query in query_context.get("queries", []):
+ query["annotation_layers"] = []
+ query_context.setdefault("form_data", {})["annotation_layers"] = []
+ chart.query_context = json.dumps(query_context)
+ db.session.commit()
+
+ contents = dict(ExportChartsCommand([chart.id]).run())
+ chart_yaml = yaml.safe_load(
+ contents[f"charts/Energy_Sankey_{chart.id}.yaml"]()
+ )
+ assert chart_yaml.get("params", {}).get("annotation_layers", [])
== []
+ assert not [
+ path for path in contents if
path.startswith("annotation_layers/")
+ ]
+ finally:
+ chart.params = json.dumps(original_params)
+ chart.query_context = original_query_context
+ db.session.commit()
+
+
+class TestImportChartsAnnotationLayers(SupersetTestCase):
+ """Tests for annotation layer handling during chart import."""
+
+ @patch("superset.utils.core.g")
+ @patch("superset.security.manager.g")
+ @patch("superset.commands.database.importers.v1.utils.add_permissions")
+ def test_import_chart_multiple_native_annotation_layers_with_children(
+ self, mock_add_permissions, sm_g, utils_g
+ ):
+ """Import multiple native layers with child annotation linkage."""
+ sm_g.user = utils_g.user = security_manager.find_user("admin")
+ main_chart_uuid = str(uuid4())
+ layer_one_uuid = str(uuid4())
+ layer_two_uuid = str(uuid4())
+ main_chart_config = _chart_import_config(
+ main_chart_uuid, "Chart With Native Layers"
+ )
+ main_chart_config["params"]["annotation_layers"] = [
+ {
+ "name": "Native One",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": layer_one_uuid,
+ "show": True,
+ "style": "solid",
+ },
+ {
+ "name": "Native Two",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": layer_two_uuid,
+ "show": False,
+ "style": "dashed",
+ },
+ ]
+
+ contents = {
+ "metadata.yaml": yaml.safe_dump(chart_metadata_config),
+ "databases/imported_database.yaml":
yaml.safe_dump(database_config),
+ "datasets/imported_dataset.yaml": yaml.safe_dump(dataset_config),
+ "charts/main_chart.yaml": yaml.safe_dump(main_chart_config),
+ "annotation_layers/layer_one.yaml": yaml.safe_dump(
+ _annotation_layer_import_config(
+ layer_one_uuid,
+ "Layer One",
+ [
+ {
+ "uuid": str(uuid4()),
+ "short_descr": "one-a",
+ "long_descr": "layer one annotation",
+ "json_metadata": {"layer": 1},
+ },
+ {
+ "uuid": str(uuid4()),
+ "short_descr": "one-b",
+ "long_descr": "layer one annotation two",
+ "json_metadata": {"layer": 1, "rank": 2},
+ },
+ ],
+ descr="layer one descr",
+ )
+ ),
+ "annotation_layers/layer_two.yaml": yaml.safe_dump(
+ _annotation_layer_import_config(
+ layer_two_uuid,
+ "Layer Two",
+ [
+ {
+ "uuid": str(uuid4()),
+ "short_descr": "two-a",
+ "long_descr": "layer two annotation",
+ "json_metadata": {"layer": 2},
+ }
+ ],
+ descr="layer two descr",
+ )
+ ),
+ }
+
+ try:
+ ImportChartsCommand(contents, overwrite=True).run()
+
+ chart =
db.session.query(Slice).filter_by(uuid=main_chart_uuid).one()
+ layer_one = (
+
db.session.query(AnnotationLayer).filter_by(uuid=layer_one_uuid).one()
+ )
+ layer_two = (
+
db.session.query(AnnotationLayer).filter_by(uuid=layer_two_uuid).one()
+ )
+ params_layers = json.loads(chart.params)["annotation_layers"]
+
+ assert [layer["value"] for layer in params_layers] == [
+ layer_one.id,
+ layer_two.id,
+ ]
+ assert [layer["style"] for layer in params_layers] == ["solid",
"dashed"]
+ assert [layer["show"] for layer in params_layers] == [True, False]
+
+ layer_one_annotations = (
+
db.session.query(Annotation).filter_by(layer_id=layer_one.id).all()
+ )
+ layer_two_annotations = (
+
db.session.query(Annotation).filter_by(layer_id=layer_two.id).all()
+ )
+ assert {annotation.short_descr for annotation in
layer_one_annotations} == {
+ "one-a",
+ "one-b",
+ }
+ assert {annotation.short_descr for annotation in
layer_two_annotations} == {
+ "two-a"
+ }
+ assert all(
+ annotation.layer_id == layer_one.id
+ for annotation in layer_one_annotations
+ )
+ assert all(
+ annotation.layer_id == layer_two.id
+ for annotation in layer_two_annotations
+ )
+ finally:
+ _cleanup_imported_chart_bundle(
+ [main_chart_uuid], [layer_one_uuid, layer_two_uuid]
+ )
+
+ @patch("superset.utils.core.g")
+ @patch("superset.security.manager.g")
+ @patch("superset.commands.database.importers.v1.utils.add_permissions")
+ def test_import_chart_mixed_annotation_dependency_graph(
+ self, mock_add_permissions, sm_g, utils_g
+ ):
+ """Resolve mixed annotation source types across params and query
context."""
+ sm_g.user = utils_g.user = security_manager.find_user("admin")
+ native_layer_uuid = str(uuid4())
+ ref_table_uuid = str(uuid4())
+ ref_line_uuid = str(uuid4())
+ main_chart_uuid = str(uuid4())
+ ref_table_chart = _chart_import_config(ref_table_uuid, "Ref Table
Chart")
+ ref_line_chart = _chart_import_config(ref_line_uuid, "Ref Line Chart")
+ main_chart = _chart_import_config(main_chart_uuid, "Main Mixed Chart")
+
+ annotations = [
+ {
+ "name": "Native",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": native_layer_uuid,
+ "show": True,
+ "style": "solid",
+ },
+ {
+ "name": "Table",
+ "annotationType": "EVENT",
+ "sourceType": "table",
+ "value": ref_table_uuid,
+ "show": False,
+ "style": "solid",
+ },
+ {
+ "name": "Line",
+ "annotationType": "TIME_SERIES",
+ "sourceType": "line",
+ "value": ref_line_uuid,
+ "show": True,
+ "style": "dashed",
+ },
+ {
+ "name": "Formula",
+ "annotationType": "FORMULA",
+ "sourceType": "FORMULA",
+ "value": "cos(x)",
+ "show": True,
+ "style": "solid",
+ },
+ ]
+ main_chart["params"]["annotation_layers"] = deepcopy(annotations)
+ main_chart["query_context"] = json.dumps(
+ {
+ "datasource": {"id": 12, "type": "table"},
+ "queries": [{"annotation_layers": deepcopy(annotations)}],
+ "form_data": {"annotation_layers": deepcopy(annotations)},
+ }
+ )
+
+ contents = {
+ "metadata.yaml": yaml.safe_dump(chart_metadata_config),
+ "databases/imported_database.yaml":
yaml.safe_dump(database_config),
+ "datasets/imported_dataset.yaml": yaml.safe_dump(dataset_config),
+ "charts/ref_table.yaml": yaml.safe_dump(ref_table_chart),
+ "charts/ref_line.yaml": yaml.safe_dump(ref_line_chart),
+ "charts/main_chart.yaml": yaml.safe_dump(main_chart),
+ "annotation_layers/native_layer.yaml": yaml.safe_dump(
+ _annotation_layer_import_config(
+ native_layer_uuid,
+ "Imported Native Layer",
+ [
+ {
+ "uuid": str(uuid4()),
+ "short_descr": "native-child",
+ "long_descr": "native child annotation",
+ "json_metadata": {"kind": "native"},
+ }
+ ],
+ )
+ ),
+ }
+
+ try:
+ ImportChartsCommand(contents, overwrite=True).run()
+
+ imported_main_chart = (
+ db.session.query(Slice).filter_by(uuid=main_chart_uuid).one()
+ )
+ imported_table_chart = (
+ db.session.query(Slice).filter_by(uuid=ref_table_uuid).one()
+ )
+ imported_line_chart = (
+ db.session.query(Slice).filter_by(uuid=ref_line_uuid).one()
+ )
+ imported_native_layer = (
+ db.session.query(AnnotationLayer)
+ .filter_by(uuid=native_layer_uuid)
+ .one()
+ )
+
+ params_layers =
json.loads(imported_main_chart.params)["annotation_layers"]
+ assert [layer["sourceType"] for layer in params_layers] == [
+ "NATIVE",
+ "table",
+ "line",
+ "FORMULA",
+ ]
+ assert [layer["value"] for layer in params_layers] == [
+ imported_native_layer.id,
+ imported_table_chart.id,
+ imported_line_chart.id,
+ "cos(x)",
+ ]
+ assert [layer["show"] for layer in params_layers] == [
+ True,
+ False,
+ True,
+ True,
+ ]
+ assert [layer["style"] for layer in params_layers] == [
+ "solid",
+ "solid",
+ "dashed",
+ "solid",
+ ]
+
+ query_context = json.loads(imported_main_chart.query_context)
+ assert [
+ layer["value"]
+ for layer in query_context["queries"][0]["annotation_layers"]
+ ] == [
+ imported_native_layer.id,
+ imported_table_chart.id,
+ imported_line_chart.id,
+ "cos(x)",
+ ]
+ assert [
+ layer["value"]
+ for layer in query_context["form_data"]["annotation_layers"]
+ ] == [
+ imported_native_layer.id,
+ imported_table_chart.id,
+ imported_line_chart.id,
+ "cos(x)",
+ ]
+ finally:
+ _cleanup_imported_chart_bundle(
+ [main_chart_uuid, ref_table_uuid, ref_line_uuid],
+ [native_layer_uuid],
+ )
+
+ @patch("superset.utils.core.g")
+ @patch("superset.security.manager.g")
+ @patch("superset.commands.database.importers.v1.utils.add_permissions")
+ def test_import_chart_missing_annotation_layer_dependency_drops_reference(
+ self, mock_add_permissions, sm_g, utils_g
+ ):
+ """Drop unresolved native layer UUID refs when dependency is
missing."""
+ sm_g.user = utils_g.user = security_manager.find_user("admin")
+ main_chart_uuid = str(uuid4())
+ missing_layer_uuid = str(uuid4())
+ main_chart = _chart_import_config(main_chart_uuid, "Missing Native
Layer")
+ annotations = [
+ {
+ "name": "Missing Native",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": missing_layer_uuid,
+ "show": True,
+ }
+ ]
+ main_chart["params"]["annotation_layers"] = deepcopy(annotations)
+ main_chart["query_context"] = json.dumps(
+ {
+ "datasource": {"id": 12, "type": "table"},
+ "queries": [{"annotation_layers": deepcopy(annotations)}],
+ "form_data": {"annotation_layers": deepcopy(annotations)},
+ }
+ )
+ contents = {
+ "metadata.yaml": yaml.safe_dump(chart_metadata_config),
+ "databases/imported_database.yaml":
yaml.safe_dump(database_config),
+ "datasets/imported_dataset.yaml": yaml.safe_dump(dataset_config),
+ "charts/main_chart.yaml": yaml.safe_dump(main_chart),
+ }
+
+ try:
+ ImportChartsCommand(contents, overwrite=True).run()
+
+ chart =
db.session.query(Slice).filter_by(uuid=main_chart_uuid).one()
+ assert json.loads(chart.params)["annotation_layers"] == []
+ query_context = json.loads(chart.query_context)
+ assert query_context["queries"][0]["annotation_layers"] == []
+ assert query_context["form_data"]["annotation_layers"] == []
+ finally:
+ _cleanup_imported_chart_bundle([main_chart_uuid], [])
+
+ @patch("superset.utils.core.g")
+ @patch("superset.security.manager.g")
+ @patch("superset.commands.database.importers.v1.utils.add_permissions")
+ def
test_import_chart_invalid_annotation_layer_uuid_drops_only_invalid_reference(
+ self, mock_add_permissions, sm_g, utils_g
+ ):
+ """Drop invalid native UUID refs and keep formula annotations."""
+ sm_g.user = utils_g.user = security_manager.find_user("admin")
+ main_chart_uuid = str(uuid4())
+ main_chart = _chart_import_config(main_chart_uuid, "Invalid Native
UUID")
+ annotations = [
+ {
+ "name": "Invalid Native",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": "not-a-uuid",
+ "show": True,
+ },
+ {
+ "name": "Formula",
+ "annotationType": "FORMULA",
+ "sourceType": "FORMULA",
+ "value": "sin(x)",
+ "show": False,
+ },
+ ]
+ main_chart["params"]["annotation_layers"] = [
+ *deepcopy(annotations),
+ ]
+ main_chart["query_context"] = json.dumps(
+ {
+ "datasource": {"id": 12, "type": "table"},
+ "queries": [{"annotation_layers": deepcopy(annotations)}],
+ "form_data": {"annotation_layers": deepcopy(annotations)},
+ }
+ )
+ contents = {
+ "metadata.yaml": yaml.safe_dump(chart_metadata_config),
+ "databases/imported_database.yaml":
yaml.safe_dump(database_config),
+ "datasets/imported_dataset.yaml": yaml.safe_dump(dataset_config),
+ "charts/main_chart.yaml": yaml.safe_dump(main_chart),
+ }
+
+ try:
+ ImportChartsCommand(contents, overwrite=True).run()
+
+ chart =
db.session.query(Slice).filter_by(uuid=main_chart_uuid).one()
+ params_layers = json.loads(chart.params)["annotation_layers"]
+ assert len(params_layers) == 1
+ assert params_layers[0]["annotationType"] == "FORMULA"
+ assert params_layers[0]["value"] == "sin(x)"
+
+ query_context = json.loads(chart.query_context)
+ query_layers = query_context["queries"][0]["annotation_layers"]
+ form_layers = query_context["form_data"]["annotation_layers"]
+ assert [layer["value"] for layer in query_layers] == ["sin(x)"]
+ assert [layer["value"] for layer in form_layers] == ["sin(x)"]
+ finally:
+ _cleanup_imported_chart_bundle([main_chart_uuid], [])
+
+ @patch("superset.utils.core.g")
+ @patch("superset.security.manager.g")
+ @patch("superset.commands.database.importers.v1.utils.add_permissions")
+ def
test_import_chart_existing_annotation_layer_dependency_overwrite_reuses_layer(
+ self, mock_add_permissions, sm_g, utils_g
+ ):
+ """Reuse and overwrite existing annotation layer on chart import."""
+ sm_g.user = utils_g.user = security_manager.find_user("admin")
+ existing_layer_uuid = str(uuid4())
+ main_chart_uuid = str(uuid4())
+ existing_layer = _create_chart_annotation_layer(
+ name="existing-layer",
+ descr="before overwrite",
+ )
+ existing_layer.uuid = existing_layer_uuid
+ db.session.commit()
+ existing_layer_id = existing_layer.id
+ _create_chart_annotation(existing_layer, short_descr="stale-child")
+
+ main_chart = _chart_import_config(
+ main_chart_uuid, "Overwrite Native Layer Chart"
+ )
+ main_chart["params"]["annotation_layers"] = [
+ {
+ "name": "Native",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": existing_layer_uuid,
+ "show": True,
+ "style": "solid",
+ }
+ ]
+ contents = {
+ "metadata.yaml": yaml.safe_dump(chart_metadata_config),
+ "databases/imported_database.yaml":
yaml.safe_dump(database_config),
+ "datasets/imported_dataset.yaml": yaml.safe_dump(dataset_config),
+ "charts/main_chart.yaml": yaml.safe_dump(main_chart),
+ "annotation_layers/native_layer.yaml": yaml.safe_dump(
+ _annotation_layer_import_config(
+ existing_layer_uuid,
+ "existing-layer-updated",
+ [
+ {
+ "uuid": str(uuid4()),
+ "short_descr": "fresh-child",
+ "long_descr": "new annotation",
+ "json_metadata": {"fresh": True},
+ }
+ ],
+ descr="after overwrite",
+ )
+ ),
+ }
+
+ try:
+ ImportChartsCommand(contents, overwrite=True).run()
+
+ chart =
db.session.query(Slice).filter_by(uuid=main_chart_uuid).one()
+ layer = (
+ db.session.query(AnnotationLayer)
+ .filter_by(uuid=existing_layer_uuid)
+ .one()
+ )
+ params_layers = json.loads(chart.params)["annotation_layers"]
+ annotations = (
+ db.session.query(Annotation).filter_by(layer_id=layer.id).all()
+ )
+
+ assert layer.id == existing_layer_id
+ assert layer.name == "existing-layer-updated"
+ assert layer.descr == "after overwrite"
+ assert params_layers[0]["value"] == layer.id
+ assert len(annotations) == 1
+ assert annotations[0].short_descr == "fresh-child"
+ finally:
+ _cleanup_imported_chart_bundle([main_chart_uuid],
[existing_layer_uuid])
+
+ @patch("superset.utils.core.g")
+ @patch("superset.security.manager.g")
+ @patch("superset.commands.database.importers.v1.utils.add_permissions")
+ def test_import_chart_circular_chart_annotation_references_keep_both_sides(
+ self, mock_add_permissions, sm_g, utils_g
+ ) -> None:
+ """Charts referencing each other both keep their annotation
references."""
+ sm_g.user = utils_g.user = security_manager.find_user("admin")
+ chart_a_uuid = str(uuid4())
+ chart_b_uuid = str(uuid4())
+ chart_a = _chart_import_config(chart_a_uuid, "Cycle Chart A")
+ chart_b = _chart_import_config(chart_b_uuid, "Cycle Chart B")
+ for config, target_uuid in ((chart_a, chart_b_uuid), (chart_b,
chart_a_uuid)):
+ annotations = [
+ {
+ "name": "Cycle Ref",
+ "annotationType": "TIME_SERIES",
+ "sourceType": "line",
+ "value": target_uuid,
+ }
+ ]
+ config["params"]["annotation_layers"] = deepcopy(annotations)
+ config["query_context"] = json.dumps(
+ {
+ "datasource": {"id": 12, "type": "table"},
+ "queries": [{"annotation_layers": deepcopy(annotations)}],
+ "form_data": {"annotation_layers": deepcopy(annotations)},
+ }
+ )
+
+ contents = {
+ "metadata.yaml": yaml.safe_dump(chart_metadata_config),
+ "databases/imported_database.yaml":
yaml.safe_dump(database_config),
+ "datasets/imported_dataset.yaml": yaml.safe_dump(dataset_config),
+ "charts/chart_a.yaml": yaml.safe_dump(chart_a),
+ "charts/chart_b.yaml": yaml.safe_dump(chart_b),
+ }
+
+ try:
+ ImportChartsCommand(contents, overwrite=True).run()
+
+ imported_a =
db.session.query(Slice).filter_by(uuid=chart_a_uuid).one()
+ imported_b =
db.session.query(Slice).filter_by(uuid=chart_b_uuid).one()
+ for chart, expected_id in (
+ (imported_a, imported_b.id),
+ (imported_b, imported_a.id),
+ ):
+ params_layers = json.loads(chart.params)["annotation_layers"]
+ query_context = json.loads(chart.query_context)
+ assert [layer["value"] for layer in params_layers] ==
[expected_id]
+ assert [
+ layer["value"]
+ for layer in
query_context["queries"][0]["annotation_layers"]
+ ] == [expected_id]
+ assert [
+ layer["value"]
+ for layer in
query_context["form_data"]["annotation_layers"]
+ ] == [expected_id]
+ finally:
+ _cleanup_imported_chart_bundle([chart_a_uuid, chart_b_uuid], [])
+
+ @patch("superset.utils.core.g")
+ @patch("superset.security.manager.g")
+ @patch("superset.commands.database.importers.v1.utils.add_permissions")
+ def test_import_chart_legacy_integer_annotation_references_are_dropped(
+ self, mock_add_permissions, sm_g, utils_g
+ ) -> None:
+ """Integer IDs from pre-UUID bundles are not bound to local rows."""
+ sm_g.user = utils_g.user = security_manager.find_user("admin")
+ local_layer = _create_chart_annotation_layer(name=f"Local {uuid4()}")
+ main_chart_uuid = str(uuid4())
+ main_chart = _chart_import_config(main_chart_uuid, "Legacy Int Chart")
+ annotations = [
+ {
+ "name": "Legacy Native",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": local_layer.id,
+ },
+ {
+ "name": "Legacy Table",
+ "annotationType": "EVENT",
+ "sourceType": "table",
+ "value": 1,
+ },
+ {
+ "name": "Formula",
+ "annotationType": "FORMULA",
+ "sourceType": "",
+ "value": "x",
+ },
+ ]
+ main_chart["params"]["annotation_layers"] = deepcopy(annotations)
+ main_chart["query_context"] = json.dumps(
+ {
+ "datasource": {"id": 12, "type": "table"},
+ "queries": [{"annotation_layers": deepcopy(annotations)}],
+ "form_data": {"annotation_layers": deepcopy(annotations)},
+ }
+ )
+ contents = {
+ "metadata.yaml": yaml.safe_dump(chart_metadata_config),
+ "databases/imported_database.yaml":
yaml.safe_dump(database_config),
+ "datasets/imported_dataset.yaml": yaml.safe_dump(dataset_config),
+ "charts/main_chart.yaml": yaml.safe_dump(main_chart),
+ }
+
+ try:
+ ImportChartsCommand(contents, overwrite=True).run()
+
+ chart =
db.session.query(Slice).filter_by(uuid=main_chart_uuid).one()
+ query_context = json.loads(chart.query_context)
+ for annotation_layers in (
+ json.loads(chart.params)["annotation_layers"],
+ query_context["queries"][0]["annotation_layers"],
+ query_context["form_data"]["annotation_layers"],
+ ):
+ assert [layer["name"] for layer in annotation_layers] ==
["Formula"]
+ finally:
+ _cleanup_imported_chart_bundle([main_chart_uuid], [])
+ _delete_chart_annotation_layer(local_layer)
+
+ @patch("superset.utils.core.g")
+ @patch("superset.security.manager.g")
+ @patch("superset.commands.database.importers.v1.utils.add_permissions")
+ def test_import_chart_reuses_existing_annotation_source_chart(
+ self, mock_add_permissions, sm_g, utils_g
+ ) -> None:
+ """An existing source chart is reused unchanged, tags included."""
+ sm_g.user = utils_g.user = security_manager.find_user("admin")
+ source_chart_uuid = str(uuid4())
+ main_chart_uuid = str(uuid4())
+ tag = None
+ try:
+ ImportChartsCommand(
+ _source_chart_bundle(
+ _chart_import_config(source_chart_uuid, "Existing Source")
+ ),
+ overwrite=True,
+ ).run()
+ existing_source = (
+ db.session.query(Slice).filter_by(uuid=source_chart_uuid).one()
+ )
+ tag = Tag(name=f"local-tag-{uuid4().hex[:8]}", type=TagType.custom)
+ db.session.add(tag)
+ db.session.flush()
+ db.session.add(
+ TaggedObject(
+ tag_id=tag.id,
+ object_id=existing_source.id,
+ object_type=ObjectType.chart,
+ )
+ )
+ db.session.commit()
+
+ changed_source = _chart_import_config(source_chart_uuid, "Changed
Source")
+ changed_source["tags"] = []
+ main_chart = _chart_using_source(main_chart_uuid,
source_chart_uuid)
+ contents = _source_chart_bundle(
+ changed_source, main_chart, selected=[main_chart_uuid]
+ )
+
+ # Alpha can write charts but isn't an editor of the admin's source.
+ sm_g.user = utils_g.user = security_manager.find_user("alpha")
+
+ # overwrite=False: the existing source isn't a conflict
+ ImportChartsCommand(contents, overwrite=False).run()
+
+ # overwrite=True: the source is reused instead of overwritten
+ ImportChartsCommand(contents, overwrite=True).run()
+
+ db.session.expire_all()
+ source =
db.session.query(Slice).filter_by(uuid=source_chart_uuid).one()
+ main =
db.session.query(Slice).filter_by(uuid=main_chart_uuid).one()
+ assert source.id == existing_source.id
+ assert source.slice_name == "Existing Source"
+ assert tag.name in {source_tag.name for source_tag in source.tags}
+ assert [
+ layer["value"] for layer in
json.loads(main.params)["annotation_layers"]
+ ] == [source.id]
+ finally:
+ if tag is not None:
+
db.session.query(TaggedObject).filter_by(tag_id=tag.id).delete()
+ db.session.delete(tag)
+ db.session.commit()
+ _cleanup_imported_chart_bundle([main_chart_uuid,
source_chart_uuid], [])
+
+ @patch("superset.utils.core.g")
+ @patch("superset.security.manager.g")
+ @patch("superset.commands.database.importers.v1.utils.add_permissions")
+ def test_import_chart_overwrites_selected_annotation_source_chart(
+ self, mock_add_permissions, sm_g, utils_g
+ ) -> None:
+ """A source chart that was picked for the export is overwritten."""
+ sm_g.user = utils_g.user = security_manager.find_user("admin")
+ source_chart_uuid = str(uuid4())
+ main_chart_uuid = str(uuid4())
+ try:
+ ImportChartsCommand(
+ _source_chart_bundle(
+ _chart_import_config(source_chart_uuid, "Existing Source")
+ ),
+ overwrite=True,
+ ).run()
+
+ contents = _source_chart_bundle(
+ _chart_import_config(source_chart_uuid, "Changed Source"),
+ _chart_using_source(main_chart_uuid, source_chart_uuid),
+ selected=[main_chart_uuid, source_chart_uuid],
+ )
+ ImportChartsCommand(contents, overwrite=True).run()
+
+ db.session.expire_all()
+ source =
db.session.query(Slice).filter_by(uuid=source_chart_uuid).one()
+ assert source.slice_name == "Changed Source"
+ finally:
+ _cleanup_imported_chart_bundle([main_chart_uuid,
source_chart_uuid], [])
+
+ @patch("superset.utils.core.g")
+ @patch("superset.security.manager.g")
+ @patch("superset.commands.database.importers.v1.utils.add_permissions")
+ def test_import_chart_gamma_overwrite_keeps_existing_annotation_layer(
+ self, mock_add_permissions, sm_g, utils_g
+ ) -> None:
+ """Gamma's overwrite chart import reuses a layer without changing
it."""
+ sm_g.user = utils_g.user = security_manager.find_user("admin")
+ layer = _create_chart_annotation_layer(
+ name=f"Gamma Kept {uuid4()}", descr="original"
+ )
+ _create_chart_annotation(layer, short_descr="original-child")
+ layer_uuid = str(layer.uuid)
+ seed_chart_uuid = str(uuid4())
+ main_chart_uuid = str(uuid4())
+ try:
+ # the database and dataset have to exist: Gamma can't create them
+ ImportChartsCommand(
+ _source_chart_bundle(_chart_import_config(seed_chart_uuid,
"Seed")),
+ overwrite=True,
+ ).run()
+
+ main_chart = _chart_import_config(main_chart_uuid, "Gamma Chart")
+ main_chart["params"]["annotation_layers"] = [
+ {
+ "name": "Native",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": layer_uuid,
+ }
+ ]
+ contents = {
+ "metadata.yaml": yaml.safe_dump(chart_metadata_config),
+ "databases/imported_database.yaml":
yaml.safe_dump(database_config),
+ "datasets/imported_dataset.yaml":
yaml.safe_dump(dataset_config),
+ "charts/main_chart.yaml": yaml.safe_dump(main_chart),
+ "annotation_layers/layer.yaml": yaml.safe_dump(
+ _annotation_layer_import_config(
+ layer_uuid, "renamed-by-gamma", [], descr="changed"
+ )
+ ),
+ }
+
+ sm_g.user = utils_g.user = security_manager.find_user("gamma")
+ ImportChartsCommand(contents, overwrite=True).run()
+
+ db.session.expire_all()
+ reloaded = (
+
db.session.query(AnnotationLayer).filter_by(uuid=layer_uuid).one()
+ )
+ children =
db.session.query(Annotation).filter_by(layer_id=reloaded.id)
+ chart =
db.session.query(Slice).filter_by(uuid=main_chart_uuid).one()
+ assert reloaded.name == layer.name
Review Comment:
Good catch, thanks. 1becc18 captures the layer name before the import and
compares against that. With `ignore_permissions=True` at the chart call site
the test now fails.
##########
tests/integration_tests/dashboards/commands_tests.py:
##########
@@ -1016,3 +1024,575 @@ def test_fave_unfave_dashboard_command_forbidden(self,
mock_get):
with self.assertRaises(DashboardAccessDeniedError): # noqa:
PT027
DelFavoriteDashboardCommand(example_dashboard.uuid).run()
+
+
+def _cleanup_dashboard_annotation_import(chart_uuids, layer_uuids,
dashboard_uuid):
+ dashboard =
db.session.query(Dashboard).filter_by(uuid=dashboard_uuid).one_or_none()
+ if dashboard:
+ db.session.delete(dashboard)
+ for chart_uuid in chart_uuids:
+ chart =
db.session.query(Slice).filter_by(uuid=chart_uuid).one_or_none()
+ if chart:
+ db.session.delete(chart)
+ for layer_uuid in layer_uuids:
+ layer = (
+
db.session.query(AnnotationLayer).filter_by(uuid=layer_uuid).one_or_none()
+ )
+ if layer:
+ db.session.query(Annotation).filter(
+ Annotation.layer_id == layer.id
+ ).delete()
+ db.session.delete(layer)
+ dataset = (
+
db.session.query(SqlaTable).filter_by(uuid=dataset_config["uuid"]).one_or_none()
+ )
+ if dataset:
+ db.session.delete(dataset)
+ database = (
+
db.session.query(Database).filter_by(uuid=database_config["uuid"]).one_or_none()
+ )
+ if database:
+ db.session.delete(database)
+ db.session.commit()
+
+
+class TestImportDashboardsAnnotationLayers(SupersetTestCase):
+ """Dashboard-level annotation layer dependency coverage for import."""
+
+ @patch("superset.utils.core.g")
+ @patch("superset.security.manager.g")
+ @patch("superset.commands.database.importers.v1.utils.add_permissions")
+ def test_import_dashboard_native_annotation_dependency_chain(
+ self, mock_add_permissions, sm_g, utils_g
+ ):
+ """Import dashboard, chart, native layer, and child annotations."""
+ sm_g.user = utils_g.user = security_manager.find_user("admin")
+ dashboard_uuid = str(uuid4())
+ chart_uuid = str(uuid4())
+ layer_uuid = str(uuid4())
+ chart_cfg = dashboard_chart_config(chart_uuid, "Dashboard Native Main")
+ chart_cfg["params"]["annotation_layers"] = [
+ {
+ "name": "Native Layer",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": layer_uuid,
+ "show": True,
+ "style": "solid",
+ }
+ ]
+ dash_cfg = dashboard_config_for_charts(
+ dashboard_uuid,
+ "Dashboard Native Test",
+ [("CHART-1", chart_uuid, chart_cfg["slice_name"])],
+ )
+ contents = dashboard_import_bundle(
+ dash_cfg,
+ {"main_chart.yaml": chart_cfg},
+ {
+ "native_layer.yaml": dashboard_annotation_layer_config(
+ layer_uuid,
+ "Native Layer",
+ [
+ {
+ "uuid": str(uuid4()),
+ "short_descr": "child-a",
+ "long_descr": "child annotation",
+ "json_metadata": {"flag": "a"},
+ },
+ {
+ "uuid": str(uuid4()),
+ "short_descr": "child-b",
+ "long_descr": "child annotation 2",
+ "json_metadata": {"flag": "b"},
+ },
+ ],
+ descr="native layer descr",
+ )
+ },
+ )
+ try:
+ v1.ImportDashboardsCommand(contents, overwrite=True).run()
+
+ dashboard =
db.session.query(Dashboard).filter_by(uuid=dashboard_uuid).one()
+ chart = db.session.query(Slice).filter_by(uuid=chart_uuid).one()
+ layer =
db.session.query(AnnotationLayer).filter_by(uuid=layer_uuid).one()
+ annotations = (
+ db.session.query(Annotation).filter_by(layer_id=layer.id).all()
+ )
+
+ assert chart in dashboard.slices
+ imported_layers = json.loads(chart.params)["annotation_layers"]
+ assert len(imported_layers) == 1
+ assert imported_layers[0]["sourceType"] == "NATIVE"
+ assert imported_layers[0]["value"] == layer.id
+ assert imported_layers[0]["show"] is True
+ assert imported_layers[0]["style"] == "solid"
+ assert layer.name == "Native Layer"
+ assert layer.descr == "native layer descr"
+ assert {annotation.short_descr for annotation in annotations} == {
+ "child-a",
+ "child-b",
+ }
+ assert all(annotation.layer_id == layer.id for annotation in
annotations)
+ finally:
+ _cleanup_dashboard_annotation_import(
+ [chart_uuid], [layer_uuid], dashboard_uuid
+ )
+
+ @patch("superset.utils.core.g")
+ @patch("superset.security.manager.g")
+ @patch("superset.commands.database.importers.v1.utils.add_permissions")
+ def test_import_dashboard_annotation_dependency_graph(
+ self, mock_add_permissions, sm_g, utils_g
+ ):
+ """Import dashboard with two native layers and chart dependency."""
+ sm_g.user = utils_g.user = security_manager.find_user("admin")
+ dashboard_uuid = str(uuid4())
+ main_chart_uuid = str(uuid4())
+ ref_chart_uuid = str(uuid4())
+ layer_a_uuid = str(uuid4())
+ layer_b_uuid = str(uuid4())
+
+ ref_chart_cfg = dashboard_chart_config(ref_chart_uuid, "Dashboard Ref
Chart")
+ main_chart_cfg = dashboard_chart_config(main_chart_uuid, "Dashboard
Main Chart")
+ main_chart_cfg["params"]["annotation_layers"] = [
+ {
+ "name": "Native Layer A",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": layer_a_uuid,
+ "show": True,
+ "style": "solid",
+ },
+ {
+ "name": "Native Layer B",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": layer_b_uuid,
+ "show": False,
+ "style": "dashed",
+ },
+ {
+ "name": "Table Annotation",
+ "annotationType": "EVENT",
+ "sourceType": "table",
+ "value": ref_chart_uuid,
+ "show": True,
+ "style": "solid",
+ },
+ ]
+ dash_cfg = dashboard_config_for_charts(
+ dashboard_uuid,
+ "Dashboard Dependency Graph Test",
+ [
+ ("CHART-1", main_chart_uuid, main_chart_cfg["slice_name"]),
+ ("CHART-2", ref_chart_uuid, ref_chart_cfg["slice_name"]),
+ ],
+ )
+ contents = dashboard_import_bundle(
+ dash_cfg,
+ {
+ "main_chart.yaml": main_chart_cfg,
+ "ref_chart.yaml": ref_chart_cfg,
+ },
+ {
+ "layer_a.yaml": dashboard_annotation_layer_config(
+ layer_a_uuid,
+ "Layer A",
+ [
+ {
+ "uuid": str(uuid4()),
+ "short_descr": "A1",
+ "long_descr": "A1 long",
+ "json_metadata": {"layer": "A"},
+ },
+ {
+ "uuid": str(uuid4()),
+ "short_descr": "A2",
+ "long_descr": "A2 long",
+ "json_metadata": {"layer": "A", "index": 2},
+ },
+ ],
+ ),
+ "layer_b.yaml": dashboard_annotation_layer_config(
+ layer_b_uuid,
+ "Layer B",
+ [
+ {
+ "uuid": str(uuid4()),
+ "short_descr": "B1",
+ "long_descr": "B1 long",
+ "json_metadata": {"layer": "B"},
+ },
+ {
+ "uuid": str(uuid4()),
+ "short_descr": "B2",
+ "long_descr": "B2 long",
+ "json_metadata": {"layer": "B", "index": 2},
+ },
+ ],
+ ),
+ },
+ )
+ try:
+ v1.ImportDashboardsCommand(contents, overwrite=True).run()
+
+ dashboard =
db.session.query(Dashboard).filter_by(uuid=dashboard_uuid).one()
+ main_chart =
db.session.query(Slice).filter_by(uuid=main_chart_uuid).one()
+ ref_chart =
db.session.query(Slice).filter_by(uuid=ref_chart_uuid).one()
+ layer_a = (
+
db.session.query(AnnotationLayer).filter_by(uuid=layer_a_uuid).one()
+ )
+ layer_b = (
+
db.session.query(AnnotationLayer).filter_by(uuid=layer_b_uuid).one()
+ )
+ assert {str(chart.uuid) for chart in dashboard.slices} == {
+ main_chart_uuid,
+ ref_chart_uuid,
+ }
+
+ imported_layers =
json.loads(main_chart.params)["annotation_layers"]
+ assert [layer_cfg["sourceType"] for layer_cfg in imported_layers]
== [
+ "NATIVE",
+ "NATIVE",
+ "table",
+ ]
+ assert [layer_cfg["value"] for layer_cfg in imported_layers] == [
+ layer_a.id,
+ layer_b.id,
+ ref_chart.id,
+ ]
+
+ a_annotations = (
+
db.session.query(Annotation).filter_by(layer_id=layer_a.id).all()
+ )
+ b_annotations = (
+
db.session.query(Annotation).filter_by(layer_id=layer_b.id).all()
+ )
+ assert {annotation.short_descr for annotation in a_annotations} ==
{
+ "A1",
+ "A2",
+ }
+ assert {annotation.short_descr for annotation in b_annotations} ==
{
+ "B1",
+ "B2",
+ }
+ assert all(
+ annotation.layer_id == layer_a.id for annotation in
a_annotations
+ )
+ assert all(
+ annotation.layer_id == layer_b.id for annotation in
b_annotations
+ )
+ finally:
+ _cleanup_dashboard_annotation_import(
+ [main_chart_uuid, ref_chart_uuid],
+ [layer_a_uuid, layer_b_uuid],
+ dashboard_uuid,
+ )
+
+ @patch("superset.utils.core.g")
+ @patch("superset.security.manager.g")
+ @patch("superset.commands.database.importers.v1.utils.add_permissions")
+ def test_import_dashboard_existing_annotation_layer_reuses_existing_layer(
+ self, mock_add_permissions, sm_g, utils_g
+ ):
+ """Reuse existing native layer during dashboard import."""
+ sm_g.user = utils_g.user = security_manager.find_user("admin")
+ dashboard_uuid = str(uuid4())
+ chart_uuid = str(uuid4())
+ layer_uuid = str(uuid4())
+
+ existing_layer = AnnotationLayer(name="existing-layer", descr="before")
+ existing_layer.uuid = layer_uuid
+ db.session.add(existing_layer)
+ db.session.commit()
+ existing_layer_id = existing_layer.id
+ db.session.add(
+ Annotation(
+ layer=existing_layer, short_descr="existing-child",
json_metadata=None
+ )
+ )
+ db.session.commit()
+
+ chart_cfg = dashboard_chart_config(chart_uuid, "Dashboard Existing
Layer")
+ chart_cfg["params"]["annotation_layers"] = [
+ {
+ "name": "Native",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": layer_uuid,
+ "show": True,
+ "style": "solid",
+ }
+ ]
+ dash_cfg = dashboard_config_for_charts(
+ dashboard_uuid,
+ "Dashboard Existing Layer Test",
+ [("CHART-1", chart_uuid, chart_cfg["slice_name"])],
+ )
+ contents = dashboard_import_bundle(
+ dash_cfg,
+ {"chart.yaml": chart_cfg},
+ {
+ "layer.yaml": dashboard_annotation_layer_config(
+ layer_uuid,
+ "incoming-layer-name",
+ [
+ {
+ "uuid": str(uuid4()),
+ "short_descr": "incoming-child",
+ "long_descr": "incoming",
+ "json_metadata": {"incoming": True},
+ }
+ ],
+ descr="incoming-descr",
+ )
+ },
+ )
+ try:
+ v1.ImportDashboardsCommand(contents, overwrite=True).run()
+
+ chart = db.session.query(Slice).filter_by(uuid=chart_uuid).one()
+ layer =
db.session.query(AnnotationLayer).filter_by(uuid=layer_uuid).one()
+ annotations = (
+ db.session.query(Annotation).filter_by(layer_id=layer.id).all()
+ )
+ assert layer.id == existing_layer_id
+ assert layer.name == "existing-layer"
+ assert layer.descr == "before"
+ assert len(annotations) == 1
+ assert annotations[0].short_descr == "existing-child"
+ assert json.loads(chart.params)["annotation_layers"][0]["value"]
== layer.id
+ finally:
+ _cleanup_dashboard_annotation_import(
+ [chart_uuid], [layer_uuid], dashboard_uuid
+ )
+
+ @patch("superset.utils.core.g")
+ @patch("superset.security.manager.g")
+ @patch("superset.commands.database.importers.v1.utils.add_permissions")
+ def test_import_dashboard_annotation_source_chart_on_other_database(
+ self, mock_add_permissions, sm_g, utils_g
+ ) -> None:
+ """Import an annotation source chart that isn't in the layout."""
+ sm_g.user = utils_g.user = security_manager.find_user("admin")
+ dashboard_uuid = str(uuid4())
+ main_chart_uuid = str(uuid4())
+ source_chart_uuid = str(uuid4())
+ source_database_config = {
+ **deepcopy(database_config),
+ "uuid": str(uuid4()),
+ "database_name": f"annotation_source_db_{uuid4().hex[:8]}",
+ "sqlalchemy_uri": "postgresql://user:pass@host2",
+ }
+ source_dataset_config = {
+ **deepcopy(dataset_config),
+ "uuid": str(uuid4()),
+ "table_name": "annotation_source_dataset",
+ "database_uuid": source_database_config["uuid"],
+ }
+ source_chart_cfg = dashboard_chart_config(
+ source_chart_uuid, "Annotation Source Chart"
+ )
+ source_chart_cfg["dataset_uuid"] = source_dataset_config["uuid"]
+ main_chart_cfg = dashboard_chart_config(main_chart_uuid, "Annotated
Chart")
+ main_chart_cfg["params"]["annotation_layers"] = [
+ {
+ "name": "Source",
+ "annotationType": "EVENT",
+ "sourceType": "table",
+ "value": source_chart_uuid,
+ }
+ ]
+ dash_cfg = dashboard_config_for_charts(
+ dashboard_uuid,
+ "Dashboard Cross Database Annotation",
+ [("CHART-1", main_chart_uuid, main_chart_cfg["slice_name"])],
+ )
+ contents = dashboard_import_bundle(
+ dash_cfg,
+ {"main_chart.yaml": main_chart_cfg, "source_chart.yaml":
source_chart_cfg},
+ )
+ contents["databases/source_database.yaml"] = yaml.safe_dump(
+ source_database_config
+ )
+ contents["datasets/source_dataset.yaml"] =
yaml.safe_dump(source_dataset_config)
+ try:
+ v1.ImportDashboardsCommand(contents, overwrite=True).run()
+
+ main_chart =
db.session.query(Slice).filter_by(uuid=main_chart_uuid).one()
+ source_chart = (
+ db.session.query(Slice).filter_by(uuid=source_chart_uuid).one()
+ )
+ assert source_chart.table.database.uuid == UUID(
+ source_database_config["uuid"]
+ )
+ assert [
+ layer["value"]
+ for layer in json.loads(main_chart.params)["annotation_layers"]
+ ] == [source_chart.id]
+ finally:
+ _cleanup_dashboard_annotation_import(
+ [main_chart_uuid, source_chart_uuid], [], dashboard_uuid
+ )
+ source_dataset = (
+ db.session.query(SqlaTable)
+ .filter_by(uuid=source_dataset_config["uuid"])
+ .one_or_none()
+ )
+ if source_dataset:
+ db.session.delete(source_dataset)
+ source_database = (
+ db.session.query(Database)
+ .filter_by(uuid=source_database_config["uuid"])
+ .one_or_none()
+ )
+ if source_database:
+ db.session.delete(source_database)
+ db.session.commit()
+
+ @patch("superset.utils.core.g")
+ @patch("superset.security.manager.g")
+ @patch("superset.commands.database.importers.v1.utils.add_permissions")
+ def test_import_dashboard_gamma_overwrite_keeps_existing_annotation_layer(
+ self, mock_add_permissions, sm_g, utils_g
+ ) -> None:
+ """Gamma's overwrite dashboard import reuses a layer without changing
it."""
+ sm_g.user = utils_g.user = security_manager.find_user("admin")
+ layer = AnnotationLayer(name=f"Gamma Dash Kept {uuid4()}",
descr="original")
+ db.session.add(layer)
+ db.session.flush()
+ db.session.add(Annotation(layer_id=layer.id,
short_descr="original-child"))
+ db.session.commit()
+ layer_uuid = str(layer.uuid)
+ layer_name = layer.name
+ dashboard_uuid = str(uuid4())
+ chart_uuid = str(uuid4())
+ chart_cfg = dashboard_chart_config(chart_uuid, "Gamma Dashboard Chart")
+ chart_cfg["params"]["annotation_layers"] = [
+ {
+ "name": "Native",
+ "annotationType": "EVENT",
+ "sourceType": "NATIVE",
+ "value": layer_uuid,
+ }
+ ]
+ dash_cfg = dashboard_config_for_charts(
+ dashboard_uuid,
+ "Gamma Dashboard",
+ [("CHART-1", chart_uuid, chart_cfg["slice_name"])],
+ )
+ contents = dashboard_import_bundle(
+ dash_cfg,
+ {"main_chart.yaml": chart_cfg},
+ {
+ "layer.yaml": dashboard_annotation_layer_config(
+ layer_uuid, "renamed-by-gamma", [], descr="changed"
+ )
+ },
+ )
+ try:
+ # the database and dataset have to exist: Gamma can't create them
+ ImportDatasetsCommand(
+ {
+ "metadata.yaml": yaml.safe_dump(dataset_metadata_config),
+ "databases/imported_database.yaml":
yaml.safe_dump(database_config),
+ "datasets/imported_dataset.yaml":
yaml.safe_dump(dataset_config),
+ },
+ overwrite=True,
+ ).run()
+
+ sm_g.user = utils_g.user = security_manager.find_user("gamma")
+ v1.ImportDashboardsCommand(
+ contents, overwrite=True, overwrite_all=True
+ ).run()
+
+ db.session.expire_all()
+ reloaded = (
+
db.session.query(AnnotationLayer).filter_by(uuid=layer_uuid).one()
+ )
+ children =
db.session.query(Annotation).filter_by(layer_id=reloaded.id)
+ assert reloaded.name == layer_name
+ assert reloaded.descr == "original"
+ assert [child.short_descr for child in children] ==
["original-child"]
Review Comment:
Added in 1becc18: the test now checks that the imported chart still points
at the reused layer's ID.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]