diff --git a/UPDATING.md b/UPDATING.md index 6ab1276147e38..ba9a6c1bc88a6 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -35,6 +35,7 @@ assists people when migrating to a new version. ### Breaking Changes +- [22328](https://github.com/apache/superset/pull/22328): For deployments that have enabled the "THUMBNAILS" feature flag, the function that calculates dashboard digests has been updated to consider additional properties to more accurately identify changes in the dashboard metadata. This change will invalidate all currently cached dashboard thumbnails. - [21765](https://github.com/apache/superset/pull/21765): For deployments that have enabled the "ALERT_REPORTS" feature flag, Gamma users will no longer have read and write access to Alerts & Reports by default. To give Gamma users the ability to schedule reports from the Dashboard and Explore view like before, create an additional role with "can read on ReportSchedule" and "can write on ReportSchedule" permissions. To further give Gamma users access to the "Alerts & Reports" menu and CRUD view, add "menu access on Manage" and "menu access on Alerts & Report" permissions to the role. ### Potential Downtime diff --git a/docs/docs/installation/cache.mdx b/docs/docs/installation/cache.mdx index 9972aa4887e9c..4838fc47e61c1 100644 --- a/docs/docs/installation/cache.mdx +++ b/docs/docs/installation/cache.mdx @@ -53,6 +53,13 @@ FEATURE_FLAGS = { } ``` +By default thumbnails are rendered using the `THUMBNAIL_SELENIUM_USER` user account. To render thumbnails as the +logged in user (e.g. in environments that are using user impersonation), use the following configuration: + +```python +THUMBNAIL_EXECUTE_AS = [ExecutorType.CURRENT_USER] +``` + For this feature you will need a cache system and celery workers. All thumbnails are stored on cache and are processed asynchronously by the workers. diff --git a/superset/charts/api.py b/superset/charts/api.py index 046379e7f5a90..c5e0eb77d8013 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -18,7 +18,7 @@ import logging from datetime import datetime from io import BytesIO -from typing import Any, Optional +from typing import Any, cast, Optional from zipfile import is_zipfile, ZipFile from flask import redirect, request, Response, send_file, url_for @@ -75,6 +75,7 @@ from superset.extensions import event_logger from superset.models.slice import Slice from superset.tasks.thumbnails import cache_chart_thumbnail +from superset.tasks.utils import get_current_user from superset.utils.screenshots import ChartScreenshot from superset.utils.urls import get_url_path from superset.views.base_api import ( @@ -557,7 +558,7 @@ def cache_screenshot(self, pk: int, **kwargs: Any) -> WerkzeugResponse: # Don't shrink the image if thumb_size is not specified thumb_size = rison_dict.get("thumb_size") or window_size - chart = self.datamodel.get(pk, self._base_filters) + chart = cast(Slice, self.datamodel.get(pk, self._base_filters)) if not chart: return self.response_404() @@ -570,14 +571,13 @@ def cache_screenshot(self, pk: int, **kwargs: Any) -> WerkzeugResponse: def trigger_celery() -> WerkzeugResponse: logger.info("Triggering screenshot ASYNC") - kwargs = { - "url": chart_url, - "digest": chart.digest, - "force": True, - "window_size": window_size, - "thumb_size": thumb_size, - } - cache_chart_thumbnail.delay(**kwargs) + cache_chart_thumbnail.delay( + current_user=get_current_user(), + chart_id=chart.id, + force=True, + window_size=window_size, + thumb_size=thumb_size, + ) return self.response( 202, cache_key=cache_key, chart_url=chart_url, image_url=image_url ) @@ -680,16 +680,21 @@ def thumbnail(self, pk: int, digest: str, **kwargs: Any) -> WerkzeugResponse: 500: $ref: '#/components/responses/500' """ - chart = self.datamodel.get(pk, self._base_filters) + chart = cast(Slice, self.datamodel.get(pk, self._base_filters)) if not chart: return self.response_404() + current_user = get_current_user() url = get_url_path("Superset.slice", slice_id=chart.id, standalone="true") if kwargs["rison"].get("force", False): logger.info( "Triggering thumbnail compute (chart id: %s) ASYNC", str(chart.id) ) - cache_chart_thumbnail.delay(url, chart.digest, force=True) + cache_chart_thumbnail.delay( + current_user=current_user, + chart_id=chart.id, + force=True, + ) return self.response(202, message="OK Async") # fetch the chart screenshot using the current user and cache if set screenshot = ChartScreenshot(url, chart.digest).get_from_cache( @@ -701,7 +706,11 @@ def thumbnail(self, pk: int, digest: str, **kwargs: Any) -> WerkzeugResponse: logger.info( "Triggering thumbnail compute (chart id: %s) ASYNC", str(chart.id) ) - cache_chart_thumbnail.delay(url, chart.digest, force=True) + cache_chart_thumbnail.delay( + current_user=current_user, + chart_id=chart.id, + force=True, + ) return self.response(202, message="OK Async") # If digests if chart.digest != digest: diff --git a/superset/config.py b/superset/config.py index a597678e599e5..120a5950f7fef 100644 --- a/superset/config.py +++ b/superset/config.py @@ -21,6 +21,8 @@ at the end of this file. """ # pylint: disable=too-many-lines +from __future__ import annotations + import imp # pylint: disable=deprecated-module import importlib.util import json @@ -57,9 +59,9 @@ from superset.advanced_data_type.types import AdvancedDataType from superset.constants import CHANGE_ME_SECRET_KEY from superset.jinja_context import BaseTemplateProcessor -from superset.reports.types import ReportScheduleExecutor from superset.stats_logger import DummyStatsLogger from superset.superset_typing import CacheConfig +from superset.tasks.types import ExecutorType from superset.utils.core import is_test, NO_TIME_RANGE, parse_boolean_string from superset.utils.encrypt import SQLAlchemyUtilsAdapter from superset.utils.log import DBEventLogger @@ -72,6 +74,8 @@ from superset.connectors.sqla.models import SqlaTable from superset.models.core import Database + from superset.models.dashboard import Dashboard + from superset.models.slice import Slice # Realtime stats logger, a StatsD implementation exists STATS_LOGGER = DummyStatsLogger() @@ -575,9 +579,33 @@ def _try_json_readsha(filepath: str, length: int) -> Optional[str]: # --------------------------------------------------- # Thumbnail config (behind feature flag) -# Also used by Alerts & Reports # --------------------------------------------------- -THUMBNAIL_SELENIUM_USER = "admin" +# When executing Alerts & Reports or Thumbnails as the Selenium user, this defines +# the username of the account used to render the queries and dashboards/charts +THUMBNAIL_SELENIUM_USER: Optional[str] = "admin" + +# To be able to have different thumbnails for different users, use these configs to +# define which user to execute the thumbnails and potentially custom functions for +# calculating thumbnail digests. To have unique thumbnails for all users, use the +# following config: +# THUMBNAIL_EXECUTE_AS = [ExecutorType.CURRENT_USER] +THUMBNAIL_EXECUTE_AS = [ExecutorType.SELENIUM] + +# By default, thumbnail digests are calculated based on various parameters in the +# chart/dashboard metadata, and in the case of user-specific thumbnails, the +# username. To specify a custom digest function, use the following config parameters +# to define callbacks that receive +# 1. the model (dashboard or chart) +# 2. the executor type (e.g. ExecutorType.SELENIUM) +# 3. the executor's username (note, this is the executor as defined by +# `THUMBNAIL_EXECUTE_AS`; the executor is only equal to the currently logged in +# user if the executor type is equal to `ExecutorType.CURRENT_USER`) +# and return the final digest string: +THUMBNAIL_DASHBOARD_DIGEST_FUNC: Optional[ + Callable[[Dashboard, ExecutorType, str], str] +] = None +THUMBNAIL_CHART_DIGEST_FUNC: Optional[Callable[[Slice, ExecutorType, str], str]] = None + THUMBNAIL_CACHE_CONFIG: CacheConfig = { "CACHE_TYPE": "NullCache", "CACHE_NO_NULL_WARNING": True, @@ -936,7 +964,7 @@ class CeleryConfig: # pylint: disable=too-few-public-methods # return f'tmp_{schema}' # Function accepts database object, user object, schema name and sql that will be run. SQLLAB_CTAS_SCHEMA_NAME_FUNC: Optional[ - Callable[["Database", "models.User", str, str], str] + Callable[[Database, models.User, str, str], str] ] = None # If enabled, it can be used to store the results of long-running queries @@ -961,8 +989,8 @@ class CeleryConfig: # pylint: disable=too-few-public-methods # Function that creates upload directory dynamically based on the # database used, user and schema provided. def CSV_TO_HIVE_UPLOAD_DIRECTORY_FUNC( # pylint: disable=invalid-name - database: "Database", - user: "models.User", # pylint: disable=unused-argument + database: Database, + user: models.User, # pylint: disable=unused-argument schema: Optional[str], ) -> str: # Note the final empty path enforces a trailing slash. @@ -980,7 +1008,7 @@ def CSV_TO_HIVE_UPLOAD_DIRECTORY_FUNC( # pylint: disable=invalid-name # db configuration and a result of this function. # mypy doesn't catch that if case ensures list content being always str -ALLOWED_USER_CSV_SCHEMA_FUNC: Callable[["Database", "models.User"], List[str]] = ( +ALLOWED_USER_CSV_SCHEMA_FUNC: Callable[[Database, models.User], List[str]] = ( lambda database, user: [UPLOADED_CSV_HIVE_NAMESPACE] if UPLOADED_CSV_HIVE_NAMESPACE else [] @@ -1180,16 +1208,14 @@ def EMAIL_HEADER_MUTATOR( # pylint: disable=invalid-name,unused-argument # creator if either is contained within the list of owners, otherwise the first owner # will be used) and finally `THUMBNAIL_SELENIUM_USER`, set as follows: # ALERT_REPORTS_EXECUTE_AS = [ -# ReportScheduleExecutor.CREATOR_OWNER, -# ReportScheduleExecutor.CREATOR, -# ReportScheduleExecutor.MODIFIER_OWNER, -# ReportScheduleExecutor.MODIFIER, -# ReportScheduleExecutor.OWNER, -# ReportScheduleExecutor.SELENIUM, +# ScheduledTaskExecutor.CREATOR_OWNER, +# ScheduledTaskExecutor.CREATOR, +# ScheduledTaskExecutor.MODIFIER_OWNER, +# ScheduledTaskExecutor.MODIFIER, +# ScheduledTaskExecutor.OWNER, +# ScheduledTaskExecutor.SELENIUM, # ] -ALERT_REPORTS_EXECUTE_AS: List[ReportScheduleExecutor] = [ - ReportScheduleExecutor.SELENIUM -] +ALERT_REPORTS_EXECUTE_AS: List[ExecutorType] = [ExecutorType.SELENIUM] # if ALERT_REPORTS_WORKING_TIME_OUT_KILL is True, set a celery hard timeout # Equal to working timeout + ALERT_REPORTS_WORKING_TIME_OUT_LAG ALERT_REPORTS_WORKING_TIME_OUT_LAG = int(timedelta(seconds=10).total_seconds()) diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index 48185ec52690b..79255d19211b2 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -20,7 +20,7 @@ import logging from datetime import datetime from io import BytesIO -from typing import Any, Callable, Optional +from typing import Any, Callable, cast, Optional from zipfile import is_zipfile, ZipFile from flask import make_response, redirect, request, Response, send_file, url_for @@ -83,6 +83,7 @@ from superset.models.dashboard import Dashboard from superset.models.embedded_dashboard import EmbeddedDashboard from superset.tasks.thumbnails import cache_dashboard_thumbnail +from superset.tasks.utils import get_current_user from superset.utils.cache import etag_cache from superset.utils.screenshots import DashboardScreenshot from superset.utils.urls import get_url_path @@ -879,7 +880,7 @@ def thumbnail(self, pk: int, digest: str, **kwargs: Any) -> WerkzeugResponse: 500: $ref: '#/components/responses/500' """ - dashboard = self.datamodel.get(pk, self._base_filters) + dashboard = cast(Dashboard, self.datamodel.get(pk, self._base_filters)) if not dashboard: return self.response_404() @@ -887,8 +888,13 @@ def thumbnail(self, pk: int, digest: str, **kwargs: Any) -> WerkzeugResponse: "Superset.dashboard", dashboard_id_or_slug=dashboard.id ) # If force, request a screenshot from the workers + current_user = get_current_user() if kwargs["rison"].get("force", False): - cache_dashboard_thumbnail.delay(dashboard_url, dashboard.digest, force=True) + cache_dashboard_thumbnail.delay( + current_user=current_user, + dashboard_id=dashboard.id, + force=True, + ) return self.response(202, message="OK Async") # fetch the dashboard screenshot using the current user and cache if set screenshot = DashboardScreenshot( @@ -897,7 +903,11 @@ def thumbnail(self, pk: int, digest: str, **kwargs: Any) -> WerkzeugResponse: # If the screenshot does not exist, request one from the workers if not screenshot: self.incr_stats("async", self.thumbnail.__name__) - cache_dashboard_thumbnail.delay(dashboard_url, dashboard.digest, force=True) + cache_dashboard_thumbnail.delay( + current_user=current_user, + dashboard_id=dashboard.id, + force=True, + ) return self.response(202, message="OK Async") # If digests if dashboard.digest != digest: diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py index a98d76e58162e..ae6bae4b733ff 100644 --- a/superset/models/dashboard.py +++ b/superset/models/dashboard.py @@ -55,11 +55,11 @@ from superset.models.slice import Slice from superset.models.user_attributes import UserAttribute from superset.tasks.thumbnails import cache_dashboard_thumbnail +from superset.tasks.utils import get_current_user +from superset.thumbnails.digest import get_dashboard_digest from superset.utils import core as utils from superset.utils.core import get_user_id from superset.utils.decorators import debounce -from superset.utils.hashing import md5_sha_from_str -from superset.utils.urls import get_url_path metadata = Model.metadata # pylint: disable=no-member config = app.config @@ -241,11 +241,7 @@ def dashboard_link(self) -> Markup: @property def digest(self) -> str: - """ - Returns a MD5 HEX digest that makes this dashboard unique - """ - unique_string = f"{self.position_json}.{self.css}.{self.json_metadata}" - return md5_sha_from_str(unique_string) + return get_dashboard_digest(self) @property def thumbnail_url(self) -> str: @@ -329,8 +325,11 @@ def position(self) -> Dict[str, Any]: return {} def update_thumbnail(self) -> None: - url = get_url_path("Superset.dashboard", dashboard_id_or_slug=self.id) - cache_dashboard_thumbnail.delay(url, self.digest, force=True) + cache_dashboard_thumbnail.delay( + current_user=get_current_user(), + dashboard_id=self.id, + force=True, + ) @debounce(0.1) def clear_cache(self) -> None: @@ -439,8 +438,7 @@ def export_dashboards( # pylint: disable=too-many-locals @classmethod def get(cls, id_or_slug: Union[str, int]) -> Dashboard: - session = db.session() - qry = session.query(Dashboard).filter(id_or_slug_filter(id_or_slug)) + qry = db.session.query(Dashboard).filter(id_or_slug_filter(id_or_slug)) return qry.one_or_none() diff --git a/superset/models/slice.py b/superset/models/slice.py index 32b347266d2b5..657ff7d38a079 100644 --- a/superset/models/slice.py +++ b/superset/models/slice.py @@ -43,10 +43,10 @@ from superset.legacy import update_time_range from superset.models.helpers import AuditMixinNullable, ImportExportMixin from superset.tasks.thumbnails import cache_chart_thumbnail +from superset.tasks.utils import get_current_user +from superset.thumbnails.digest import get_chart_digest from superset.utils import core as utils -from superset.utils.hashing import md5_sha_from_str from superset.utils.memoized import memoized -from superset.utils.urls import get_url_path from superset.viz import BaseViz, viz_types if TYPE_CHECKING: @@ -234,10 +234,7 @@ def data(self) -> Dict[str, Any]: @property def digest(self) -> str: - """ - Returns a MD5 HEX digest that makes this dashboard unique - """ - return md5_sha_from_str(self.params or "") + return get_chart_digest(self) @property def thumbnail_url(self) -> str: @@ -344,6 +341,11 @@ def get_query_context_factory(self) -> QueryContextFactory: self.query_context_factory = QueryContextFactory() return self.query_context_factory + @classmethod + def get(cls, id_: int) -> Slice: + qry = db.session.query(Slice).filter_by(id=id_) + return qry.one_or_none() + def set_related_perm(_mapper: Mapper, _connection: Connection, target: Slice) -> None: src_class = target.cls_model @@ -358,8 +360,11 @@ def set_related_perm(_mapper: Mapper, _connection: Connection, target: Slice) -> def event_after_chart_changed( _mapper: Mapper, _connection: Connection, target: Slice ) -> None: - url = get_url_path("Superset.slice", slice_id=target.id, standalone="true") - cache_chart_thumbnail.delay(url, target.digest, force=True) + cache_chart_thumbnail.delay( + current_user=get_current_user(), + chart_id=target.id, + force=True, + ) sqla.event.listen(Slice, "before_insert", set_related_perm) diff --git a/superset/reports/commands/alert.py b/superset/reports/commands/alert.py index 255280704e78f..1044b64505220 100644 --- a/superset/reports/commands/alert.py +++ b/superset/reports/commands/alert.py @@ -25,7 +25,7 @@ from celery.exceptions import SoftTimeLimitExceeded from flask_babel import lazy_gettext as _ -from superset import app, jinja_context +from superset import app, jinja_context, security_manager from superset.commands.base import BaseCommand from superset.reports.commands.exceptions import ( AlertQueryError, @@ -36,7 +36,7 @@ AlertValidatorConfigError, ) from superset.reports.models import ReportSchedule, ReportScheduleValidatorType -from superset.reports.utils import get_executor +from superset.tasks.utils import get_executor from superset.utils.core import override_user from superset.utils.retries import retry_call @@ -149,7 +149,11 @@ def _execute_query(self) -> pd.DataFrame: rendered_sql, ALERT_SQL_LIMIT ) - user = get_executor(self._report_schedule) + _, username = get_executor( + executor_types=app.config["ALERT_REPORTS_EXECUTE_AS"], + model=self._report_schedule, + ) + user = security_manager.find_user(username) with override_user(user): start = default_timer() df = self._report_schedule.database.get_df(sql=limited_rendered_sql) diff --git a/superset/reports/commands/exceptions.py b/superset/reports/commands/exceptions.py index a068b3c62860d..48dbfcaab9915 100644 --- a/superset/reports/commands/exceptions.py +++ b/superset/reports/commands/exceptions.py @@ -253,10 +253,6 @@ class ReportScheduleNotificationError(CommandException): message = _("Alert on grace period") -class ReportScheduleUserNotFoundError(CommandException): - message = _("Report Schedule user not found") - - class ReportScheduleStateNotFoundError(CommandException): message = _("Report Schedule state not found") diff --git a/superset/reports/commands/execute.py b/superset/reports/commands/execute.py index d20775ffd6d3b..8133cec29b971 100644 --- a/superset/reports/commands/execute.py +++ b/superset/reports/commands/execute.py @@ -24,7 +24,7 @@ from celery.exceptions import SoftTimeLimitExceeded from sqlalchemy.orm import Session -from superset import app +from superset import app, security_manager from superset.commands.base import BaseCommand from superset.commands.exceptions import CommandException from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType @@ -69,7 +69,7 @@ from superset.reports.notifications import create_notification from superset.reports.notifications.base import NotificationContent from superset.reports.notifications.exceptions import NotificationError -from superset.reports.utils import get_executor +from superset.tasks.utils import get_executor from superset.utils.celery import session_scope from superset.utils.core import HeaderDataType, override_user from superset.utils.csv import get_chart_csv_data, get_chart_dataframe @@ -201,7 +201,11 @@ def _get_screenshots(self) -> List[bytes]: :raises: ReportScheduleScreenshotFailedError """ url = self._get_url() - user = get_executor(self._report_schedule) + _, username = get_executor( + executor_types=app.config["ALERT_REPORTS_EXECUTE_AS"], + model=self._report_schedule, + ) + user = security_manager.find_user(username) if self._report_schedule.chart: screenshot: Union[ChartScreenshot, DashboardScreenshot] = ChartScreenshot( url, @@ -231,7 +235,11 @@ def _get_screenshots(self) -> List[bytes]: def _get_csv_data(self) -> bytes: url = self._get_url(result_format=ChartDataResultFormat.CSV) - user = get_executor(self._report_schedule) + _, username = get_executor( + executor_types=app.config["ALERT_REPORTS_EXECUTE_AS"], + model=self._report_schedule, + ) + user = security_manager.find_user(username) auth_cookies = machine_auth_provider_factory.instance.get_auth_cookies(user) if self._report_schedule.chart.query_context is None: @@ -240,7 +248,7 @@ def _get_csv_data(self) -> bytes: try: logger.info("Getting chart from %s as user %s", url, user.username) - csv_data = get_chart_csv_data(url, auth_cookies) + csv_data = get_chart_csv_data(chart_url=url, auth_cookies=auth_cookies) except SoftTimeLimitExceeded as ex: raise ReportScheduleCsvTimeout() from ex except Exception as ex: @@ -256,7 +264,11 @@ def _get_embedded_data(self) -> pd.DataFrame: Return data as a Pandas dataframe, to embed in notifications as a table. """ url = self._get_url(result_format=ChartDataResultFormat.JSON) - user = get_executor(self._report_schedule) + _, username = get_executor( + executor_types=app.config["ALERT_REPORTS_EXECUTE_AS"], + model=self._report_schedule, + ) + user = security_manager.find_user(username) auth_cookies = machine_auth_provider_factory.instance.get_auth_cookies(user) if self._report_schedule.chart.query_context is None: @@ -692,12 +704,16 @@ def run(self) -> None: self.validate(session=session) if not self._model: raise ReportScheduleExecuteUnexpectedError() - user = get_executor(self._model) + _, username = get_executor( + executor_types=app.config["ALERT_REPORTS_EXECUTE_AS"], + model=self._model, + ) + user = security_manager.find_user(username) with override_user(user): logger.info( "Running report schedule %s as user %s", self._execution_id, - user.username, + username, ) ReportScheduleStateMachine( session, self._execution_id, self._model, self._scheduled_dttm diff --git a/superset/reports/types.py b/superset/reports/types.py index 7977a2defa9ad..d487e3ad23766 100644 --- a/superset/reports/types.py +++ b/superset/reports/types.py @@ -14,7 +14,6 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from enum import Enum from typing import TypedDict from superset.dashboards.permalink.types import DashboardPermalinkState @@ -22,12 +21,3 @@ class ReportScheduleExtra(TypedDict): dashboard: DashboardPermalinkState - - -class ReportScheduleExecutor(str, Enum): - SELENIUM = "selenium" - CREATOR = "creator" - CREATOR_OWNER = "creator_owner" - MODIFIER = "modifier" - MODIFIER_OWNER = "modifier_owner" - OWNER = "owner" diff --git a/superset/reports/utils.py b/superset/reports/utils.py deleted file mode 100644 index 215fca99887a5..0000000000000 --- a/superset/reports/utils.py +++ /dev/null @@ -1,71 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -from flask_appbuilder.security.sqla.models import User - -from superset import app, security_manager -from superset.reports.commands.exceptions import ReportScheduleUserNotFoundError -from superset.reports.models import ReportSchedule -from superset.reports.types import ReportScheduleExecutor - - -# pylint: disable=too-many-branches -def get_executor(report_schedule: ReportSchedule) -> User: - """ - Extract the user that should be used to execute a report schedule as. - - :param report_schedule: The report to execute - :return: User to execute the report as - """ - user_types = app.config["ALERT_REPORTS_EXECUTE_AS"] - owners = report_schedule.owners - owner_dict = {owner.id: owner for owner in owners} - for user_type in user_types: - if user_type == ReportScheduleExecutor.SELENIUM: - username = app.config["THUMBNAIL_SELENIUM_USER"] - if username and (user := security_manager.find_user(username=username)): - return user - if user_type == ReportScheduleExecutor.CREATOR_OWNER: - if (user := report_schedule.created_by) and ( - owner := owner_dict.get(user.id) - ): - return owner - if user_type == ReportScheduleExecutor.CREATOR: - if user := report_schedule.created_by: - return user - if user_type == ReportScheduleExecutor.MODIFIER_OWNER: - if (user := report_schedule.changed_by) and ( - owner := owner_dict.get(user.id) - ): - return owner - if user_type == ReportScheduleExecutor.MODIFIER: - if user := report_schedule.changed_by: - return user - if user_type == ReportScheduleExecutor.OWNER: - owners = report_schedule.owners - if len(owners) == 1: - return owners[0] - if len(owners) > 1: - if modifier := report_schedule.changed_by: - if modifier and (user := owner_dict.get(modifier.id)): - return user - if creator := report_schedule.created_by: - if creator and (user := owner_dict.get(creator.id)): - return user - return owners[0] - - raise ReportScheduleUserNotFoundError() diff --git a/superset/tasks/exceptions.py b/superset/tasks/exceptions.py new file mode 100644 index 0000000000000..6698661754e5e --- /dev/null +++ b/superset/tasks/exceptions.py @@ -0,0 +1,24 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from flask_babel import lazy_gettext as _ + +from superset.exceptions import SupersetException + + +class ExecutorNotFoundError(SupersetException): + message = _("Scheduled task executor not found") diff --git a/superset/tasks/thumbnails.py b/superset/tasks/thumbnails.py index 94b83ddb372cf..c03d13b0bdbeb 100644 --- a/superset/tasks/thumbnails.py +++ b/superset/tasks/thumbnails.py @@ -18,14 +18,16 @@ """Utility functions used across Superset""" import logging -from typing import Optional +from typing import cast, Optional from flask import current_app from superset import security_manager, thumbnail_cache from superset.extensions import celery_app -from superset.utils.celery import session_scope +from superset.tasks.utils import get_executor +from superset.utils.core import override_user from superset.utils.screenshots import ChartScreenshot, DashboardScreenshot +from superset.utils.urls import get_url_path from superset.utils.webdriver import WindowSize logger = logging.getLogger(__name__) @@ -33,21 +35,29 @@ @celery_app.task(name="cache_chart_thumbnail", soft_time_limit=300) def cache_chart_thumbnail( - url: str, - digest: str, + current_user: Optional[str], + chart_id: int, force: bool = False, window_size: Optional[WindowSize] = None, thumb_size: Optional[WindowSize] = None, ) -> None: + # pylint: disable=import-outside-toplevel + from superset.models.slice import Slice + if not thumbnail_cache: logger.warning("No cache set, refusing to compute") return None + chart = cast(Slice, Slice.get(chart_id)) + url = get_url_path("Superset.slice", slice_id=chart.id, standalone="true") logger.info("Caching chart: %s", url) - screenshot = ChartScreenshot(url, digest) - with session_scope(nullpool=True) as session: - user = security_manager.get_user_by_username( - current_app.config["THUMBNAIL_SELENIUM_USER"], session=session - ) + _, username = get_executor( + executor_types=current_app.config["THUMBNAIL_EXECUTE_AS"], + model=chart, + current_user=current_user, + ) + user = security_manager.find_user(username) + with override_user(user): + screenshot = ChartScreenshot(url, chart.digest) screenshot.compute_and_cache( user=user, cache=thumbnail_cache, @@ -60,17 +70,29 @@ def cache_chart_thumbnail( @celery_app.task(name="cache_dashboard_thumbnail", soft_time_limit=300) def cache_dashboard_thumbnail( - url: str, digest: str, force: bool = False, thumb_size: Optional[WindowSize] = None + current_user: Optional[str], + dashboard_id: int, + force: bool = False, + thumb_size: Optional[WindowSize] = None, ) -> None: + # pylint: disable=import-outside-toplevel + from superset.models.dashboard import Dashboard + if not thumbnail_cache: logging.warning("No cache set, refusing to compute") return + dashboard = Dashboard.get(dashboard_id) + url = get_url_path("Superset.dashboard", dashboard_id_or_slug=dashboard.id) + logger.info("Caching dashboard: %s", url) - screenshot = DashboardScreenshot(url, digest) - with session_scope(nullpool=True) as session: - user = security_manager.get_user_by_username( - current_app.config["THUMBNAIL_SELENIUM_USER"], session=session - ) + _, username = get_executor( + executor_types=current_app.config["THUMBNAIL_EXECUTE_AS"], + model=dashboard, + current_user=current_user, + ) + user = security_manager.find_user(username) + with override_user(user): + screenshot = DashboardScreenshot(url, dashboard.digest) screenshot.compute_and_cache( user=user, cache=thumbnail_cache, diff --git a/superset/tasks/types.py b/superset/tasks/types.py new file mode 100644 index 0000000000000..cc337a81edb6f --- /dev/null +++ b/superset/tasks/types.py @@ -0,0 +1,44 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from enum import Enum + + +class ExecutorType(str, Enum): + """ + Which user should scheduled tasks be executed as. Used as follows: + For Alerts & Reports: the "model" refers to the AlertSchedule object + For Thumbnails: The "model" refers to the Slice or Dashboard object + """ + + # See the THUMBNAIL_SELENIUM_USER config parameter + SELENIUM = "selenium" + # The creator of the model + CREATOR = "creator" + # The creator of the model, if found in the owners list + CREATOR_OWNER = "creator_owner" + # The currently logged in user. In the case of Alerts & Reports, this is always + # None. For Thumbnails, this is the user that requested the thumbnail + CURRENT_USER = "current_user" + # The last modifier of the model + MODIFIER = "modifier" + # The last modifier of the model, if found in the owners list + MODIFIER_OWNER = "modifier_owner" + # An owner of the model. If the last modifier is in the owners list, returns that + # user. If the modifier is not found, returns the creator if found in the owners + # list. Finally, if neither are present, returns the first user in the owners list. + OWNER = "owner" diff --git a/superset/tasks/utils.py b/superset/tasks/utils.py new file mode 100644 index 0000000000000..9c1dab82202b8 --- /dev/null +++ b/superset/tasks/utils.py @@ -0,0 +1,94 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +from typing import List, Optional, Tuple, TYPE_CHECKING, Union + +from flask import current_app, g + +from superset.tasks.exceptions import ExecutorNotFoundError +from superset.tasks.types import ExecutorType + +if TYPE_CHECKING: + from superset.models.dashboard import Dashboard + from superset.models.slice import Slice + from superset.reports.models import ReportSchedule + + +# pylint: disable=too-many-branches +def get_executor( + executor_types: List[ExecutorType], + model: Union[Dashboard, ReportSchedule, Slice], + current_user: Optional[str] = None, +) -> Tuple[ExecutorType, str]: + """ + Extract the user that should be used to execute a scheduled task. Certain executor + types extract the user from the underlying object (e.g. CREATOR), the constant + Selenium user (SELENIUM), or the user that initiated the request. + + :param executor_types: The requested executor type in descending order. When the + first user is found it is returned. + :param model: The underlying object + :param current_user: The username of the user that initiated the task. For + thumbnails this is the user that requested the thumbnail, while for alerts + and reports this is None (=initiated by Celery). + :return: User to execute the report as + :raises ScheduledTaskExecutorNotFoundError: If no users were found in after + iterating through all entries in `executor_types` + """ + owners = model.owners + owner_dict = {owner.id: owner for owner in owners} + for executor_type in executor_types: + if executor_type == ExecutorType.SELENIUM: + return executor_type, current_app.config["THUMBNAIL_SELENIUM_USER"] + if executor_type == ExecutorType.CURRENT_USER and current_user: + return executor_type, current_user + if executor_type == ExecutorType.CREATOR_OWNER: + if (user := model.created_by) and (owner := owner_dict.get(user.id)): + return executor_type, owner.username + if executor_type == ExecutorType.CREATOR: + if user := model.created_by: + return executor_type, user.username + if executor_type == ExecutorType.MODIFIER_OWNER: + if (user := model.changed_by) and (owner := owner_dict.get(user.id)): + return executor_type, owner.username + if executor_type == ExecutorType.MODIFIER: + if user := model.changed_by: + return executor_type, user.username + if executor_type == ExecutorType.OWNER: + owners = model.owners + if len(owners) == 1: + return executor_type, owners[0].username + if len(owners) > 1: + if modifier := model.changed_by: + if modifier and (user := owner_dict.get(modifier.id)): + return executor_type, user.username + if creator := model.created_by: + if creator and (user := owner_dict.get(creator.id)): + return executor_type, user.username + return executor_type, owners[0].username + + raise ExecutorNotFoundError() + + +def get_current_user() -> Optional[str]: + user = g.user if hasattr(g, "user") and g.user else None + if user and not user.is_anonymous: + return user.username + + return None diff --git a/tests/unit_tests/reports/__init__.py b/superset/thumbnails/__init__.py similarity index 100% rename from tests/unit_tests/reports/__init__.py rename to superset/thumbnails/__init__.py diff --git a/superset/thumbnails/digest.py b/superset/thumbnails/digest.py new file mode 100644 index 0000000000000..fb209fcd5072d --- /dev/null +++ b/superset/thumbnails/digest.py @@ -0,0 +1,83 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from flask import current_app + +from superset.tasks.types import ExecutorType +from superset.tasks.utils import get_current_user, get_executor +from superset.utils.hashing import md5_sha_from_str + +if TYPE_CHECKING: + from superset.models.dashboard import Dashboard + from superset.models.slice import Slice + +logger = logging.getLogger(__name__) + + +def _adjust_string_for_executor( + unique_string: str, + executor_type: ExecutorType, + executor: str, +) -> str: + """ + Add the executor to the unique string if the thumbnail is + user-specific. + """ + if executor_type == ExecutorType.CURRENT_USER: + # add the user id to the string to make it unique + unique_string = f"{unique_string}\n{executor}" + + return unique_string + + +def get_dashboard_digest(dashboard: Dashboard) -> str: + config = current_app.config + executor_type, executor = get_executor( + executor_types=config["THUMBNAIL_EXECUTE_AS"], + model=dashboard, + current_user=get_current_user(), + ) + if func := config["THUMBNAIL_DASHBOARD_DIGEST_FUNC"]: + return func(dashboard, executor_type, executor) + + unique_string = ( + f"{dashboard.id}\n{dashboard.charts}\n{dashboard.position_json}\n" + f"{dashboard.css}\n{dashboard.json_metadata}" + ) + + unique_string = _adjust_string_for_executor(unique_string, executor_type, executor) + return md5_sha_from_str(unique_string) + + +def get_chart_digest(chart: Slice) -> str: + config = current_app.config + executor_type, executor = get_executor( + executor_types=config["THUMBNAIL_EXECUTE_AS"], + model=chart, + current_user=get_current_user(), + ) + if func := config["THUMBNAIL_CHART_DIGEST_FUNC"]: + return func(chart, executor_type, executor) + + unique_string = f"{chart.params or ''}.{executor}" + unique_string = _adjust_string_for_executor(unique_string, executor_type, executor) + return md5_sha_from_str(unique_string) diff --git a/tests/integration_tests/reports/alert_tests.py b/tests/integration_tests/reports/alert_tests.py index ef51bf1d0db45..6c5c41a81ff23 100644 --- a/tests/integration_tests/reports/alert_tests.py +++ b/tests/integration_tests/reports/alert_tests.py @@ -16,7 +16,7 @@ # under the License. # pylint: disable=invalid-name, unused-argument, import-outside-toplevel from contextlib import nullcontext -from typing import List, Optional, Union +from typing import List, Optional, Tuple, Union import pandas as pd import pytest @@ -24,7 +24,7 @@ from superset.reports.commands.exceptions import AlertQueryError from superset.reports.models import ReportCreationMethod, ReportScheduleType -from superset.reports.types import ReportScheduleExecutor +from superset.tasks.types import ExecutorType from superset.utils.database import get_example_database from tests.integration_tests.test_app import app @@ -32,23 +32,34 @@ @pytest.mark.parametrize( "owner_names,creator_name,config,expected_result", [ - (["gamma"], None, [ReportScheduleExecutor.SELENIUM], "admin"), - (["gamma"], None, [ReportScheduleExecutor.OWNER], "gamma"), - (["alpha", "gamma"], "gamma", [ReportScheduleExecutor.CREATOR_OWNER], "gamma"), - (["alpha", "gamma"], "alpha", [ReportScheduleExecutor.CREATOR_OWNER], "alpha"), + (["gamma"], None, [ExecutorType.SELENIUM], "admin"), + (["gamma"], None, [ExecutorType.OWNER], "gamma"), + ( + ["alpha", "gamma"], + "gamma", + [ExecutorType.CREATOR_OWNER], + "gamma", + ), + ( + ["alpha", "gamma"], + "alpha", + [ExecutorType.CREATOR_OWNER], + "alpha", + ), ( ["alpha", "gamma"], "admin", - [ReportScheduleExecutor.CREATOR_OWNER], + [ExecutorType.CREATOR_OWNER], AlertQueryError(), ), + (["gamma"], None, [ExecutorType.CURRENT_USER], AlertQueryError()), ], ) def test_execute_query_as_report_executor( owner_names: List[str], creator_name: Optional[str], - config: List[ReportScheduleExecutor], - expected_result: Union[str, Exception], + config: List[ExecutorType], + expected_result: Union[Tuple[ExecutorType, str], Exception], mocker: MockFixture, app_context: None, get_user, diff --git a/tests/integration_tests/reports/commands_tests.py b/tests/integration_tests/reports/commands_tests.py index 288e6746cc5c7..ebbba499281be 100644 --- a/tests/integration_tests/reports/commands_tests.py +++ b/tests/integration_tests/reports/commands_tests.py @@ -41,13 +41,11 @@ ReportScheduleClientErrorsException, ReportScheduleCsvFailedError, ReportScheduleCsvTimeout, - ReportScheduleForbiddenError, ReportScheduleNotFoundError, ReportSchedulePreviousWorkingError, ReportScheduleScreenshotFailedError, ReportScheduleScreenshotTimeout, ReportScheduleSystemErrorsException, - ReportScheduleUnexpectedError, ReportScheduleWorkingTimeoutError, ) from superset.reports.commands.execute import ( @@ -67,7 +65,7 @@ NotificationError, NotificationParamException, ) -from superset.reports.types import ReportScheduleExecutor +from superset.tasks.types import ExecutorType from superset.utils.database import get_example_database from tests.integration_tests.fixtures.birth_names_dashboard import ( load_birth_names_dashboard_with_slices, @@ -686,7 +684,7 @@ def test_email_chart_report_schedule_alpha_owner( """ config_key = "ALERT_REPORTS_EXECUTE_AS" original_config_value = app.config[config_key] - app.config[config_key] = [ReportScheduleExecutor.OWNER] + app.config[config_key] = [ExecutorType.OWNER] # setup screenshot mock username = "" diff --git a/tests/integration_tests/thumbnails_tests.py b/tests/integration_tests/thumbnails_tests.py index 81557c7d89b54..332d71da331ec 100644 --- a/tests/integration_tests/thumbnails_tests.py +++ b/tests/integration_tests/thumbnails_tests.py @@ -16,8 +16,11 @@ # under the License. # from superset import db # from superset.models.dashboard import Dashboard + +import json import urllib.request from io import BytesIO +from typing import Tuple from unittest import skipUnless from unittest.mock import ANY, call, MagicMock, patch @@ -29,14 +32,22 @@ from superset.extensions import machine_auth_provider_factory from superset.models.dashboard import Dashboard from superset.models.slice import Slice +from superset.tasks.types import ExecutorType from superset.utils.screenshots import ChartScreenshot, DashboardScreenshot -from superset.utils.urls import get_url_host, get_url_path +from superset.utils.urls import get_url_path from superset.utils.webdriver import find_unexpected_errors, WebDriverProxy from tests.integration_tests.conftest import with_feature_flags +from tests.integration_tests.fixtures.birth_names_dashboard import ( + load_birth_names_dashboard_with_slices, + load_birth_names_data, +) from tests.integration_tests.test_app import app from .base_tests import SupersetTestCase +CHART_URL = "/api/v1/chart/" +DASHBOARD_URL = "/api/v1/dashboard/" + class TestThumbnailsSeleniumLive(LiveServerTestCase): def create_app(self): @@ -54,11 +65,14 @@ def test_get_async_dashboard_screenshot(self): """ Thumbnails: Simple get async dashboard screenshot """ - dashboard = db.session.query(Dashboard).all()[0] with patch("superset.dashboards.api.DashboardRestApi.get") as mock_get: + rv = self.client.get(DASHBOARD_URL) + resp = json.loads(rv.data.decode("utf-8")) + thumbnail_url = resp["result"][0]["thumbnail_url"] + response = self.url_open_auth( "admin", - f"api/v1/dashboard/{dashboard.id}/thumbnail/{dashboard.digest}/", + thumbnail_url, ) self.assertEqual(response.getcode(), 202) @@ -187,50 +201,82 @@ def test_screenshot_selenium_animation_wait( class TestThumbnails(SupersetTestCase): mock_image = b"bytes mock image" + digest_return_value = "foo_bar" + digest_hash = "5c7d96a3dd7a87850a2ef34087565a6e" + def _get_id_and_thumbnail_url(self, url: str) -> Tuple[int, str]: + rv = self.client.get(url) + resp = json.loads(rv.data.decode("utf-8")) + obj = resp["result"][0] + return obj["id"], obj["thumbnail_url"] + + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") @with_feature_flags(THUMBNAILS=False) def test_dashboard_thumbnail_disabled(self): """ Thumbnails: Dashboard thumbnail disabled """ - dashboard = db.session.query(Dashboard).all()[0] self.login(username="admin") - uri = f"api/v1/dashboard/{dashboard.id}/thumbnail/{dashboard.digest}/" - rv = self.client.get(uri) + _, thumbnail_url = self._get_id_and_thumbnail_url(DASHBOARD_URL) + rv = self.client.get(thumbnail_url) self.assertEqual(rv.status_code, 404) + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") @with_feature_flags(THUMBNAILS=False) def test_chart_thumbnail_disabled(self): """ Thumbnails: Chart thumbnail disabled """ - chart = db.session.query(Slice).all()[0] self.login(username="admin") - uri = f"api/v1/chart/{chart}/thumbnail/{chart.digest}/" - rv = self.client.get(uri) + _, thumbnail_url = self._get_id_and_thumbnail_url(CHART_URL) + rv = self.client.get(thumbnail_url) self.assertEqual(rv.status_code, 404) + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") @with_feature_flags(THUMBNAILS=True) - def test_get_async_dashboard_screenshot(self): + def test_get_async_dashboard_screenshot_as_selenium(self): """ - Thumbnails: Simple get async dashboard screenshot + Thumbnails: Simple get async dashboard screenshot as selenium user """ - dashboard = db.session.query(Dashboard).all()[0] - self.login(username="admin") - uri = f"api/v1/dashboard/{dashboard.id}/thumbnail/{dashboard.digest}/" + self.login(username="alpha") with patch( - "superset.tasks.thumbnails.cache_dashboard_thumbnail.delay" - ) as mock_task: - rv = self.client.get(uri) + "superset.thumbnails.digest._adjust_string_for_executor" + ) as mock_adjust_string: + mock_adjust_string.return_value = self.digest_return_value + _, thumbnail_url = self._get_id_and_thumbnail_url(DASHBOARD_URL) + assert self.digest_hash in thumbnail_url + assert mock_adjust_string.call_args[0][1] == ExecutorType.SELENIUM + assert mock_adjust_string.call_args[0][2] == "admin" + + rv = self.client.get(thumbnail_url) self.assertEqual(rv.status_code, 202) - expected_uri = f"{get_url_host()}superset/dashboard/{dashboard.id}/" - expected_digest = dashboard.digest - expected_kwargs = {"force": True} - mock_task.assert_called_with( - expected_uri, expected_digest, **expected_kwargs - ) + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") + @with_feature_flags(THUMBNAILS=True) + def test_get_async_dashboard_screenshot_as_current_user(self): + """ + Thumbnails: Simple get async dashboard screenshot as current user + """ + username = "alpha" + self.login(username=username) + with patch.dict( + "superset.thumbnails.digest.current_app.config", + { + "THUMBNAIL_EXECUTE_AS": [ExecutorType.CURRENT_USER], + }, + ), patch( + "superset.thumbnails.digest._adjust_string_for_executor" + ) as mock_adjust_string: + mock_adjust_string.return_value = self.digest_return_value + _, thumbnail_url = self._get_id_and_thumbnail_url(DASHBOARD_URL) + assert self.digest_hash in thumbnail_url + assert mock_adjust_string.call_args[0][1] == ExecutorType.CURRENT_USER + assert mock_adjust_string.call_args[0][2] == username + + rv = self.client.get(thumbnail_url) + self.assertEqual(rv.status_code, 202) + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") @with_feature_flags(THUMBNAILS=True) def test_get_async_dashboard_notfound(self): """ @@ -242,37 +288,62 @@ def test_get_async_dashboard_notfound(self): rv = self.client.get(uri) self.assertEqual(rv.status_code, 404) + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") @skipUnless((is_feature_enabled("THUMBNAILS")), "Thumbnails feature") def test_get_async_dashboard_not_allowed(self): """ Thumbnails: Simple get async dashboard not allowed """ - dashboard = db.session.query(Dashboard).all()[0] self.login(username="gamma") - uri = f"api/v1/dashboard/{dashboard.id}/thumbnail/{dashboard.digest}/" - rv = self.client.get(uri) + _, thumbnail_url = self._get_id_and_thumbnail_url(DASHBOARD_URL) + rv = self.client.get(thumbnail_url) self.assertEqual(rv.status_code, 404) + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") @with_feature_flags(THUMBNAILS=True) - def test_get_async_chart_screenshot(self): + def test_get_async_chart_screenshot_as_selenium(self): """ - Thumbnails: Simple get async chart screenshot + Thumbnails: Simple get async chart screenshot as selenium user """ - chart = db.session.query(Slice).all()[0] - self.login(username="admin") - uri = f"api/v1/chart/{chart.id}/thumbnail/{chart.digest}/" + self.login(username="alpha") with patch( - "superset.tasks.thumbnails.cache_chart_thumbnail.delay" - ) as mock_task: - rv = self.client.get(uri) + "superset.thumbnails.digest._adjust_string_for_executor" + ) as mock_adjust_string: + mock_adjust_string.return_value = self.digest_return_value + _, thumbnail_url = self._get_id_and_thumbnail_url(CHART_URL) + assert self.digest_hash in thumbnail_url + assert mock_adjust_string.call_args[0][1] == ExecutorType.SELENIUM + assert mock_adjust_string.call_args[0][2] == "admin" + + rv = self.client.get(thumbnail_url) + self.assertEqual(rv.status_code, 202) + + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") + @with_feature_flags(THUMBNAILS=True) + def test_get_async_chart_screenshot_as_current_user(self): + """ + Thumbnails: Simple get async chart screenshot as current user + """ + username = "alpha" + self.login(username=username) + with patch.dict( + "superset.thumbnails.digest.current_app.config", + { + "THUMBNAIL_EXECUTE_AS": [ExecutorType.CURRENT_USER], + }, + ), patch( + "superset.thumbnails.digest._adjust_string_for_executor" + ) as mock_adjust_string: + mock_adjust_string.return_value = self.digest_return_value + _, thumbnail_url = self._get_id_and_thumbnail_url(CHART_URL) + assert self.digest_hash in thumbnail_url + assert mock_adjust_string.call_args[0][1] == ExecutorType.CURRENT_USER + assert mock_adjust_string.call_args[0][2] == username + + rv = self.client.get(thumbnail_url) self.assertEqual(rv.status_code, 202) - expected_uri = f"{get_url_host()}superset/slice/{chart.id}/?standalone=true" - expected_digest = chart.digest - expected_kwargs = {"force": True} - mock_task.assert_called_with( - expected_uri, expected_digest, **expected_kwargs - ) + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") @with_feature_flags(THUMBNAILS=True) def test_get_async_chart_notfound(self): """ @@ -284,66 +355,62 @@ def test_get_async_chart_notfound(self): rv = self.client.get(uri) self.assertEqual(rv.status_code, 404) + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") @with_feature_flags(THUMBNAILS=True) def test_get_cached_chart_wrong_digest(self): """ Thumbnails: Simple get chart with wrong digest """ - chart = db.session.query(Slice).all()[0] with patch.object( ChartScreenshot, "get_from_cache", return_value=BytesIO(self.mock_image) ): self.login(username="admin") - uri = f"api/v1/chart/{chart.id}/thumbnail/1234/" - rv = self.client.get(uri) + id_, thumbnail_url = self._get_id_and_thumbnail_url(CHART_URL) + rv = self.client.get(f"api/v1/chart/{id_}/thumbnail/1234/") self.assertEqual(rv.status_code, 302) - self.assertRedirects( - rv, f"api/v1/chart/{chart.id}/thumbnail/{chart.digest}/" - ) + self.assertRedirects(rv, thumbnail_url) + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") @with_feature_flags(THUMBNAILS=True) def test_get_cached_dashboard_screenshot(self): """ Thumbnails: Simple get cached dashboard screenshot """ - dashboard = db.session.query(Dashboard).all()[0] with patch.object( DashboardScreenshot, "get_from_cache", return_value=BytesIO(self.mock_image) ): self.login(username="admin") - uri = f"api/v1/dashboard/{dashboard.id}/thumbnail/{dashboard.digest}/" - rv = self.client.get(uri) + _, thumbnail_url = self._get_id_and_thumbnail_url(DASHBOARD_URL) + rv = self.client.get(thumbnail_url) self.assertEqual(rv.status_code, 200) self.assertEqual(rv.data, self.mock_image) + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") @with_feature_flags(THUMBNAILS=True) def test_get_cached_chart_screenshot(self): """ Thumbnails: Simple get cached chart screenshot """ - chart = db.session.query(Slice).all()[0] with patch.object( ChartScreenshot, "get_from_cache", return_value=BytesIO(self.mock_image) ): self.login(username="admin") - uri = f"api/v1/chart/{chart.id}/thumbnail/{chart.digest}/" - rv = self.client.get(uri) + id_, thumbnail_url = self._get_id_and_thumbnail_url(CHART_URL) + rv = self.client.get(thumbnail_url) self.assertEqual(rv.status_code, 200) self.assertEqual(rv.data, self.mock_image) + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") @with_feature_flags(THUMBNAILS=True) def test_get_cached_dashboard_wrong_digest(self): """ Thumbnails: Simple get dashboard with wrong digest """ - dashboard = db.session.query(Dashboard).all()[0] with patch.object( DashboardScreenshot, "get_from_cache", return_value=BytesIO(self.mock_image) ): self.login(username="admin") - uri = f"api/v1/dashboard/{dashboard.id}/thumbnail/1234/" - rv = self.client.get(uri) + id_, thumbnail_url = self._get_id_and_thumbnail_url(DASHBOARD_URL) + rv = self.client.get(f"api/v1/dashboard/{id_}/thumbnail/1234/") self.assertEqual(rv.status_code, 302) - self.assertRedirects( - rv, f"api/v1/dashboard/{dashboard.id}/thumbnail/{dashboard.digest}/" - ) + self.assertRedirects(rv, thumbnail_url) diff --git a/tests/unit_tests/reports/test_utils.py b/tests/unit_tests/reports/test_utils.py deleted file mode 100644 index 8b4bf93e718a8..0000000000000 --- a/tests/unit_tests/reports/test_utils.py +++ /dev/null @@ -1,178 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -from dataclasses import dataclass -from typing import List, Optional, Union -from unittest.mock import patch - -import pytest -from flask_appbuilder.security.sqla.models import User - -from superset.reports.types import ReportScheduleExecutor - -SELENIUM_USER_ID = 1234 - - -def _get_users( - params: Optional[Union[int, List[int]]] -) -> Optional[Union[User, List[User]]]: - if params is None: - return None - if isinstance(params, int): - return User(id=params) - return [User(id=user) for user in params] - - -@dataclass -class ReportConfig: - owners: List[int] - creator: Optional[int] = None - modifier: Optional[int] = None - - -@pytest.mark.parametrize( - "config,report_config,expected_user", - [ - ( - [ReportScheduleExecutor.SELENIUM], - ReportConfig( - owners=[1, 2], - creator=3, - modifier=4, - ), - SELENIUM_USER_ID, - ), - ( - [ - ReportScheduleExecutor.CREATOR, - ReportScheduleExecutor.CREATOR_OWNER, - ReportScheduleExecutor.OWNER, - ReportScheduleExecutor.MODIFIER, - ReportScheduleExecutor.MODIFIER_OWNER, - ReportScheduleExecutor.SELENIUM, - ], - ReportConfig(owners=[]), - SELENIUM_USER_ID, - ), - ( - [ - ReportScheduleExecutor.CREATOR, - ReportScheduleExecutor.CREATOR_OWNER, - ReportScheduleExecutor.OWNER, - ReportScheduleExecutor.MODIFIER, - ReportScheduleExecutor.MODIFIER_OWNER, - ReportScheduleExecutor.SELENIUM, - ], - ReportConfig(owners=[], modifier=1), - 1, - ), - ( - [ - ReportScheduleExecutor.CREATOR, - ReportScheduleExecutor.CREATOR_OWNER, - ReportScheduleExecutor.OWNER, - ReportScheduleExecutor.MODIFIER, - ReportScheduleExecutor.MODIFIER_OWNER, - ReportScheduleExecutor.SELENIUM, - ], - ReportConfig(owners=[2], modifier=1), - 2, - ), - ( - [ - ReportScheduleExecutor.CREATOR, - ReportScheduleExecutor.CREATOR_OWNER, - ReportScheduleExecutor.OWNER, - ReportScheduleExecutor.MODIFIER, - ReportScheduleExecutor.MODIFIER_OWNER, - ReportScheduleExecutor.SELENIUM, - ], - ReportConfig(owners=[2], creator=3, modifier=1), - 3, - ), - ( - [ - ReportScheduleExecutor.OWNER, - ], - ReportConfig(owners=[1, 2, 3, 4, 5, 6, 7], creator=3, modifier=4), - 4, - ), - ( - [ - ReportScheduleExecutor.OWNER, - ], - ReportConfig(owners=[1, 2, 3, 4, 5, 6, 7], creator=3, modifier=8), - 3, - ), - ( - [ - ReportScheduleExecutor.MODIFIER_OWNER, - ], - ReportConfig(owners=[1, 2, 3, 4, 5, 6, 7], creator=8, modifier=9), - None, - ), - ( - [ - ReportScheduleExecutor.MODIFIER_OWNER, - ], - ReportConfig(owners=[1, 2, 3, 4, 5, 6, 7], creator=8, modifier=4), - 4, - ), - ( - [ - ReportScheduleExecutor.CREATOR_OWNER, - ], - ReportConfig(owners=[1, 2, 3, 4, 5, 6, 7], creator=8, modifier=9), - None, - ), - ( - [ - ReportScheduleExecutor.CREATOR_OWNER, - ], - ReportConfig(owners=[1, 2, 3, 4, 5, 6, 7], creator=4, modifier=8), - 4, - ), - ], -) -def test_get_executor( - config: List[ReportScheduleExecutor], - report_config: ReportConfig, - expected_user: Optional[int], -) -> None: - from superset import app, security_manager - from superset.reports.commands.exceptions import ReportScheduleUserNotFoundError - from superset.reports.models import ReportSchedule - from superset.reports.utils import get_executor - - selenium_user = User(id=SELENIUM_USER_ID) - - with patch.dict(app.config, {"ALERT_REPORTS_EXECUTE_AS": config}), patch.object( - security_manager, "find_user", return_value=selenium_user - ): - report_schedule = ReportSchedule( - id=1, - type="report", - name="test_report", - owners=_get_users(report_config.owners), - created_by=_get_users(report_config.creator), - changed_by=_get_users(report_config.modifier), - ) - if expected_user is None: - with pytest.raises(ReportScheduleUserNotFoundError): - get_executor(report_schedule) - else: - assert get_executor(report_schedule).id == expected_user diff --git a/tests/unit_tests/tasks/__init__.py b/tests/unit_tests/tasks/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/tests/unit_tests/tasks/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/tests/unit_tests/tasks/test_utils.py b/tests/unit_tests/tasks/test_utils.py new file mode 100644 index 0000000000000..7854717201229 --- /dev/null +++ b/tests/unit_tests/tasks/test_utils.py @@ -0,0 +1,323 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from contextlib import nullcontext +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, List, Optional, Tuple, Type, Union + +import pytest +from flask_appbuilder.security.sqla.models import User + +from superset.tasks.exceptions import ExecutorNotFoundError +from superset.tasks.types import ExecutorType + +SELENIUM_USER_ID = 1234 +SELENIUM_USERNAME = "admin" + + +def _get_users( + params: Optional[Union[int, List[int]]] +) -> Optional[Union[User, List[User]]]: + if params is None: + return None + if isinstance(params, int): + return User(id=params, username=str(params)) + return [User(id=user, username=str(user)) for user in params] + + +@dataclass +class ModelConfig: + owners: List[int] + creator: Optional[int] = None + modifier: Optional[int] = None + + +class ModelType(int, Enum): + DASHBOARD = 1 + CHART = 2 + REPORT_SCHEDULE = 3 + + +@pytest.mark.parametrize( + "model_type,executor_types,model_config,current_user,expected_result", + [ + ( + ModelType.REPORT_SCHEDULE, + [ExecutorType.SELENIUM], + ModelConfig( + owners=[1, 2], + creator=3, + modifier=4, + ), + None, + (ExecutorType.SELENIUM, SELENIUM_USER_ID), + ), + ( + ModelType.REPORT_SCHEDULE, + [ + ExecutorType.CREATOR, + ExecutorType.CREATOR_OWNER, + ExecutorType.OWNER, + ExecutorType.MODIFIER, + ExecutorType.MODIFIER_OWNER, + ExecutorType.SELENIUM, + ], + ModelConfig(owners=[]), + None, + (ExecutorType.SELENIUM, SELENIUM_USER_ID), + ), + ( + ModelType.REPORT_SCHEDULE, + [ + ExecutorType.CREATOR, + ExecutorType.CREATOR_OWNER, + ExecutorType.OWNER, + ExecutorType.MODIFIER, + ExecutorType.MODIFIER_OWNER, + ExecutorType.SELENIUM, + ], + ModelConfig(owners=[], modifier=1), + None, + (ExecutorType.MODIFIER, 1), + ), + ( + ModelType.REPORT_SCHEDULE, + [ + ExecutorType.CREATOR, + ExecutorType.CREATOR_OWNER, + ExecutorType.OWNER, + ExecutorType.MODIFIER, + ExecutorType.MODIFIER_OWNER, + ExecutorType.SELENIUM, + ], + ModelConfig(owners=[2], modifier=1), + None, + (ExecutorType.OWNER, 2), + ), + ( + ModelType.REPORT_SCHEDULE, + [ + ExecutorType.CREATOR, + ExecutorType.CREATOR_OWNER, + ExecutorType.OWNER, + ExecutorType.MODIFIER, + ExecutorType.MODIFIER_OWNER, + ExecutorType.SELENIUM, + ], + ModelConfig(owners=[2], creator=3, modifier=1), + None, + (ExecutorType.CREATOR, 3), + ), + ( + ModelType.REPORT_SCHEDULE, + [ + ExecutorType.OWNER, + ], + ModelConfig(owners=[1, 2, 3, 4, 5, 6, 7], creator=3, modifier=4), + None, + (ExecutorType.OWNER, 4), + ), + ( + ModelType.REPORT_SCHEDULE, + [ + ExecutorType.OWNER, + ], + ModelConfig(owners=[1, 2, 3, 4, 5, 6, 7], creator=3, modifier=8), + None, + (ExecutorType.OWNER, 3), + ), + ( + ModelType.REPORT_SCHEDULE, + [ + ExecutorType.MODIFIER_OWNER, + ], + ModelConfig(owners=[1, 2, 3, 4, 5, 6, 7], creator=8, modifier=9), + None, + ExecutorNotFoundError(), + ), + ( + ModelType.REPORT_SCHEDULE, + [ + ExecutorType.MODIFIER_OWNER, + ], + ModelConfig(owners=[1, 2, 3, 4, 5, 6, 7], creator=8, modifier=4), + None, + (ExecutorType.MODIFIER_OWNER, 4), + ), + ( + ModelType.REPORT_SCHEDULE, + [ + ExecutorType.CREATOR_OWNER, + ], + ModelConfig(owners=[1, 2, 3, 4, 5, 6, 7], creator=8, modifier=9), + None, + ExecutorNotFoundError(), + ), + ( + ModelType.REPORT_SCHEDULE, + [ + ExecutorType.CREATOR_OWNER, + ], + ModelConfig(owners=[1, 2, 3, 4, 5, 6, 7], creator=4, modifier=8), + None, + (ExecutorType.CREATOR_OWNER, 4), + ), + ( + ModelType.REPORT_SCHEDULE, + [ + ExecutorType.CURRENT_USER, + ], + ModelConfig(owners=[1, 2, 3, 4, 5, 6, 7], creator=4, modifier=8), + None, + ExecutorNotFoundError(), + ), + ( + ModelType.DASHBOARD, + [ + ExecutorType.CURRENT_USER, + ], + ModelConfig(owners=[1], creator=2, modifier=3), + 4, + (ExecutorType.CURRENT_USER, 4), + ), + ( + ModelType.DASHBOARD, + [ + ExecutorType.SELENIUM, + ], + ModelConfig(owners=[1], creator=2, modifier=3), + 4, + (ExecutorType.SELENIUM, SELENIUM_USER_ID), + ), + ( + ModelType.DASHBOARD, + [ + ExecutorType.CURRENT_USER, + ], + ModelConfig(owners=[1], creator=2, modifier=3), + None, + ExecutorNotFoundError(), + ), + ( + ModelType.DASHBOARD, + [ + ExecutorType.CREATOR_OWNER, + ExecutorType.MODIFIER_OWNER, + ExecutorType.CURRENT_USER, + ExecutorType.SELENIUM, + ], + ModelConfig(owners=[1], creator=2, modifier=3), + None, + (ExecutorType.SELENIUM, SELENIUM_USER_ID), + ), + ( + ModelType.CHART, + [ + ExecutorType.CURRENT_USER, + ], + ModelConfig(owners=[1], creator=2, modifier=3), + 4, + (ExecutorType.CURRENT_USER, 4), + ), + ( + ModelType.CHART, + [ + ExecutorType.SELENIUM, + ], + ModelConfig(owners=[1], creator=2, modifier=3), + 4, + (ExecutorType.SELENIUM, SELENIUM_USER_ID), + ), + ( + ModelType.CHART, + [ + ExecutorType.CURRENT_USER, + ], + ModelConfig(owners=[1], creator=2, modifier=3), + None, + ExecutorNotFoundError(), + ), + ( + ModelType.CHART, + [ + ExecutorType.CREATOR_OWNER, + ExecutorType.MODIFIER_OWNER, + ExecutorType.CURRENT_USER, + ExecutorType.SELENIUM, + ], + ModelConfig(owners=[1], creator=2, modifier=3), + None, + (ExecutorType.SELENIUM, SELENIUM_USER_ID), + ), + ], +) +def test_get_executor( + model_type: ModelType, + executor_types: List[ExecutorType], + model_config: ModelConfig, + current_user: Optional[int], + expected_result: Tuple[int, ExecutorNotFoundError], +) -> None: + from superset.models.dashboard import Dashboard + from superset.models.slice import Slice + from superset.reports.models import ReportSchedule + from superset.tasks.utils import get_executor + + model: Type[Union[Dashboard, ReportSchedule, Slice]] + model_kwargs: Dict[str, Any] = {} + if model_type == ModelType.REPORT_SCHEDULE: + model = ReportSchedule + model_kwargs = { + "type": "report", + "name": "test_report", + } + elif model_type == ModelType.DASHBOARD: + model = Dashboard + elif model_type == ModelType.CHART: + model = Slice + else: + raise Exception(f"Unsupported model type: {model_type}") + + obj = model( + id=1, + owners=_get_users(model_config.owners), + created_by=_get_users(model_config.creator), + changed_by=_get_users(model_config.modifier), + **model_kwargs, + ) + if isinstance(expected_result, Exception): + cm = pytest.raises(type(expected_result)) + expected_executor_type = None + expected_executor = None + else: + cm = nullcontext() + expected_executor_type = expected_result[0] + expected_executor = ( + SELENIUM_USERNAME + if expected_executor_type == ExecutorType.SELENIUM + else str(expected_result[1]) + ) + + with cm: + executor_type, executor = get_executor( + executor_types=executor_types, + model=obj, + current_user=str(current_user) if current_user else None, + ) + assert executor_type == expected_executor_type + assert executor == expected_executor diff --git a/tests/unit_tests/thumbnails/__init__.py b/tests/unit_tests/thumbnails/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/tests/unit_tests/thumbnails/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/tests/unit_tests/thumbnails/test_digest.py b/tests/unit_tests/thumbnails/test_digest.py new file mode 100644 index 0000000000000..04f244e629b59 --- /dev/null +++ b/tests/unit_tests/thumbnails/test_digest.py @@ -0,0 +1,258 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from contextlib import nullcontext +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union +from unittest.mock import patch + +import pytest +from flask_appbuilder.security.sqla.models import User + +from superset.tasks.exceptions import ExecutorNotFoundError +from superset.tasks.types import ExecutorType +from superset.utils.core import override_user + +if TYPE_CHECKING: + from superset.models.dashboard import Dashboard + from superset.models.slice import Slice + +_DEFAULT_DASHBOARD_KWARGS: Dict[str, Any] = { + "id": 1, + "dashboard_title": "My Title", + "slices": [{"id": 1, "slice_name": "My Chart"}], + "position_json": '{"a": "b"}', + "css": "background-color: lightblue;", + "json_metadata": '{"c": "d"}', +} + +_DEFAULT_CHART_KWARGS = { + "id": 2, + "params": {"a": "b"}, +} + + +def CUSTOM_DASHBOARD_FUNC( + dashboard: Dashboard, + executor_type: ExecutorType, + executor: str, +) -> str: + return f"{dashboard.id}.{executor_type.value}.{executor}" + + +def CUSTOM_CHART_FUNC( + chart: Slice, + executor_type: ExecutorType, + executor: str, +) -> str: + return f"{chart.id}.{executor_type.value}.{executor}" + + +@pytest.mark.parametrize( + "dashboard_overrides,execute_as,has_current_user,use_custom_digest,expected_result", + [ + ( + None, + [ExecutorType.SELENIUM], + False, + False, + "71452fee8ffbd8d340193d611bcd4559", + ), + ( + None, + [ExecutorType.CURRENT_USER], + True, + False, + "209dc060ac19271b8708731e3b8280f5", + ), + ( + { + "dashboard_title": "My Other Title", + }, + [ExecutorType.CURRENT_USER], + True, + False, + "209dc060ac19271b8708731e3b8280f5", + ), + ( + { + "id": 2, + }, + [ExecutorType.CURRENT_USER], + True, + False, + "06a4144466dbd5ffad0c3c2225e96296", + ), + ( + { + "slices": [{"id": 2, "slice_name": "My Other Chart"}], + }, + [ExecutorType.CURRENT_USER], + True, + False, + "a823ece9563895ccb14f3d9095e84f7a", + ), + ( + { + "position_json": {"b": "c"}, + }, + [ExecutorType.CURRENT_USER], + True, + False, + "33c5475f92a904925ab3ef493526e5b5", + ), + ( + { + "css": "background-color: darkblue;", + }, + [ExecutorType.CURRENT_USER], + True, + False, + "cec57345e6402c0d4b3caee5cfaa0a03", + ), + ( + { + "json_metadata": {"d": "e"}, + }, + [ExecutorType.CURRENT_USER], + True, + False, + "5380dcbe94621a0759b09554404f3d02", + ), + ( + None, + [ExecutorType.CURRENT_USER], + True, + True, + "1.current_user.1", + ), + ( + None, + [ExecutorType.CURRENT_USER], + False, + False, + ExecutorNotFoundError(), + ), + ], +) +def test_dashboard_digest( + dashboard_overrides: Optional[Dict[str, Any]], + execute_as: List[ExecutorType], + has_current_user: bool, + use_custom_digest: bool, + expected_result: Union[str, Exception], +) -> None: + from superset import app + from superset.models.dashboard import Dashboard + from superset.models.slice import Slice + from superset.thumbnails.digest import get_dashboard_digest + + kwargs = { + **_DEFAULT_DASHBOARD_KWARGS, + **(dashboard_overrides or {}), + } + slices = [Slice(**slice_kwargs) for slice_kwargs in kwargs.pop("slices")] + dashboard = Dashboard(**kwargs, slices=slices) + user: Optional[User] = None + if has_current_user: + user = User(id=1, username="1") + func = CUSTOM_DASHBOARD_FUNC if use_custom_digest else None + + with patch.dict( + app.config, + { + "THUMBNAIL_EXECUTE_AS": execute_as, + "THUMBNAIL_DASHBOARD_DIGEST_FUNC": func, + }, + ), override_user(user): + cm = ( + pytest.raises(type(expected_result)) + if isinstance(expected_result, Exception) + else nullcontext() + ) + with cm: + assert get_dashboard_digest(dashboard=dashboard) == expected_result + + +@pytest.mark.parametrize( + "chart_overrides,execute_as,has_current_user,use_custom_digest,expected_result", + [ + ( + None, + [ExecutorType.SELENIUM], + False, + False, + "47d852b5c4df211c115905617bb722c1", + ), + ( + None, + [ExecutorType.CURRENT_USER], + True, + False, + "4f8109d3761e766e650af514bb358f10", + ), + ( + None, + [ExecutorType.CURRENT_USER], + True, + True, + "2.current_user.1", + ), + ( + None, + [ExecutorType.CURRENT_USER], + False, + False, + ExecutorNotFoundError(), + ), + ], +) +def test_chart_digest( + chart_overrides: Optional[Dict[str, Any]], + execute_as: List[ExecutorType], + has_current_user: bool, + use_custom_digest: bool, + expected_result: Union[str, Exception], +) -> None: + from superset import app + from superset.models.slice import Slice + from superset.thumbnails.digest import get_chart_digest + + kwargs = { + **_DEFAULT_CHART_KWARGS, + **(chart_overrides or {}), + } + chart = Slice(**kwargs) + user: Optional[User] = None + if has_current_user: + user = User(id=1, username="1") + func = CUSTOM_CHART_FUNC if use_custom_digest else None + + with patch.dict( + app.config, + { + "THUMBNAIL_EXECUTE_AS": execute_as, + "THUMBNAIL_CHART_DIGEST_FUNC": func, + }, + ), override_user(user): + cm = ( + pytest.raises(type(expected_result)) + if isinstance(expected_result, Exception) + else nullcontext() + ) + with cm: + assert get_chart_digest(chart=chart) == expected_result