diff --git a/superset/charts/api.py b/superset/charts/api.py index c7266866eda5e..70bddd518c24e 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -30,7 +30,7 @@ from werkzeug.wrappers import Response as WerkzeugResponse from werkzeug.wsgi import FileWrapper -from superset import app, is_feature_enabled, thumbnail_cache +from superset import app, is_feature_enabled from superset.charts.filters import ( ChartAllTextFilter, ChartCertifiedFilter, @@ -84,7 +84,12 @@ from superset.tasks.thumbnails import cache_chart_thumbnail from superset.tasks.utils import get_current_user from superset.utils import json -from superset.utils.screenshots import ChartScreenshot, DEFAULT_CHART_WINDOW_SIZE +from superset.utils.screenshots import ( + ChartScreenshot, + DEFAULT_CHART_WINDOW_SIZE, + ScreenshotCachePayload, + StatusValues, +) from superset.utils.urls import get_url_path from superset.views.base_api import ( BaseSupersetModelRestApi, @@ -564,8 +569,14 @@ def cache_screenshot(self, pk: int, **kwargs: Any) -> WerkzeugResponse: schema: $ref: '#/components/schemas/screenshot_query_schema' responses: + 200: + description: Chart async result + content: + application/json: + schema: + $ref: "#/components/schemas/ChartCacheScreenshotResponseSchema" 202: - description: Chart async result + description: Chart async created content: application/json: schema: @@ -580,6 +591,7 @@ def cache_screenshot(self, pk: int, **kwargs: Any) -> WerkzeugResponse: $ref: '#/components/responses/500' """ rison_dict = kwargs["rison"] + force = rison_dict.get("force") window_size = rison_dict.get("window_size") or DEFAULT_CHART_WINDOW_SIZE # Don't shrink the image if thumb_size is not specified @@ -591,25 +603,38 @@ def cache_screenshot(self, pk: int, **kwargs: Any) -> WerkzeugResponse: chart_url = get_url_path("Superset.slice", slice_id=chart.id) screenshot_obj = ChartScreenshot(chart_url, chart.digest) - cache_key = screenshot_obj.cache_key(window_size, thumb_size) + cache_key = screenshot_obj.get_cache_key(window_size, thumb_size) image_url = get_url_path( "ChartRestApi.screenshot", pk=chart.id, digest=cache_key ) - def trigger_celery() -> WerkzeugResponse: - logger.info("Triggering screenshot ASYNC") - 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 - ) + if force or not screenshot_obj.get_from_cache_key(cache_key): + payload = ScreenshotCachePayload() + screenshot_obj.cache.set(cache_key, payload) - return trigger_celery() + if (cache_payload := screenshot_obj.get_from_cache_key(cache_key)) is not None: + + def build_response(status_code: int) -> WerkzeugResponse: + return self.response( + status_code, + cache_key=cache_key, + chart_url=chart_url, + image_url=image_url, + updated_at=cache_payload.get_timestamp(), + update_status=cache_payload.get_status(), + ) + + if cache_payload.status != StatusValues.UPDATED: + logger.info("Triggering screenshot ASYNC") + cache_chart_thumbnail.delay( + current_user=get_current_user(), + chart_id=chart.id, + window_size=window_size, + thumb_size=thumb_size, + ) + return build_response(202) + return build_response(200) + return self.response_500() @expose("//screenshot//", methods=("GET",)) @protect() @@ -635,7 +660,7 @@ def screenshot(self, pk: int, digest: str) -> WerkzeugResponse: name: digest responses: 200: - description: Chart thumbnail image + description: Chart screenshot image content: image/*: schema: @@ -652,16 +677,16 @@ def screenshot(self, pk: int, digest: str) -> WerkzeugResponse: """ chart = self.datamodel.get(pk, self._base_filters) - # Making sure the chart still exists if not chart: return self.response_404() - # fetch the chart screenshot using the current user and cache if set - if img := ChartScreenshot.get_from_cache_key(thumbnail_cache, digest): - return Response( - FileWrapper(img), mimetype="image/png", direct_passthrough=True - ) - # TODO: return an empty image + if cache_payload := ChartScreenshot.get_from_cache_key(digest): + if cache_payload.status == StatusValues.UPDATED: + return Response( + FileWrapper(cache_payload.get_image()), + mimetype="image/png", + direct_passthrough=True, + ) return self.response_404() @expose("//thumbnail//", methods=("GET",)) @@ -713,22 +738,18 @@ def thumbnail(self, pk: int, digest: str, **kwargs: Any) -> WerkzeugResponse: current_user = get_current_user() url = get_url_path("Superset.slice", slice_id=chart.id) - if kwargs["rison"].get("force", False): - logger.info( - "Triggering thumbnail compute (chart id: %s) ASYNC", str(chart.id) - ) - 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( - cache=thumbnail_cache - ) - # If not screenshot then send request to compute thumb to celery - if not screenshot: + screenshot_obj = ChartScreenshot(url, chart.digest) + cache_key = screenshot_obj.get_cache_key() + cache_payload = screenshot_obj.get_from_cache_key(cache_key) + + if not cache_payload: + cache_payload = ScreenshotCachePayload() + screenshot_obj.cache.set(cache_key, cache_payload) + + if ( + kwargs["rison"].get("force", False) + or cache_payload.status != StatusValues.UPDATED + ): self.incr_stats("async", self.thumbnail.__name__) logger.info( "Triggering thumbnail compute (chart id: %s) ASYNC", str(chart.id) @@ -736,10 +757,13 @@ def thumbnail(self, pk: int, digest: str, **kwargs: Any) -> WerkzeugResponse: cache_chart_thumbnail.delay( current_user=current_user, chart_id=chart.id, - force=True, ) - return self.response(202, message="OK Async") - # If digests + return self.response( + 202, + updated_at=cache_payload.get_timestamp(), + update_status=cache_payload.get_status(), + ) + # TODO remove digest from params... currently does nothing if chart.digest != digest: self.incr_stats("redirect", self.thumbnail.__name__) return redirect( @@ -749,7 +773,9 @@ def thumbnail(self, pk: int, digest: str, **kwargs: Any) -> WerkzeugResponse: ) self.incr_stats("from_cache", self.thumbnail.__name__) return Response( - FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True + FileWrapper(cache_payload.get_image()), + mimetype="image/png", + direct_passthrough=True, ) @expose("/export/", methods=("GET",)) diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index 5531e057c5587..6ea563e98d298 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -304,6 +304,21 @@ class ChartCacheScreenshotResponseSchema(Schema): image_url = fields.String( metadata={"description": "The url to fetch the screenshot"} ) + update_status = fields.String( + metadata={"description": "The status of the async screenshot"} + ) + updated_at = fields.String( + metadata={"description": "The timestamp of the last change in status"} + ) + + +class ChartGetCachedScreenshotResponseSchema(Schema): + update_status = fields.String( + metadata={"description": "The status of the async screenshot"} + ) + updated_at = fields.String( + metadata={"description": "The timestamp of the last change in status"} + ) class ChartDataColumnSchema(Schema): diff --git a/superset/config.py b/superset/config.py index 5d03016298aae..c091561ebc43c 100644 --- a/superset/config.py +++ b/superset/config.py @@ -716,6 +716,7 @@ class D3TimeFormat(TypedDict, total=False): THUMBNAIL_CACHE_CONFIG: CacheConfig = { "CACHE_TYPE": "NullCache", + "CACHE_DEFAULT_TIMEOUT": 3600, "CACHE_NO_NULL_WARNING": True, } diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index 3a3752830a285..1523edfd4d377 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -32,7 +32,7 @@ from werkzeug.wrappers import Response as WerkzeugResponse from werkzeug.wsgi import FileWrapper -from superset import db, is_feature_enabled, thumbnail_cache +from superset import db, is_feature_enabled from superset.charts.schemas import ChartEntityResponseSchema from superset.commands.dashboard.copy import CopyDashboardCommand from superset.commands.dashboard.create import CreateDashboardCommand @@ -116,6 +116,8 @@ from superset.utils.screenshots import ( DashboardScreenshot, DEFAULT_DASHBOARD_WINDOW_SIZE, + ScreenshotCachePayload, + StatusValues, ) from superset.utils.urls import get_url_path from superset.views.base_api import ( @@ -1105,27 +1107,24 @@ def thumbnail(self, pk: int, digest: str, **kwargs: Any) -> WerkzeugResponse: ) # If force, request a screenshot from the workers current_user = get_current_user() - if kwargs["rison"].get("force", False): + screenshot_obj = DashboardScreenshot(dashboard_url, digest) + cache_key = screenshot_obj.get_cache_key() + cache_payload = screenshot_obj.get_from_cache_key(cache_key) + if not cache_payload or kwargs["rison"].get("force", False): + cache_payload = ScreenshotCachePayload() + screenshot_obj.cache.set(cache_key, cache_payload) + + if cache_payload.status != StatusValues.UPDATED: 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( - dashboard_url, dashboard.digest - ).get_from_cache(cache=thumbnail_cache) - # 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( - current_user=current_user, - dashboard_id=dashboard.id, - force=True, + return self.response( + 202, + updated_at=cache_payload.get_timestamp(), + update_status=cache_payload.get_status(), ) - return self.response(202, message="OK Async") - # If digests + if dashboard.digest != digest: self.incr_stats("redirect", self.thumbnail.__name__) return redirect( @@ -1137,7 +1136,9 @@ def thumbnail(self, pk: int, digest: str, **kwargs: Any) -> WerkzeugResponse: ) self.incr_stats("from_cache", self.thumbnail.__name__) return Response( - FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True + FileWrapper(cache_payload.get_image()), + mimetype="image/png", + direct_passthrough=True, ) @expose("//cache_dashboard_screenshot/", methods=("POST",)) @@ -1150,7 +1151,9 @@ def thumbnail(self, pk: int, digest: str, **kwargs: Any) -> WerkzeugResponse: f".cache_dashboard_screenshot", log_to_statsd=False, ) - def cache_dashboard_screenshot(self, pk: int, **kwargs: Any) -> WerkzeugResponse: + def cache_dashboard_screenshot( + self, pk: int, force: bool, **kwargs: Any + ) -> WerkzeugResponse: """Compute and cache a screenshot. --- post: @@ -1185,7 +1188,6 @@ def cache_dashboard_screenshot(self, pk: int, **kwargs: Any) -> WerkzeugResponse 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() @@ -1210,10 +1212,17 @@ def cache_dashboard_screenshot(self, pk: int, **kwargs: Any) -> WerkzeugResponse 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, dashboard_state) + cache_key = screenshot_obj.get_cache_key( + window_size, thumb_size, dashboard_state + ) image_url = get_url_path( "DashboardRestApi.screenshot", pk=dashboard.id, digest=cache_key ) + force = kwargs["rison"].get("force", False) + cache_payload = screenshot_obj.get_from_cache_key(cache_key) + if force or cache_payload is None: + cache_payload = ScreenshotCachePayload() + screenshot_obj.cache.set(cache_key, cache_payload) def trigger_celery() -> WerkzeugResponse: logger.info("Triggering screenshot ASYNC") @@ -1226,8 +1235,6 @@ def trigger_celery() -> WerkzeugResponse: ), dashboard_id=dashboard.id, dashboard_url=dashboard_url, - cache_key=cache_key, - force=False, thumb_size=thumb_size, window_size=window_size, ) @@ -1236,6 +1243,8 @@ def trigger_celery() -> WerkzeugResponse: cache_key=cache_key, dashboard_url=dashboard_url, image_url=image_url, + updated_at=cache_payload.get_timestamp(), + update_status=cache_payload.get_status(), ) return trigger_celery() @@ -1294,9 +1303,12 @@ def screenshot(self, pk: int, digest: str) -> WerkzeugResponse: # fetch the dashboard screenshot using the current user and cache if set - if img := DashboardScreenshot.get_from_cache_key(thumbnail_cache, digest): + if cache_payload := DashboardScreenshot.get_from_cache_key(digest): + image = cache_payload.get_image() + if not image: + return self.response_404() if download_format == "pdf": - pdf_img = img.getvalue() + pdf_img = image.getvalue() # Convert the screenshot to PDF pdf_data = build_pdf_from_screenshots([pdf_img]) @@ -1308,7 +1320,7 @@ def screenshot(self, pk: int, digest: str) -> WerkzeugResponse: ) if download_format == "png": return Response( - FileWrapper(img), + FileWrapper(image), mimetype="image/png", direct_passthrough=True, ) diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index 1295f0b206538..9230c9e68ea46 100644 --- a/superset/dashboards/schemas.py +++ b/superset/dashboards/schemas.py @@ -507,6 +507,12 @@ class DashboardCacheScreenshotResponseSchema(Schema): image_url = fields.String( metadata={"description": "The url to fetch the screenshot"} ) + update_status = fields.String( + metadata={"description": "The status of the async screenshot"} + ) + updated_at = fields.String( + metadata={"description": "The timestamp of the last change in status"} + ) class CacheScreenshotSchema(Schema): diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py index 3af3a63ab7668..d2b672f3e8b24 100644 --- a/superset/models/dashboard.py +++ b/superset/models/dashboard.py @@ -350,7 +350,6 @@ def update_thumbnail(self) -> None: cache_dashboard_thumbnail.delay( current_user=get_current_user(), dashboard_id=self.id, - force=True, ) @classmethod diff --git a/superset/models/slice.py b/superset/models/slice.py index 1e6daa8321112..9a208bbff6725 100644 --- a/superset/models/slice.py +++ b/superset/models/slice.py @@ -379,7 +379,6 @@ def event_after_chart_changed( cache_chart_thumbnail.delay( current_user=get_current_user(), chart_id=target.id, - force=True, ) diff --git a/superset/tasks/thumbnails.py b/superset/tasks/thumbnails.py index dd9b5065dce34..061c551666c4a 100644 --- a/superset/tasks/thumbnails.py +++ b/superset/tasks/thumbnails.py @@ -38,7 +38,6 @@ def cache_chart_thumbnail( current_user: Optional[str], chart_id: int, - force: bool = False, window_size: Optional[WindowSize] = None, thumb_size: Optional[WindowSize] = None, ) -> None: @@ -64,8 +63,6 @@ def cache_chart_thumbnail( screenshot = ChartScreenshot(url, chart.digest) screenshot.compute_and_cache( user=user, - cache=thumbnail_cache, - force=force, window_size=window_size, thumb_size=thumb_size, ) @@ -76,7 +73,6 @@ def cache_chart_thumbnail( def cache_dashboard_thumbnail( current_user: Optional[str], dashboard_id: int, - force: bool = False, thumb_size: Optional[WindowSize] = None, window_size: Optional[WindowSize] = None, ) -> None: @@ -101,8 +97,6 @@ def cache_dashboard_thumbnail( screenshot = DashboardScreenshot(url, dashboard.digest) screenshot.compute_and_cache( user=user, - cache=thumbnail_cache, - force=force, window_size=window_size, thumb_size=thumb_size, ) @@ -145,8 +139,6 @@ def cache_dashboard_screenshot( # pylint: disable=too-many-arguments screenshot = DashboardScreenshot(dashboard_url, dashboard.digest) screenshot.compute_and_cache( user=current_user, - cache=thumbnail_cache, - force=force, window_size=window_size, thumb_size=thumb_size, cache_key=cache_key, diff --git a/superset/utils/screenshots.py b/superset/utils/screenshots.py index 96c0f40d6da51..8cb7a9afae737 100644 --- a/superset/utils/screenshots.py +++ b/superset/utils/screenshots.py @@ -17,12 +17,14 @@ from __future__ import annotations import logging +from datetime import datetime +from enum import Enum from io import BytesIO -from typing import TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from flask import current_app -from superset import feature_flag_manager +from superset import feature_flag_manager, thumbnail_cache from superset.dashboards.permalink.types import DashboardPermalinkState from superset.extensions import event_logger from superset.utils.hashing import md5_sha_from_dict @@ -54,12 +56,58 @@ from flask_caching import Cache +class StatusValues(Enum): + PENDING = "Pending" + COMPUTING = "Computing" + UPDATED = "Updated" + ERROR = "Error" + + +class ScreenshotCachePayload: + def __init__(self, image: Optional[bytes] = None): + self._image = image + self._timestamp = datetime.now().strftime("%Y/%m/%d-%H:%M:%S") + self.status = StatusValues.PENDING + if image: + self.status = StatusValues.UPDATED + + def update_timestamp(self) -> None: + self._timestamp = datetime.now().strftime("%Y/%m/%d-%H:%M:%S") + + def computing(self) -> None: + self.update_timestamp() + self.status = StatusValues.COMPUTING + + def update(self, image: bytes) -> None: + self.update_timestamp() + self.status = StatusValues.UPDATED + self._image = image + + def error( + self, + ) -> None: + self.update_timestamp() + self.status = StatusValues.ERROR + + def get_image(self) -> BytesIO | None: + if not self._image: + return None + return BytesIO(self._image) + + def get_timestamp(self) -> str: + return self._timestamp + + def get_status(self) -> str: + return self.status.value + + class BaseScreenshot: driver_type = current_app.config["WEBDRIVER_TYPE"] thumbnail_type: str = "" element: str = "" window_size: WindowSize = DEFAULT_SCREENSHOT_WINDOW_SIZE thumb_size: WindowSize = DEFAULT_SCREENSHOT_THUMBNAIL_SIZE + cache: Cache = thumbnail_cache def __init__(self, url: str, digest: str): self.digest: str = digest @@ -72,7 +120,14 @@ def driver(self, window_size: WindowSize | None = None) -> WebDriver: return WebDriverPlaywright(self.driver_type, window_size) return WebDriverSelenium(self.driver_type, window_size) - def cache_key( + def get_screenshot( + self, user: User, window_size: WindowSize | None = None + ) -> bytes | None: + driver = self.driver(window_size) + self.screenshot = driver.get_screenshot(self.url, self.element, user) + return self.screenshot + + def get_cache_key( self, window_size: bool | WindowSize | None = None, thumb_size: bool | WindowSize | None = None, @@ -88,55 +143,22 @@ def cache_key( } return md5_sha_from_dict(args) - def get_screenshot( - self, user: User, window_size: WindowSize | None = None - ) -> bytes | None: - driver = self.driver(window_size) - with event_logger.log_context("screenshot", screenshot_url=self.url): - self.screenshot = driver.get_screenshot(self.url, self.element, user) - return self.screenshot - - def get( - self, - user: User = None, - cache: Cache = None, - thumb_size: WindowSize | None = None, - ) -> BytesIO | None: - """ - Get thumbnail screenshot has BytesIO from cache or fetch - - :param user: None to use current user or User Model to login and fetch - :param cache: The cache to use - :param thumb_size: Override thumbnail site - """ - payload: bytes | None = None - cache_key = self.cache_key(self.window_size, thumb_size) - if cache: - payload = cache.get(cache_key) - if not payload: - payload = self.compute_and_cache( - user=user, thumb_size=thumb_size, cache=cache - ) - else: - logger.info("Loaded thumbnail from cache: %s", cache_key) - if payload: - return BytesIO(payload) - return None - def get_from_cache( self, - cache: Cache, window_size: WindowSize | None = None, thumb_size: WindowSize | None = None, - ) -> BytesIO | None: - cache_key = self.cache_key(window_size, thumb_size) - return self.get_from_cache_key(cache, cache_key) + ) -> ScreenshotCachePayload | None: + cache_key = self.get_cache_key(window_size, thumb_size) + return self.get_from_cache_key(cache_key) - @staticmethod - def get_from_cache_key(cache: Cache, cache_key: str) -> BytesIO | None: + @classmethod + def get_from_cache_key(cls, cache_key: str) -> ScreenshotCachePayload | None: logger.info("Attempting to get from cache: %s", cache_key) - if payload := cache.get(cache_key): - return BytesIO(payload) + if payload := cls.cache.get(cache_key): + # for backwards compatability, byte objects should be converted + if not isinstance(payload, ScreenshotCachePayload): + payload = ScreenshotCachePayload(payload) + return payload logger.info("Failed at getting from cache: %s", cache_key) return None @@ -145,12 +167,10 @@ def compute_and_cache( # pylint: disable=too-many-arguments user: User = None, window_size: WindowSize | None = None, thumb_size: WindowSize | None = None, - cache: Cache = None, - force: bool = True, cache_key: str | None = None, - ) -> bytes | None: + ) -> None: """ - Fetches the screenshot, computes the thumbnail and caches the result + Computes the thumbnail and caches the result :param user: If no user is given will use the current context :param cache: The cache to keep the thumbnail payload @@ -159,40 +179,43 @@ def compute_and_cache( # pylint: disable=too-many-arguments :param force: Will force the computation even if it's already cached :return: Image payload """ - cache_key = cache_key or self.cache_key(window_size, thumb_size) + cache_key = cache_key or self.get_cache_key(window_size, thumb_size) + cache_payload = self.get_from_cache_key(cache_key) or ScreenshotCachePayload() + if cache_payload and cache_payload.status != StatusValues.PENDING: + logger.info( + "Skipping compute - already in progress for thumbnail: %s", cache_key + ) + return + window_size = window_size or self.window_size thumb_size = thumb_size or self.thumb_size - if not force and cache and cache.get(cache_key): - logger.info("Thumb already cached, skipping...") - return None logger.info("Processing url for thumbnail: %s", cache_key) - - payload = None - + cache_payload.computing() + self.cache.set(cache_key, cache_payload) + image = None # Assuming all sorts of things can go wrong with Selenium try: - with event_logger.log_context( - f"screenshot.compute.{self.thumbnail_type}", force=force - ): - payload = self.get_screenshot(user=user, window_size=window_size) + with event_logger.log_context(f"screenshot.compute.{self.thumbnail_type}"): + image = self.get_screenshot(user=user, window_size=window_size) except Exception as ex: # pylint: disable=broad-except logger.warning("Failed at generating thumbnail %s", ex, exc_info=True) + cache_payload.error() - if payload and window_size != thumb_size: + if image and window_size != thumb_size: try: - payload = self.resize_image(payload, thumb_size=thumb_size) + image = self.resize_image(image, thumb_size=thumb_size) except Exception as ex: # pylint: disable=broad-except logger.warning("Failed at resizing thumbnail %s", ex, exc_info=True) - payload = None + cache_payload.error() + image = None - if payload: + if image: logger.info("Caching thumbnail: %s", cache_key) - with event_logger.log_context( - f"screenshot.cache.{self.thumbnail_type}", force=force - ): - cache.set(cache_key, payload) - logger.info("Done caching thumbnail") - return payload + with event_logger.log_context(f"screenshot.cache.{self.thumbnail_type}"): + cache_payload.update(image) + self.cache.set(cache_key, cache_payload) + logger.info("Done caching thumbnail") + return @classmethod def resize_image( @@ -262,7 +285,7 @@ def __init__( self.window_size = window_size or DEFAULT_DASHBOARD_WINDOW_SIZE self.thumb_size = thumb_size or DEFAULT_DASHBOARD_THUMBNAIL_SIZE - def cache_key( + def get_cache_key( self, window_size: bool | WindowSize | None = None, thumb_size: bool | WindowSize | None = None, diff --git a/tests/integration_tests/cli_tests.py b/tests/integration_tests/cli_tests.py index 048612a08a7bb..45f90002ff273 100644 --- a/tests/integration_tests/cli_tests.py +++ b/tests/integration_tests/cli_tests.py @@ -322,6 +322,5 @@ def test_compute_thumbnails(thumbnail_mock, app_context, fs): thumbnail_mock.assert_called_with( None, dashboard.id, - force=False, ) assert response.exit_code == 0