This is an automated email from the ASF dual-hosted git repository.
diegopucci 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 d89648147f feat(dashboard): add API endpoints for generating and
downloading screenshots (#29187)
d89648147f is described below
commit d89648147f40750a1207bb11d73047a2887b54a7
Author: Edgar Ulloa <[email protected]>
AuthorDate: Mon Jul 8 02:52:23 2024 -0700
feat(dashboard): add API endpoints for generating and downloading
screenshots (#29187)
Co-authored-by: Diego Pucci <[email protected]>
---
superset/dashboards/api.py | 184 +++++++++++++++++++++++-
superset/dashboards/schemas.py | 53 ++++++-
superset/tasks/thumbnails.py | 39 +++++
tests/integration_tests/dashboards/api_tests.py | 146 +++++++++++++++++++
4 files changed, 419 insertions(+), 3 deletions(-)
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 823bfdfa8c..2967fd1abd 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -47,6 +47,7 @@ from superset.commands.dashboard.exceptions import (
)
from superset.commands.dashboard.export import ExportDashboardsCommand
from superset.commands.dashboard.importers.dispatcher import
ImportDashboardsCommand
+from superset.commands.dashboard.permalink.create import
CreateDashboardPermalinkCommand
from superset.commands.dashboard.update import UpdateDashboardCommand
from superset.commands.exceptions import TagForbiddenError
from superset.commands.importers.exceptions import NoValidFilesFoundError
@@ -63,7 +64,10 @@ from superset.dashboards.filters import (
DashboardTitleOrSlugFilter,
FilterRelatedRoles,
)
+from superset.dashboards.permalink.types import DashboardPermalinkState
from superset.dashboards.schemas import (
+ CacheScreenshotSchema,
+ DashboardCacheScreenshotResponseSchema,
DashboardCopySchema,
DashboardDatasetSchema,
DashboardGetResponseSchema,
@@ -76,16 +80,24 @@ from superset.dashboards.schemas import (
get_fav_star_ids_schema,
GetFavStarIdsSchema,
openapi_spec_methods_override,
+ screenshot_query_schema,
TabsPayloadSchema,
thumbnail_query_schema,
)
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.thumbnails import (
+ cache_dashboard_screenshot,
+ cache_dashboard_thumbnail,
+)
from superset.tasks.utils import get_current_user
from superset.utils import json
-from superset.utils.screenshots import DashboardScreenshot
+from superset.utils.pdf import build_pdf_from_screenshots
+from superset.utils.screenshots import (
+ DashboardScreenshot,
+ DEFAULT_DASHBOARD_WINDOW_SIZE,
+)
from superset.utils.urls import get_url_path
from superset.views.base_api import (
BaseSupersetModelRestApi,
@@ -124,6 +136,7 @@ def with_dashboard(
return functools.update_wrapper(wraps, f)
+# pylint: disable=too-many-public-methods
class DashboardRestApi(BaseSupersetModelRestApi):
datamodel = SQLAInterface(Dashboard)
@@ -149,6 +162,8 @@ class DashboardRestApi(BaseSupersetModelRestApi):
"delete_embedded",
"thumbnail",
"copy_dash",
+ "cache_dashboard_screenshot",
+ "screenshot",
}
resource_name = "dashboard"
allow_browser_login = True
@@ -269,6 +284,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
""" Override the name set for this collection of endpoints """
openapi_spec_component_schemas = (
ChartEntityResponseSchema,
+ DashboardCacheScreenshotResponseSchema,
DashboardCopySchema,
DashboardGetResponseSchema,
DashboardDatasetSchema,
@@ -939,6 +955,170 @@ class DashboardRestApi(BaseSupersetModelRestApi):
FileWrapper(screenshot), mimetype="image/png",
direct_passthrough=True
)
+ @expose("/<pk>/cache_dashboard_screenshot/", methods=("POST",))
+ @protect()
+ @rison(screenshot_query_schema)
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
+ f".cache_dashboard_screenshot",
+ log_to_statsd=False,
+ )
+ def cache_dashboard_screenshot(self, pk: int, **kwargs: Any) ->
WerkzeugResponse:
+ """Compute and cache a screenshot.
+ ---
+ post:
+ summary: Compute and cache a screenshot
+ parameters:
+ - in: path
+ schema:
+ type: integer
+ name: pk
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DashboardScreenshotPostSchema'
+ responses:
+ 202:
+ description: Dashboard async result
+ content:
+ application/json:
+ schema:
+ $ref:
"#/components/schemas/DashboardCacheScreenshotResponseSchema"
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 404:
+ $ref: '#/components/responses/404'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ try:
+ payload = CacheScreenshotSchema().load(request.json)
+ except ValidationError as error:
+ return self.response_400(message=error.messages)
+
+ dashboard = cast(Dashboard, self.datamodel.get(pk, self._base_filters))
+ if not dashboard:
+ return self.response_404()
+
+ window_size = (
+ kwargs["rison"].get("window_size") or DEFAULT_DASHBOARD_WINDOW_SIZE
+ )
+ # Don't shrink the image if thumb_size is not specified
+ thumb_size = kwargs["rison"].get("thumb_size") or window_size
+
+ dashboard_state: DashboardPermalinkState = {
+ "dataMask": payload.get("dataMask", {}),
+ "activeTabs": payload.get("activeTabs", []),
+ "anchor": payload.get("anchor", ""),
+ "urlParams": payload.get("urlParams", []),
+ }
+
+ permalink_key = CreateDashboardPermalinkCommand(
+ dashboard_id=str(dashboard.id),
+ state=dashboard_state,
+ ).run()
+
+ dashboard_url = get_url_path("Superset.dashboard_permalink",
key=permalink_key)
+ screenshot_obj = DashboardScreenshot(dashboard_url, dashboard.digest)
+ cache_key = screenshot_obj.cache_key(window_size, thumb_size)
+ image_url = get_url_path(
+ "DashboardRestApi.screenshot", pk=dashboard.id, digest=cache_key
+ )
+
+ def trigger_celery() -> WerkzeugResponse:
+ logger.info("Triggering screenshot ASYNC")
+ cache_dashboard_screenshot.delay(
+ current_user=get_current_user(),
+ dashboard_id=dashboard.id,
+ dashboard_url=dashboard_url,
+ force=True,
+ thumb_size=thumb_size,
+ window_size=window_size,
+ )
+ return self.response(
+ 202,
+ cache_key=cache_key,
+ dashboard_url=dashboard_url,
+ image_url=image_url,
+ )
+
+ return trigger_celery()
+
+ @expose("/<pk>/screenshot/<digest>/", methods=("GET",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs:
f"{self.__class__.__name__}.screenshot",
+ log_to_statsd=False,
+ )
+ def screenshot(self, pk: int, digest: str) -> WerkzeugResponse:
+ """Get a computed dashboard screenshot from cache.
+ ---
+ get:
+ summary: Get a computed screenshot from cache
+ parameters:
+ - in: path
+ schema:
+ type: integer
+ name: pk
+ - in: path
+ schema:
+ type: string
+ name: digest
+ responses:
+ 200:
+ description: Dashboard thumbnail image
+ content:
+ image/*:
+ schema:
+ type: string
+ format: binary
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 404:
+ $ref: '#/components/responses/404'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ dashboard = self.datamodel.get(pk, self._base_filters)
+
+ # Making sure the dashboard still exists
+ if not dashboard:
+ return self.response_404()
+
+ download_format = request.args.get("download_format", "png")
+
+ # fetch the dashboard screenshot using the current user and cache if
set
+
+ if img := DashboardScreenshot.get_from_cache_key(thumbnail_cache,
digest):
+ if download_format == "pdf":
+ pdf_img = img.getvalue()
+ # Convert the screenshot to PDF
+ pdf_data = build_pdf_from_screenshots([pdf_img])
+
+ return Response(
+ pdf_data,
+ mimetype="application/pdf",
+ headers={"Content-Disposition": "inline;
filename=dashboard.pdf"},
+ direct_passthrough=True,
+ )
+ if download_format == "png":
+ return Response(
+ FileWrapper(img),
+ mimetype="image/png",
+ direct_passthrough=True,
+ )
+
+ return self.response_404()
+
@expose("/favorite_status/", methods=("GET",))
@protect()
@safe
diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py
index 3d4d131b9e..1f78a22358 100644
--- a/superset/dashboards/schemas.py
+++ b/superset/dashboards/schemas.py
@@ -31,7 +31,19 @@ thumbnail_query_schema = {
"type": "object",
"properties": {"force": {"type": "boolean"}},
}
-
+width_height_schema = {
+ "type": "array",
+ "items": {"type": "integer"},
+}
+screenshot_query_schema = {
+ "type": "object",
+ "properties": {
+ "force": {"type": "boolean"},
+ "permalink": {"type": "string"},
+ "window_size": width_height_schema,
+ "thumb_size": width_height_schema,
+ },
+}
dashboard_title_description = "A title for the dashboard."
slug_description = "Unique identifying part for the web address of the
dashboard."
owners_description = (
@@ -385,6 +397,26 @@ class DashboardPutSchema(BaseDashboardSchema):
)
+class DashboardScreenshotPostSchema(Schema):
+ dataMask = fields.Dict(
+ keys=fields.Str(),
+ values=fields.Raw(),
+ metadata={"description": "An object representing the data mask."},
+ )
+ activeTabs = fields.List(
+ fields.Str(), metadata={"description": "A list representing active
tabs."}
+ )
+ anchor = fields.String(
+ metadata={"description": "A string representing the anchor."}
+ )
+ urlParams = fields.List(
+ fields.Tuple(
+ (fields.Str(), fields.Str()),
+ ),
+ metadata={"description": "A list of tuples, each containing two
strings."},
+ )
+
+
class ChartFavStarResponseResult(Schema):
id = fields.Integer(metadata={"description": "The Chart id"})
value = fields.Boolean(metadata={"description": "The FaveStar value"})
@@ -425,3 +457,22 @@ class EmbeddedDashboardResponseSchema(Schema):
dashboard_id = fields.String()
changed_on = fields.DateTime()
changed_by = fields.Nested(UserSchema)
+
+
+class DashboardCacheScreenshotResponseSchema(Schema):
+ cache_key = fields.String(metadata={"description": "The cache key"})
+ dashboard_url = fields.String(
+ metadata={"description": "The url to render the dashboard"}
+ )
+ image_url = fields.String(
+ metadata={"description": "The url to fetch the screenshot"}
+ )
+
+
+class CacheScreenshotSchema(Schema):
+ dataMask = fields.Dict(keys=fields.Str(), values=fields.Raw(),
required=False)
+ activeTabs = fields.List(fields.Str(), required=False)
+ anchor = fields.Str(required=False)
+ urlParams = fields.List(
+ fields.List(fields.Str(), validate=lambda x: len(x) == 2),
required=False
+ )
diff --git a/superset/tasks/thumbnails.py b/superset/tasks/thumbnails.py
index 03b3999dce..da42b1b146 100644
--- a/superset/tasks/thumbnails.py
+++ b/superset/tasks/thumbnails.py
@@ -77,6 +77,7 @@ def cache_dashboard_thumbnail(
dashboard_id: int,
force: bool = False,
thumb_size: Optional[WindowSize] = None,
+ window_size: Optional[WindowSize] = None,
) -> None:
# pylint: disable=import-outside-toplevel
from superset.models.dashboard import Dashboard
@@ -100,5 +101,43 @@ def cache_dashboard_thumbnail(
user=user,
cache=thumbnail_cache,
force=force,
+ window_size=window_size,
+ thumb_size=thumb_size,
+ )
+
+
+# pylint: disable=too-many-arguments
+@celery_app.task(name="cache_dashboard_screenshot", soft_time_limit=60)
+def cache_dashboard_screenshot(
+ current_user: Optional[str],
+ dashboard_id: int,
+ dashboard_url: str,
+ force: bool = False,
+ thumb_size: Optional[WindowSize] = None,
+ window_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)
+
+ logger.info("Caching dashboard: %s", dashboard_url)
+ _, 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(dashboard_url, dashboard.digest)
+ screenshot.compute_and_cache(
+ user=user,
+ cache=thumbnail_cache,
+ force=force,
+ window_size=window_size,
thumb_size=thumb_size,
)
diff --git a/tests/integration_tests/dashboards/api_tests.py
b/tests/integration_tests/dashboards/api_tests.py
index b4213039c4..99a784e95f 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -532,6 +532,7 @@ class TestDashboardApi(ApiOwnersTestCaseMixin,
InsertChartMixin, SupersetTestCas
"can_get_embedded",
"can_delete_embedded",
"can_set_embedded",
+ "can_cache_dashboard_screenshot",
}
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
@@ -2715,3 +2716,148 @@ class TestDashboardApi(ApiOwnersTestCaseMixin,
InsertChartMixin, SupersetTestCas
security_manager.add_permission_role(gamma_role, write_tags_perm)
security_manager.add_permission_role(gamma_role, tag_dashboards_perm)
+
+ def _cache_screenshot(self, dashboard_id, payload=None):
+ if payload is None:
+ payload = {"dataMask": {}, "activeTabs": [], "anchor": "",
"urlParams": []}
+ uri = f"/api/v1/dashboard/{dashboard_id}/cache_dashboard_screenshot/"
+ return self.client.post(uri, json=payload)
+
+ def _get_screenshot(self, dashboard_id, cache_key, download_format):
+ uri =
f"/api/v1/dashboard/{dashboard_id}/screenshot/{cache_key}/?download_format={download_format}"
+ return self.client.get(uri)
+
+ @pytest.mark.usefixtures("create_dashboard_with_tag")
+ def test_cache_dashboard_screenshot_success(self):
+ self.login(ADMIN_USERNAME)
+ dashboard = (
+ db.session.query(Dashboard)
+ .filter(Dashboard.dashboard_title == "dash with tag")
+ .first()
+ )
+ response = self._cache_screenshot(dashboard.id)
+ self.assertEqual(response.status_code, 202)
+
+ @pytest.mark.usefixtures("create_dashboard_with_tag")
+ def test_cache_dashboard_screenshot_dashboard_validation(self):
+ self.login(ADMIN_USERNAME)
+ dashboard = (
+ db.session.query(Dashboard)
+ .filter(Dashboard.dashboard_title == "dash with tag")
+ .first()
+ )
+ invalid_payload = {
+ "dataMask": ["should be a dict"],
+ "activeTabs": "should be a list",
+ "anchor": 1,
+ "urlParams": "should be a list",
+ }
+ response = self._cache_screenshot(dashboard.id, invalid_payload)
+ self.assertEqual(response.status_code, 400)
+
+ def test_cache_dashboard_screenshot_dashboard_not_found(self):
+ self.login(ADMIN_USERNAME)
+ non_existent_id = 999
+ response = self._cache_screenshot(non_existent_id)
+ self.assertEqual(response.status_code, 404)
+
+ @pytest.mark.usefixtures("create_dashboard_with_tag")
+ @patch("superset.dashboards.api.cache_dashboard_screenshot")
+ @patch("superset.dashboards.api.DashboardScreenshot.get_from_cache_key")
+ def test_screenshot_success_png(self, mock_get_cache, mock_cache_task):
+ """
+ Validate screenshot returns png
+ """
+ self.login(ADMIN_USERNAME)
+ mock_cache_task.return_value = None
+ mock_get_cache.return_value = BytesIO(b"fake image data")
+
+ dashboard = (
+ db.session.query(Dashboard)
+ .filter(Dashboard.dashboard_title == "dash with tag")
+ .first()
+ )
+ cache_resp = self._cache_screenshot(dashboard.id)
+ self.assertEqual(cache_resp.status_code, 202)
+ cache_key = json.loads(cache_resp.data.decode("utf-8"))["cache_key"]
+
+ response = self._get_screenshot(dashboard.id, cache_key, "png")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.mimetype, "image/png")
+ self.assertEqual(response.data, b"fake image data")
+
+ @pytest.mark.usefixtures("create_dashboard_with_tag")
+ @patch("superset.dashboards.api.cache_dashboard_screenshot")
+ @patch("superset.dashboards.api.build_pdf_from_screenshots")
+ @patch("superset.dashboards.api.DashboardScreenshot.get_from_cache_key")
+ def test_screenshot_success_pdf(
+ self, mock_get_from_cache, mock_build_pdf, mock_cache_task
+ ):
+ """
+ Validate screenshot can return pdf.
+ """
+ self.login(ADMIN_USERNAME)
+ mock_cache_task.return_value = None
+ mock_get_from_cache.return_value = BytesIO(b"fake image data")
+ mock_build_pdf.return_value = b"fake pdf data"
+
+ dashboard = (
+ db.session.query(Dashboard)
+ .filter(Dashboard.dashboard_title == "dash with tag")
+ .first()
+ )
+ cache_resp = self._cache_screenshot(dashboard.id)
+ self.assertEqual(cache_resp.status_code, 202)
+ cache_key = json.loads(cache_resp.data.decode("utf-8"))["cache_key"]
+
+ response = self._get_screenshot(dashboard.id, cache_key, "pdf")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.mimetype, "application/pdf")
+ self.assertEqual(response.data, b"fake pdf data")
+
+ @pytest.mark.usefixtures("create_dashboard_with_tag")
+ @patch("superset.dashboards.api.cache_dashboard_screenshot")
+ @patch("superset.dashboards.api.DashboardScreenshot.get_from_cache_key")
+ def test_screenshot_not_in_cache(self, mock_get_cache, mock_cache_task):
+ self.login(ADMIN_USERNAME)
+ mock_cache_task.return_value = None
+ mock_get_cache.return_value = None
+
+ dashboard = (
+ db.session.query(Dashboard)
+ .filter(Dashboard.dashboard_title == "dash with tag")
+ .first()
+ )
+ cache_resp = self._cache_screenshot(dashboard.id)
+ self.assertEqual(cache_resp.status_code, 202)
+ cache_key = json.loads(cache_resp.data.decode("utf-8"))["cache_key"]
+
+ response = self._get_screenshot(dashboard.id, cache_key, "pdf")
+ self.assertEqual(response.status_code, 404)
+
+ def test_screenshot_dashboard_not_found(self):
+ self.login(ADMIN_USERNAME)
+ non_existent_id = 999
+ response = self._get_screenshot(non_existent_id, "some_cache_key",
"png")
+ self.assertEqual(response.status_code, 404)
+
+ @pytest.mark.usefixtures("create_dashboard_with_tag")
+ @patch("superset.dashboards.api.cache_dashboard_screenshot")
+ @patch("superset.dashboards.api.DashboardScreenshot.get_from_cache_key")
+ def test_screenshot_invalid_download_format(self, mock_get_cache,
mock_cache_task):
+ self.login(ADMIN_USERNAME)
+ mock_cache_task.return_value = None
+ mock_get_cache.return_value = BytesIO(b"fake png data")
+
+ dashboard = (
+ db.session.query(Dashboard)
+ .filter(Dashboard.dashboard_title == "dash with tag")
+ .first()
+ )
+
+ cache_resp = self._cache_screenshot(dashboard.id)
+ self.assertEqual(cache_resp.status_code, 202)
+ cache_key = json.loads(cache_resp.data.decode("utf-8"))["cache_key"]
+
+ response = self._get_screenshot(dashboard.id, cache_key, "invalid")
+ self.assertEqual(response.status_code, 404)