fix(Dashboard): Sync color configuration via dedicated endpoint (#31374)
This commit is contained in:
parent
bf56a327f4
commit
e1f98e246f
|
|
@ -73,8 +73,9 @@ import {
|
|||
isLabelsColorMapSynced,
|
||||
getColorSchemeDomain,
|
||||
getColorNamespace,
|
||||
getLabelsColorMapEntries,
|
||||
getFreshLabelsColorMapEntries,
|
||||
getFreshSharedLabels,
|
||||
getDynamicLabelsColors,
|
||||
} from '../../utils/colorScheme';
|
||||
|
||||
export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
|
||||
|
|
@ -253,10 +254,13 @@ export function setDashboardSharedLabelsColorsSynced() {
|
|||
return { type: SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCED };
|
||||
}
|
||||
|
||||
export const setDashboardMetadata = updatedMetadata => async dispatch => {
|
||||
export const setDashboardMetadata =
|
||||
updatedMetadata => async (dispatch, getState) => {
|
||||
const { dashboardInfo } = getState();
|
||||
dispatch(
|
||||
dashboardInfoChanged({
|
||||
metadata: {
|
||||
...(dashboardInfo?.metadata || {}),
|
||||
...updatedMetadata,
|
||||
},
|
||||
}),
|
||||
|
|
@ -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;
|
||||
const fullLabelsColors = getDynamicLabelsColors(
|
||||
metadata.map_label_colors || {},
|
||||
customLabelsColor,
|
||||
);
|
||||
const freshColorMapEntries =
|
||||
getFreshLabelsColorMapEntries(customLabelsColor);
|
||||
const isMapSynced = isLabelsColorMapSynced(
|
||||
fullLabelsColors,
|
||||
freshColorMapEntries,
|
||||
customLabelsColor,
|
||||
);
|
||||
|
||||
if (mustSync) {
|
||||
const freshestColorMapEntries =
|
||||
getLabelsColorMapEntries(customLabelsColor);
|
||||
updatedMetadata.map_label_colors = freshestColorMapEntries;
|
||||
dispatch(setDashboardMetadata(updatedMetadata));
|
||||
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,8 +931,7 @@ export const ensureSyncedSharedLabelsColors =
|
|||
* @param {*} renderedChartIds - the charts that have finished rendering
|
||||
* @returns void
|
||||
*/
|
||||
export const updateDashboardLabelsColor =
|
||||
renderedChartIds => (dispatch, getState) => {
|
||||
export const updateDashboardLabelsColor = renderedChartIds => (_, getState) => {
|
||||
try {
|
||||
const {
|
||||
dashboardInfo: { metadata },
|
||||
|
|
@ -910,11 +939,14 @@ export const updateDashboardLabelsColor =
|
|||
} = 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 || {};
|
||||
const fullLabelsColors = getDynamicLabelsColors(
|
||||
metadata.map_label_colors || {},
|
||||
customLabelsColors,
|
||||
);
|
||||
|
||||
// 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
|
||||
|
|
@ -942,8 +974,7 @@ export const updateDashboardLabelsColor =
|
|||
);
|
||||
const currentChartLabels = currentChartConfig?.labels || [];
|
||||
const uniqueChartLabels = currentChartLabels.filter(
|
||||
l =>
|
||||
!sharedLabelsSet.has(l) && !customLabelsColors.hasOwnProperty(l),
|
||||
l => !sharedLabelsSet.has(l) && !customLabelsColors.hasOwnProperty(l),
|
||||
);
|
||||
|
||||
// Map unique labels to colors
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import {
|
|||
getCategoricalSchemeRegistry,
|
||||
getLabelsColorMap,
|
||||
} from '@superset-ui/core';
|
||||
import { intersection, omit, pick } from 'lodash';
|
||||
import { areObjectsEqual } from 'src/reduxUtils';
|
||||
|
||||
const EMPTY_ARRAY: string[] = [];
|
||||
|
||||
|
|
@ -91,14 +93,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];
|
||||
});
|
||||
|
|
@ -106,6 +107,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 || [];
|
||||
|
||||
|
|
@ -116,20 +130,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;
|
||||
|
||||
const isSynced = Object.entries(fullFreshColorMap).every(
|
||||
([label, color]) =>
|
||||
storedLabelsColorMap.hasOwnProperty(label) &&
|
||||
storedLabelsColorMap[label] === color,
|
||||
// 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 = areObjectsEqual(
|
||||
comparableStoredLabelsColors,
|
||||
comparableFreshLabelsColors,
|
||||
{
|
||||
ignoreFields: Object.keys(customLabelColors),
|
||||
},
|
||||
);
|
||||
|
||||
return isSynced;
|
||||
|
|
@ -227,7 +250,7 @@ export const applyColors = (
|
|||
if (fresh) {
|
||||
// requires a new map all together
|
||||
applicableColorMapEntries = {
|
||||
...getLabelsColorMapEntries(customLabelsColor),
|
||||
...getFreshLabelsColorMapEntries(customLabelsColor),
|
||||
};
|
||||
}
|
||||
if (merge) {
|
||||
|
|
@ -235,7 +258,7 @@ export const applyColors = (
|
|||
// without overriding existing ones
|
||||
applicableColorMapEntries = {
|
||||
...fullLabelsColor,
|
||||
...getLabelsColorMapEntries(customLabelsColor),
|
||||
...getFreshLabelsColorMapEntries(customLabelsColor),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,10 @@ class DashboardNativeFiltersUpdateFailedError(UpdateFailedError):
|
|||
message = _("Dashboard native filters could not be patched.")
|
||||
|
||||
|
||||
class DashboardColorsConfigUpdateFailedError(UpdateFailedError):
|
||||
message = _("Dashboard color configuration could not be updated.")
|
||||
|
||||
|
||||
class DashboardDeleteFailedError(DeleteFailedError):
|
||||
message = _("Dashboard could not be deleted.")
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,10 @@ from typing import Any, Optional
|
|||
from flask_appbuilder.models.sqla import Model
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset import app, security_manager
|
||||
from superset import app, db, security_manager
|
||||
from superset.commands.base import BaseCommand, UpdateMixin
|
||||
from superset.commands.dashboard.exceptions import (
|
||||
DashboardColorsConfigUpdateFailedError,
|
||||
DashboardForbiddenError,
|
||||
DashboardInvalidError,
|
||||
DashboardNativeFiltersUpdateFailedError,
|
||||
|
|
@ -202,3 +203,29 @@ class UpdateDashboardNativeFiltersCommand(UpdateDashboardCommand):
|
|||
)
|
||||
|
||||
return configuration
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
|
|||
"csv_metadata": "csv_upload",
|
||||
"slack_channels": "write",
|
||||
"put_filters": "write",
|
||||
"put_colors": "write",
|
||||
}
|
||||
|
||||
EXTRA_FORM_DATA_APPEND_KEYS = {
|
||||
|
|
|
|||
|
|
@ -391,6 +391,24 @@ class DashboardDAO(BaseDAO[Dashboard]):
|
|||
|
||||
return updated_configuration
|
||||
|
||||
@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])
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ from superset.commands.dashboard.delete import (
|
|||
)
|
||||
from superset.commands.dashboard.exceptions import (
|
||||
DashboardAccessDeniedError,
|
||||
DashboardColorsConfigUpdateFailedError,
|
||||
DashboardCopyError,
|
||||
DashboardCreateFailedError,
|
||||
DashboardDeleteFailedError,
|
||||
|
|
@ -57,6 +58,7 @@ from superset.commands.dashboard.importers.dispatcher import ImportDashboardsCom
|
|||
from superset.commands.dashboard.permalink.create import CreateDashboardPermalinkCommand
|
||||
from superset.commands.dashboard.unfave import DelFavoriteDashboardCommand
|
||||
from superset.commands.dashboard.update import (
|
||||
UpdateDashboardColorsConfigCommand,
|
||||
UpdateDashboardCommand,
|
||||
UpdateDashboardNativeFiltersCommand,
|
||||
)
|
||||
|
|
@ -81,6 +83,7 @@ from superset.dashboards.permalink.types import DashboardPermalinkState
|
|||
from superset.dashboards.schemas import (
|
||||
CacheScreenshotSchema,
|
||||
DashboardCacheScreenshotResponseSchema,
|
||||
DashboardColorsConfigUpdateSchema,
|
||||
DashboardCopySchema,
|
||||
DashboardDatasetSchema,
|
||||
DashboardGetResponseSchema,
|
||||
|
|
@ -108,6 +111,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,
|
||||
|
|
@ -187,6 +191,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
|
|||
"cache_dashboard_screenshot",
|
||||
"screenshot",
|
||||
"put_filters",
|
||||
"put_colors",
|
||||
}
|
||||
resource_name = "dashboard"
|
||||
allow_browser_login = True
|
||||
|
|
@ -275,6 +280,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
|
|||
add_model_schema = DashboardPostSchema()
|
||||
edit_model_schema = DashboardPutSchema()
|
||||
update_filters_model_schema = DashboardNativeFiltersConfigUpdateSchema()
|
||||
update_colors_model_schema = DashboardColorsConfigUpdateSchema()
|
||||
chart_entity_response_schema = ChartEntityResponseSchema()
|
||||
dashboard_get_response_schema = DashboardGetResponseSchema()
|
||||
dashboard_dataset_schema = DashboardDatasetSchema()
|
||||
|
|
@ -767,6 +773,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
|
||||
|
|
@ -1174,6 +1262,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
|
||||
|
|
|
|||
|
|
@ -428,6 +428,15 @@ class DashboardNativeFiltersConfigUpdateSchema(BaseDashboardSchema):
|
|||
reordered = fields.List(fields.String(), allow_none=False)
|
||||
|
||||
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -3197,3 +3197,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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue