diff --git a/docker/pythonpath_dev/superset_config.py b/docker/pythonpath_dev/superset_config.py index 108305cf900..ce7f1998708 100644 --- a/docker/pythonpath_dev/superset_config.py +++ b/docker/pythonpath_dev/superset_config.py @@ -105,7 +105,13 @@ class CeleryConfig: CELERY_CONFIG = CeleryConfig -FEATURE_FLAGS = {"ALERT_REPORTS": True, "DATASET_FOLDERS": True} +FEATURE_FLAGS = { + "ALERT_REPORTS": True, + "DATASET_FOLDERS": True, + "ENABLE_EXTENSIONS": True, + "SEMANTIC_LAYERS": True, +} +EXTENSIONS_PATH = "/app/docker/extensions" ALERT_REPORTS_NOTIFICATION_DRY_RUN = True WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/" # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/ # noqa: E501 # The base URL for the email report hyperlinks. diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index f7ea57d8f44..11fe2949d37 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -4076,6 +4076,18 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@googleapis/sheets": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.1.tgz", + "integrity": "sha512-XTYObncN5Rqexc0uITZIN9OWTEyE/ZR2S6c7wAniqHe2oGXW9gcHR9f9hQwPMHFUTHjH7Jkj8SLdt0O0u37y2A==", + "license": "Apache-2.0", + "dependencies": { + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@great-expectations/jsonforms-antd-renderers": { "version": "2.2.11", "resolved": "https://registry.npmjs.org/@great-expectations/jsonforms-antd-renderers/-/jsonforms-antd-renderers-2.2.11.tgz", @@ -4096,18 +4108,6 @@ "react": "^17 || ^18" } }, - "node_modules/@googleapis/sheets": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.1.tgz", - "integrity": "sha512-XTYObncN5Rqexc0uITZIN9OWTEyE/ZR2S6c7wAniqHe2oGXW9gcHR9f9hQwPMHFUTHjH7Jkj8SLdt0O0u37y2A==", - "license": "Apache-2.0", - "dependencies": { - "googleapis-common": "^8.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", diff --git a/superset/commands/datasource/list.py b/superset/commands/datasource/list.py index 50ea765b4c3..caec143994d 100644 --- a/superset/commands/datasource/list.py +++ b/superset/commands/datasource/list.py @@ -65,6 +65,9 @@ class GetCombinedDatasourceListCommand(BaseCommand): source_type, name_filter, sql_filter, type_filter = self._parse_filters(filters) source_type = self._resolve_source_type(source_type, sql_filter, type_filter) + if source_type == "empty": + return {"count": 0, "result": []} + ds_q = DatasourceDAO.build_dataset_query(name_filter, sql_filter) sv_q = DatasourceDAO.build_semantic_view_query(name_filter) @@ -108,8 +111,18 @@ class GetCombinedDatasourceListCommand(BaseCommand): sql_filter: bool | None, type_filter: str | None, ) -> str: - """Narrow source_type based on access flags, sql filter, and type filter.""" + """Narrow source_type based on access flags, sql filter, and type filter. + + Returns one of: "database", "semantic_layer", "all", or "empty". + "empty" signals that the caller should short-circuit and return no results + (used when the user explicitly requests semantic views but lacks access). + """ if not self._can_read_semantic_views: + # If the user explicitly asked for semantic views but cannot read them, + # return "empty" so the caller yields zero results rather than silently + # falling back to the full dataset list. + if source_type == "semantic_layer" or type_filter == "semantic_view": + return "empty" return "database" if not self._can_read_datasets: return "semantic_layer" diff --git a/superset/daos/semantic_layer.py b/superset/daos/semantic_layer.py index 9690a2fa12b..c92745bd119 100644 --- a/superset/daos/semantic_layer.py +++ b/superset/daos/semantic_layer.py @@ -22,18 +22,20 @@ from __future__ import annotations from typing import Any from sqlalchemy.exc import StatementError - from superset_core.semantic_layers.daos import ( AbstractSemanticLayerDAO, AbstractSemanticViewDAO, ) +from superset.daos.base import BaseDAO from superset.extensions import db from superset.semantic_layers.models import SemanticLayer, SemanticView from superset.utils import json -class SemanticLayerDAO(AbstractSemanticLayerDAO): +class SemanticLayerDAO(BaseDAO[SemanticLayer], AbstractSemanticLayerDAO): + # SemanticLayer uses uuid as the primary key + id_column_name = "uuid" """ Data Access Object for SemanticLayer model. """ @@ -112,7 +114,7 @@ class SemanticLayerDAO(AbstractSemanticLayerDAO): ) -class SemanticViewDAO(AbstractSemanticViewDAO): +class SemanticViewDAO(BaseDAO[SemanticView], AbstractSemanticViewDAO): """Data Access Object for SemanticView model.""" model_cls = SemanticView diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py index b5d5bdc6bf2..f161ce53465 100644 --- a/superset/semantic_layers/api.py +++ b/superset/semantic_layers/api.py @@ -55,7 +55,7 @@ from superset.commands.semantic_layer.update import ( UpdateSemanticViewCommand, ) from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP -from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO +from superset.daos.semantic_layer import SemanticLayerDAO from superset.datasets.schemas import get_delete_ids_schema from superset.models.core import Database from superset.semantic_layers.models import SemanticLayer, SemanticView @@ -174,6 +174,11 @@ class SemanticViewRestApi(BaseSupersetModelRestApi): class_permission_name = "SemanticView" method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP include_route_methods = {"put", "post", "delete", "bulk_delete"} + # SemanticViewRestApi exposes only write endpoints, but can_read must be + # declared explicitly so that FAB registers the permission. It is used by + # DatasourceRestApi.combined_list to gate access to semantic views in the + # combined datasource list. + base_permissions = ["can_read", "can_write"] edit_model_schema = SemanticViewPutSchema() @@ -625,7 +630,7 @@ class SemanticLayerRestApi(BaseSupersetApi): ) # Check which views already exist with the same runtime config - existing = SemanticViewDAO.find_by_semantic_layer(str(layer.uuid)) + existing = SemanticLayerDAO.get_semantic_views(str(layer.uuid)) existing_keys: set[tuple[str, str]] = set() for v in existing: config = v.configuration diff --git a/superset/semantic_layers/mapper.py b/superset/semantic_layers/mapper.py index f2c13865cdf..23dea06124d 100644 --- a/superset/semantic_layers/mapper.py +++ b/superset/semantic_layers/mapper.py @@ -291,11 +291,7 @@ def map_query_object(query_object: ValidatedQueryObject) -> list[SemanticQuery]: metrics = [all_metrics[metric] for metric in (query_object.metrics or [])] - grain = ( - _convert_time_grain(query_object.extras["time_grain_sqla"]) - if "time_grain_sqla" in query_object.extras - else None - ) + grain = _convert_time_grain(query_object.extras.get("time_grain_sqla")) dimensions = [ dimension for dimension in semantic_view.dimensions @@ -740,13 +736,17 @@ def _get_group_limit_filters( return filters if filters else None -def _convert_time_grain(time_grain: str) -> Grain | None: +def _convert_time_grain(time_grain: str | None) -> Grain | None: """ Convert a time grain string (ISO 8601 duration) to a Grain instance. + + Returns None when ``time_grain`` is None or empty (no grain selected). """ + if not time_grain: + return None try: return Grains.get(time_grain) - except (ValueError, isodate.ISO8601Error): + except (TypeError, ValueError, isodate.ISO8601Error): return None diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py index f7acfb47e34..47e8aa24135 100644 --- a/tests/unit_tests/semantic_layers/api_test.py +++ b/tests/unit_tests/semantic_layers/api_test.py @@ -1813,8 +1813,7 @@ def test_get_views( mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO") mock_dao.find_by_uuid.return_value = mock_layer - mock_sv_dao = mocker.patch("superset.semantic_layers.api.SemanticViewDAO") - mock_sv_dao.find_by_semantic_layer.return_value = [] + mock_dao.get_semantic_views.return_value = [] response = client.post( f"/api/v1/semantic_layer/{test_uuid}/views", @@ -1851,8 +1850,7 @@ def test_get_views_with_existing( existing_view.name = "Existing View" existing_view.configuration = '{"database": "mydb"}' - mock_sv_dao = mocker.patch("superset.semantic_layers.api.SemanticViewDAO") - mock_sv_dao.find_by_semantic_layer.return_value = [existing_view] + mock_dao.get_semantic_views.return_value = [existing_view] response = client.post( f"/api/v1/semantic_layer/{test_uuid}/views", @@ -1932,8 +1930,7 @@ def test_get_views_existing_dict_config( existing_view.name = "View X" existing_view.configuration = {"key": "val"} # dict, not string - mock_sv_dao = mocker.patch("superset.semantic_layers.api.SemanticViewDAO") - mock_sv_dao.find_by_semantic_layer.return_value = [existing_view] + mock_dao.get_semantic_views.return_value = [existing_view] response = client.post( f"/api/v1/semantic_layer/{test_uuid}/views",