This is an automated email from the ASF dual-hosted git repository.
jli pushed a commit to branch 4.1
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/4.1 by this push:
new 3b736925e6 fix(Dashboard): Sync color configuration via dedicated
endpoint (#31374)
3b736925e6 is described below
commit 3b736925e6a27d41895485d70786e99b65e2226c
Author: Geido <[email protected]>
AuthorDate: Fri Dec 13 15:58:02 2024 +0200
fix(Dashboard): Sync color configuration via dedicated endpoint (#31374)
(cherry picked from commit e1f98e246f1636e4791a37694142c39e4599589b)
---
.../src/dashboard/actions/dashboardState.js | 251 ++++++++++++---------
.../dashboard/components/PropertiesModal/index.tsx | 4 +-
superset-frontend/src/utils/colorScheme.ts | 55 +++--
superset/commands/dashboard/exceptions.py | 4 +
superset/commands/dashboard/update.py | 29 ++-
superset/constants.py | 1 +
superset/daos/dashboard.py | 18 ++
superset/dashboards/api.py | 98 +++++++-
superset/dashboards/schemas.py | 9 +
tests/integration_tests/dashboards/api_tests.py | 111 +++++++++
10 files changed, 450 insertions(+), 130 deletions(-)
diff --git a/superset-frontend/src/dashboard/actions/dashboardState.js
b/superset-frontend/src/dashboard/actions/dashboardState.js
index 6f46ffe60e..2e160f652b 100644
--- a/superset-frontend/src/dashboard/actions/dashboardState.js
+++ b/superset-frontend/src/dashboard/actions/dashboardState.js
@@ -73,8 +73,9 @@ import {
isLabelsColorMapSynced,
getColorSchemeDomain,
getColorNamespace,
- getLabelsColorMapEntries,
+ getFreshLabelsColorMapEntries,
getFreshSharedLabels,
+ getDynamicLabelsColors,
} from '../../utils/colorScheme';
export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
@@ -253,15 +254,18 @@ export function setDashboardSharedLabelsColorsSynced() {
return { type: SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCED };
}
-export const setDashboardMetadata = updatedMetadata => async dispatch => {
- dispatch(
- dashboardInfoChanged({
- metadata: {
- ...updatedMetadata,
- },
- }),
- );
-};
+export const setDashboardMetadata =
+ updatedMetadata => async (dispatch, getState) => {
+ const { dashboardInfo } = getState();
+ dispatch(
+ dashboardInfoChanged({
+ metadata: {
+ ...(dashboardInfo?.metadata || {}),
+ ...updatedMetadata,
+ },
+ }),
+ );
+ };
export function saveDashboardRequest(data, id, saveType) {
return (dispatch, getState) => {
@@ -320,7 +324,7 @@ export function saveDashboardRequest(data, id, saveType) {
expanded_slices: data.metadata?.expanded_slices || {},
label_colors: customLabelsColor,
shared_label_colors: getFreshSharedLabels(sharedLabelsColor),
- map_label_colors: getLabelsColorMapEntries(customLabelsColor),
+ map_label_colors: getFreshLabelsColorMapEntries(customLabelsColor),
refresh_frequency: data.metadata?.refresh_frequency || 0,
timed_refresh_immune_slices:
data.metadata?.timed_refresh_immune_slices || [],
@@ -719,11 +723,18 @@ export function setDatasetsStatus(status) {
};
}
-const storeDashboardMetadata = async (id, metadata) =>
+const storeDashboardColorConfig = async (id, metadata) =>
SupersetClient.put({
- endpoint: `/api/v1/dashboard/${id}`,
+ endpoint: `/api/v1/dashboard/${id}/colors?mark_updated=false`,
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ json_metadata: JSON.stringify(metadata) }),
+ body: JSON.stringify({
+ color_namespace: metadata.color_namespace,
+ color_scheme: metadata.color_scheme,
+ color_scheme_domain: metadata.color_scheme_domain || [],
+ shared_label_colors: metadata.shared_label_colors || [],
+ map_label_colors: metadata.map_label_colors || {},
+ label_colors: metadata.label_colors || {},
+ }),
});
/**
@@ -742,7 +753,7 @@ export const persistDashboardLabelsColor = () => async
(dispatch, getState) => {
if (labelsColorMapMustSync || sharedLabelsColorsMustSync) {
dispatch(setDashboardLabelsColorMapSynced());
dispatch(setDashboardSharedLabelsColorsSynced());
- storeDashboardMetadata(id, metadata);
+ storeDashboardColorConfig(id, metadata);
}
};
@@ -756,7 +767,6 @@ export const persistDashboardLabelsColor = () => async
(dispatch, getState) => {
*/
export const applyDashboardLabelsColorOnLoad = metadata => async dispatch => {
try {
- const updatedMetadata = { ...metadata };
const customLabelsColor = metadata.label_colors || {};
let hasChanged = false;
@@ -764,11 +774,14 @@ export const applyDashboardLabelsColorOnLoad = metadata
=> async dispatch => {
const sharedLabels = metadata.shared_label_colors || [];
if (!Array.isArray(sharedLabels) && Object.keys(sharedLabels).length > 0) {
hasChanged = true;
- updatedMetadata.shared_label_colors = [];
+ dispatch(
+ setDashboardMetadata({
+ shared_label_colors: [],
+ }),
+ );
}
// backward compatibility of map_label_colors
- const hasMapLabelColors =
- Object.keys(metadata.map_label_colors || {}).length > 0;
+ const hasMapLabelColors = !!metadata.map_label_colors;
let updatedScheme = metadata.color_scheme;
const categoricalSchemes = getCategoricalSchemeRegistry();
@@ -780,11 +793,14 @@ export const applyDashboardLabelsColorOnLoad = metadata
=> async dispatch => {
const defaultScheme = categoricalSchemes.defaultKey;
const fallbackScheme = defaultScheme?.toString() || 'supersetColors';
hasChanged = true;
-
updatedScheme = fallbackScheme;
- updatedMetadata.color_scheme = updatedScheme;
dispatch(setColorScheme(updatedScheme));
+ dispatch(
+ setDashboardMetadata({
+ color_scheme: updatedScheme,
+ }),
+ );
}
// the stored color domain registry and fresh might differ at this point
@@ -795,24 +811,28 @@ export const applyDashboardLabelsColorOnLoad = metadata
=> async dispatch => {
if (!isEqual(freshColorSchemeDomain, currentColorSchemeDomain)) {
hasChanged = true;
- updatedMetadata.color_scheme_domain = freshColorSchemeDomain;
+ dispatch(
+ setDashboardMetadata({
+ color_scheme_domain: freshColorSchemeDomain,
+ }),
+ );
}
// if color scheme is invalid or map is missing, apply a fresh color map
// if valid, apply the stored map to keep consistency across refreshes
const shouldGoFresh = !hasMapLabelColors || hasInvalidColorScheme;
- applyColors(updatedMetadata, shouldGoFresh);
+ applyColors(metadata, shouldGoFresh);
if (shouldGoFresh) {
- // a fresh color map has been applied
- // needs to be stored for consistency
hasChanged = true;
- updatedMetadata.map_label_colors =
- getLabelsColorMapEntries(customLabelsColor);
+ dispatch(
+ setDashboardMetadata({
+ map_label_colors: getFreshLabelsColorMapEntries(customLabelsColor),
+ }),
+ );
}
if (hasChanged) {
- dispatch(setDashboardMetadata(updatedMetadata));
dispatch(setDashboardLabelsColorMapSync());
}
} catch (e) {
@@ -832,19 +852,28 @@ export const ensureSyncedLabelsColorMap = metadata =>
(dispatch, getState) => {
const {
dashboardState: { labelsColorMapMustSync },
} = getState();
- const updatedMetadata = { ...metadata };
const customLabelsColor = metadata.label_colors || {};
- const isMapSynced = isLabelsColorMapSynced(metadata);
- const mustSync = !isMapSynced;
-
- if (mustSync) {
- const freshestColorMapEntries =
- getLabelsColorMapEntries(customLabelsColor);
- updatedMetadata.map_label_colors = freshestColorMapEntries;
- dispatch(setDashboardMetadata(updatedMetadata));
+ const fullLabelsColors = getDynamicLabelsColors(
+ metadata.map_label_colors || {},
+ customLabelsColor,
+ );
+ const freshColorMapEntries =
+ getFreshLabelsColorMapEntries(customLabelsColor);
+ const isMapSynced = isLabelsColorMapSynced(
+ fullLabelsColors,
+ freshColorMapEntries,
+ customLabelsColor,
+ );
+
+ if (!isMapSynced) {
+ dispatch(
+ setDashboardMetadata({
+ map_label_colors: freshColorMapEntries,
+ }),
+ );
}
- if (mustSync && !labelsColorMapMustSync) {
+ if (!isMapSynced && !labelsColorMapMustSync) {
// prepare to persist the just applied labels color map
dispatch(setDashboardLabelsColorMapSync());
}
@@ -867,7 +896,6 @@ export const ensureSyncedSharedLabelsColors =
const {
dashboardState: { sharedLabelsColorsMustSync },
} = getState();
- const updatedMetadata = { ...metadata };
const sharedLabelsColors = enforceSharedLabelsColorsArray(
metadata.shared_label_colors,
);
@@ -875,15 +903,17 @@ export const ensureSyncedSharedLabelsColors =
forceFresh ? [] : sharedLabelsColors,
);
const isSharedLabelsColorsSynced = isEqual(
- sharedLabelsColors,
- freshLabelsColors,
+ sharedLabelsColors.sort(),
+ freshLabelsColors.sort(),
);
-
const mustSync = !isSharedLabelsColorsSynced;
if (mustSync) {
- updatedMetadata.shared_label_colors = freshLabelsColors;
- dispatch(setDashboardMetadata(updatedMetadata));
+ dispatch(
+ setDashboardMetadata({
+ shared_label_colors: freshLabelsColors,
+ }),
+ );
}
if (mustSync && !sharedLabelsColorsMustSync) {
@@ -901,77 +931,78 @@ export const ensureSyncedSharedLabelsColors =
* @param {*} renderedChartIds - the charts that have finished rendering
* @returns void
*/
-export const updateDashboardLabelsColor =
- renderedChartIds => (dispatch, getState) => {
- try {
- const {
- dashboardInfo: { metadata },
- charts,
- } = getState();
- const colorScheme = metadata.color_scheme;
- const labelsColorMapInstance = getLabelsColorMap();
- const fullLabelsColors = metadata.map_label_colors || {};
- const sharedLabelsColors = enforceSharedLabelsColorsArray(
- metadata.shared_label_colors,
- );
- const customLabelsColors = metadata.label_colors || {};
-
- // for dashboards with no color scheme, the charts should always use
their individual schemes
- // this logic looks for unique labels (not shared across multiple
charts) of each rendered chart
- // it applies a new color to those unique labels when the applied scheme
is not up to date
- // while leaving shared label colors and custom label colors intact for
color consistency
- const shouldReset = [];
- if (renderedChartIds.length > 0) {
- const sharedLabelsSet = new Set(sharedLabelsColors);
- renderedChartIds.forEach(id => {
- const chart = charts[id];
- const formData = chart.form_data || chart.latestQueryFormData;
- // ensure charts have their original color scheme always available
- labelsColorMapInstance.setOwnColorScheme(
- formData.slice_id,
- formData.color_scheme,
- );
+export const updateDashboardLabelsColor = renderedChartIds => (_, getState) =>
{
+ try {
+ const {
+ dashboardInfo: { metadata },
+ charts,
+ } = getState();
+ const colorScheme = metadata.color_scheme;
+ const labelsColorMapInstance = getLabelsColorMap();
+ const sharedLabelsColors = enforceSharedLabelsColorsArray(
+ metadata.shared_label_colors,
+ );
+ const customLabelsColors = metadata.label_colors || {};
+ const fullLabelsColors = getDynamicLabelsColors(
+ metadata.map_label_colors || {},
+ customLabelsColors,
+ );
- // if dashboard has a scheme, charts should ignore individual schemes
- // thus following logic is inapplicable if a dashboard color scheme
exists
- if (colorScheme) return;
+ // for dashboards with no color scheme, the charts should always use their
individual schemes
+ // this logic looks for unique labels (not shared across multiple charts)
of each rendered chart
+ // it applies a new color to those unique labels when the applied scheme
is not up to date
+ // while leaving shared label colors and custom label colors intact for
color consistency
+ const shouldReset = [];
+ if (renderedChartIds.length > 0) {
+ const sharedLabelsSet = new Set(sharedLabelsColors);
+ renderedChartIds.forEach(id => {
+ const chart = charts[id];
+ const formData = chart.form_data || chart.latestQueryFormData;
+ // ensure charts have their original color scheme always available
+ labelsColorMapInstance.setOwnColorScheme(
+ formData.slice_id,
+ formData.color_scheme,
+ );
- const chartColorScheme = formData.color_scheme;
- const currentChartConfig =
labelsColorMapInstance.chartsLabelsMap.get(
- formData.slice_id,
- );
- const currentChartLabels = currentChartConfig?.labels || [];
- const uniqueChartLabels = currentChartLabels.filter(
- l =>
- !sharedLabelsSet.has(l) && !customLabelsColors.hasOwnProperty(l),
- );
+ // if dashboard has a scheme, charts should ignore individual schemes
+ // thus following logic is inapplicable if a dashboard color scheme
exists
+ if (colorScheme) return;
- // Map unique labels to colors
- const uniqueChartLabelsColor = new Set(
- uniqueChartLabels.map(l => fullLabelsColors[l]).filter(Boolean),
- );
+ const chartColorScheme = formData.color_scheme;
+ const currentChartConfig = labelsColorMapInstance.chartsLabelsMap.get(
+ formData.slice_id,
+ );
+ const currentChartLabels = currentChartConfig?.labels || [];
+ const uniqueChartLabels = currentChartLabels.filter(
+ l => !sharedLabelsSet.has(l) &&
!customLabelsColors.hasOwnProperty(l),
+ );
- const expectedColorsForChartScheme = new Set(
- getColorSchemeDomain(chartColorScheme),
- );
+ // Map unique labels to colors
+ const uniqueChartLabelsColor = new Set(
+ uniqueChartLabels.map(l => fullLabelsColors[l]).filter(Boolean),
+ );
- // Check if any unique label color is not in the expected colors set
- const shouldResetColors = [...uniqueChartLabelsColor].some(
- color => !expectedColorsForChartScheme.has(color),
- );
+ const expectedColorsForChartScheme = new Set(
+ getColorSchemeDomain(chartColorScheme),
+ );
- // Only push uniqueChartLabels if they require resetting
- if (shouldResetColors) shouldReset.push(...uniqueChartLabels);
- });
- }
+ // Check if any unique label color is not in the expected colors set
+ const shouldResetColors = [...uniqueChartLabelsColor].some(
+ color => !expectedColorsForChartScheme.has(color),
+ );
- // an existing map is available, use mrge option
- // to only apply colors to newly found labels
- const shouldGoFresh = shouldReset.length > 0 ? shouldReset : false;
- const shouldMerge = !shouldGoFresh;
- // re-apply the color map first to get fresh maps accordingly
- applyColors(metadata, shouldGoFresh, shouldMerge);
- } catch (e) {
- console.error('Failed to update colors for new charts and labels:', e);
+ // Only push uniqueChartLabels if they require resetting
+ if (shouldResetColors) shouldReset.push(...uniqueChartLabels);
+ });
}
- };
+
+ // an existing map is available, use mrge option
+ // to only apply colors to newly found labels
+ const shouldGoFresh = shouldReset.length > 0 ? shouldReset : false;
+ const shouldMerge = !shouldGoFresh;
+ // re-apply the color map first to get fresh maps accordingly
+ applyColors(metadata, shouldGoFresh, shouldMerge);
+ } catch (e) {
+ console.error('Failed to update colors for new charts and labels:', e);
+ }
+};
diff --git
a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
index 998e8540dc..eff3116cd5 100644
--- a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
+++ b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
@@ -47,7 +47,7 @@ import { loadTags } from 'src/components/Tags/utils';
import {
applyColors,
getColorNamespace,
- getLabelsColorMapEntries,
+ getFreshLabelsColorMapEntries,
} from 'src/utils/colorScheme';
import getOwnerName from 'src/utils/getOwnerName';
import Owner from 'src/types/Owner';
@@ -369,7 +369,7 @@ const PropertiesModal = ({
dispatch(
setDashboardMetadata({
...updatedDashboardMetadata,
- map_label_colors: getLabelsColorMapEntries(customLabelColors),
+ map_label_colors: getFreshLabelsColorMapEntries(customLabelColors),
}),
);
diff --git a/superset-frontend/src/utils/colorScheme.ts
b/superset-frontend/src/utils/colorScheme.ts
index 1b9f2d12ec..4411fba5e4 100644
--- a/superset-frontend/src/utils/colorScheme.ts
+++ b/superset-frontend/src/utils/colorScheme.ts
@@ -22,6 +22,8 @@ import {
getCategoricalSchemeRegistry,
getLabelsColorMap,
} from '@superset-ui/core';
+import { intersection, omit, pick } from 'lodash';
+import { areObjectsEqual } from 'src/reduxUtils';
/**
* Force falsy namespace values to undefined to default to GLOBAL
@@ -86,14 +88,13 @@ export const getSharedLabelsColorMapEntries = (
* @param customLabelsColor - the custom label colors in label_colors field
* @returns all color entries except custom label colors
*/
-export const getLabelsColorMapEntries = (
+export const getFreshLabelsColorMapEntries = (
customLabelsColor: Record<string, string> = {},
): Record<string, string> => {
const labelsColorMapInstance = getLabelsColorMap();
const allEntries = Object.fromEntries(labelsColorMapInstance.getColorMap());
// custom label colors are applied and stored separetely via label_colors
- // removing all instances of custom label colors from the entries
Object.keys(customLabelsColor).forEach(label => {
delete allEntries[label];
});
@@ -101,6 +102,19 @@ export const getLabelsColorMapEntries = (
return allEntries;
};
+/**
+ * Returns all dynamic labels and colors (excluding custom label colors).
+ *
+ * @param labelsColorMap - the labels color map
+ * @param customLabelsColor - the custom label colors in label_colors field
+ * @returns all color entries except custom label colors
+ */
+export const getDynamicLabelsColors = (
+ fullLabelsColors: Record<string, string>,
+ customLabelsColor: Record<string, string> = {},
+): Record<string, string> =>
+ omit(fullLabelsColors, Object.keys(customLabelsColor));
+
export const getColorSchemeDomain = (colorScheme: string) =>
getCategoricalSchemeRegistry().get(colorScheme)?.colors || [];
@@ -111,20 +125,29 @@ export const getColorSchemeDomain = (colorScheme: string)
=>
* @returns true if the labels color map is the same as fresh
*/
export const isLabelsColorMapSynced = (
- metadata: Record<string, any>,
+ storedLabelsColors: Record<string, any>,
+ freshLabelsColors: Record<string, any>,
+ customLabelColors: Record<string, string>,
): boolean => {
- const storedLabelsColorMap = metadata.map_label_colors || {};
- const customLabelColors = metadata.label_colors || {};
- const freshColorMap = getLabelsColorMap().getColorMap();
- const fullFreshColorMap = {
- ...Object.fromEntries(freshColorMap),
- ...customLabelColors,
- };
+ const freshLabelsCount = Object.keys(freshLabelsColors).length;
+
+ // still updating, pass
+ if (!freshLabelsCount) return true;
+
+ const commonKeys = intersection(
+ Object.keys(storedLabelsColors),
+ Object.keys(freshLabelsColors),
+ );
+
+ const comparableStoredLabelsColors = pick(storedLabelsColors, commonKeys);
+ const comparableFreshLabelsColors = pick(freshLabelsColors, commonKeys);
- const isSynced = Object.entries(fullFreshColorMap).every(
- ([label, color]) =>
- storedLabelsColorMap.hasOwnProperty(label) &&
- storedLabelsColorMap[label] === color,
+ const isSynced = areObjectsEqual(
+ comparableStoredLabelsColors,
+ comparableFreshLabelsColors,
+ {
+ ignoreFields: Object.keys(customLabelColors),
+ },
);
return isSynced;
@@ -222,7 +245,7 @@ export const applyColors = (
if (fresh) {
// requires a new map all together
applicableColorMapEntries = {
- ...getLabelsColorMapEntries(customLabelsColor),
+ ...getFreshLabelsColorMapEntries(customLabelsColor),
};
}
if (merge) {
@@ -230,7 +253,7 @@ export const applyColors = (
// without overriding existing ones
applicableColorMapEntries = {
...fullLabelsColor,
- ...getLabelsColorMapEntries(customLabelsColor),
+ ...getFreshLabelsColorMapEntries(customLabelsColor),
};
}
diff --git a/superset/commands/dashboard/exceptions.py
b/superset/commands/dashboard/exceptions.py
index 9281119b32..07f580d1c9 100644
--- a/superset/commands/dashboard/exceptions.py
+++ b/superset/commands/dashboard/exceptions.py
@@ -58,6 +58,10 @@ class DashboardUpdateFailedError(UpdateFailedError):
message = _("Dashboard could not be updated.")
+class DashboardColorsConfigUpdateFailedError(UpdateFailedError):
+ message = _("Dashboard color configuration could not be updated.")
+
+
class DashboardDeleteFailedError(DeleteFailedError):
message = _("Dashboard could not be deleted.")
diff --git a/superset/commands/dashboard/update.py
b/superset/commands/dashboard/update.py
index 2effd7bd2e..c78db7e1d6 100644
--- a/superset/commands/dashboard/update.py
+++ b/superset/commands/dashboard/update.py
@@ -21,9 +21,10 @@ from typing import Any, Optional
from flask_appbuilder.models.sqla import Model
from marshmallow import ValidationError
-from superset import security_manager
+from superset import db, security_manager
from superset.commands.base import BaseCommand, UpdateMixin
from superset.commands.dashboard.exceptions import (
+ DashboardColorsConfigUpdateFailedError,
DashboardForbiddenError,
DashboardInvalidError,
DashboardNotFoundError,
@@ -112,3 +113,29 @@ class UpdateDashboardCommand(UpdateMixin, BaseCommand):
exceptions.append(ex)
if exceptions:
raise DashboardInvalidError(exceptions=exceptions)
+
+
+class UpdateDashboardColorsConfigCommand(UpdateDashboardCommand):
+ def __init__(
+ self, model_id: int, data: dict[str, Any], mark_updated: bool = True
+ ) -> None:
+ super().__init__(model_id, data)
+ self._mark_updated = mark_updated
+
+ @transaction(
+ on_error=partial(on_error,
reraise=DashboardColorsConfigUpdateFailedError)
+ )
+ def run(self) -> Model:
+ super().validate()
+ assert self._model
+
+ original_changed_on = self._model.changed_on
+
+ DashboardDAO.update_colors_config(self._model, self._properties)
+
+ if not self._mark_updated:
+ db.session.commit() # pylint: disable=consider-using-transaction
+ # restore the original changed_on value
+ self._model.changed_on = original_changed_on
+
+ return self._model
diff --git a/superset/constants.py b/superset/constants.py
index d233f271c6..9f81eaf397 100644
--- a/superset/constants.py
+++ b/superset/constants.py
@@ -173,6 +173,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
"columnar_metadata": "columnar_upload",
"csv_metadata": "csv_upload",
"slack_channels": "write",
+ "put_colors": "write",
}
EXTRA_FORM_DATA_APPEND_KEYS = {
diff --git a/superset/daos/dashboard.py b/superset/daos/dashboard.py
index d5cc08582c..894c4ee652 100644
--- a/superset/daos/dashboard.py
+++ b/superset/daos/dashboard.py
@@ -319,6 +319,24 @@ class DashboardDAO(BaseDAO[Dashboard]):
db.session.add(dash)
return dash
+ @classmethod
+ def update_colors_config(
+ cls, dashboard: Dashboard, attributes: dict[str, Any]
+ ) -> None:
+ metadata = json.loads(dashboard.json_metadata or "{}")
+
+ for key in [
+ "color_scheme_domain",
+ "color_scheme",
+ "shared_label_colors",
+ "map_label_colors",
+ "label_colors",
+ ]:
+ if key in attributes:
+ metadata[key] = attributes[key]
+
+ dashboard.json_metadata = json.dumps(metadata)
+
@staticmethod
def add_favorite(dashboard: Dashboard) -> None:
ids = DashboardDAO.favorited_ids([dashboard])
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 8b459fa945..6e8478f899 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -42,6 +42,7 @@ from superset.commands.dashboard.delete import (
)
from superset.commands.dashboard.exceptions import (
DashboardAccessDeniedError,
+ DashboardColorsConfigUpdateFailedError,
DashboardCopyError,
DashboardCreateFailedError,
DashboardDeleteFailedError,
@@ -55,7 +56,11 @@ from superset.commands.dashboard.fave import
AddFavoriteDashboardCommand
from superset.commands.dashboard.importers.dispatcher import
ImportDashboardsCommand
from superset.commands.dashboard.permalink.create import
CreateDashboardPermalinkCommand
from superset.commands.dashboard.unfave import DelFavoriteDashboardCommand
-from superset.commands.dashboard.update import UpdateDashboardCommand
+from superset.commands.dashboard.update import (
+ UpdateDashboardColorsConfigCommand,
+ UpdateDashboardCommand,
+)
+from superset.commands.database.exceptions import DatasetValidationError
from superset.commands.exceptions import TagForbiddenError
from superset.commands.importers.exceptions import NoValidFilesFoundError
from superset.commands.importers.v1.utils import get_contents_from_bundle
@@ -76,6 +81,7 @@ from superset.dashboards.permalink.types import
DashboardPermalinkState
from superset.dashboards.schemas import (
CacheScreenshotSchema,
DashboardCacheScreenshotResponseSchema,
+ DashboardColorsConfigUpdateSchema,
DashboardCopySchema,
DashboardDatasetSchema,
DashboardGetResponseSchema,
@@ -102,6 +108,7 @@ from superset.tasks.thumbnails import (
)
from superset.tasks.utils import get_current_user
from superset.utils import json
+from superset.utils.core import parse_boolean_string
from superset.utils.pdf import build_pdf_from_screenshots
from superset.utils.screenshots import (
DashboardScreenshot,
@@ -179,6 +186,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
"copy_dash",
"cache_dashboard_screenshot",
"screenshot",
+ "put_colors",
}
resource_name = "dashboard"
allow_browser_login = True
@@ -266,6 +274,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
add_model_schema = DashboardPostSchema()
edit_model_schema = DashboardPutSchema()
+ update_colors_model_schema = DashboardColorsConfigUpdateSchema()
chart_entity_response_schema = ChartEntityResponseSchema()
dashboard_get_response_schema = DashboardGetResponseSchema()
dashboard_dataset_schema = DashboardDatasetSchema()
@@ -687,6 +696,88 @@ class DashboardRestApi(BaseSupersetModelRestApi):
response = self.response_422(message=str(ex))
return response
+ @expose("/<pk>/colors", methods=("PUT",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs:
f"{self.__class__.__name__}.put_colors",
+ log_to_statsd=False,
+ )
+ @requires_json
+ def put_colors(self, pk: int) -> Response:
+ """
+ Modify colors configuration for a dashboard.
+ ---
+ put:
+ summary: Update colors configuration for a dashboard.
+ parameters:
+ - in: path
+ schema:
+ type: integer
+ name: pk
+ - in: query
+ name: mark_updated
+ schema:
+ type: boolean
+ description: Whether to update the dashboard changed_on field
+ requestBody:
+ description: Colors configuration
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref:
'#/components/schemas/DashboardColorsConfigUpdateSchema'
+ responses:
+ 200:
+ description: Dashboard colors updated
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ result:
+ type: array
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 403:
+ $ref: '#/components/responses/403'
+ 404:
+ $ref: '#/components/responses/404'
+ 422:
+ $ref: '#/components/responses/422'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ try:
+ item = self.update_colors_model_schema.load(request.json,
partial=True)
+ except ValidationError as error:
+ return self.response_400(message=error.messages)
+
+ try:
+ mark_updated = parse_boolean_string(
+ request.args.get("mark_updated", "true")
+ )
+ UpdateDashboardColorsConfigCommand(pk, item, mark_updated).run()
+ response = self.response(200)
+ except DashboardNotFoundError:
+ response = self.response_404()
+ except DashboardForbiddenError:
+ response = self.response_403()
+ except DashboardInvalidError as ex:
+ return self.response_422(message=ex.normalized_messages())
+ except DashboardColorsConfigUpdateFailedError as ex:
+ logger.error(
+ "Error changing color configuration for dashboard %s: %s",
+ self.__class__.__name__,
+ str(ex),
+ exc_info=True,
+ )
+ response = self.response_422(message=str(ex))
+ return response
+
@expose("/<pk>", methods=("DELETE",))
@protect()
@safe
@@ -1094,6 +1185,11 @@ class DashboardRestApi(BaseSupersetModelRestApi):
schema:
type: string
name: digest
+ - in: query
+ name: download_format
+ schema:
+ type: string
+ enum: [png, pdf]
responses:
200:
description: Dashboard thumbnail image
diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py
index c3c655e7e8..9c29025e7c 100644
--- a/superset/dashboards/schemas.py
+++ b/superset/dashboards/schemas.py
@@ -421,6 +421,15 @@ class DashboardPutSchema(BaseDashboardSchema):
)
+class DashboardColorsConfigUpdateSchema(BaseDashboardSchema):
+ color_namespace = fields.String(allow_none=True)
+ color_scheme = fields.String(allow_none=True)
+ map_label_colors = fields.Dict(allow_none=False)
+ shared_label_colors = SharedLabelsColorsField()
+ label_colors = fields.Dict(allow_none=False)
+ color_scheme_domain = fields.List(fields.String(), allow_none=False)
+
+
class DashboardScreenshotPostSchema(Schema):
dataMask = fields.Dict(
keys=fields.Str(),
diff --git a/tests/integration_tests/dashboards/api_tests.py
b/tests/integration_tests/dashboards/api_tests.py
index d78f2e4759..d4d06698e1 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -3005,3 +3005,114 @@ class TestDashboardApi(ApiOwnersTestCaseMixin,
InsertChartMixin, SupersetTestCas
response = self._cache_screenshot(dashboard.id)
assert response.status_code == 404
+
+ @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
+ def test_put_dashboard_colors(self):
+ """
+ Dashboard API: Test updating dashboard colors
+ """
+ self.login(ADMIN_USERNAME)
+ dashboard = Dashboard.get("world_health")
+
+ colors = {
+ "label_colors": {"Sales": "#FF0000", "Profit": "#00FF00"},
+ "shared_label_colors": ["#0000FF", "#FFFF00"],
+ "map_label_colors": {"Revenue": "#FFFFFF"},
+ "color_scheme": "d3Category10",
+ }
+
+ uri = f"api/v1/dashboard/{dashboard.id}/colors"
+ rv = self.client.put(uri, json=colors)
+ assert rv.status_code == 200
+
+ updated_dashboard = db.session.query(Dashboard).get(dashboard.id)
+ updated_label_colors = json.loads(updated_dashboard.json_metadata).get(
+ "label_colors"
+ )
+ updated_shared_label_colors =
json.loads(updated_dashboard.json_metadata).get(
+ "shared_label_colors"
+ )
+ updated_map_label_colors =
json.loads(updated_dashboard.json_metadata).get(
+ "map_label_colors"
+ )
+ updated_color_scheme = json.loads(updated_dashboard.json_metadata).get(
+ "color_scheme"
+ )
+
+ assert updated_label_colors == colors["label_colors"]
+ assert updated_shared_label_colors == colors["shared_label_colors"]
+ assert updated_map_label_colors == colors["map_label_colors"]
+ assert updated_color_scheme == colors["color_scheme"]
+
+ @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
+ def test_put_dashboard_colors_no_mark_updated(self):
+ """
+ Dashboard API: Test updating dashboard colors without marking the
dashboard as updated
+ """
+ self.login(ADMIN_USERNAME)
+ dashboard = Dashboard.get("world_health")
+
+ colors = {"color_scheme": "d3Category10"}
+
+ previous_changed_on = dashboard.changed_on
+ uri = f"api/v1/dashboard/{dashboard.id}/colors?mark_updated=false"
+ rv = self.client.put(uri, json=colors)
+ assert rv.status_code == 200
+
+ updated_dashboard = db.session.query(Dashboard).get(dashboard.id)
+ updated_color_scheme = json.loads(updated_dashboard.json_metadata).get(
+ "color_scheme"
+ )
+
+ assert updated_color_scheme == colors["color_scheme"]
+ assert updated_dashboard.changed_on == previous_changed_on
+
+ def test_put_dashboard_colors_not_found(self):
+ """
+ Dashboard API: Test updating colors for dashboard that does not exist
+ """
+ self.login(ADMIN_USERNAME)
+
+ colors = {"label_colors": {"Sales": "#FF0000"}}
+
+ invalid_id = self.get_nonexistent_numeric_id(Dashboard)
+ uri = f"api/v1/dashboard/{invalid_id}/colors"
+ rv = self.client.put(uri, json=colors)
+ assert rv.status_code == 404
+
+ @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
+ def test_put_dashboard_colors_invalid(self):
+ """
+ Dashboard API: Test updating dashboard colors with invalid color format
+ """
+ self.login(ADMIN_USERNAME)
+ dashboard = Dashboard.get("world_health")
+
+ colors = {"test_invalid_prop": {"Sales": "invalid"}}
+
+ uri = f"api/v1/dashboard/{dashboard.id}/colors"
+ rv = self.client.put(uri, json=colors)
+ assert rv.status_code == 400
+
+ def test_put_dashboard_colors_not_authorized(self):
+ """
+ Dashboard API: Test updating colors without authorization
+ """
+ with self.create_app().app_context():
+ admin = security_manager.find_user("admin")
+ dashboard = self.insert_dashboard("title", None, [admin.id])
+
+ assert dashboard.id is not None
+
+ colors = {"label_colors": {"Sales": "#FF0000"}}
+
+ self.login(GAMMA_USERNAME)
+ uri = f"api/v1/dashboard/{dashboard.id}/colors"
+ rv = self.client.put(uri, json=colors)
+ assert rv.status_code == 403
+
+ yield dashboard
+
+ # Cleanup
+ db.session.delete(dashboard)
+ db.session.commit()