feat(thumbnails): add support for user specific thumbs (#22328)

This commit is contained in:
Ville Brofeldt 2022-12-14 15:02:31 +02:00 committed by GitHub
parent 1014a327f5
commit aa0cae9b49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1182 additions and 413 deletions

View File

@ -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

View File

@ -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.

View File

@ -18,7 +18,7 @@ import json
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.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
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 @@ class ChartRestApi(BaseSupersetModelRestApi):
# 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 @@ class ChartRestApi(BaseSupersetModelRestApi):
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 @@ class ChartRestApi(BaseSupersetModelRestApi):
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 @@ class ChartRestApi(BaseSupersetModelRestApi):
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:

View File

@ -21,6 +21,8 @@ in your PYTHONPATH as there is a ``from superset_config import *``
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.plugins.internet_port import internet_port
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 @@ if TYPE_CHECKING:
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 @@ EXTRA_SEQUENTIAL_COLOR_SCHEMES: List[Dict[str, Any]] = []
# ---------------------------------------------------
# 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 @@ SQLLAB_CTAS_NO_LIMIT = False
# 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 @@ CSV_TO_HIVE_UPLOAD_DIRECTORY = "EXTERNAL_HIVE_TABLES/"
# 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 @@ UPLOADED_CSV_HIVE_NAMESPACE: Optional[str] = None
# 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 @@ ALERT_REPORTS_WORKING_TIME_OUT_KILL = True
# 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())

View File

@ -20,7 +20,7 @@ import json
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.extensions import event_logger
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 @@ class DashboardRestApi(BaseSupersetModelRestApi):
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 @@ class DashboardRestApi(BaseSupersetModelRestApi):
"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 @@ class DashboardRestApi(BaseSupersetModelRestApi):
# 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:

View File

@ -55,11 +55,11 @@ from superset.models.helpers import AuditMixinNullable, ImportExportMixin
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 @@ class Dashboard(Model, AuditMixinNullable, ImportExportMixin):
@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 @@ class Dashboard(Model, AuditMixinNullable, ImportExportMixin):
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 @@ class Dashboard(Model, AuditMixinNullable, ImportExportMixin):
@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()

View File

@ -43,10 +43,10 @@ from superset import db, is_feature_enabled, security_manager
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 @@ class Slice( # pylint: disable=too-many-public-methods
@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 @@ class Slice( # pylint: disable=too-many-public-methods
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)

View File

@ -25,7 +25,7 @@ import pandas as pd
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 @@ from superset.reports.commands.exceptions import (
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 @@ class AlertCommand(BaseCommand):
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)

View File

@ -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")

View File

@ -24,7 +24,7 @@ import pandas as pd
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.models import (
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 @@ class BaseReportState:
: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 @@ class BaseReportState:
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 @@ class BaseReportState:
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 @@ class BaseReportState:
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 @@ class AsyncExecuteReportScheduleCommand(BaseCommand):
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

View File

@ -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 @@ from superset.dashboards.permalink.types import DashboardPermalinkState
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"

View File

@ -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()

View File

@ -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")

View File

@ -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 @@ logger = logging.getLogger(__name__)
@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,

44
superset/tasks/types.py Normal file
View File

@ -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"

94
superset/tasks/utils.py Normal file
View File

@ -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

View File

@ -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)

View File

@ -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 pytest_mock import MockFixture
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 @@ from tests.integration_tests.test_app import app
@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,

View File

@ -41,13 +41,11 @@ from superset.reports.commands.exceptions import (
ReportScheduleClientErrorsException,
ReportScheduleCsvFailedError,
ReportScheduleCsvTimeout,
ReportScheduleForbiddenError,
ReportScheduleNotFoundError,
ReportSchedulePreviousWorkingError,
ReportScheduleScreenshotFailedError,
ReportScheduleScreenshotTimeout,
ReportScheduleSystemErrorsException,
ReportScheduleUnexpectedError,
ReportScheduleWorkingTimeoutError,
)
from superset.reports.commands.execute import (
@ -67,7 +65,7 @@ from superset.reports.notifications.exceptions import (
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 = ""

View File

@ -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 import db, is_feature_enabled, security_manager
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 @@ class TestThumbnailsSeleniumLive(LiveServerTestCase):
"""
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 @@ class TestWebDriverProxy(SupersetTestCase):
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 @@ class TestThumbnails(SupersetTestCase):
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)
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
)
"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)
@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 @@ class TestThumbnails(SupersetTestCase):
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)

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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