feat: Embedded dashboard configuration (#19364)
* embedded dashboard model * embedded dashboard endpoints * DRY up using the with_dashboard decorator elsewhere * wip * check feature flags and permissions * wip * sdk * urls * dao option for id column * got it working * Update superset/embedded/view.py * use the curator check * put back old endpoint, for now * allow access by either embedded.uuid or dashboard.id * keep the old endpoint around, for the time being * openapi * lint * lint * lint * test stuff * lint, test * typo * Update superset-frontend/src/embedded/index.tsx * Update superset-frontend/src/embedded/index.tsx * fix tests * bump sdk
This commit is contained in:
parent
a4c261d72c
commit
8e29ec5a66
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@superset-ui/embedded-sdk",
|
||||
"version": "0.1.0-alpha.6",
|
||||
"version": "0.1.0-alpha.7",
|
||||
"description": "SDK for embedding resources from Superset into your own application",
|
||||
"access": "public",
|
||||
"keywords": [
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export async function embedDashboard({
|
|||
resolve(new Switchboard({ port: ourPort, name: 'superset-embedded-sdk', debug }));
|
||||
});
|
||||
|
||||
iframe.src = `${supersetDomain}/dashboard/${id}/embedded${dashboardConfig}`;
|
||||
iframe.src = `${supersetDomain}/embedded/${id}${dashboardConfig}`;
|
||||
mountPoint.replaceChildren(iframe);
|
||||
log('placed the iframe')
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { makeApi, styled, SupersetApiError, t } from '@superset-ui/core';
|
||||
import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls';
|
||||
import Modal from 'src/components/Modal';
|
||||
import Loading from 'src/components/Loading';
|
||||
import Button from 'src/components/Button';
|
||||
import { Input } from 'src/components/Input';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { FormItem } from 'src/components/Form';
|
||||
import { EmbeddedDashboard } from '../types';
|
||||
|
||||
type Props = {
|
||||
dashboardId: string;
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
};
|
||||
|
||||
type EmbeddedApiPayload = { allowed_domains: string[] };
|
||||
|
||||
const stringToList = (stringyList: string): string[] =>
|
||||
stringyList.split(/(?:\s|,)+/).filter(x => x);
|
||||
|
||||
const ButtonRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
|
||||
const { addInfoToast, addDangerToast } = useToasts();
|
||||
const [ready, setReady] = useState(true); // whether we have initialized yet
|
||||
const [loading, setLoading] = useState(false); // whether we are currently doing an async thing
|
||||
const [embedded, setEmbedded] = useState<EmbeddedDashboard | null>(null); // the embedded dashboard config
|
||||
const [allowedDomains, setAllowedDomains] = useState<string>('');
|
||||
|
||||
const endpoint = `/api/v1/dashboard/${dashboardId}/embedded`;
|
||||
// whether saveable changes have been made to the config
|
||||
const isDirty =
|
||||
!embedded ||
|
||||
stringToList(allowedDomains).join() !== embedded.allowed_domains.join();
|
||||
|
||||
const enableEmbedded = useCallback(() => {
|
||||
setLoading(true);
|
||||
makeApi<EmbeddedApiPayload, { result: EmbeddedDashboard }>({
|
||||
method: 'POST',
|
||||
endpoint,
|
||||
})({
|
||||
allowed_domains: stringToList(allowedDomains),
|
||||
})
|
||||
.then(
|
||||
({ result }) => {
|
||||
setEmbedded(result);
|
||||
setAllowedDomains(result.allowed_domains.join(', '));
|
||||
addInfoToast(t('Changes saved.'));
|
||||
},
|
||||
err => {
|
||||
console.error(err);
|
||||
addDangerToast(
|
||||
t(
|
||||
t('Sorry, something went wrong. The changes could not be saved.'),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [endpoint, allowedDomains]);
|
||||
|
||||
const disableEmbedded = useCallback(() => {
|
||||
Modal.confirm({
|
||||
title: t('Disable embedding?'),
|
||||
content: t('This will remove your current embed configuration.'),
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
setLoading(true);
|
||||
makeApi<{}>({ method: 'DELETE', endpoint })({})
|
||||
.then(
|
||||
() => {
|
||||
setEmbedded(null);
|
||||
setAllowedDomains('');
|
||||
addInfoToast(t('Embedding deactivated.'));
|
||||
onHide();
|
||||
},
|
||||
err => {
|
||||
console.error(err);
|
||||
addDangerToast(
|
||||
t(
|
||||
'Sorry, something went wrong. Embedding could not be deactivated.',
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [endpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
setReady(false);
|
||||
makeApi<{}, { result: EmbeddedDashboard }>({
|
||||
method: 'GET',
|
||||
endpoint,
|
||||
})({})
|
||||
.catch(err => {
|
||||
if ((err as SupersetApiError).status === 404) {
|
||||
// 404 just means the dashboard isn't currently embedded
|
||||
return { result: null };
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
.then(({ result }) => {
|
||||
setReady(true);
|
||||
setEmbedded(result);
|
||||
setAllowedDomains(result ? result.allowed_domains.join(', ') : '');
|
||||
});
|
||||
}, [dashboardId]);
|
||||
|
||||
if (!ready) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{embedded ? (
|
||||
<>
|
||||
{t(
|
||||
'This dashboard is ready to embed. In your application, pass the following id to the SDK:',
|
||||
)}
|
||||
<br />
|
||||
<code>{embedded.uuid}</code>
|
||||
</>
|
||||
) : (
|
||||
t(
|
||||
'Configure this dashboard to embed it into an external web application.',
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{t('For further instructions, consult the')}{' '}
|
||||
<a
|
||||
href="https://www.npmjs.com/package/@superset-ui/embedded-sdk"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('Superset Embedded SDK documentation.')}
|
||||
</a>
|
||||
</p>
|
||||
<h3>Settings</h3>
|
||||
<FormItem>
|
||||
<label htmlFor="allowed-domains">
|
||||
{t('Allowed Domains (comma separated)')}{' '}
|
||||
<InfoTooltipWithTrigger
|
||||
tooltip={t(
|
||||
'A list of domain names that can embed this dashboard. Leaving this field empty will allow embedding from any domain.',
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<Input
|
||||
name="allowed-domains"
|
||||
value={allowedDomains}
|
||||
placeholder="superset.example.com"
|
||||
onChange={event => setAllowedDomains(event.target.value)}
|
||||
/>
|
||||
</FormItem>
|
||||
<ButtonRow>
|
||||
{embedded ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={disableEmbedded}
|
||||
buttonStyle="secondary"
|
||||
loading={loading}
|
||||
>
|
||||
{t('Deactivate')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={enableEmbedded}
|
||||
buttonStyle="primary"
|
||||
disabled={!isDirty}
|
||||
loading={loading}
|
||||
>
|
||||
{t('Save changes')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={enableEmbedded}
|
||||
buttonStyle="primary"
|
||||
loading={loading}
|
||||
>
|
||||
{t('Enable embedding')}
|
||||
</Button>
|
||||
)}
|
||||
</ButtonRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const DashboardEmbedModal = (props: Props) => {
|
||||
const { show, onHide } = props;
|
||||
|
||||
return (
|
||||
<Modal show={show} onHide={onHide} title={t('Embed')} hideFooter>
|
||||
<DashboardEmbedControls {...props} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
@ -59,11 +59,13 @@ const propTypes = {
|
|||
userCanEdit: PropTypes.bool.isRequired,
|
||||
userCanShare: PropTypes.bool.isRequired,
|
||||
userCanSave: PropTypes.bool.isRequired,
|
||||
userCanCurate: PropTypes.bool.isRequired,
|
||||
isLoading: PropTypes.bool.isRequired,
|
||||
layout: PropTypes.object.isRequired,
|
||||
expandedSlices: PropTypes.object.isRequired,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
showPropertiesModal: PropTypes.func.isRequired,
|
||||
manageEmbedded: PropTypes.func.isRequired,
|
||||
refreshLimit: PropTypes.number,
|
||||
refreshWarning: PropTypes.string,
|
||||
lastModifiedTime: PropTypes.number.isRequired,
|
||||
|
|
@ -88,6 +90,7 @@ const MENU_KEYS = {
|
|||
EDIT_CSS: 'edit-css',
|
||||
DOWNLOAD_AS_IMAGE: 'download-as-image',
|
||||
TOGGLE_FULLSCREEN: 'toggle-fullscreen',
|
||||
MANAGE_EMBEDDED: 'manage-embedded',
|
||||
};
|
||||
|
||||
const DropdownButton = styled.div`
|
||||
|
|
@ -182,6 +185,10 @@ class HeaderActionsDropdown extends React.PureComponent {
|
|||
window.location.replace(url);
|
||||
break;
|
||||
}
|
||||
case MENU_KEYS.MANAGE_EMBEDDED: {
|
||||
this.props.manageEmbedded();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
@ -204,6 +211,7 @@ class HeaderActionsDropdown extends React.PureComponent {
|
|||
userCanEdit,
|
||||
userCanShare,
|
||||
userCanSave,
|
||||
userCanCurate,
|
||||
isLoading,
|
||||
refreshLimit,
|
||||
refreshWarning,
|
||||
|
|
@ -313,6 +321,12 @@ class HeaderActionsDropdown extends React.PureComponent {
|
|||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{!editMode && userCanCurate && (
|
||||
<Menu.Item key={MENU_KEYS.MANAGE_EMBEDDED}>
|
||||
{t('Embed dashboard')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{!editMode && (
|
||||
<Menu.Item key={MENU_KEYS.DOWNLOAD_AS_IMAGE}>
|
||||
{t('Download as image')}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,9 @@ import setPeriodicRunner, {
|
|||
stopPeriodicRender,
|
||||
} from 'src/dashboard/util/setPeriodicRunner';
|
||||
import { options as PeriodicRefreshOptions } from 'src/dashboard/components/RefreshIntervalModal';
|
||||
import findPermission from 'src/dashboard/util/findPermission';
|
||||
import { FILTER_BOX_MIGRATION_STATES } from 'src/explore/constants';
|
||||
import { DashboardEmbedModal } from '../DashboardEmbedControls';
|
||||
|
||||
const propTypes = {
|
||||
addSuccessToast: PropTypes.func.isRequired,
|
||||
|
|
@ -420,6 +422,14 @@ class Header extends React.PureComponent {
|
|||
this.setState({ showingReportModal: false });
|
||||
}
|
||||
|
||||
showEmbedModal = () => {
|
||||
this.setState({ showingEmbedModal: true });
|
||||
};
|
||||
|
||||
hideEmbedModal = () => {
|
||||
this.setState({ showingEmbedModal: false });
|
||||
};
|
||||
|
||||
renderReportModal() {
|
||||
const attachedReportExists = !!Object.keys(this.props.reports).length;
|
||||
return attachedReportExists ? (
|
||||
|
|
@ -498,6 +508,9 @@ class Header extends React.PureComponent {
|
|||
const userCanSaveAs =
|
||||
dashboardInfo.dash_save_perm &&
|
||||
filterboxMigrationState !== FILTER_BOX_MIGRATION_STATES.REVIEWING;
|
||||
const userCanCurate =
|
||||
isFeatureEnabled(FeatureFlag.EMBEDDED_SUPERSET) &&
|
||||
findPermission('can_set_embedded', 'Dashboard', user.roles);
|
||||
const shouldShowReport = !editMode && this.canAddReports();
|
||||
const refreshLimit =
|
||||
dashboardInfo.common?.conf?.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT;
|
||||
|
|
@ -658,6 +671,14 @@ class Header extends React.PureComponent {
|
|||
/>
|
||||
)}
|
||||
|
||||
{userCanCurate && (
|
||||
<DashboardEmbedModal
|
||||
show={this.state.showingEmbedModal}
|
||||
onHide={this.hideEmbedModal}
|
||||
dashboardId={dashboardInfo.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
<HeaderActionsDropdown
|
||||
addSuccessToast={this.props.addSuccessToast}
|
||||
addDangerToast={this.props.addDangerToast}
|
||||
|
|
@ -683,8 +704,10 @@ class Header extends React.PureComponent {
|
|||
userCanEdit={userCanEdit}
|
||||
userCanShare={userCanShare}
|
||||
userCanSave={userCanSaveAs}
|
||||
userCanCurate={userCanCurate}
|
||||
isLoading={isLoading}
|
||||
showPropertiesModal={this.showPropertiesModal}
|
||||
manageEmbedded={this.showEmbedModal}
|
||||
refreshLimit={refreshLimit}
|
||||
refreshWarning={refreshWarning}
|
||||
lastModifiedTime={lastModifiedTime}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import {
|
|||
} from '@superset-ui/core';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Global } from '@emotion/react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import Loading from 'src/components/Loading';
|
||||
import FilterBoxMigrationModal from 'src/dashboard/components/FilterBoxMigrationModal';
|
||||
|
|
@ -79,14 +78,17 @@ const DashboardContainer = React.lazy(
|
|||
|
||||
const originalDocumentTitle = document.title;
|
||||
|
||||
const DashboardPage: FC = () => {
|
||||
type PageProps = {
|
||||
idOrSlug: string;
|
||||
};
|
||||
|
||||
export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const theme = useTheme();
|
||||
const user = useSelector<any, UserWithPermissionsAndRoles>(
|
||||
state => state.user,
|
||||
);
|
||||
const { addDangerToast } = useToasts();
|
||||
const { idOrSlug } = useParams<{ idOrSlug: string }>();
|
||||
const { result: dashboard, error: dashboardApiError } =
|
||||
useDashboard(idOrSlug);
|
||||
const { result: charts, error: chartsApiError } =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { FC } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { DashboardPage } from './DashboardPage';
|
||||
|
||||
const DashboardRoute: FC = () => {
|
||||
const { idOrSlug } = useParams<{ idOrSlug: string }>();
|
||||
return <DashboardPage idOrSlug={idOrSlug} />;
|
||||
};
|
||||
|
||||
export default DashboardRoute;
|
||||
|
|
@ -152,3 +152,9 @@ export type DashboardPermalinkValue = {
|
|||
hash: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type EmbeddedDashboard = {
|
||||
uuid: string;
|
||||
dashboard_id: string;
|
||||
allowed_domains: string[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -45,18 +45,22 @@ const LazyDashboardPage = lazy(
|
|||
),
|
||||
);
|
||||
|
||||
const EmbeddedApp = () => (
|
||||
<Router>
|
||||
<Route path="/dashboard/:idOrSlug/embedded">
|
||||
const EmbeddedRoute = () => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<RootContextProviders>
|
||||
<ErrorBoundary>
|
||||
<LazyDashboardPage />
|
||||
<LazyDashboardPage idOrSlug={bootstrapData.embedded!.dashboard_id} />
|
||||
</ErrorBoundary>
|
||||
<ToastContainer position="top" />
|
||||
</RootContextProviders>
|
||||
</Suspense>
|
||||
</Route>
|
||||
);
|
||||
|
||||
const EmbeddedApp = () => (
|
||||
<Router>
|
||||
{/* todo (embedded) remove this line after uuids are deployed */}
|
||||
<Route path="/dashboard/:idOrSlug/embedded/" component={EmbeddedRoute} />
|
||||
<Route path="/embedded/:uuid/" component={EmbeddedRoute} />
|
||||
</Router>
|
||||
);
|
||||
|
||||
|
|
@ -64,9 +68,9 @@ const appMountPoint = document.getElementById('app')!;
|
|||
|
||||
const MESSAGE_TYPE = '__embedded_comms__';
|
||||
|
||||
if (!window.parent) {
|
||||
if (!window.parent || window.parent === window) {
|
||||
appMountPoint.innerHTML =
|
||||
'This page is intended to be embedded in an iframe, but no window.parent was found.';
|
||||
'This page is intended to be embedded in an iframe, but it looks like that is not the case.';
|
||||
}
|
||||
|
||||
// if the page is embedded in an origin that hasn't
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { Dashboard, Datasource } from 'src/dashboard/types';
|
||||
import { Dashboard, Datasource, EmbeddedDashboard } from 'src/dashboard/types';
|
||||
import { Chart } from 'src/types/Chart';
|
||||
import { useApiV1Resource, useTransformedResource } from './apiResources';
|
||||
|
||||
|
|
@ -42,3 +42,6 @@ export const useDashboardCharts = (idOrSlug: string | number) =>
|
|||
// that are necessary for rendering the given dashboard
|
||||
export const useDashboardDatasets = (idOrSlug: string | number) =>
|
||||
useApiV1Resource<Datasource[]>(`/api/v1/dashboard/${idOrSlug}/datasets`);
|
||||
|
||||
export const useEmbeddedDashboard = (idOrSlug: string | number) =>
|
||||
useApiV1Resource<EmbeddedDashboard>(`/api/v1/dashboard/${idOrSlug}/embedded`);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ export let bootstrapData: {
|
|||
user?: User | undefined;
|
||||
common?: any;
|
||||
config?: any;
|
||||
embedded?: {
|
||||
dashboard_id: string;
|
||||
};
|
||||
} = {};
|
||||
// Configure translation
|
||||
if (typeof window !== 'undefined') {
|
||||
|
|
|
|||
|
|
@ -57,10 +57,10 @@ const DashboardList = lazy(
|
|||
/* webpackChunkName: "DashboardList" */ 'src/views/CRUD/dashboard/DashboardList'
|
||||
),
|
||||
);
|
||||
const DashboardPage = lazy(
|
||||
const DashboardRoute = lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "DashboardPage" */ 'src/dashboard/containers/DashboardPage'
|
||||
/* webpackChunkName: "DashboardRoute" */ 'src/dashboard/containers/DashboardRoute'
|
||||
),
|
||||
);
|
||||
const DatabaseList = lazy(
|
||||
|
|
@ -112,7 +112,7 @@ export const routes: Routes = [
|
|||
},
|
||||
{
|
||||
path: '/superset/dashboard/:idOrSlug/',
|
||||
Component: DashboardPage,
|
||||
Component: DashboardRoute,
|
||||
},
|
||||
{
|
||||
path: '/chart/list/',
|
||||
|
|
|
|||
|
|
@ -471,9 +471,9 @@ class QueryContextProcessor:
|
|||
annotation_layer: Dict[str, Any], force: bool
|
||||
) -> Dict[str, Any]:
|
||||
chart = ChartDAO.find_by_id(annotation_layer["value"])
|
||||
form_data = chart.form_data.copy()
|
||||
if not chart:
|
||||
raise QueryObjectValidationError(_("The chart does not exist"))
|
||||
form_data = chart.form_data.copy()
|
||||
try:
|
||||
viz_obj = get_viz(
|
||||
datasource_type=chart.datasource.type,
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@
|
|||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# pylint: disable=isinstance-second-argument-not-valid-type
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
from typing import Any, Dict, List, Optional, Type, Union
|
||||
|
||||
from flask_appbuilder.models.filters import BaseFilter
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.exc import SQLAlchemyError, StatementError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from superset.dao.exceptions import (
|
||||
|
|
@ -46,9 +46,12 @@ class BaseDAO:
|
|||
"""
|
||||
Child classes can register base filtering to be aplied to all filter methods
|
||||
"""
|
||||
id_column_name = "id"
|
||||
|
||||
@classmethod
|
||||
def find_by_id(cls, model_id: int, session: Session = None) -> Model:
|
||||
def find_by_id(
|
||||
cls, model_id: Union[str, int], session: Session = None
|
||||
) -> Optional[Model]:
|
||||
"""
|
||||
Find a model by id, if defined applies `base_filter`
|
||||
"""
|
||||
|
|
@ -57,23 +60,28 @@ class BaseDAO:
|
|||
if cls.base_filter:
|
||||
data_model = SQLAInterface(cls.model_cls, session)
|
||||
query = cls.base_filter( # pylint: disable=not-callable
|
||||
"id", data_model
|
||||
cls.id_column_name, data_model
|
||||
).apply(query, None)
|
||||
return query.filter_by(id=model_id).one_or_none()
|
||||
id_filter = {cls.id_column_name: model_id}
|
||||
try:
|
||||
return query.filter_by(**id_filter).one_or_none()
|
||||
except StatementError:
|
||||
# can happen if int is passed instead of a string or similar
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def find_by_ids(cls, model_ids: List[int]) -> List[Model]:
|
||||
def find_by_ids(cls, model_ids: Union[List[str], List[int]]) -> List[Model]:
|
||||
"""
|
||||
Find a List of models by a list of ids, if defined applies `base_filter`
|
||||
"""
|
||||
id_col = getattr(cls.model_cls, "id", None)
|
||||
id_col = getattr(cls.model_cls, cls.id_column_name, None)
|
||||
if id_col is None:
|
||||
return []
|
||||
query = db.session.query(cls.model_cls).filter(id_col.in_(model_ids))
|
||||
if cls.base_filter:
|
||||
data_model = SQLAInterface(cls.model_cls, db.session)
|
||||
query = cls.base_filter( # pylint: disable=not-callable
|
||||
"id", data_model
|
||||
cls.id_column_name, data_model
|
||||
).apply(query, None)
|
||||
return query.all()
|
||||
|
||||
|
|
@ -86,7 +94,7 @@ class BaseDAO:
|
|||
if cls.base_filter:
|
||||
data_model = SQLAInterface(cls.model_cls, db.session)
|
||||
query = cls.base_filter( # pylint: disable=not-callable
|
||||
"id", data_model
|
||||
cls.id_column_name, data_model
|
||||
).apply(query, None)
|
||||
return query.all()
|
||||
|
||||
|
|
@ -99,7 +107,7 @@ class BaseDAO:
|
|||
if cls.base_filter:
|
||||
data_model = SQLAInterface(cls.model_cls, db.session)
|
||||
query = cls.base_filter( # pylint: disable=not-callable
|
||||
"id", data_model
|
||||
cls.id_column_name, data_model
|
||||
).apply(query, None)
|
||||
return query.filter_by(**filter_by).one_or_none()
|
||||
|
||||
|
|
|
|||
|
|
@ -15,14 +15,16 @@
|
|||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# pylint: disable=too-many-lines
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
from zipfile import is_zipfile, ZipFile
|
||||
|
||||
from flask import g, make_response, redirect, request, Response, send_file, url_for
|
||||
from flask_appbuilder import permission_name
|
||||
from flask_appbuilder.api import expose, protect, rison, safe
|
||||
from flask_appbuilder.hooks import before_request
|
||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
|
|
@ -65,6 +67,8 @@ from superset.dashboards.schemas import (
|
|||
DashboardGetResponseSchema,
|
||||
DashboardPostSchema,
|
||||
DashboardPutSchema,
|
||||
EmbeddedDashboardConfigSchema,
|
||||
EmbeddedDashboardResponseSchema,
|
||||
get_delete_ids_schema,
|
||||
get_export_ids_schema,
|
||||
get_fav_star_ids_schema,
|
||||
|
|
@ -72,8 +76,10 @@ from superset.dashboards.schemas import (
|
|||
openapi_spec_methods_override,
|
||||
thumbnail_query_schema,
|
||||
)
|
||||
from superset.embedded.dao import EmbeddedDAO
|
||||
from superset.extensions import event_logger
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.embedded_dashboard import EmbeddedDashboard
|
||||
from superset.tasks.thumbnails import cache_dashboard_thumbnail
|
||||
from superset.utils.cache import etag_cache
|
||||
from superset.utils.screenshots import DashboardScreenshot
|
||||
|
|
@ -91,6 +97,27 @@ from superset.views.filters import FilterRelatedOwners
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def with_dashboard(
|
||||
f: Callable[[BaseSupersetModelRestApi, Dashboard], Response]
|
||||
) -> Callable[[BaseSupersetModelRestApi, str], Response]:
|
||||
"""
|
||||
A decorator that looks up the dashboard by id or slug and passes it to the api.
|
||||
Route must include an <id_or_slug> parameter.
|
||||
Responds with 403 or 404 without calling the route, if necessary.
|
||||
"""
|
||||
|
||||
def wraps(self: BaseSupersetModelRestApi, id_or_slug: str) -> Response:
|
||||
try:
|
||||
dash = DashboardDAO.get_by_id_or_slug(id_or_slug)
|
||||
return f(self, dash)
|
||||
except DashboardAccessDeniedError:
|
||||
return self.response_403()
|
||||
except DashboardNotFoundError:
|
||||
return self.response_404()
|
||||
|
||||
return functools.update_wrapper(wraps, f)
|
||||
|
||||
|
||||
class DashboardRestApi(BaseSupersetModelRestApi):
|
||||
datamodel = SQLAInterface(Dashboard)
|
||||
|
||||
|
|
@ -108,6 +135,9 @@ class DashboardRestApi(BaseSupersetModelRestApi):
|
|||
"favorite_status",
|
||||
"get_charts",
|
||||
"get_datasets",
|
||||
"get_embedded",
|
||||
"set_embedded",
|
||||
"delete_embedded",
|
||||
"thumbnail",
|
||||
}
|
||||
resource_name = "dashboard"
|
||||
|
|
@ -193,6 +223,8 @@ class DashboardRestApi(BaseSupersetModelRestApi):
|
|||
chart_entity_response_schema = ChartEntityResponseSchema()
|
||||
dashboard_get_response_schema = DashboardGetResponseSchema()
|
||||
dashboard_dataset_schema = DashboardDatasetSchema()
|
||||
embedded_response_schema = EmbeddedDashboardResponseSchema()
|
||||
embedded_config_schema = EmbeddedDashboardConfigSchema()
|
||||
|
||||
base_filters = [["id", DashboardAccessFilter, lambda: []]]
|
||||
|
||||
|
|
@ -215,6 +247,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
|
|||
DashboardGetResponseSchema,
|
||||
DashboardDatasetSchema,
|
||||
GetFavStarIdsSchema,
|
||||
EmbeddedDashboardResponseSchema,
|
||||
)
|
||||
apispec_parameter_schemas = {
|
||||
"get_delete_ids_schema": get_delete_ids_schema,
|
||||
|
|
@ -248,9 +281,11 @@ class DashboardRestApi(BaseSupersetModelRestApi):
|
|||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get",
|
||||
log_to_statsd=False, # pylint: disable=arguments-renamed
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def get(self, id_or_slug: str) -> Response:
|
||||
@with_dashboard
|
||||
# pylint: disable=arguments-renamed, arguments-differ
|
||||
def get(self, dash: Dashboard) -> Response:
|
||||
"""Gets a dashboard
|
||||
---
|
||||
get:
|
||||
|
|
@ -283,15 +318,8 @@ class DashboardRestApi(BaseSupersetModelRestApi):
|
|||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
"""
|
||||
# pylint: disable=arguments-differ
|
||||
try:
|
||||
dash = DashboardDAO.get_by_id_or_slug(id_or_slug)
|
||||
result = self.dashboard_get_response_schema.dump(dash)
|
||||
return self.response(200, result=result)
|
||||
except DashboardAccessDeniedError:
|
||||
return self.response_403()
|
||||
except DashboardNotFoundError:
|
||||
return self.response_404()
|
||||
|
||||
@etag_cache(
|
||||
get_last_modified=lambda _self, id_or_slug: DashboardDAO.get_dashboard_and_datasets_changed_on( # pylint: disable=line-too-long,useless-suppression
|
||||
|
|
@ -1001,3 +1029,168 @@ class DashboardRestApi(BaseSupersetModelRestApi):
|
|||
)
|
||||
command.run()
|
||||
return self.response(200, message="OK")
|
||||
|
||||
@expose("/<id_or_slug>/embedded", methods=["GET"])
|
||||
@protect()
|
||||
@safe
|
||||
@permission_name("read")
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_embedded",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
@with_dashboard
|
||||
def get_embedded(self, dashboard: Dashboard) -> Response:
|
||||
"""Response
|
||||
Returns the dashboard's embedded configuration
|
||||
---
|
||||
get:
|
||||
description: >-
|
||||
Returns the dashboard's embedded configuration
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: string
|
||||
name: id_or_slug
|
||||
description: The dashboard id or slug
|
||||
responses:
|
||||
200:
|
||||
description: Result contains the embedded dashboard config
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: '#/components/schemas/EmbeddedDashboardResponseSchema'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
if not dashboard.embedded:
|
||||
return self.response(404)
|
||||
embedded: EmbeddedDashboard = dashboard.embedded[0]
|
||||
result = self.embedded_response_schema.dump(embedded)
|
||||
return self.response(200, result=result)
|
||||
|
||||
@expose("/<id_or_slug>/embedded", methods=["POST", "PUT"])
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.set_embedded",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
@with_dashboard
|
||||
def set_embedded(self, dashboard: Dashboard) -> Response:
|
||||
"""Response
|
||||
Sets a dashboard's embedded configuration.
|
||||
---
|
||||
post:
|
||||
description: >-
|
||||
Sets a dashboard's embedded configuration.
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: string
|
||||
name: id_or_slug
|
||||
description: The dashboard id or slug
|
||||
requestBody:
|
||||
description: The embedded configuration to set
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: EmbeddedDashboardConfigSchema
|
||||
responses:
|
||||
200:
|
||||
description: Successfully set the configuration
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: '#/components/schemas/EmbeddedDashboardResponseSchema'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
put:
|
||||
description: >-
|
||||
Sets a dashboard's embedded configuration.
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: string
|
||||
name: id_or_slug
|
||||
description: The dashboard id or slug
|
||||
requestBody:
|
||||
description: The embedded configuration to set
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: EmbeddedDashboardConfigSchema
|
||||
responses:
|
||||
200:
|
||||
description: Successfully set the configuration
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: '#/components/schemas/EmbeddedDashboardResponseSchema'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
try:
|
||||
body = self.embedded_config_schema.load(request.json)
|
||||
embedded = EmbeddedDAO.upsert(dashboard, body["allowed_domains"])
|
||||
result = self.embedded_response_schema.dump(embedded)
|
||||
return self.response(200, result=result)
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
@expose("/<id_or_slug>/embedded", methods=["DELETE"])
|
||||
@protect()
|
||||
@safe
|
||||
@permission_name("set_embedded")
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete_embedded",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
@with_dashboard
|
||||
def delete_embedded(self, dashboard: Dashboard) -> Response:
|
||||
"""Response
|
||||
Removes a dashboard's embedded configuration.
|
||||
---
|
||||
delete:
|
||||
description: >-
|
||||
Removes a dashboard's embedded configuration.
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: string
|
||||
name: id_or_slug
|
||||
description: The dashboard id or slug
|
||||
responses:
|
||||
200:
|
||||
description: Successfully removed the configuration
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
dashboard.embedded = []
|
||||
return self.response(200, message="OK")
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ class ExportDashboardsCommand(ExportModelsCommand):
|
|||
dataset_id = target.pop("datasetId", None)
|
||||
if dataset_id is not None:
|
||||
dataset = DatasetDAO.find_by_id(dataset_id)
|
||||
if dataset:
|
||||
target["datasetUuid"] = str(dataset.uuid)
|
||||
if export_related:
|
||||
yield from ExportDatasetsCommand([dataset_id]).run()
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from typing import Any, Optional
|
||||
import uuid
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from flask import g
|
||||
from flask_appbuilder.security.sqla.models import Role
|
||||
|
|
@ -25,6 +26,7 @@ from sqlalchemy.orm.query import Query
|
|||
from superset import db, is_feature_enabled, security_manager
|
||||
from superset.models.core import FavStar
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.embedded_dashboard import EmbeddedDashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.security.guest_token import GuestTokenResourceType, GuestUser
|
||||
from superset.views.base import BaseFilter, is_user_admin
|
||||
|
|
@ -59,6 +61,14 @@ class DashboardFavoriteFilter( # pylint: disable=too-few-public-methods
|
|||
model = Dashboard
|
||||
|
||||
|
||||
def is_uuid(value: Union[str, int]) -> bool:
|
||||
try:
|
||||
uuid.UUID(str(value))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
List dashboards with the following criteria:
|
||||
|
|
@ -133,14 +143,24 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
|
|||
if is_feature_enabled("EMBEDDED_SUPERSET") and security_manager.is_guest_user(
|
||||
g.user
|
||||
):
|
||||
|
||||
guest_user: GuestUser = g.user
|
||||
embedded_dashboard_ids = [
|
||||
r["id"]
|
||||
for r in guest_user.resources
|
||||
if r["type"] == GuestTokenResourceType.DASHBOARD.value
|
||||
]
|
||||
if len(embedded_dashboard_ids) != 0:
|
||||
feature_flagged_filters.append(Dashboard.id.in_(embedded_dashboard_ids))
|
||||
|
||||
# TODO (embedded): only use uuid filter once uuids are rolled out
|
||||
condition = (
|
||||
Dashboard.embedded.any(
|
||||
EmbeddedDashboard.uuid.in_(embedded_dashboard_ids)
|
||||
)
|
||||
if any(is_uuid(id_) for id_ in embedded_dashboard_ids)
|
||||
else Dashboard.id.in_(embedded_dashboard_ids)
|
||||
)
|
||||
|
||||
feature_flagged_filters.append(condition)
|
||||
|
||||
query = query.filter(
|
||||
or_(
|
||||
|
|
|
|||
|
|
@ -309,3 +309,15 @@ class ImportV1DashboardSchema(Schema):
|
|||
version = fields.String(required=True)
|
||||
is_managed_externally = fields.Boolean(allow_none=True, default=False)
|
||||
external_url = fields.String(allow_none=True)
|
||||
|
||||
|
||||
class EmbeddedDashboardConfigSchema(Schema):
|
||||
allowed_domains = fields.List(fields.String(), required=True)
|
||||
|
||||
|
||||
class EmbeddedDashboardResponseSchema(Schema):
|
||||
uuid = fields.String()
|
||||
allowed_domains = fields.List(fields.String())
|
||||
dashboard_id = fields.String()
|
||||
changed_on = fields.DateTime()
|
||||
changed_by = fields.Nested(UserSchema)
|
||||
|
|
|
|||
|
|
@ -68,7 +68,8 @@ class DatabaseDAO(BaseDAO):
|
|||
|
||||
@classmethod
|
||||
def get_related_objects(cls, database_id: int) -> Dict[str, Any]:
|
||||
datasets = cls.find_by_id(database_id).tables
|
||||
database: Any = cls.find_by_id(database_id)
|
||||
datasets = database.tables
|
||||
dataset_ids = [dataset.id for dataset in datasets]
|
||||
|
||||
charts = (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from superset.dao.base import BaseDAO
|
||||
from superset.extensions import db
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.embedded_dashboard import EmbeddedDashboard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmbeddedDAO(BaseDAO):
|
||||
model_cls = EmbeddedDashboard
|
||||
# There isn't really a regular scenario where we would rather get Embedded by id
|
||||
id_column_name = "uuid"
|
||||
|
||||
@staticmethod
|
||||
def upsert(dashboard: Dashboard, allowed_domains: List[str]) -> EmbeddedDashboard:
|
||||
"""
|
||||
Sets up a dashboard to be embeddable.
|
||||
Upsert is used to preserve the embedded_dashboard uuid across updates.
|
||||
"""
|
||||
embedded: EmbeddedDashboard = (
|
||||
dashboard.embedded[0] if dashboard.embedded else EmbeddedDashboard()
|
||||
)
|
||||
embedded.allow_domain_list = ",".join(allowed_domains)
|
||||
dashboard.embedded = [embedded]
|
||||
db.session.commit()
|
||||
return embedded
|
||||
|
||||
@classmethod
|
||||
def create(cls, properties: Dict[str, Any], commit: bool = True) -> Any:
|
||||
"""
|
||||
Use EmbeddedDAO.upsert() instead.
|
||||
At least, until we are ok with more than one embedded instance per dashboard.
|
||||
"""
|
||||
raise NotImplementedError("Use EmbeddedDAO.upsert() instead.")
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import json
|
||||
from typing import Callable
|
||||
|
||||
from flask import abort
|
||||
from flask_appbuilder import expose
|
||||
from flask_login import AnonymousUserMixin, LoginManager
|
||||
|
||||
from superset import event_logger, is_feature_enabled, security_manager
|
||||
from superset.embedded.dao import EmbeddedDAO
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.utils import core as utils
|
||||
from superset.views.base import BaseSupersetView, common_bootstrap_payload
|
||||
|
||||
|
||||
class EmbeddedView(BaseSupersetView):
|
||||
"""The views for embedded resources to be rendered in an iframe"""
|
||||
|
||||
route_base = "/embedded"
|
||||
|
||||
@expose("/<uuid>")
|
||||
@event_logger.log_this_with_extra_payload
|
||||
def embedded(
|
||||
self,
|
||||
uuid: str,
|
||||
add_extra_log_payload: Callable[..., None] = lambda **kwargs: None,
|
||||
) -> FlaskResponse:
|
||||
"""
|
||||
Server side rendering for the embedded dashboard page
|
||||
:param uuid: identifier for embedded dashboard
|
||||
:param add_extra_log_payload: added by `log_this_with_manual_updates`, set a
|
||||
default value to appease pylint
|
||||
"""
|
||||
if not is_feature_enabled("EMBEDDED_SUPERSET"):
|
||||
abort(404)
|
||||
|
||||
embedded = EmbeddedDAO.find_by_id(uuid)
|
||||
if not embedded:
|
||||
abort(404)
|
||||
|
||||
# Log in as an anonymous user, just for this view.
|
||||
# This view needs to be visible to all users,
|
||||
# and building the page fails if g.user and/or ctx.user aren't present.
|
||||
login_manager: LoginManager = security_manager.lm
|
||||
login_manager.reload_user(AnonymousUserMixin())
|
||||
|
||||
add_extra_log_payload(
|
||||
embedded_dashboard_id=uuid,
|
||||
dashboard_version="v2",
|
||||
)
|
||||
|
||||
bootstrap_data = {
|
||||
"common": common_bootstrap_payload(),
|
||||
"embedded": {
|
||||
"dashboard_id": embedded.dashboard_id,
|
||||
},
|
||||
}
|
||||
|
||||
return self.render_template(
|
||||
"superset/spa.html",
|
||||
entry="embedded",
|
||||
bootstrap_data=json.dumps(
|
||||
bootstrap_data, default=utils.pessimistic_json_iso_dttm_ser
|
||||
),
|
||||
)
|
||||
|
|
@ -141,6 +141,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
|||
from superset.datasets.api import DatasetRestApi
|
||||
from superset.datasets.columns.api import DatasetColumnsRestApi
|
||||
from superset.datasets.metrics.api import DatasetMetricRestApi
|
||||
from superset.embedded.view import EmbeddedView
|
||||
from superset.explore.form_data.api import ExploreFormDataRestApi
|
||||
from superset.explore.permalink.api import ExplorePermalinkRestApi
|
||||
from superset.importexport.api import ImportExportRestApi
|
||||
|
|
@ -292,6 +293,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
|||
appbuilder.add_view_no_menu(Dashboard)
|
||||
appbuilder.add_view_no_menu(DashboardModelViewAsync)
|
||||
appbuilder.add_view_no_menu(Datasource)
|
||||
appbuilder.add_view_no_menu(EmbeddedView)
|
||||
appbuilder.add_view_no_menu(KV)
|
||||
appbuilder.add_view_no_menu(R)
|
||||
appbuilder.add_view_no_menu(SavedQueryView)
|
||||
|
|
|
|||
|
|
@ -152,6 +152,11 @@ class Dashboard(Model, AuditMixinNullable, ImportExportMixin):
|
|||
is_managed_externally = Column(Boolean, nullable=False, default=False)
|
||||
external_url = Column(Text, nullable=True)
|
||||
roles = relationship(security_manager.role_model, secondary=DashboardRoles)
|
||||
embedded = relationship(
|
||||
"EmbeddedDashboard",
|
||||
back_populates="dashboard",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
_filter_sets = relationship(
|
||||
"FilterSet", back_populates="dashboard", cascade="all, delete"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import uuid
|
||||
from typing import List
|
||||
|
||||
from flask_appbuilder import Model
|
||||
from sqlalchemy import Column, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy_utils import UUIDType
|
||||
|
||||
from superset.models.helpers import AuditMixinNullable
|
||||
|
||||
|
||||
class EmbeddedDashboard(Model, AuditMixinNullable):
|
||||
"""
|
||||
A configuration of embedding for a dashboard.
|
||||
Currently, the only embeddable resource is the Dashboard.
|
||||
If we add new embeddable resource types, this model should probably be renamed.
|
||||
|
||||
References the dashboard, and contains a config for embedding that dashboard.
|
||||
|
||||
This data model allows multiple configurations for a given dashboard,
|
||||
but at this time the API only allows setting one.
|
||||
"""
|
||||
|
||||
__tablename__ = "embedded_dashboards"
|
||||
|
||||
uuid = Column(UUIDType(binary=True), default=uuid.uuid4, primary_key=True)
|
||||
allow_domain_list = Column(Text) # reference the `allowed_domains` property instead
|
||||
dashboard_id = Column(Integer, ForeignKey("dashboards.id"), nullable=False)
|
||||
dashboard = relationship(
|
||||
"Dashboard",
|
||||
back_populates="embedded",
|
||||
foreign_keys=[dashboard_id],
|
||||
)
|
||||
|
||||
@property
|
||||
def allowed_domains(self) -> List[str]:
|
||||
"""
|
||||
A list of domains which are allowed to embed the dashboard.
|
||||
An empty list means any domain can embed.
|
||||
"""
|
||||
return self.allow_domain_list.split(",") if self.allow_domain_list else []
|
||||
|
|
@ -189,6 +189,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
|||
"can_update_role",
|
||||
"all_query_access",
|
||||
"can_grant_guest_token",
|
||||
"can_set_embedded",
|
||||
}
|
||||
|
||||
READ_ONLY_PERMISSION = {
|
||||
|
|
@ -1268,10 +1269,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
|||
for dashboard_role in dashboard.roles
|
||||
)
|
||||
|
||||
if self.is_guest_user():
|
||||
can_access = self.has_guest_access(
|
||||
GuestTokenResourceType.DASHBOARD, dashboard.id
|
||||
)
|
||||
if self.is_guest_user() and dashboard.embedded:
|
||||
can_access = self.has_guest_access(dashboard)
|
||||
else:
|
||||
can_access = (
|
||||
is_user_admin()
|
||||
|
|
@ -1410,15 +1409,26 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
|||
return g.user
|
||||
return None
|
||||
|
||||
def has_guest_access(
|
||||
self, resource_type: GuestTokenResourceType, resource_id: Union[str, int]
|
||||
) -> bool:
|
||||
def has_guest_access(self, dashboard: "Dashboard") -> bool:
|
||||
user = self.get_current_guest_user_if_guest()
|
||||
if not user:
|
||||
return False
|
||||
|
||||
strid = str(resource_id)
|
||||
for resource in user.resources:
|
||||
if resource["type"] == resource_type.value and str(resource["id"]) == strid:
|
||||
dashboards = [
|
||||
r
|
||||
for r in user.resources
|
||||
if r["type"] == GuestTokenResourceType.DASHBOARD.value
|
||||
]
|
||||
|
||||
# TODO (embedded): remove this check once uuids are rolled out
|
||||
for resource in dashboards:
|
||||
if str(resource["id"]) == str(dashboard.id):
|
||||
return True
|
||||
|
||||
if not dashboard.embedded:
|
||||
return False
|
||||
|
||||
for resource in dashboards:
|
||||
if str(resource["id"]) == str(dashboard.embedded[0].uuid):
|
||||
return True
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ class ExecuteSqlCommand(BaseCommand):
|
|||
raise ex
|
||||
|
||||
def _get_the_query_db(self) -> Database:
|
||||
mydb = self._database_dao.find_by_id(self._execution_context.database_id)
|
||||
mydb: Any = self._database_dao.find_by_id(self._execution_context.database_id)
|
||||
self._validate_query_db(mydb)
|
||||
return mydb
|
||||
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ class Dashboard(BaseSupersetView):
|
|||
|
||||
bootstrap_data = {
|
||||
"common": common_bootstrap_payload(),
|
||||
"embedded": {"dashboard_id": dashboard_id_or_slug},
|
||||
}
|
||||
|
||||
return self.render_template(
|
||||
|
|
|
|||
|
|
@ -388,7 +388,14 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin, InsertChartMixi
|
|||
rv = self.get_assert_metric(uri, "info")
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
assert rv.status_code == 200
|
||||
assert set(data["permissions"]) == {"can_read", "can_write", "can_export"}
|
||||
assert set(data["permissions"]) == {
|
||||
"can_read",
|
||||
"can_write",
|
||||
"can_export",
|
||||
"can_get_embedded",
|
||||
"can_delete_embedded",
|
||||
"can_set_embedded",
|
||||
}
|
||||
|
||||
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
|
||||
def test_get_dashboard_not_found(self):
|
||||
|
|
@ -1710,3 +1717,58 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin, InsertChartMixi
|
|||
|
||||
response_roles = [result["text"] for result in response["result"]]
|
||||
assert "Alpha" in response_roles
|
||||
|
||||
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
|
||||
def test_embedded_dashboards(self):
|
||||
self.login(username="admin")
|
||||
uri = "api/v1/dashboard/world_health/embedded"
|
||||
|
||||
# initial get should return 404
|
||||
resp = self.get_assert_metric(uri, "get_embedded")
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
# post succeeds and returns value
|
||||
allowed_domains = ["test.example", "embedded.example"]
|
||||
resp = self.post_assert_metric(
|
||||
uri,
|
||||
{"allowed_domains": allowed_domains},
|
||||
"set_embedded",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
result = json.loads(resp.data.decode("utf-8"))["result"]
|
||||
self.assertIsNotNone(result["uuid"])
|
||||
self.assertNotEqual(result["uuid"], "")
|
||||
self.assertEqual(result["allowed_domains"], allowed_domains)
|
||||
|
||||
# get returns value
|
||||
resp = self.get_assert_metric(uri, "get_embedded")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
result = json.loads(resp.data.decode("utf-8"))["result"]
|
||||
self.assertIsNotNone(result["uuid"])
|
||||
self.assertNotEqual(result["uuid"], "")
|
||||
self.assertEqual(result["allowed_domains"], allowed_domains)
|
||||
|
||||
# save uuid for later
|
||||
original_uuid = result["uuid"]
|
||||
|
||||
# put succeeds and returns value
|
||||
resp = self.post_assert_metric(uri, {"allowed_domains": []}, "set_embedded")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIsNotNone(result["uuid"])
|
||||
self.assertNotEqual(result["uuid"], "")
|
||||
self.assertEqual(result["allowed_domains"], allowed_domains)
|
||||
|
||||
# get returns changed value
|
||||
resp = self.get_assert_metric(uri, "get_embedded")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
result = json.loads(resp.data.decode("utf-8"))["result"]
|
||||
self.assertEqual(result["uuid"], original_uuid)
|
||||
self.assertEqual(result["allowed_domains"], [])
|
||||
|
||||
# delete succeeds
|
||||
resp = self.delete_assert_metric(uri, "delete_embedded")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# get returns 404
|
||||
resp = self.get_assert_metric(uri, "get_embedded")
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# isort:skip_file
|
||||
import pytest
|
||||
|
||||
import tests.integration_tests.test_app # pylint: disable=unused-import
|
||||
from superset import db
|
||||
from superset.embedded.dao import EmbeddedDAO
|
||||
from superset.models.dashboard import Dashboard
|
||||
from tests.integration_tests.base_tests import SupersetTestCase
|
||||
from tests.integration_tests.fixtures.world_bank_dashboard import (
|
||||
load_world_bank_dashboard_with_slices,
|
||||
load_world_bank_data,
|
||||
)
|
||||
|
||||
|
||||
class TestEmbeddedDAO(SupersetTestCase):
|
||||
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
|
||||
def test_upsert(self):
|
||||
dash = db.session.query(Dashboard).filter_by(slug="world_health").first()
|
||||
assert not dash.embedded
|
||||
EmbeddedDAO.upsert(dash, ["test.example.com"])
|
||||
assert dash.embedded
|
||||
self.assertEqual(dash.embedded[0].allowed_domains, ["test.example.com"])
|
||||
original_uuid = dash.embedded[0].uuid
|
||||
self.assertIsNotNone(original_uuid)
|
||||
EmbeddedDAO.upsert(dash, [])
|
||||
self.assertEqual(dash.embedded[0].allowed_domains, [])
|
||||
self.assertEqual(dash.embedded[0].uuid, original_uuid)
|
||||
|
||||
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
|
||||
def test_get_by_uuid(self):
|
||||
dash = db.session.query(Dashboard).filter_by(slug="world_health").first()
|
||||
uuid = str(EmbeddedDAO.upsert(dash, ["test.example.com"]).uuid)
|
||||
db.session.expire_all()
|
||||
embedded = EmbeddedDAO.find_by_id(uuid)
|
||||
self.assertIsNotNone(embedded)
|
||||
|
|
@ -22,6 +22,7 @@ from flask import g
|
|||
|
||||
from superset import db, security_manager
|
||||
from superset.dashboards.commands.exceptions import DashboardAccessDeniedError
|
||||
from superset.embedded.dao import EmbeddedDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.security.guest_token import GuestTokenResourceType
|
||||
|
|
@ -38,14 +39,9 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
|
|||
EMBEDDED_SUPERSET=True,
|
||||
)
|
||||
class TestGuestUserSecurity(SupersetTestCase):
|
||||
# This test doesn't use a dashboard fixture, the next test does.
|
||||
# That way tests are faster.
|
||||
|
||||
resource_id = 42
|
||||
|
||||
def authorized_guest(self):
|
||||
return security_manager.get_guest_user_from_token(
|
||||
{"user": {}, "resources": [{"type": "dashboard", "id": self.resource_id}]}
|
||||
{"user": {}, "resources": [{"type": "dashboard", "id": "some-uuid"}]}
|
||||
)
|
||||
|
||||
def test_is_guest_user__regular_user(self):
|
||||
|
|
@ -83,60 +79,6 @@ class TestGuestUserSecurity(SupersetTestCase):
|
|||
guest_user = security_manager.get_current_guest_user_if_guest()
|
||||
self.assertEqual(guest_user, g.user)
|
||||
|
||||
def test_has_guest_access__regular_user(self):
|
||||
g.user = security_manager.find_user("admin")
|
||||
has_guest_access = security_manager.has_guest_access(
|
||||
GuestTokenResourceType.DASHBOARD, self.resource_id
|
||||
)
|
||||
self.assertFalse(has_guest_access)
|
||||
|
||||
def test_has_guest_access__anonymous_user(self):
|
||||
g.user = security_manager.get_anonymous_user()
|
||||
has_guest_access = security_manager.has_guest_access(
|
||||
GuestTokenResourceType.DASHBOARD, self.resource_id
|
||||
)
|
||||
self.assertFalse(has_guest_access)
|
||||
|
||||
def test_has_guest_access__authorized_guest_user(self):
|
||||
g.user = self.authorized_guest()
|
||||
has_guest_access = security_manager.has_guest_access(
|
||||
GuestTokenResourceType.DASHBOARD, self.resource_id
|
||||
)
|
||||
self.assertTrue(has_guest_access)
|
||||
|
||||
def test_has_guest_access__authorized_guest_user__non_zero_resource_index(self):
|
||||
guest = self.authorized_guest()
|
||||
guest.resources = [
|
||||
{"type": "dashboard", "id": self.resource_id - 1}
|
||||
] + guest.resources
|
||||
g.user = guest
|
||||
|
||||
has_guest_access = security_manager.has_guest_access(
|
||||
GuestTokenResourceType.DASHBOARD, self.resource_id
|
||||
)
|
||||
self.assertTrue(has_guest_access)
|
||||
|
||||
def test_has_guest_access__unauthorized_guest_user__different_resource_id(self):
|
||||
g.user = security_manager.get_guest_user_from_token(
|
||||
{
|
||||
"user": {},
|
||||
"resources": [{"type": "dashboard", "id": self.resource_id - 1}],
|
||||
}
|
||||
)
|
||||
has_guest_access = security_manager.has_guest_access(
|
||||
GuestTokenResourceType.DASHBOARD, self.resource_id
|
||||
)
|
||||
self.assertFalse(has_guest_access)
|
||||
|
||||
def test_has_guest_access__unauthorized_guest_user__different_resource_type(self):
|
||||
g.user = security_manager.get_guest_user_from_token(
|
||||
{"user": {}, "resources": [{"type": "dirt", "id": self.resource_id}]}
|
||||
)
|
||||
has_guest_access = security_manager.has_guest_access(
|
||||
GuestTokenResourceType.DASHBOARD, self.resource_id
|
||||
)
|
||||
self.assertFalse(has_guest_access)
|
||||
|
||||
def test_get_guest_user_roles_explicit(self):
|
||||
guest = self.authorized_guest()
|
||||
roles = security_manager.get_user_roles(guest)
|
||||
|
|
@ -158,13 +100,65 @@ class TestGuestUserSecurity(SupersetTestCase):
|
|||
class TestGuestUserDashboardAccess(SupersetTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.dash = db.session.query(Dashboard).filter_by(slug="births").first()
|
||||
self.embedded = EmbeddedDAO.upsert(self.dash, [])
|
||||
self.authorized_guest = security_manager.get_guest_user_from_token(
|
||||
{"user": {}, "resources": [{"type": "dashboard", "id": self.dash.id}]}
|
||||
{
|
||||
"user": {},
|
||||
"resources": [{"type": "dashboard", "id": str(self.embedded.uuid)}],
|
||||
}
|
||||
)
|
||||
self.unauthorized_guest = security_manager.get_guest_user_from_token(
|
||||
{"user": {}, "resources": [{"type": "dashboard", "id": self.dash.id + 1}]}
|
||||
{
|
||||
"user": {},
|
||||
"resources": [
|
||||
{"type": "dashboard", "id": "06383667-3e02-4e5e-843f-44e9c5896b6c"}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
def test_has_guest_access__regular_user(self):
|
||||
g.user = security_manager.find_user("admin")
|
||||
has_guest_access = security_manager.has_guest_access(self.dash)
|
||||
self.assertFalse(has_guest_access)
|
||||
|
||||
def test_has_guest_access__anonymous_user(self):
|
||||
g.user = security_manager.get_anonymous_user()
|
||||
has_guest_access = security_manager.has_guest_access(self.dash)
|
||||
self.assertFalse(has_guest_access)
|
||||
|
||||
def test_has_guest_access__authorized_guest_user(self):
|
||||
g.user = self.authorized_guest
|
||||
has_guest_access = security_manager.has_guest_access(self.dash)
|
||||
self.assertTrue(has_guest_access)
|
||||
|
||||
def test_has_guest_access__authorized_guest_user__non_zero_resource_index(self):
|
||||
# set up a user who has authorized access, plus another resource
|
||||
guest = self.authorized_guest
|
||||
guest.resources = [
|
||||
{"type": "dashboard", "id": "not-a-real-id"}
|
||||
] + guest.resources
|
||||
g.user = guest
|
||||
|
||||
has_guest_access = security_manager.has_guest_access(self.dash)
|
||||
self.assertTrue(has_guest_access)
|
||||
|
||||
def test_has_guest_access__unauthorized_guest_user__different_resource_id(self):
|
||||
g.user = security_manager.get_guest_user_from_token(
|
||||
{
|
||||
"user": {},
|
||||
"resources": [{"type": "dashboard", "id": "not-a-real-id"}],
|
||||
}
|
||||
)
|
||||
has_guest_access = security_manager.has_guest_access(self.dash)
|
||||
self.assertFalse(has_guest_access)
|
||||
|
||||
def test_has_guest_access__unauthorized_guest_user__different_resource_type(self):
|
||||
g.user = security_manager.get_guest_user_from_token(
|
||||
{"user": {}, "resources": [{"type": "dirt", "id": self.embedded.uuid}]}
|
||||
)
|
||||
has_guest_access = security_manager.has_guest_access(self.dash)
|
||||
self.assertFalse(has_guest_access)
|
||||
|
||||
def test_chart_raise_for_access_as_guest(self):
|
||||
chart = self.dash.slices[0]
|
||||
g.user = self.authorized_guest
|
||||
|
|
|
|||
|
|
@ -888,7 +888,9 @@ class TestRolePermission(SupersetTestCase):
|
|||
["AuthDBView", "login"],
|
||||
["AuthDBView", "logout"],
|
||||
["CurrentUserRestApi", "get_me"],
|
||||
# TODO (embedded) remove Dashboard:embedded after uuids have been shipped
|
||||
["Dashboard", "embedded"],
|
||||
["EmbeddedView", "embedded"],
|
||||
["R", "index"],
|
||||
["Superset", "log"],
|
||||
["Superset", "theme"],
|
||||
|
|
|
|||
Loading…
Reference in New Issue