From 2e734190daad2e5559e3bca0ef59d9b6077db04c Mon Sep 17 00:00:00 2001 From: Danylo Korostil Date: Mon, 18 Aug 2025 15:27:38 +0300 Subject: [PATCH] feat(api): Added initial uuid field support to dataset, chart, dashboard API --- superset/charts/api.py | 77 ++++++++++++------- superset/charts/schemas.py | 47 +++++++++++ superset/common/query_context_processor.py | 4 +- superset/constants.py | 4 + superset/daos/chart.py | 15 +++- superset/dashboards/api.py | 2 + superset/dashboards/schemas.py | 3 + superset/datasets/api.py | 5 ++ superset/datasets/schemas.py | 2 + superset/models/slice.py | 11 ++- superset/tasks/thumbnails.py | 2 +- superset/utils/cache.py | 10 ++- superset/viz.py | 3 +- tests/integration_tests/charts/api_tests.py | 6 ++ .../charts/data/api_tests.py | 3 +- .../integration_tests/dashboards/api_tests.py | 1 + tests/integration_tests/datasets/api_tests.py | 1 + 17 files changed, 160 insertions(+), 36 deletions(-) diff --git a/superset/charts/api.py b/superset/charts/api.py index 5abead3f6af5..a33c0bc943dc 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -45,6 +45,7 @@ from superset.charts.schemas import ( CHART_SCHEMAS, ChartCacheWarmUpRequestSchema, + ChartGetResponseSchema, ChartPostSchema, ChartPutSchema, get_delete_ids_schema, @@ -131,34 +132,7 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: } class_permission_name = "Chart" method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP - show_columns = [ - "cache_timeout", - "certified_by", - "certification_details", - "changed_on_delta_humanized", - "dashboards.dashboard_title", - "dashboards.id", - "dashboards.json_metadata", - "description", - "id", - "owners.first_name", - "owners.id", - "owners.last_name", - "dashboards.id", - "dashboards.dashboard_title", - "params", - "slice_name", - "thumbnail_url", - "url", - "viz_type", - "query_context", - "is_managed_externally", - "tags.id", - "tags.name", - "tags.type", - ] - show_select_columns = show_columns + ["table.id"] list_columns = [ "is_managed_externally", "certified_by", @@ -230,6 +204,7 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: "datasource_type", "description", "id", + "uuid", "owners", "dashboards", "slice_name", @@ -255,6 +230,7 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: add_model_schema = ChartPostSchema() edit_model_schema = ChartPutSchema() + chart_get_response_schema = ChartGetResponseSchema() openapi_spec_tag = "Charts" """ Override the name set for this collection of endpoints """ @@ -287,6 +263,53 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: allowed_rel_fields = {"owners", "created_by", "changed_by"} + @expose("/", methods=["GET"]) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get", + log_to_statsd=False, + ) + def get(self, id_or_uuid: str) -> Response: + """Gets a chart + --- + get: + description: >- + Get a chart + parameters: + - in: path + schema: + type: string + name: id_or_uuid + description: Either the id of the chart, or its uuid + responses: + 200: + description: Chart + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/ChartGetResponseSchema' + 302: + description: Redirects to the current digest + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + """ + # pylint: disable=arguments-differ + try: + dash = ChartDAO.get_by_id_or_uuid(id_or_uuid) + result = self.chart_get_response_schema.dump(dash) + return self.response(200, result=result) + except ChartNotFoundError: + return self.response_404() + @expose("/", methods=("POST",)) @protect() @safe diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index f1f7a7a19e7d..3a05c3ff1767 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -27,6 +27,7 @@ from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType from superset.db_engine_specs.base import builtin_time_grains +from superset.tags.models import TagType from superset.utils import pandas_postprocessing, schema as utils from superset.utils.core import ( AnnotationType, @@ -241,6 +242,7 @@ class ChartPostSchema(Schema): ) is_managed_externally = fields.Boolean(allow_none=True, dump_default=False) external_url = fields.String(allow_none=True) + uuid = fields.UUID(allow_none=True) class ChartPutSchema(Schema): @@ -297,6 +299,7 @@ class ChartPutSchema(Schema): is_managed_externally = fields.Boolean(allow_none=True, dump_default=False) external_url = fields.String(allow_none=True) tags = fields.List(fields.Integer(metadata={"description": tags_description})) + uuid = fields.UUID(allow_none=True) class ChartGetDatasourceObjectDataResponseSchema(Schema): @@ -1617,6 +1620,49 @@ class ChartCacheWarmUpResponseSchema(Schema): ) +class TagSchema(Schema): + id = fields.Int() + name = fields.String() + type = fields.Enum(TagType, by_value=True) + + +class UserSchema(Schema): + id = fields.Int() + first_name = fields.String() + last_name = fields.String() + + +class DashboardSchema(Schema): + id = fields.Int() + dashboard_title = fields.String() + json_metadata = fields.String() + + +class ChartGetResponseSchema(Schema): + id = fields.Int(description=id_description) + url = fields.String() + cache_timeout = fields.String() + certified_by = fields.String() + certification_details = fields.String() + changed_on_humanized = fields.String(data_key="changed_on_delta_humanized") + description = fields.String() + params = fields.String() + slice_name = fields.String() + thumbnail_url = fields.String() + viz_type = fields.String() + query_context = fields.String() + is_managed_externally = fields.Boolean() + tags = fields.Nested(TagSchema, many=True) + owners = fields.List(fields.Nested(UserSchema)) + dashboards = fields.List(fields.Nested(DashboardSchema)) + uuid = fields.UUID() + datasource_id = fields.Int() + datasource_name_text = fields.Function(lambda obj: obj.datasource_name_text()) + datasource_type = fields.String() + datasource_url = fields.Function(lambda obj: obj.datasource_url()) + datasource_uuid = fields.UUID(attribute="table.uuid") + + CHART_SCHEMAS = ( ChartCacheWarmUpRequestSchema, ChartCacheWarmUpResponseSchema, @@ -1640,6 +1686,7 @@ class ChartCacheWarmUpResponseSchema(Schema): ChartDataGeodeticParseOptionsSchema, ChartEntityResponseSchema, ChartGetDatasourceResponseSchema, + ChartGetResponseSchema, ChartCacheScreenshotResponseSchema, GetFavStarIdsSchema, ) diff --git a/superset/common/query_context_processor.py b/superset/common/query_context_processor.py index a0228cedeef4..d806a4be9faa 100644 --- a/superset/common/query_context_processor.py +++ b/superset/common/query_context_processor.py @@ -38,7 +38,7 @@ get_since_until_from_time_range, ) from superset.connectors.sqla.models import BaseDatasource -from superset.constants import CacheRegion, TimeGrain +from superset.constants import CACHE_DISABLED_TIMEOUT, CacheRegion, TimeGrain from superset.daos.annotation_layer import AnnotationLayerDAO from superset.daos.chart import ChartDAO from superset.exceptions import ( @@ -131,7 +131,7 @@ def get_df_payload( """Handles caching around the df payload retrieval""" cache_key = self.query_cache_key(query_obj) timeout = self.get_cache_timeout() - force_query = self._query_context.force or timeout == -1 + force_query = self._query_context.force or timeout == CACHE_DISABLED_TIMEOUT cache = QueryCacheManager.get( key=cache_key, region=CacheRegion.DATA, diff --git a/superset/constants.py b/superset/constants.py index f60b79a9615c..d285fb1f9014 100644 --- a/superset/constants.py +++ b/superset/constants.py @@ -243,3 +243,7 @@ class CacheRegion(StrEnum): DEFAULT = "default" DATA = "data" THUMBNAIL = "thumbnail" + + +# Cache timeout constants +CACHE_DISABLED_TIMEOUT = -1 # Special value indicating no caching should occur diff --git a/superset/daos/chart.py b/superset/daos/chart.py index 35afb7f7a91b..adca95b8a621 100644 --- a/superset/daos/chart.py +++ b/superset/daos/chart.py @@ -20,11 +20,14 @@ from datetime import datetime from typing import TYPE_CHECKING +from flask_appbuilder.models.sqla.interface import SQLAInterface + from superset.charts.filters import ChartFilter +from superset.commands.chart.exceptions import ChartNotFoundError from superset.daos.base import BaseDAO from superset.extensions import db from superset.models.core import FavStar, FavStarClassName -from superset.models.slice import Slice +from superset.models.slice import id_or_uuid_filter, Slice from superset.utils.core import get_user_id if TYPE_CHECKING: @@ -36,6 +39,16 @@ class ChartDAO(BaseDAO[Slice]): base_filter = ChartFilter + @staticmethod + def get_by_id_or_uuid(id_or_uuid: str) -> Slice: + query = db.session.query(Slice).filter(id_or_uuid_filter(id_or_uuid)) + # Apply chart base filters + query = ChartFilter("id", SQLAInterface(Slice, db.session)).apply(query, None) + chart = query.one_or_none() + if not chart: + raise ChartNotFoundError() + return chart + @staticmethod def favorited_ids(charts: list[Slice]) -> list[FavStar]: ids = [chart.id for chart in charts] diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index 6d8fd0da555e..54e05df8b755 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -191,6 +191,7 @@ class DashboardRestApi(BaseSupersetModelRestApi): list_columns = [ "id", + "uuid", "published", "status", "slug", @@ -251,6 +252,7 @@ class DashboardRestApi(BaseSupersetModelRestApi): "changed_by", "dashboard_title", "id", + "uuid", "owners", "published", "roles", diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index bdfcb94fc7a9..df6c1dd04ba9 100644 --- a/superset/dashboards/schemas.py +++ b/superset/dashboards/schemas.py @@ -238,6 +238,7 @@ class DashboardGetResponseSchema(Schema): changed_on_humanized = fields.String(data_key="changed_on_delta_humanized") created_on_humanized = fields.String(data_key="created_on_delta_humanized") is_managed_externally = fields.Boolean(allow_none=True, dump_default=False) + uuid = fields.UUID(allow_none=True) # pylint: disable=unused-argument @post_dump() @@ -365,6 +366,7 @@ class DashboardPostSchema(BaseDashboardSchema): ) is_managed_externally = fields.Boolean(allow_none=True, dump_default=False) external_url = fields.String(allow_none=True) + uuid = fields.UUID(allow_none=True) class DashboardCopySchema(Schema): @@ -431,6 +433,7 @@ class DashboardPutSchema(BaseDashboardSchema): tags = fields.List( fields.Integer(metadata={"description": tags_description}, allow_none=True) ) + uuid = fields.UUID(allow_none=True) class DashboardNativeFiltersConfigUpdateSchema(BaseDashboardSchema): diff --git a/superset/datasets/api.py b/superset/datasets/api.py index 0af6ecca4bdb..2afd0411c281 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -117,8 +117,10 @@ class DatasetRestApi(BaseSupersetModelRestApi): } list_columns = [ "id", + "uuid", "database.id", "database.database_name", + "database.uuid", "changed_by_name", "changed_by.first_name", "changed_by.last_name", @@ -153,6 +155,7 @@ class DatasetRestApi(BaseSupersetModelRestApi): "id", "database.database_name", "database.id", + "database.uuid", "table_name", "sql", "filter_select_enabled", @@ -222,6 +225,7 @@ class DatasetRestApi(BaseSupersetModelRestApi): "columns.advanced_data_type", "is_managed_externally", "uid", + "uuid", "datasource_name", "name", "column_formats", @@ -273,6 +277,7 @@ class DatasetRestApi(BaseSupersetModelRestApi): } search_columns = [ "id", + "uuid", "database", "owners", "catalog", diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py index 3b781f4b1c1d..a0d5747dd73c 100644 --- a/superset/datasets/schemas.py +++ b/superset/datasets/schemas.py @@ -150,6 +150,7 @@ class DatasetPostSchema(Schema): normalize_columns = fields.Boolean(load_default=False) always_filter_main_dttm = fields.Boolean(load_default=False) template_params = fields.String(allow_none=True) + uuid = fields.UUID(allow_none=True) class DatasetPutSchema(Schema): @@ -176,6 +177,7 @@ class DatasetPutSchema(Schema): extra = fields.String(allow_none=True) is_managed_externally = fields.Boolean(allow_none=True, dump_default=False) external_url = fields.String(allow_none=True) + uuid = fields.UUID(allow_none=True) def handle_error( self, diff --git a/superset/models/slice.py b/superset/models/slice.py index 2469db90ad06..f47f424fc3e2 100644 --- a/superset/models/slice.py +++ b/superset/models/slice.py @@ -37,6 +37,7 @@ from sqlalchemy.engine.base import Connection from sqlalchemy.orm import relationship from sqlalchemy.orm.mapper import Mapper +from sqlalchemy.sql.elements import BinaryExpression from superset import db, is_feature_enabled, security_manager from superset.legacy import update_time_range @@ -361,11 +362,17 @@ def get_query_context_factory(self) -> QueryContextFactory: return self.query_context_factory @classmethod - def get(cls, id_: int) -> Slice: - qry = db.session.query(Slice).filter_by(id=id_) + def get(cls, id_or_uuid: str) -> Slice: + qry = db.session.query(Slice).filter_by(id_or_uuid_filter(id_or_uuid)) return qry.one_or_none() +def id_or_uuid_filter(id_or_uuid: str) -> BinaryExpression: + if id_or_uuid.isdigit(): + return Slice.id == int(id_or_uuid) + return Slice.uuid == id_or_uuid + + def set_related_perm(_mapper: Mapper, _connection: Connection, target: Slice) -> None: src_class = target.cls_model if id_ := target.datasource_id: diff --git a/superset/tasks/thumbnails.py b/superset/tasks/thumbnails.py index 5c3e2e412dcd..9ff5ccfedf89 100644 --- a/superset/tasks/thumbnails.py +++ b/superset/tasks/thumbnails.py @@ -37,7 +37,7 @@ @celery_app.task(name="cache_chart_thumbnail", soft_time_limit=300) def cache_chart_thumbnail( current_user: Optional[str], - chart_id: int, + chart_id: str, force: bool, window_size: Optional[WindowSize] = None, thumb_size: Optional[WindowSize] = None, diff --git a/superset/utils/cache.py b/superset/utils/cache.py index f217b62a6348..881b4abccbe7 100644 --- a/superset/utils/cache.py +++ b/superset/utils/cache.py @@ -28,6 +28,7 @@ from werkzeug.wrappers import Response from superset import db +from superset.constants import CACHE_DISABLED_TIMEOUT from superset.extensions import cache_manager from superset.models.cache import CacheKey from superset.utils.hashing import md5_sha_from_dict @@ -56,6 +57,10 @@ def set_and_log_cache( if cache_timeout is not None else app.config["CACHE_DEFAULT_TIMEOUT"] ) + + # Skip caching if timeout is CACHE_DISABLED_TIMEOUT (no caching requested) + if timeout == CACHE_DISABLED_TIMEOUT: + return try: dttm = datetime.utcnow().isoformat().split(".")[0] value = {**cache_value, "dttm": dttm} @@ -134,7 +139,10 @@ def wrapped_f(*args: Any, **kwargs: Any) -> Any: if not force and obj is not None: return obj obj = f(*args, **kwargs) - cache.set(cache_key, obj, timeout=cache_timeout) + + # Skip caching if timeout is CACHE_DISABLED_TIMEOUT (no caching requested) + if cache_timeout != CACHE_DISABLED_TIMEOUT: + cache.set(cache_key, obj, timeout=cache_timeout) return obj return wrapped_f diff --git a/superset/viz.py b/superset/viz.py index 25915a7e6497..b821033c248e 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -44,6 +44,7 @@ from pandas.tseries.frequencies import to_offset from superset.common.db_query_status import QueryStatus +from superset.constants import CACHE_DISABLED_TIMEOUT from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.exceptions import ( CacheLoadError, @@ -527,7 +528,7 @@ def get_df_payload( # pylint: disable=too-many-statements # noqa: C901 stacktrace = None df = None cache_timeout = self.cache_timeout - force = self.force or cache_timeout == -1 + force = self.force or cache_timeout == CACHE_DISABLED_TIMEOUT if cache_key and cache_manager.data_cache and not force: cache_value = cache_manager.data_cache.get(cache_key) if cache_value: diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py index 89fb23fc393e..1b95ed1f6398 100644 --- a/tests/integration_tests/charts/api_tests.py +++ b/tests/integration_tests/charts/api_tests.py @@ -1055,6 +1055,12 @@ def test_get_chart(self): "id", "thumbnail_url", "url", + "uuid", + "datasource_id", + "datasource_name_text", + "datasource_type", + "datasource_url", + "datasource_uuid", ): assert value == expected_result[key] db.session.delete(chart) diff --git a/tests/integration_tests/charts/data/api_tests.py b/tests/integration_tests/charts/data/api_tests.py index bccd093c6e02..7e76a93cec9c 100644 --- a/tests/integration_tests/charts/data/api_tests.py +++ b/tests/integration_tests/charts/data/api_tests.py @@ -33,6 +33,7 @@ from superset.commands.chart.data.get_data_command import ChartDataCommand from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType from superset.connectors.sqla.models import SqlaTable, TableColumn +from superset.constants import CACHE_DISABLED_TIMEOUT from superset.errors import SupersetErrorType from superset.extensions import async_query_manager_factory, db from superset.models.annotations import AnnotationLayer @@ -1488,7 +1489,7 @@ def test_time_filter_with_grain(test_client, login_as_admin, physical_query_cont def test_force_cache_timeout(test_client, login_as_admin, physical_query_context): - physical_query_context["custom_cache_timeout"] = -1 + physical_query_context["custom_cache_timeout"] = CACHE_DISABLED_TIMEOUT test_client.post(CHART_DATA_URI, json=physical_query_context) rv = test_client.post(CHART_DATA_URI, json=physical_query_context) assert rv.json["result"][0]["cached_dttm"] is None diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py index 45aad249ecb8..43da49abbbeb 100644 --- a/tests/integration_tests/dashboards/api_tests.py +++ b/tests/integration_tests/dashboards/api_tests.py @@ -530,6 +530,7 @@ def test_get_dashboard(self): "last_name": "user", }, "id": dashboard.id, + "uuid": str(dashboard.uuid), "css": "", "dashboard_title": "title", "datasources": [], diff --git a/tests/integration_tests/datasets/api_tests.py b/tests/integration_tests/datasets/api_tests.py index 91bada22759f..2316414a12c2 100644 --- a/tests/integration_tests/datasets/api_tests.py +++ b/tests/integration_tests/datasets/api_tests.py @@ -400,6 +400,7 @@ def test_get_dataset_item(self): "backend": main_db.backend, "database_name": "examples", "id": 1, + "uuid": ANY, }, "default_endpoint": None, "description": "Energy consumption",