mirror of
https://github.com/apache/superset.git
synced 2026-08-25 17:41:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29f9905b84 | ||
|
|
9557995b2f | ||
|
|
a69021003b | ||
|
|
8fa48d73f6 | ||
|
|
90a3d93002 | ||
|
|
5ffb72151e | ||
|
|
c3ed8b312d | ||
|
|
9f505eb0cb |
Generated
+19
-22
@@ -86,7 +86,7 @@
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"dayjs": "^1.11.22",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^6.1.0",
|
||||
@@ -220,7 +220,7 @@
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.14",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"concurrently": "^10.0.5",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
@@ -17190,9 +17190,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz",
|
||||
"integrity": "sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==",
|
||||
"version": "10.0.5",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz",
|
||||
"integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -18541,9 +18541,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"version": "1.11.22",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.22.tgz",
|
||||
"integrity": "sha512-1YRnxzt/AabP3GHxnaB9/b+ZScCKu5TeF+co+BWG+lnWVIwEcTFc1FVE0WLNmNO3sA6GGXL40i5qkHfbLzpwrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
@@ -34873,12 +34873,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-error-boundary": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.2.tgz",
|
||||
"integrity": "sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng==",
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.3.tgz",
|
||||
"integrity": "sha512-GnSKpCohFi2nQmJCWwP8O8wub7zexlePvpsejvQr35vS5RTouS1+utTNOmyc540yw5vyOXnSL1rBWsCQDmkyUA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-google-recaptcha": {
|
||||
@@ -42962,7 +42968,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dayjs": "^1.11.22",
|
||||
"dompurify": "^3.4.13",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
@@ -42974,7 +42980,7 @@
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.7.1",
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-error-boundary": "^6.1.3",
|
||||
"react-js-cron": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
@@ -43073,15 +43079,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-ace": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"dayjs": "^1.11.22",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^6.1.0",
|
||||
@@ -297,7 +297,7 @@
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.14",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"concurrently": "^10.0.5",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dayjs": "^1.11.22",
|
||||
"dompurify": "^3.4.13",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
@@ -79,7 +79,7 @@
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.7.1",
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-error-boundary": "^6.1.3",
|
||||
"react-js-cron": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
|
||||
@@ -259,6 +259,21 @@ describe('isUserEditorOrAdmin', () => {
|
||||
test('returns false when editors is omitted', () => {
|
||||
expect(isUserEditorOrAdmin(outsiderUser)).toEqual(false);
|
||||
});
|
||||
|
||||
test('returns true when the user is granted editorship only through extra_editors', () => {
|
||||
expect(isUserEditorOrAdmin(editorUser, [], [10])).toEqual(true);
|
||||
});
|
||||
|
||||
test('unions editors and extra_editors rather than preferring one', () => {
|
||||
const nonMatchingSubject: Subject = { id: 999, label: 'Other', type: 1 };
|
||||
expect(isUserEditorOrAdmin(editorUser, [nonMatchingSubject], [10])).toEqual(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns false when extra_editors names other subjects', () => {
|
||||
expect(isUserEditorOrAdmin(editorUser, [], [999])).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
|
||||
@@ -55,9 +55,6 @@ export const isUserInSubjects = (
|
||||
);
|
||||
};
|
||||
|
||||
const isUserInEditors = (editors: Subject[] = []): boolean =>
|
||||
isUserInSubjects(editors);
|
||||
|
||||
export const isUserAdmin = (
|
||||
user?: UserWithPermissionsAndRoles | UndefinedUser,
|
||||
) =>
|
||||
@@ -66,10 +63,12 @@ export const isUserAdmin = (
|
||||
role => role.toLowerCase() === ADMIN_ROLE_NAME.toLowerCase(),
|
||||
);
|
||||
|
||||
/** `extraEditors` is editorship granted via a deployment's EXTRA_EDITORS_RESOLVER. */
|
||||
export const isUserEditorOrAdmin = (
|
||||
user?: UserWithPermissionsAndRoles | UndefinedUser,
|
||||
editors: Subject[] = [],
|
||||
): boolean => isUserInEditors(editors) || isUserAdmin(user);
|
||||
extraEditors?: SubjectRef[] | null,
|
||||
): boolean => isUserInSubjects(editors, extraEditors) || isUserAdmin(user);
|
||||
|
||||
/**
|
||||
* Editorship of *dashboard*, matching the server's `is_editor`: the explicit
|
||||
|
||||
@@ -96,7 +96,11 @@ export default function ChartCard({
|
||||
const canEdit = hasPerm('can_write');
|
||||
const canDelete = hasPerm('can_write');
|
||||
const canExport = hasPerm('can_export');
|
||||
const allowEdit = isUserEditorOrAdmin(user, chart.editors);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
chart.editors,
|
||||
chart.extra_editors,
|
||||
);
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
if (canEdit) {
|
||||
|
||||
@@ -83,7 +83,11 @@ function DashboardCard({
|
||||
const canEdit = hasPerm('can_write');
|
||||
const canDelete = hasPerm('can_write');
|
||||
const canExport = hasPerm('can_export');
|
||||
const allowEdit = isUserEditorOrAdmin(user, dashboard.editors);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
dashboard.editors,
|
||||
dashboard.extra_editors,
|
||||
);
|
||||
const digest = dashboard.changed_on_utc || dashboard.changed_on;
|
||||
const thumbnailUrl =
|
||||
isFeatureEnabled(FeatureFlag.Thumbnails) && dashboard.id && digest
|
||||
|
||||
@@ -650,7 +650,11 @@ function ChartList(props: ChartListProps) {
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: CellProps<Chart>) => {
|
||||
const allowEdit = isUserEditorOrAdmin(user, original.editors);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
original.editors,
|
||||
original.extra_editors,
|
||||
);
|
||||
const openEditModal = () => openChartEditModal(original);
|
||||
const handleExport = () => handleBulkChartExport([original]);
|
||||
if (!canEdit && !canDelete && !canExport) {
|
||||
|
||||
@@ -122,6 +122,8 @@ export interface Dashboard {
|
||||
description?: string;
|
||||
thumbnail_url?: string | null;
|
||||
editors?: Subject[];
|
||||
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
|
||||
extra_editors?: number[];
|
||||
viewers?: Subject[];
|
||||
tags: TagType[];
|
||||
created_by: object;
|
||||
@@ -505,7 +507,11 @@ function DashboardList(props: DashboardListProps) {
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: CellProps<Dashboard>) => {
|
||||
const allowEdit = isUserEditorOrAdmin(user, original.editors);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
original.editors,
|
||||
original.extra_editors,
|
||||
);
|
||||
const handleDelete = () =>
|
||||
handleDashboardDelete(
|
||||
original,
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface Chart {
|
||||
cache_timeout: number | null;
|
||||
thumbnail_url?: string;
|
||||
editors?: Subject[];
|
||||
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
|
||||
extra_editors?: number[];
|
||||
viewers?: Subject[];
|
||||
tags?: TagType[];
|
||||
last_saved_at?: string;
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface Dashboard {
|
||||
url: string;
|
||||
thumbnail_url?: string | null;
|
||||
editors?: Subject[];
|
||||
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
|
||||
extra_editors?: number[];
|
||||
viewers?: Subject[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
+13
-1
@@ -92,7 +92,10 @@ from superset.exceptions import (
|
||||
)
|
||||
from superset.extensions import event_logger, security_manager
|
||||
from superset.models.slice import Slice
|
||||
from superset.security.manager import get_extra_editor_subject_ids
|
||||
from superset.security.manager import (
|
||||
get_extra_editor_subject_ids,
|
||||
get_extra_editors_by_pk,
|
||||
)
|
||||
from superset.subjects.filters import (
|
||||
FilterRelatedSubjects,
|
||||
subject_type_filter,
|
||||
@@ -410,6 +413,15 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
except ChartNotFoundError:
|
||||
return self.response_404()
|
||||
|
||||
def pre_get_list(self, data: dict[str, Any]) -> None:
|
||||
"""Attach ``extra_editors`` to each row, matching the single-object GET."""
|
||||
super().pre_get_list(data)
|
||||
ids = data.get("ids", [])
|
||||
extra_editors_by_id = get_extra_editors_by_pk(Slice, ids)
|
||||
for row, row_id in zip(data.get("result", []), ids, strict=False):
|
||||
if row_id in extra_editors_by_id:
|
||||
row["extra_editors"] = extra_editors_by_id[row_id]
|
||||
|
||||
@expose("/<pk>/deck_layers/", methods=("GET",))
|
||||
@protect()
|
||||
@safe
|
||||
|
||||
@@ -18,8 +18,6 @@ import logging
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand, CreateMixin
|
||||
from superset.commands.tag.exceptions import TagCreateFailedError, TagInvalidError
|
||||
@@ -29,7 +27,7 @@ from superset.commands.tag.utils import (
|
||||
to_object_type,
|
||||
)
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.exceptions import SupersetParseError, SupersetSecurityException
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.tags.models import ObjectType, TagType
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
@@ -100,30 +98,8 @@ class CreateCustomTagCommand(CreateMixin, BaseCommand):
|
||||
)
|
||||
)
|
||||
except SupersetSecurityException:
|
||||
# A routine, expected authorization denial; swallowed silently by
|
||||
# design (no logging) and surfaced to the caller as a validation
|
||||
# failure rather than an unhandled 500.
|
||||
exceptions.append(
|
||||
TagCreateFailedError(
|
||||
f"Could not validate access for {object_type} {object_id}"
|
||||
)
|
||||
)
|
||||
except (TemplateError, SupersetParseError) as ex:
|
||||
# Authorizing a saved query parses its Jinja-templated SQL to resolve
|
||||
# table references. Malformed Jinja (TemplateError) or an
|
||||
# unresolvable partition macro (SupersetParseError) is a validation
|
||||
# failure, not an unhandled 500 -- but unlike an access denial it is
|
||||
# genuinely unexpected, so log it for server-side visibility and
|
||||
# preserve the underlying error text instead of discarding it.
|
||||
logger.warning(
|
||||
"Could not parse query %s while validating tag access: %s",
|
||||
object_id,
|
||||
str(ex),
|
||||
)
|
||||
exceptions.append(
|
||||
TagCreateFailedError(
|
||||
f"Could not validate access for {object_type} {object_id}: {ex}"
|
||||
)
|
||||
TagCreateFailedError(f"Access denied for {object_type} {object_id}")
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -213,7 +213,9 @@ def orderby_from_form_data(
|
||||
# The drag-and-drop "sort by" control persists a list; the frontend unwraps it
|
||||
# with ``ensureIsArray(...)[0]`` (``plugin-chart-table/src/buildQuery.ts:67``).
|
||||
# Read raw, a list would nest inside ``orderby`` and fail the query.
|
||||
raw_sort_metric = form_data.get("timeseries_limit_metric")
|
||||
raw_sort_metric = form_data.get("series_limit_metric") or form_data.get(
|
||||
"timeseries_limit_metric"
|
||||
)
|
||||
sort_metric = (
|
||||
next(iter(as_list(raw_sort_metric)), None) if raw_sort_metric else None
|
||||
) or (metrics[0] if form_data.get("sort_by_metric") else None)
|
||||
|
||||
@@ -142,7 +142,10 @@ from superset.extensions import event_logger, security_manager
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.embedded_dashboard import EmbeddedDashboard
|
||||
from superset.security.guest_token import GuestUser
|
||||
from superset.security.manager import get_extra_editor_subject_ids
|
||||
from superset.security.manager import (
|
||||
get_extra_editor_subject_ids,
|
||||
get_extra_editors_by_pk,
|
||||
)
|
||||
from superset.subjects.filters import (
|
||||
FilterRelatedSubjects,
|
||||
subject_type_filter,
|
||||
@@ -433,6 +436,15 @@ class DashboardRestApi(
|
||||
"""
|
||||
return super().get_list(**kwargs)
|
||||
|
||||
def pre_get_list(self, data: dict[str, Any]) -> None:
|
||||
"""Attach ``extra_editors`` to each row, matching the single-object GET."""
|
||||
super().pre_get_list(data)
|
||||
ids = data.get("ids", [])
|
||||
extra_editors_by_id = get_extra_editors_by_pk(Dashboard, ids)
|
||||
for row, row_id in zip(data.get("result", []), ids, strict=False):
|
||||
if row_id in extra_editors_by_id:
|
||||
row["extra_editors"] = extra_editors_by_id[row_id]
|
||||
|
||||
list_select_columns = list_columns + ["changed_on", "created_on", "changed_by_fk"]
|
||||
order_columns = [
|
||||
"changed_by.first_name",
|
||||
|
||||
@@ -290,7 +290,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
groupby=["name"],
|
||||
adhoc_filters=[gen_filter("gender", "girl")],
|
||||
row_limit=50,
|
||||
timeseries_limit_metric=metric,
|
||||
series_limit_metric=metric,
|
||||
metrics=[metric],
|
||||
),
|
||||
editors=[],
|
||||
@@ -321,7 +321,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
groupby=["name"],
|
||||
adhoc_filters=[gen_filter("gender", "boy")],
|
||||
row_limit=50,
|
||||
timeseries_limit_metric=metric,
|
||||
series_limit_metric=metric,
|
||||
metrics=[metric],
|
||||
),
|
||||
editors=[],
|
||||
@@ -498,7 +498,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
viz_type="echarts_timeseries_line",
|
||||
granularity_sqla="ds",
|
||||
groupby=["name"],
|
||||
timeseries_limit_metric={
|
||||
series_limit_metric={
|
||||
"expressionType": "SIMPLE",
|
||||
"column": {
|
||||
"column_name": "num_california",
|
||||
@@ -522,7 +522,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
metrics=metrics,
|
||||
groupby=["name"],
|
||||
row_limit=50,
|
||||
timeseries_limit_metric={
|
||||
series_limit_metric={
|
||||
"expressionType": "SIMPLE",
|
||||
"column": {
|
||||
"column_name": "num_california",
|
||||
|
||||
@@ -36,8 +36,8 @@ params:
|
||||
metrics:
|
||||
- sum__num
|
||||
row_limit: 50
|
||||
series_limit_metric: sum__num
|
||||
time_range: '100 years ago : now'
|
||||
timeseries_limit_metric: sum__num
|
||||
viz_type: table
|
||||
query_context: null
|
||||
slice_name: Boys
|
||||
|
||||
@@ -36,8 +36,8 @@ params:
|
||||
metrics:
|
||||
- sum__num
|
||||
row_limit: 50
|
||||
series_limit_metric: sum__num
|
||||
time_range: '100 years ago : now'
|
||||
timeseries_limit_metric: sum__num
|
||||
viz_type: table
|
||||
query_context: null
|
||||
slice_name: Girls
|
||||
|
||||
@@ -170,6 +170,36 @@ def get_extra_editor_subject_ids(resource: Model) -> list[int]:
|
||||
return subject_ids
|
||||
|
||||
|
||||
def get_extra_editors_by_pk(
|
||||
model_cls: type[Model], primary_keys: list[Any]
|
||||
) -> dict[Any, list[int]]:
|
||||
"""
|
||||
Resolve extra editor subject IDs for a batch of resources, keyed by
|
||||
primary key. List responses only have serialized rows, not model
|
||||
instances, so this re-queries the page's rows in one batched query.
|
||||
"""
|
||||
if not primary_keys or not (
|
||||
has_app_context() and current_app.config.get("EXTRA_EDITORS_RESOLVER")
|
||||
):
|
||||
return {}
|
||||
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset import db
|
||||
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES
|
||||
|
||||
pk_col = inspect(model_cls).primary_key[0]
|
||||
resources = (
|
||||
db.session.query(model_cls)
|
||||
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {model_cls}})
|
||||
.filter(pk_col.in_(primary_keys))
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
getattr(resource, pk_col.name): get_extra_editor_subject_ids(resource)
|
||||
for resource in resources
|
||||
}
|
||||
|
||||
|
||||
def _render_permission_instructions_link(
|
||||
*,
|
||||
datasource_id: str = "",
|
||||
|
||||
@@ -106,6 +106,10 @@ def load_chart_data_into_cache(
|
||||
) -> None:
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset.commands.chart.data.get_data_command import ChartDataCommand
|
||||
from superset.commands.chart.exceptions import (
|
||||
ChartDataCacheLoadError,
|
||||
ChartDataQueryFailedError,
|
||||
)
|
||||
|
||||
with override_user(_load_user_from_job_metadata(job_metadata), force=False):
|
||||
try:
|
||||
@@ -123,6 +127,20 @@ def load_chart_data_into_cache(
|
||||
except SoftTimeLimitExceeded as ex:
|
||||
_handle_soft_time_limit(job_metadata, ex, "loading chart data")
|
||||
raise
|
||||
except (ChartDataCacheLoadError, ChartDataQueryFailedError) as ex:
|
||||
# These map to 422/400 in the synchronous chart/data endpoint (see
|
||||
# ChartDataRestApi._get_data_response) - expected, client-facing
|
||||
# validation failures (e.g. a chart still referencing columns a
|
||||
# customer has since dropped from the dataset), not application
|
||||
# bugs. The failure is already delivered to the client via
|
||||
# update_job below; re-raising would only surface it a second
|
||||
# time as an unhandled Celery task exception.
|
||||
logger.info("Chart data query failed while loading into cache: %s", ex)
|
||||
async_query_manager.update_job(
|
||||
job_metadata,
|
||||
async_query_manager.STATUS_ERROR,
|
||||
errors=sanitize_error_dicts([{"message": str(ex.message)}]),
|
||||
)
|
||||
except Exception as ex:
|
||||
# Extract SIP-40 style errors when available
|
||||
if isinstance(ex, SupersetErrorException):
|
||||
|
||||
@@ -922,6 +922,55 @@ class TestDashboardApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCa
|
||||
db.session.delete(dashboard)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_dashboards_list_omits_extra_editors_by_default(self):
|
||||
"""No EXTRA_EDITORS_RESOLVER configured: list rows omit extra_editors."""
|
||||
admin = self.get_user("admin")
|
||||
dashboard = self.insert_dashboard(
|
||||
"no_extra_editors_list_dashboard",
|
||||
"no-extra-editors-list-dashboard",
|
||||
[admin.id],
|
||||
)
|
||||
try:
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.get("api/v1/dashboard/")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
row = next(
|
||||
d
|
||||
for d in data["result"]
|
||||
if d["dashboard_title"] == dashboard.dashboard_title
|
||||
)
|
||||
assert "extra_editors" not in row
|
||||
finally:
|
||||
db.session.delete(dashboard)
|
||||
db.session.commit()
|
||||
|
||||
@with_config({"EXTRA_EDITORS_RESOLVER": lambda resource: [123]})
|
||||
def test_get_dashboards_list_includes_extra_editors_when_resolver_configured(
|
||||
self,
|
||||
):
|
||||
"""List rows get extra_editors too, mirroring the single-object GET."""
|
||||
admin = self.get_user("admin")
|
||||
dashboard = self.insert_dashboard(
|
||||
"extra_editors_list_dashboard",
|
||||
"extra-editors-list-dashboard",
|
||||
[admin.id],
|
||||
)
|
||||
try:
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.get("api/v1/dashboard/")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
row = next(
|
||||
d
|
||||
for d in data["result"]
|
||||
if d["dashboard_title"] == dashboard.dashboard_title
|
||||
)
|
||||
assert row["extra_editors"] == [123]
|
||||
finally:
|
||||
db.session.delete(dashboard)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_charts_admin_sees_existing_charts(self):
|
||||
"""Regression for #25890: GET /api/v1/chart/ as an Admin user should
|
||||
return existing charts, not an empty list."""
|
||||
@@ -944,6 +993,41 @@ class TestDashboardApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCa
|
||||
db.session.delete(chart)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_charts_list_omits_extra_editors_by_default(self):
|
||||
"""No EXTRA_EDITORS_RESOLVER configured: list rows omit extra_editors."""
|
||||
admin = self.get_user("admin")
|
||||
chart = self.insert_chart(
|
||||
"no_extra_editors_list_chart", [admin.id], 1, params="{}"
|
||||
)
|
||||
try:
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.get("api/v1/chart/")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
row = next(c for c in data["result"] if c["slice_name"] == chart.slice_name)
|
||||
assert "extra_editors" not in row
|
||||
finally:
|
||||
db.session.delete(chart)
|
||||
db.session.commit()
|
||||
|
||||
@with_config({"EXTRA_EDITORS_RESOLVER": lambda resource: [123]})
|
||||
def test_get_charts_list_includes_extra_editors_when_resolver_configured(self):
|
||||
"""List rows get extra_editors too, mirroring the single-object GET."""
|
||||
admin = self.get_user("admin")
|
||||
chart = self.insert_chart(
|
||||
"extra_editors_list_chart", [admin.id], 1, params="{}"
|
||||
)
|
||||
try:
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.get("api/v1/chart/")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
row = next(c for c in data["result"] if c["slice_name"] == chart.slice_name)
|
||||
assert row["extra_editors"] == [123]
|
||||
finally:
|
||||
db.session.delete(chart)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_dashboards_filter(self):
|
||||
"""
|
||||
Dashboard API: Test get dashboards filter
|
||||
|
||||
@@ -110,8 +110,11 @@ class TestAsyncQueries(SupersetTestCase):
|
||||
"status": "pending",
|
||||
"errors": [],
|
||||
}
|
||||
with pytest.raises(ChartDataQueryFailedError):
|
||||
load_chart_data_into_cache(job_metadata, query_context)
|
||||
# ChartDataQueryFailedError mirrors the synchronous chart/data endpoint's
|
||||
# 400 (see ChartDataRestApi._get_data_response) - an expected validation
|
||||
# failure, not a bug, so the task reports it via update_job and does not
|
||||
# re-raise (see superset/tasks/async_queries.py).
|
||||
load_chart_data_into_cache(job_metadata, query_context)
|
||||
|
||||
mock_run_command.assert_called_once_with(cache=True)
|
||||
errors = [{"message": "Error: foo"}]
|
||||
|
||||
@@ -218,6 +218,31 @@ def test_orderby_uses_timeseries_limit_metric_and_order_desc() -> None:
|
||||
assert query["orderby"] == [["revenue", True]]
|
||||
|
||||
|
||||
def test_orderby_uses_series_limit_metric_and_order_desc() -> None:
|
||||
# series_limit_metric is the current field name; timeseries_limit_metric is
|
||||
# the deprecated alias kept above for back-compat with old saved charts.
|
||||
form_data = {
|
||||
"metrics": ["count"],
|
||||
"groupby": ["c"],
|
||||
"series_limit_metric": "revenue",
|
||||
"order_desc": False,
|
||||
}
|
||||
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
|
||||
assert query["orderby"] == [["revenue", True]]
|
||||
|
||||
|
||||
def test_orderby_prefers_series_limit_metric_over_deprecated_alias() -> None:
|
||||
form_data = {
|
||||
"metrics": ["count"],
|
||||
"groupby": ["c"],
|
||||
"series_limit_metric": "revenue",
|
||||
"timeseries_limit_metric": "profit",
|
||||
"order_desc": False,
|
||||
}
|
||||
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
|
||||
assert query["orderby"] == [["revenue", True]]
|
||||
|
||||
|
||||
def test_orderby_pie_sort_by_metric() -> None:
|
||||
form_data = {"metric": "count", "groupby": ["c"], "sort_by_metric": True}
|
||||
query = build_query_context_from_form_data(form_data, DATASOURCE, viz_type="pie")[
|
||||
|
||||
@@ -108,75 +108,6 @@ def test_create_command_success(session_with_data: Session, mocker: MockerFixtur
|
||||
)
|
||||
|
||||
|
||||
def test_validate_object_access_query_malformed_jinja(
|
||||
session_with_data: Session, mocker: MockerFixture
|
||||
):
|
||||
"""A saved query whose Jinja-templated SQL fails to parse during access
|
||||
checks must surface as a validation error, not an unhandled
|
||||
``jinja2.TemplateError`` escaping as a 500.
|
||||
|
||||
When ``raise_for_access(query=...)`` authorizes a saved query via
|
||||
per-table permissions it parses the query's Jinja SQL (e.g. an unclosed
|
||||
``{% if %}`` block raises ``TemplateSyntaxError``). Mock that call to raise
|
||||
the ``TemplateError`` directly so the test stays hermetic and does not open
|
||||
a live DB connection to introspect table-level perms.
|
||||
"""
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from superset.commands.tag.create import CreateCustomTagCommand
|
||||
from superset.commands.tag.exceptions import TagInvalidError
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
from superset.tags.models import ObjectType
|
||||
|
||||
query = db.session.query(SavedQuery).first()
|
||||
|
||||
mocker.patch("superset.commands.tag.create.to_object_model", return_value=query)
|
||||
mocker.patch(
|
||||
"superset.commands.tag.create.security_manager.raise_for_access",
|
||||
side_effect=TemplateError("unclosed {% if %}"),
|
||||
)
|
||||
|
||||
command = CreateCustomTagCommand(ObjectType.query, query.id, ["tag"])
|
||||
|
||||
with pytest.raises(TagInvalidError):
|
||||
command.validate()
|
||||
|
||||
|
||||
def test_validate_object_access_query_unresolvable_partition_macro(
|
||||
session_with_data: Session, mocker: MockerFixture
|
||||
):
|
||||
"""A saved query whose partition macro cannot be resolved statically raises
|
||||
``SupersetParseError`` during access checks. Like ``TemplateError``, it is a
|
||||
sibling of ``SupersetSecurityException`` under ``SupersetErrorException`` and
|
||||
would otherwise escape as an unhandled 500, so it must also surface as a
|
||||
validation error.
|
||||
|
||||
Mock ``raise_for_access`` to raise the error directly so the test stays
|
||||
hermetic and does not open a live DB connection to introspect table perms.
|
||||
"""
|
||||
from superset.commands.tag.create import CreateCustomTagCommand
|
||||
from superset.commands.tag.exceptions import TagInvalidError
|
||||
from superset.exceptions import SupersetParseError
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
from superset.tags.models import ObjectType
|
||||
|
||||
query = db.session.query(SavedQuery).first()
|
||||
|
||||
mocker.patch("superset.commands.tag.create.to_object_model", return_value=query)
|
||||
mocker.patch(
|
||||
"superset.commands.tag.create.security_manager.raise_for_access",
|
||||
side_effect=SupersetParseError(
|
||||
sql="select * from {{ latest_partition('foo') }}",
|
||||
message="Unresolvable partition macro",
|
||||
),
|
||||
)
|
||||
|
||||
command = CreateCustomTagCommand(ObjectType.query, query.id, ["tag"])
|
||||
|
||||
with pytest.raises(TagInvalidError):
|
||||
command.validate()
|
||||
|
||||
|
||||
def test_create_command_success_clear(
|
||||
session_with_data: Session, mocker: MockerFixture
|
||||
):
|
||||
|
||||
@@ -21,7 +21,10 @@ import pytest
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from flask_babel import lazy_gettext as _
|
||||
|
||||
from superset.commands.chart.exceptions import ChartDataQueryFailedError
|
||||
from superset.commands.chart.exceptions import (
|
||||
ChartDataCacheLoadError,
|
||||
ChartDataQueryFailedError,
|
||||
)
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import (
|
||||
OAuth2RedirectError,
|
||||
@@ -43,7 +46,7 @@ def test_load_chart_data_into_cache_with_error(
|
||||
job_metadata = {"user_id": 1}
|
||||
form_data = {}
|
||||
err_message = "Something went wrong"
|
||||
err = ChartDataQueryFailedError(_(err_message))
|
||||
err = RuntimeError(err_message)
|
||||
|
||||
mock_user = mock.MagicMock()
|
||||
mock_query_context_schema = mock.MagicMock()
|
||||
@@ -54,7 +57,7 @@ def test_load_chart_data_into_cache_with_error(
|
||||
|
||||
mock_query_context_schema.load.side_effect = err
|
||||
|
||||
with pytest.raises(ChartDataQueryFailedError):
|
||||
with pytest.raises(RuntimeError):
|
||||
load_chart_data_into_cache(job_metadata, form_data)
|
||||
|
||||
expected_errors = [{"message": err_message}]
|
||||
@@ -64,6 +67,76 @@ def test_load_chart_data_into_cache_with_error(
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("superset.tasks.async_queries.security_manager")
|
||||
@mock.patch("superset.tasks.async_queries.async_query_manager")
|
||||
@mock.patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
|
||||
@mock.patch("superset.tasks.async_queries.ChartDataQueryContextSchema")
|
||||
def test_load_chart_data_into_cache_with_query_failed_error_does_not_reraise(
|
||||
mock_query_context_schema_cls: mock.MagicMock,
|
||||
mock_command_cls: mock.MagicMock,
|
||||
mock_async_query_manager: mock.MagicMock,
|
||||
mock_security_manager: mock.MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
ChartDataQueryFailedError maps to a 400 in the synchronous chart/data
|
||||
endpoint (see ChartDataRestApi._get_data_response) - an expected,
|
||||
client-facing validation failure (e.g. a chart still referencing columns
|
||||
a customer has since dropped from the dataset), not an application bug.
|
||||
The task must still report it to the client via update_job, but must not
|
||||
re-raise it - that would surface it a second time as an unhandled Celery
|
||||
task exception.
|
||||
"""
|
||||
from superset.tasks.async_queries import load_chart_data_into_cache
|
||||
|
||||
job_metadata = {"user_id": 1}
|
||||
form_data: dict[str, Any] = {}
|
||||
err_message = "Columns missing in dataset: ['foo']"
|
||||
|
||||
mock_security_manager.get_user_by_id.return_value = mock.MagicMock()
|
||||
mock_async_query_manager.STATUS_ERROR = "error"
|
||||
mock_query_context_schema_cls.return_value.load.return_value = mock.MagicMock()
|
||||
mock_command_cls.return_value.run.side_effect = ChartDataQueryFailedError(
|
||||
_(err_message)
|
||||
)
|
||||
|
||||
# Should not raise.
|
||||
load_chart_data_into_cache(job_metadata, form_data)
|
||||
|
||||
mock_async_query_manager.update_job.assert_called_once_with(
|
||||
job_metadata, "error", errors=[{"message": err_message}]
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("superset.tasks.async_queries.security_manager")
|
||||
@mock.patch("superset.tasks.async_queries.async_query_manager")
|
||||
@mock.patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
|
||||
@mock.patch("superset.tasks.async_queries.ChartDataQueryContextSchema")
|
||||
def test_load_chart_data_into_cache_with_cache_load_error_does_not_reraise(
|
||||
mock_query_context_schema_cls: mock.MagicMock,
|
||||
mock_command_cls: mock.MagicMock,
|
||||
mock_async_query_manager: mock.MagicMock,
|
||||
mock_security_manager: mock.MagicMock,
|
||||
) -> None:
|
||||
"""Same as above, for the sibling 422-mapped ChartDataCacheLoadError."""
|
||||
from superset.tasks.async_queries import load_chart_data_into_cache
|
||||
|
||||
job_metadata = {"user_id": 1}
|
||||
form_data: dict[str, Any] = {}
|
||||
err_message = "Cache load failed"
|
||||
|
||||
mock_security_manager.get_user_by_id.return_value = mock.MagicMock()
|
||||
mock_async_query_manager.STATUS_ERROR = "error"
|
||||
mock_query_context_schema_cls.return_value.load.return_value = mock.MagicMock()
|
||||
mock_command_cls.return_value.run.side_effect = ChartDataCacheLoadError(err_message)
|
||||
|
||||
# Should not raise.
|
||||
load_chart_data_into_cache(job_metadata, form_data)
|
||||
|
||||
mock_async_query_manager.update_job.assert_called_once_with(
|
||||
job_metadata, "error", errors=[{"message": err_message}]
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("superset.tasks.async_queries.security_manager")
|
||||
@mock.patch("superset.tasks.async_queries.async_query_manager")
|
||||
@mock.patch("superset.tasks.async_queries.ChartDataQueryContextSchema")
|
||||
|
||||
Reference in New Issue
Block a user