feat(chart): list semantic views in the Create Chart datasource picker (#43629)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mike Bridge
2026-09-02 16:33:19 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent b49c1fd7e4
commit 64bfbf75a4
11 changed files with 924 additions and 82 deletions
@@ -15,21 +15,101 @@
# specific language governing permissions and limitations
# under the License.
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import datetime
from unittest.mock import ANY, patch
import pytest
from flask_appbuilder.security.sqla.models import PermissionView
from sqlalchemy.sql.elements import TextClause
from superset import db, security_manager
from superset.connectors.sqla.models import SqlaTable
from superset.daos.exceptions import DatasourceTypeNotSupportedError
from superset.extensions import cache_manager
from superset.semantic_layers.models import SemanticLayer, SemanticView
from superset.utils import json
from tests.integration_tests.base_tests import SupersetTestCase
from tests.integration_tests.conftest import with_feature_flags
from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
@contextmanager
def _semantic_views(*layer_names: str) -> Iterator[list[SemanticView]]:
"""Persist one semantic view per named layer and remove them on exit.
Names are suffixed to stay unique across runs. The layer and view insert
hooks create the matching ``datasource_access`` permissions; the delete
hooks remove them again together with any role associations.
"""
suffix = uuid.uuid4().hex
layers = [
SemanticLayer(
uuid=uuid.uuid4(),
name=f"{name}_{suffix}",
type="test",
configuration="{}",
)
for name in layer_names
]
views = [
SemanticView(
uuid=uuid.uuid4(),
name=f"{layer.name}_view",
semantic_layer_uuid=layer.uuid,
configuration="{}",
)
for layer in layers
]
db.session.add_all([*layers, *views])
db.session.commit()
try:
for obj in [*layers, *views]:
db.session.refresh(obj)
yield views
finally:
db.session.rollback()
for obj in [*views, *layers]:
db.session.delete(obj)
db.session.commit()
@contextmanager
def _gamma_granted(*pvms: tuple[str, str]) -> Iterator[None]:
"""Grant ``(permission, view_menu)`` pairs to Gamma for the block's duration.
A permission-view that does not exist yet (``can_read SemanticView`` is only
registered when the semantic view API is mounted) is created and removed
again afterwards; one that already exists is left in place.
"""
gamma = security_manager.find_role("Gamma")
assert gamma is not None
created: list[PermissionView] = []
granted: list[PermissionView] = []
for permission, view_menu in pvms:
pvm = security_manager.find_permission_view_menu(permission, view_menu)
if pvm is None:
pvm = security_manager.add_permission_view_menu(permission, view_menu)
created.append(pvm)
if pvm not in gamma.permissions:
security_manager.add_permission_role(gamma, pvm)
granted.append(pvm)
db.session.commit()
try:
yield
finally:
db.session.rollback()
for pvm in granted:
security_manager.del_permission_role(gamma, pvm)
for pvm in created:
security_manager.del_permission_view_menu(
pvm.permission.name, pvm.view_menu.name
)
db.session.commit()
class TestDatasourceApi(SupersetTestCase):
def setUp(self):
# Clear the column-values cache before every test so that
@@ -477,3 +557,74 @@ class TestDatasourceApi(SupersetTestCase):
response = json.loads(rv.data.decode("utf-8"))
assert response == {"count": 0, "result": []}
run_mock.assert_called_once()
def _list_semantic_views_as_gamma(self) -> tuple[int, set[str]]:
"""Fetch the semantic-view slice of the combined list as Gamma."""
self.login(GAMMA_USERNAME)
rv = self.client.get(
"api/v1/datasource/?q="
"(filters:!((col:source_type,opr:eq,value:semantic_layer)),"
"order_column:table_name,order_direction:asc,page:0,page_size:25)"
)
payload = json.loads(rv.data.decode("utf-8"))
names = {item["table_name"] for item in payload.get("result", [])}
return rv.status_code, names
@with_feature_flags(SEMANTIC_LAYERS=True)
def test_combined_list_gamma_sees_views_of_granted_layers(self):
"""A layer-level grant exposes that layer's views and no others."""
with _semantic_views("permitted", "denied") as (permitted, denied):
layer_perm = permitted.semantic_layer.perm
assert layer_perm is not None
# The layer insert hook creates this permission; a regression
# there must fail here rather than be papered over by the grant.
assert (
security_manager.find_permission_view_menu(
"datasource_access", layer_perm
)
is not None
)
with _gamma_granted(
("can_read", "SemanticView"), ("datasource_access", layer_perm)
):
status, names = self._list_semantic_views_as_gamma()
assert status == 200
assert permitted.name in names
assert denied.name not in names
@with_feature_flags(SEMANTIC_LAYERS=True)
def test_combined_list_gamma_sees_individually_granted_views(self):
"""A view-level grant exposes that view without touching other layers."""
with _semantic_views("granted", "other") as (granted, other):
view_perm = granted.perm
assert view_perm is not None
assert (
security_manager.find_permission_view_menu(
"datasource_access", view_perm
)
is not None
)
with _gamma_granted(
("can_read", "SemanticView"), ("datasource_access", view_perm)
):
status, names = self._list_semantic_views_as_gamma()
assert status == 200
assert granted.name in names
assert other.name not in names
@with_feature_flags(SEMANTIC_LAYERS=True)
def test_combined_list_gamma_without_semantic_view_read_gets_none(self):
"""Without can_read on SemanticView the semantic-layer slice is empty."""
gamma = security_manager.find_role("Gamma")
assert gamma is not None
read_pvm = security_manager.find_permission_view_menu(
"can_read", "SemanticView"
)
assert read_pvm is None or read_pvm not in gamma.permissions
with _semantic_views("layer") as (view,):
layer_perm = view.semantic_layer.perm
assert layer_perm is not None
with _gamma_granted(("datasource_access", layer_perm)):
status, names = self._list_semantic_views_as_gamma()
assert status == 200
assert names == set()