This is an automated email from the ASF dual-hosted git repository.
villebro pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new aa0cae9b49 feat(thumbnails): add support for user specific thumbs
(#22328)
aa0cae9b49 is described below
commit aa0cae9b490405ede604804877304b1df6dd08c1
Author: Ville Brofeldt <[email protected]>
AuthorDate: Wed Dec 14 15:02:31 2022 +0200
feat(thumbnails): add support for user specific thumbs (#22328)
---
UPDATING.md | 1 +
docs/docs/installation/cache.mdx | 7 +
superset/charts/api.py | 35 ++-
superset/config.py | 58 +++-
superset/dashboards/api.py | 18 +-
superset/models/dashboard.py | 20 +-
superset/models/slice.py | 21 +-
superset/reports/commands/alert.py | 10 +-
superset/reports/commands/exceptions.py | 4 -
superset/reports/commands/execute.py | 32 +-
superset/reports/types.py | 10 -
superset/reports/utils.py | 71 -----
.../__init__.py => superset/tasks/exceptions.py | 8 +
superset/tasks/thumbnails.py | 52 +++-
superset/{reports => tasks}/types.py | 25 +-
superset/tasks/utils.py | 94 ++++++
.../reports => superset/thumbnails}/__init__.py | 0
superset/thumbnails/digest.py | 83 ++++++
tests/integration_tests/reports/alert_tests.py | 29 +-
tests/integration_tests/reports/commands_tests.py | 6 +-
tests/integration_tests/thumbnails_tests.py | 183 ++++++++----
tests/unit_tests/reports/test_utils.py | 178 ------------
tests/unit_tests/{reports => tasks}/__init__.py | 0
tests/unit_tests/tasks/test_utils.py | 323 +++++++++++++++++++++
.../unit_tests/{reports => thumbnails}/__init__.py | 0
tests/unit_tests/thumbnails/test_digest.py | 258 ++++++++++++++++
26 files changed, 1107 insertions(+), 419 deletions(-)
diff --git a/UPDATING.md b/UPDATING.md
index 6ab1276147..ba9a6c1bc8 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 CR [...]
### Potential Downtime
diff --git a/docs/docs/installation/cache.mdx b/docs/docs/installation/cache.mdx
index 9972aa4887..4838fc47e6 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 046379e7f5..c5e0eb77d8 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -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:
diff --git a/superset/config.py b/superset/config.py
index a597678e59..120a5950f7 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -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())
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 48185ec526..79255d1921 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -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:
diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py
index a98d76e581..ae6bae4b73 100644
--- a/superset/models/dashboard.py
+++ b/superset/models/dashboard.py
@@ -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()
diff --git a/superset/models/slice.py b/superset/models/slice.py
index 32b347266d..657ff7d38a 100644
--- a/superset/models/slice.py
+++ b/superset/models/slice.py
@@ -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)
diff --git a/superset/reports/commands/alert.py
b/superset/reports/commands/alert.py
index 255280704e..1044b64505 100644
--- a/superset/reports/commands/alert.py
+++ b/superset/reports/commands/alert.py
@@ -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)
diff --git a/superset/reports/commands/exceptions.py
b/superset/reports/commands/exceptions.py
index a068b3c628..48dbfcaab9 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 d20775ffd6..8133cec29b 100644
--- a/superset/reports/commands/execute.py
+++ b/superset/reports/commands/execute.py
@@ -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
diff --git a/superset/reports/types.py b/superset/reports/types.py
index 7977a2defa..d487e3ad23 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 @@ 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"
diff --git a/superset/reports/utils.py b/superset/reports/utils.py
deleted file mode 100644
index 215fca9988..0000000000
--- 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/tests/unit_tests/reports/__init__.py b/superset/tasks/exceptions.py
similarity index 79%
copy from tests/unit_tests/reports/__init__.py
copy to superset/tasks/exceptions.py
index 13a83393a9..6698661754 100644
--- a/tests/unit_tests/reports/__init__.py
+++ b/superset/tasks/exceptions.py
@@ -14,3 +14,11 @@
# 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 94b83ddb37..c03d13b0bd 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 @@ 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,
diff --git a/superset/reports/types.py b/superset/tasks/types.py
similarity index 50%
copy from superset/reports/types.py
copy to superset/tasks/types.py
index 7977a2defa..cc337a81ed 100644
--- a/superset/reports/types.py
+++ b/superset/tasks/types.py
@@ -14,20 +14,31 @@
# 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
+from enum import Enum
-class ReportScheduleExtra(TypedDict):
- dashboard: DashboardPermalinkState
+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
+ """
-class ReportScheduleExecutor(str, Enum):
+ # 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 0000000000..9c1dab8220
--- /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%
copy from tests/unit_tests/reports/__init__.py
copy to superset/thumbnails/__init__.py
diff --git a/superset/thumbnails/digest.py b/superset/thumbnails/digest.py
new file mode 100644
index 0000000000..fb209fcd50
--- /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 ef51bf1d0d..6c5c41a81f 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 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,
diff --git a/tests/integration_tests/reports/commands_tests.py
b/tests/integration_tests/reports/commands_tests.py
index 288e6746cc..ebbba49928 100644
--- a/tests/integration_tests/reports/commands_tests.py
+++ b/tests/integration_tests/reports/commands_tests.py
@@ -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 = ""
diff --git a/tests/integration_tests/thumbnails_tests.py
b/tests/integration_tests/thumbnails_tests.py
index 81557c7d89..332d71da33 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 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)
+ "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 @@ 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)
diff --git a/tests/unit_tests/reports/test_utils.py
b/tests/unit_tests/reports/test_utils.py
deleted file mode 100644
index 8b4bf93e71..0000000000
--- 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
-
-
[email protected](
- "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/reports/__init__.py
b/tests/unit_tests/tasks/__init__.py
similarity index 100%
copy from tests/unit_tests/reports/__init__.py
copy to tests/unit_tests/tasks/__init__.py
diff --git a/tests/unit_tests/tasks/test_utils.py
b/tests/unit_tests/tasks/test_utils.py
new file mode 100644
index 0000000000..7854717201
--- /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
+
+
[email protected](
+ "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/reports/__init__.py
b/tests/unit_tests/thumbnails/__init__.py
similarity index 100%
rename from tests/unit_tests/reports/__init__.py
rename to tests/unit_tests/thumbnails/__init__.py
diff --git a/tests/unit_tests/thumbnails/test_digest.py
b/tests/unit_tests/thumbnails/test_digest.py
new file mode 100644
index 0000000000..04f244e629
--- /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}"
+
+
[email protected](
+
"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
+
+
[email protected](
+
"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