mirror of https://github.com/apache/superset.git
feat(thumbnails): add support for user specific thumbs (#22328)
This commit is contained in:
parent
1014a327f5
commit
aa0cae9b49
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
|
@ -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")
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
|
@ -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
|
|
@ -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)
|
|
@ -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,
|
||||
|
|
|
@ -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 = ""
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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.
|
|
@ -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
|
|
@ -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.
|
|
@ -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
|
Loading…
Reference in New Issue