Compare commits

...

3 Commits

Author SHA1 Message Date
alexandrusoare
f9fb95bfc1 latest changes for phase 2: 2026-07-20 14:15:22 +03:00
alexandrusoare
c5671bf47c pushing changes 2026-07-14 16:02:08 +03:00
alexandrusoare
cdede589d8 latest changes 2026-07-14 15:48:21 +03:00
44 changed files with 10223 additions and 17 deletions

View File

@@ -0,0 +1,83 @@
/**
* 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 { action } from 'storybook/actions';
import { FolderBreadcrumb } from './FolderBreadcrumb';
import type { FolderBreadcrumbProps } from './types';
export default {
title: 'Components/Folders/FolderBreadcrumb',
component: FolderBreadcrumb,
parameters: {
docs: {
description: {
component:
'Folder navigation breadcrumb. Uses a `>` separator, a folder icon per ' +
'segment (open folder for the current location), and semantic emphasis ' +
'on the trailing segment.',
},
},
},
};
const samplePath = [
{ key: 'root', title: 'All assets' },
{ key: 'marketing', title: 'Marketing' },
{ key: 'q2', title: 'Q2 Campaigns' },
];
export const InteractiveFolderBreadcrumb = (args: FolderBreadcrumbProps) => (
<FolderBreadcrumb {...args} />
);
InteractiveFolderBreadcrumb.args = {
items: samplePath,
separator: '>',
};
InteractiveFolderBreadcrumb.argTypes = {
separator: {
description: 'Separator rendered between segments.',
control: 'text',
},
items: {
description:
'Ordered folder path from root to current; the last item is the current location.',
control: false,
},
};
export const Clickable = () => (
<FolderBreadcrumb
items={samplePath.map(item => ({
...item,
onClick: action(`navigate:${item.key}`),
}))}
/>
);
Clickable.parameters = {
controls: { disable: true },
docs: {
description: {
story:
'Parent segments become links when given an `onClick`; hovering shows the ' +
'primary color and clicking fires the handler.',
},
},
};

View File

@@ -0,0 +1,87 @@
/**
* 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 { render, screen, userEvent } from '@superset-ui/core/spec';
import { FolderBreadcrumb } from './FolderBreadcrumb';
import type { FolderBreadcrumbItem } from './types';
const items: FolderBreadcrumbItem[] = [
{ key: 'root', title: 'All assets' },
{ key: 'analytics', title: 'Analytics' },
{ key: 'sales', title: 'Sales' },
];
test('renders every segment in order', () => {
render(<FolderBreadcrumb items={items} />);
expect(screen.getByText('All assets')).toBeInTheDocument();
expect(screen.getByText('Analytics')).toBeInTheDocument();
expect(screen.getByText('Sales')).toBeInTheDocument();
});
test('renders the default ">" separator between segments', () => {
render(<FolderBreadcrumb items={items} />);
expect(screen.getAllByText('>')).toHaveLength(items.length - 1);
});
test('renders a custom separator', () => {
render(<FolderBreadcrumb items={items} separator="/" />);
expect(screen.getAllByText('/')).toHaveLength(items.length - 1);
expect(screen.queryByText('>')).not.toBeInTheDocument();
});
test('marks the trailing segment as current with the open-folder icon', () => {
render(<FolderBreadcrumb items={items} />);
// Ancestors get the closed-folder icon; the current leaf gets the open one.
expect(screen.getAllByTestId('folder')).toHaveLength(items.length - 1);
expect(screen.getByTestId('folder-open')).toBeInTheDocument();
});
test('omits the icon for a segment with hideIcon', () => {
render(
<FolderBreadcrumb
items={[
{ key: 'root', title: 'All assets', hideIcon: true },
{ key: 'sales', title: 'Sales' },
]}
/>,
);
// The only icon left is the current leaf's open-folder icon.
expect(screen.queryByTestId('folder')).not.toBeInTheDocument();
expect(screen.getByTestId('folder-open')).toBeInTheDocument();
});
test('invokes onClick with the segment key', async () => {
const onClick = jest.fn();
render(
<FolderBreadcrumb
items={[
{ key: 'root', title: 'All assets', onClick },
{ key: 'sales', title: 'Sales' },
]}
/>,
);
await userEvent.click(screen.getByText('All assets'));
expect(onClick).toHaveBeenCalledWith('root');
});

View File

@@ -0,0 +1,99 @@
/**
* 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 { styled } from '@apache-superset/core/theme';
import { Breadcrumb as AntdBreadcrumb } from 'antd';
import { FolderIcon, FolderOpenIcon } from './assetIcons';
import type { FolderBreadcrumbProps } from './types';
const StyledBreadcrumb = styled(AntdBreadcrumb)`
${({ theme }) => `
margin: ${theme.sizeUnit * 2}px 0 ${theme.sizeUnit * 4}px;
font-size: ${theme.fontSize}px;
line-height: ${theme.sizeUnit * 6}px;
.ant-breadcrumb-separator {
color: ${theme.colorTextTertiary};
margin-inline: ${theme.sizeUnit * 2}px;
}
.ant-breadcrumb-link {
color: ${theme.colorTextSecondary};
}
a.ant-breadcrumb-link:hover,
.ant-breadcrumb-link a:hover {
color: ${theme.colorPrimary};
background: transparent;
}
/* Semantic emphasis: the trailing item is the current location. */
li:last-of-type .ant-breadcrumb-link {
color: ${theme.colorText};
font-weight: ${theme.fontWeightStrong};
}
`}
`;
const ItemLabel = styled.span<{ clickable?: boolean }>`
display: inline-flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit}px;
${({ clickable, theme }) =>
clickable &&
`
cursor: pointer;
&:hover {
color: ${theme.colorPrimary};
}
`}
`;
const DEFAULT_SEPARATOR = '>';
export function FolderBreadcrumb({
items,
separator = DEFAULT_SEPARATOR,
className,
}: FolderBreadcrumbProps) {
const antdItems = items.map((item, index) => {
const isCurrent = index === items.length - 1;
const Icon = isCurrent ? FolderOpenIcon : FolderIcon;
const clickable = !isCurrent && Boolean(item.onClick || item.href);
return {
key: item.key,
href: item.href,
onClick: item.onClick ? () => item.onClick?.(item.key) : undefined,
title: (
<ItemLabel clickable={clickable}>
{!item.hideIcon && <Icon iconSize="m" aria-hidden />}
{item.title}
</ItemLabel>
),
};
});
return (
<StyledBreadcrumb
className={className}
separator={separator}
items={antdItems}
/>
);
}

View File

@@ -0,0 +1,292 @@
/**
* 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 { useState } from 'react';
import { action } from 'storybook/actions';
import { FolderBreadcrumb } from './FolderBreadcrumb';
import { FolderList } from './FolderList';
import type {
Folder,
FolderAsset,
FolderBreadcrumbItem,
FolderListProps,
} from './types';
const sampleFolders: Folder[] = [
{
key: 'sales',
title: 'Sales',
assets: [
{ key: 'd1', name: 'Revenue Overview', type: 'dashboard' },
{ key: 'd2', name: 'Pipeline Health', type: 'dashboard' },
{ key: 'ds1', name: 'opportunities', type: 'dataset' },
],
},
{
key: 'marketing',
title: 'Marketing',
assets: [
{ key: 'd3', name: 'Campaign Performance', type: 'dashboard' },
{ key: 'c1', name: 'Spend by Channel', type: 'chart' },
{ key: 'ds2', name: 'web_events', type: 'dataset' },
{ key: 'ds3', name: 'ad_spend', type: 'dataset' },
],
},
{
key: 'product',
title: 'Product',
assets: [{ key: 'c2', name: 'Daily Active Users', type: 'chart' }],
},
];
/**
* A nested hierarchy for the drill-down story: top-level folders, each holding
* dashboards, each dashboard holding its charts.
*/
const dashboardsByFolder: {
key: string;
title: string;
dashboards: { key: string; title: string; charts: FolderAsset[] }[];
}[] = [
{
key: 'sales',
title: 'Sales',
dashboards: [
{
key: 'revenue',
title: 'Revenue Overview',
charts: [
{ key: 'mrr', name: 'MRR by month', type: 'chart' },
{ key: 'region', name: 'Revenue by region', type: 'chart' },
],
},
{
key: 'pipeline',
title: 'Pipeline Health',
charts: [{ key: 'opps', name: 'Open opportunities', type: 'chart' }],
},
],
},
{
key: 'marketing',
title: 'Marketing',
dashboards: [
{
key: 'campaigns',
title: 'Campaign Performance',
charts: [
{ key: 'spend', name: 'Spend by channel', type: 'chart' },
{ key: 'conversions', name: 'Conversions', type: 'chart' },
],
},
],
},
];
export default {
title: 'Components/Folders/FolderList',
component: FolderList,
parameters: {
docs: {
description: {
component:
'A list of folders. A row whose `type` is listed in `expandableTypes` ' +
'is a Collapse panel that expands to reveal its assets (or any custom ' +
'body supplied via `renderExpandedContent`). Any other row is a single, ' +
'non-collapsible entry that calls `onFolderClick` instead — e.g. to ' +
'navigate into its own page. With the default empty `expandableTypes`, ' +
'nothing expands.',
},
},
},
};
export const InteractiveFolderList = (args: FolderListProps) => (
<FolderList {...args} />
);
InteractiveFolderList.args = {
folders: sampleFolders,
expandableTypes: ['folder'],
defaultActiveKeys: ['sales'],
accordion: false,
showCount: true,
onAssetClick: action('asset-click'),
onFolderClick: action('folder-click'),
};
InteractiveFolderList.argTypes = {
expandableTypes: {
description:
'Entry types that expand in place. Types left out render as a single ' +
'row that fires onFolderClick instead.',
control: 'check',
options: ['folder', 'dashboard', 'dataset', 'chart'],
},
accordion: {
description: 'Only allow a single folder to be open at a time.',
control: 'boolean',
},
showCount: {
description: 'Show the asset-count badge next to each folder title.',
control: 'boolean',
},
defaultActiveKeys: {
description: 'Keys of folders expanded on first render.',
control: false,
},
folders: {
description: 'Folders to render, each with its nested assets.',
control: false,
},
};
export const NavigableFolders = () => (
<div>
<FolderBreadcrumb
items={[
{ key: 'root', title: 'All assets', onClick: action('navigate:root') },
{ key: 'analytics', title: 'Analytics' },
]}
/>
<FolderList
folders={sampleFolders}
onFolderClick={action('navigate:folder')}
/>
</div>
);
NavigableFolders.parameters = {
controls: { disable: true },
docs: {
description: {
story:
'With an empty `expandableTypes`, folders do not expand; clicking a row ' +
'fires `onFolderClick` to drill into it, tracked by the breadcrumb.',
},
},
};
export const CustomExpandedContent = () => (
<FolderList
folders={sampleFolders}
expandableTypes={['folder']}
defaultActiveKeys={['sales']}
renderExpandedContent={folder => (
<div style={{ padding: 12 }}>
Custom body for <strong>{String(folder.title)}</strong> {' '}
{folder.assets.length} assets
</div>
)}
/>
);
CustomExpandedContent.parameters = {
controls: { disable: true },
docs: {
description: {
story:
'`renderExpandedContent` replaces the default asset list with arbitrary ' +
'content, e.g. a preview for an expandable dashboard entry.',
},
},
};
export const DrillIntoExpandableDashboards = () => {
const [openFolder, setOpenFolder] = useState<string | null>(null);
const folder = dashboardsByFolder.find(entry => entry.key === openFolder);
const breadcrumbItems: FolderBreadcrumbItem[] = [
{ key: 'root', title: 'All assets', onClick: () => setOpenFolder(null) },
...(folder ? [{ key: folder.key, title: folder.title }] : []),
];
// At the root we list folders, which navigate on click and extend the
// breadcrumb. Inside a folder we list its dashboards — the only type in
// `expandableTypes` — so each expands in place to reveal its charts.
const folders: Folder[] = folder
? folder.dashboards.map((dashboard): Folder => ({
key: dashboard.key,
title: dashboard.title,
type: 'dashboard',
assets: dashboard.charts,
}))
: dashboardsByFolder.map((entry): Folder => ({
key: entry.key,
title: entry.title,
type: 'folder',
assets: entry.dashboards.map((dashboard): FolderAsset => ({
key: dashboard.key,
name: dashboard.title,
type: 'dashboard',
})),
}));
return (
<div>
<FolderBreadcrumb items={breadcrumbItems} />
<FolderList
folders={folders}
expandableTypes={['dashboard']}
onFolderClick={({ key }) => setOpenFolder(key)}
onAssetClick={action('open-chart')}
/>
</div>
);
};
DrillIntoExpandableDashboards.parameters = {
controls: { disable: true },
docs: {
description: {
story:
'Only the `dashboard` type is expandable. At the root, folders are not ' +
'expandable: clicking one drills in and extends the breadcrumb, whose ' +
'segments are clickable to navigate back. Inside a folder, each ' +
'dashboard expands in place to reveal its charts.',
},
},
};
export const WithBreadcrumb = () => (
<div>
<FolderBreadcrumb
items={[
{ key: 'root', title: 'All assets', onClick: action('navigate:root') },
{ key: 'analytics', title: 'Analytics' },
]}
/>
<FolderList
folders={sampleFolders}
expandableTypes={['folder']}
defaultActiveKeys={['sales', 'marketing']}
onAssetClick={action('asset-click')}
/>
</div>
);
WithBreadcrumb.parameters = {
controls: { disable: true },
docs: {
description: {
story:
'Breadcrumb and folder list composed together, as they appear on an ' +
'analytics listing page.',
},
},
};

View File

@@ -0,0 +1,200 @@
/**
* 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 { render, screen, userEvent } from '@superset-ui/core/spec';
import { FolderList } from './FolderList';
import type { Folder } from './types';
const folders: Folder[] = [
{
key: 'sales',
title: 'Sales',
assets: [
{ key: 'd1', name: 'Revenue Overview', type: 'dashboard' },
{ key: 'ds1', name: 'opportunities', type: 'dataset' },
],
},
{
key: 'marketing',
title: 'Marketing',
assets: [{ key: 'c1', name: 'Spend by Channel', type: 'chart' }],
},
];
test('renders a header with the title and asset count for each folder', () => {
render(<FolderList folders={folders} expandableTypes={['folder']} />);
expect(screen.getByRole('button', { name: /Sales/ })).toHaveTextContent('2');
expect(screen.getByRole('button', { name: /Marketing/ })).toHaveTextContent(
'1',
);
});
test('omits the asset count when showCount is false', () => {
render(
<FolderList
folders={folders}
expandableTypes={['folder']}
showCount={false}
/>,
);
expect(screen.getByRole('button', { name: /Sales/ })).not.toHaveTextContent(
'2',
);
expect(
screen.getByRole('button', { name: /Marketing/ }),
).not.toHaveTextContent('1');
});
test('does not expand folders whose type is not in expandableTypes', () => {
render(<FolderList folders={folders} />);
// Headers still render, but there is no toggle and the assets stay hidden.
expect(screen.getByText('Sales')).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /Sales/ }),
).not.toBeInTheDocument();
expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
});
test('keeps folder contents collapsed by default', () => {
render(<FolderList folders={folders} expandableTypes={['folder']} />);
expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
expect(screen.queryByText('Spend by Channel')).not.toBeInTheDocument();
});
test('expands the folders named in defaultActiveKeys', () => {
render(
<FolderList
folders={folders}
expandableTypes={['folder']}
defaultActiveKeys={['sales']}
/>,
);
expect(screen.getByText('Revenue Overview')).toBeInTheDocument();
expect(screen.getByText('opportunities')).toBeInTheDocument();
expect(screen.queryByText('Spend by Channel')).not.toBeInTheDocument();
});
test('reveals folder assets when its header is clicked', async () => {
render(<FolderList folders={folders} expandableTypes={['folder']} />);
await userEvent.click(screen.getByRole('button', { name: /Marketing/ }));
expect(screen.getByText('Spend by Channel')).toBeInTheDocument();
});
test('calls onAssetClick with the clicked asset', async () => {
const onAssetClick = jest.fn();
render(
<FolderList
folders={folders}
expandableTypes={['folder']}
defaultActiveKeys={['sales']}
onAssetClick={onAssetClick}
/>,
);
await userEvent.click(screen.getByText('Revenue Overview'));
expect(onAssetClick).toHaveBeenCalledWith(folders[0].assets[0]);
});
test('accordion mode hides the open folder when another is opened', async () => {
render(
<FolderList
folders={folders}
expandableTypes={['folder']}
defaultActiveKeys={['sales']}
accordion
/>,
);
expect(screen.getByText('Revenue Overview')).toBeInTheDocument();
// In accordion mode antd renders the headers as tabs rather than buttons.
await userEvent.click(screen.getByRole('tab', { name: /Marketing/ }));
expect(screen.getByText('Spend by Channel')).toBeInTheDocument();
// antd keeps collapsed content mounted but flags it hidden.
expect(
screen.getByText('Revenue Overview').closest('.ant-collapse-content'),
).toHaveClass('ant-collapse-content-hidden');
});
test('calls onFolderClick when a non-expandable row is clicked', async () => {
const onFolderClick = jest.fn();
render(<FolderList folders={folders} onFolderClick={onFolderClick} />);
await userEvent.click(screen.getByRole('button', { name: /Sales/ }));
expect(onFolderClick).toHaveBeenCalledWith(folders[0]);
// Clicking navigates rather than expanding, so the assets stay hidden.
expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
});
test('renders custom expanded content instead of the asset list', () => {
render(
<FolderList
folders={folders}
expandableTypes={['folder']}
defaultActiveKeys={['sales']}
renderExpandedContent={folder => <div>preview of {folder.key}</div>}
/>,
);
expect(screen.getByText('preview of sales')).toBeInTheDocument();
expect(screen.queryByText('Revenue Overview')).not.toBeInTheDocument();
});
test('expands only the types listed in expandableTypes', async () => {
const mixed: Folder[] = [
{
key: 'sales',
title: 'Sales',
type: 'folder',
assets: [{ key: 'd1', name: 'Revenue Overview', type: 'dashboard' }],
},
{
key: 'kpis',
title: 'KPIs',
type: 'dashboard',
assets: [{ key: 'c1', name: 'Active Users', type: 'chart' }],
},
];
const onFolderClick = jest.fn();
render(
<FolderList
folders={mixed}
expandableTypes={['folder']}
defaultActiveKeys={['sales']}
onFolderClick={onFolderClick}
/>,
);
// The folder expands in place; the dashboard row navigates on click.
expect(screen.getByText('Revenue Overview')).toBeInTheDocument();
expect(screen.queryByText('Active Users')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /KPIs/ }));
expect(onFolderClick).toHaveBeenCalledWith(mixed[1]);
});

View File

@@ -0,0 +1,209 @@
/**
* 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 { styled } from '@apache-superset/core/theme';
import { Collapse, type CollapseProps } from '../Collapse';
import { FolderIcon, assetTypeIcons } from './assetIcons';
import type { Folder, FolderItemType, FolderListProps } from './types';
type FolderCollapseItem = NonNullable<CollapseProps['items']>[number];
// Keep the header text at its content width and drop antd's auto end-margin so
// the end-positioned caret sits immediately after the folder label rather than
// at the far right of the row. The doubled root (&&) raises specificity above
// antd's `flex: auto` / `margin-inline-end: auto` rules on the header text.
const StyledFolderCollapse = styled(Collapse)`
${({ theme }) => `
&& .ant-collapse-item > .ant-collapse-header > .ant-collapse-header-text {
flex: none;
margin-inline-end: 0;
}
.ant-collapse-expand-icon {
margin-inline-start: ${theme.sizeUnit}px;
}
/* Non-expandable rows have no caret, so let the label fill the row. */
&& .folder-row--static > .ant-collapse-header > .ant-collapse-header-text {
flex: auto;
}
/* A non-expandable row that navigates on click reads as one target. */
&& .folder-row--clickable > .ant-collapse-header {
cursor: pointer;
}
&& .folder-row--clickable > .ant-collapse-header:hover {
background: ${theme.colorFillTertiary};
}
`}
`;
const FolderLabel = styled.span`
${({ theme }) => `
display: inline-flex;
align-items: center;
gap: ${theme.sizeUnit * 2}px;
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorText};
`}
`;
const AssetCount = styled.span`
${({ theme }) => `
color: ${theme.colorTextTertiary};
font-weight: normal;
font-size: ${theme.fontSizeSM}px;
`}
`;
const AssetList = styled.ul`
${({ theme }) => `
list-style: none;
margin: 0;
padding: 0;
li + li {
border-top: 1px solid ${theme.colorBorderSecondary};
}
`}
`;
const AssetRow = styled.button`
${({ theme }) => `
display: flex;
align-items: center;
gap: ${theme.sizeUnit * 2}px;
width: 100%;
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit}px;
border: 0;
background: transparent;
color: ${theme.colorText};
font-size: ${theme.fontSize}px;
text-align: left;
cursor: pointer;
&:hover {
background: ${theme.colorFillTertiary};
}
`}
`;
// Strips the native button chrome so a navigable folder row matches the look
// of an expandable header while staying a real, focusable button.
const FolderRowButton = styled.button`
display: flex;
align-items: center;
width: 100%;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
`;
const iconFor = (type: FolderItemType) =>
type === 'folder' ? FolderIcon : assetTypeIcons[type];
export function FolderList({
folders,
expandableTypes = [],
defaultActiveKeys,
accordion = false,
showCount = true,
onAssetClick,
onFolderClick,
renderExpandedContent,
className,
}: FolderListProps) {
const isExpandable = (folder: Folder) =>
expandableTypes.includes(folder.type ?? 'folder');
const defaultAssetList = (folder: Folder) => (
<AssetList>
{folder.assets.map(asset => {
const AssetIcon = assetTypeIcons[asset.type];
return (
<li key={asset.key}>
<AssetRow type="button" onClick={() => onAssetClick?.(asset)}>
<AssetIcon iconSize="m" aria-hidden />
{asset.name}
</AssetRow>
</li>
);
})}
</AssetList>
);
const items = folders.map((folder): FolderCollapseItem => {
const Icon = iconFor(folder.type ?? 'folder');
const label = (
<FolderLabel>
<Icon iconSize="m" aria-hidden />
{folder.title}
{showCount && <AssetCount>{folder.assets.length}</AssetCount>}
</FolderLabel>
);
if (isExpandable(folder)) {
return {
key: folder.key,
label,
children: renderExpandedContent?.(folder) ?? defaultAssetList(folder),
};
}
// A non-expandable row carries no caret and never toggles (`collapsible:
// 'icon'` + `showArrow: false`). With a handler the label becomes a button
// that navigates; without one the row is an inert header.
return {
key: folder.key,
showArrow: false,
collapsible: 'icon',
className: onFolderClick
? 'folder-row--static folder-row--clickable'
: 'folder-row--static',
label: onFolderClick ? (
<FolderRowButton type="button" onClick={() => onFolderClick(folder)}>
{label}
</FolderRowButton>
) : (
label
),
};
});
// antd would still mark a key active even if its row can't expand; keep the
// initial open set to rows that actually have a body.
const activeKeys = defaultActiveKeys?.filter(key =>
folders.some(folder => folder.key === key && isExpandable(folder)),
);
return (
<StyledFolderCollapse
className={className}
items={items}
accordion={accordion}
expandIconPosition="end"
defaultActiveKey={activeKeys}
/>
);
}

View File

@@ -0,0 +1,35 @@
/**
* 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 type { FC } from 'react';
import { Icons } from '../Icons';
import type { IconType } from '../Icons/types';
import type { FolderAssetType } from './types';
/** Icon for a collapsed folder. */
export const FolderIcon = Icons.FolderOutlined;
/** Icon for an expanded folder. */
export const FolderOpenIcon = Icons.FolderOpenOutlined;
/** Maps each asset type to its semantic icon. */
export const assetTypeIcons: Record<FolderAssetType, FC<IconType>> = {
dashboard: Icons.DashboardOutlined,
dataset: Icons.TableOutlined,
chart: Icons.AreaChartOutlined,
};

View File

@@ -0,0 +1,30 @@
/**
* 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.
*/
export { FolderBreadcrumb } from './FolderBreadcrumb';
export { FolderList } from './FolderList';
export { FolderIcon, FolderOpenIcon, assetTypeIcons } from './assetIcons';
export type {
FolderBreadcrumbItem,
FolderBreadcrumbProps,
Folder,
FolderAsset,
FolderAssetType,
FolderItemType,
FolderListProps,
} from './types';

View File

@@ -0,0 +1,106 @@
/**
* 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 type { ReactNode } from 'react';
/**
* The kinds of leaf assets that can live inside a folder. Each maps to a
* dedicated icon in `assetTypeIcons`.
*/
export type FolderAssetType = 'dashboard' | 'dataset' | 'chart';
/**
* The kind of a top-level entry in a {@link FolderList}. A folder groups
* assets; the asset kinds may also appear as top-level entries in their own
* right. The type drives the row's icon and, via `expandableTypes`, whether
* the row expands in place or navigates on click.
*/
export type FolderItemType = 'folder' | FolderAssetType;
/** A single navigable segment of the folder path. */
export interface FolderBreadcrumbItem {
/** Stable identifier, passed back to `onClick`. */
key: string;
/** Label rendered for the segment. */
title: ReactNode;
/** Marks the segment as a link; called with the item `key` when clicked. */
onClick?: (key: string) => void;
/** Optional href when the segment should navigate via an anchor. */
href?: string;
/** Hide the leading folder icon for this segment (defaults to shown). */
hideIcon?: boolean;
}
export interface FolderBreadcrumbProps {
/** Ordered path from root to current folder; the last item is the current location. */
items: FolderBreadcrumbItem[];
/** Separator between segments. Defaults to a `>` chevron. */
separator?: ReactNode;
className?: string;
}
/** A leaf asset (dashboard, dataset or chart) shown inside a folder. */
export interface FolderAsset {
key: string;
name: ReactNode;
type: FolderAssetType;
}
/** A top-level entry in a {@link FolderList}, typically a folder of assets. */
export interface Folder {
key: string;
title: ReactNode;
/**
* Entry kind; controls the icon and whether the row is expandable (see
* {@link FolderListProps.expandableTypes}). Defaults to `'folder'`.
*/
type?: FolderItemType;
assets: FolderAsset[];
}
export interface FolderListProps {
folders: Folder[];
/**
* Entry types that expand in place to reveal their contents. A row whose
* type is not listed renders as a single, non-collapsible row that calls
* `onFolderClick` instead (e.g. to navigate into it). Defaults to `[]`, so
* nothing is expandable unless a consumer opts a type in.
*/
expandableTypes?: FolderItemType[];
/** Keys of folders expanded on first render (expandable types only). */
defaultActiveKeys?: string[];
/** Only allow one folder open at a time. */
accordion?: boolean;
/** Show the asset-count badge next to each folder title. Defaults to `true`. */
showCount?: boolean;
/** Invoked when an asset row inside an expanded folder is clicked. */
onAssetClick?: (asset: FolderAsset) => void;
/**
* Invoked when a non-expandable row is clicked, e.g. to open the folder or
* asset's own page. Without a handler the row renders as an inert header.
*/
onFolderClick?: (folder: Folder) => void;
/**
* Overrides the body shown when an expandable row is open. Receives the
* entry and may branch on `folder.type`; return `undefined` to fall back to
* the default asset list. Use it to render, say, a chart preview for an
* expandable `dashboard` entry instead of a list of assets.
*/
renderExpandedContent?: (folder: Folder) => ReactNode;
className?: string;
}

View File

@@ -0,0 +1,118 @@
/**
* 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 { useCallback, useState } from 'react';
import { SupersetClient } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { Checkbox, Input } from '@superset-ui/core/components';
import { StandardModal } from 'src/components/Modal';
interface CreateFolderModalProps {
show: boolean;
/** UUID of the folder to create this one under, or null for the root. */
parentFolderUuid: string | null;
onHide: () => void;
onSuccess: () => void;
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
}
import { ModalContent, FormGroup } from './styles';
export default function CreateFolderModal({
show,
parentFolderUuid,
onHide,
onSuccess,
addDangerToast,
addSuccessToast,
}: CreateFolderModalProps) {
const [name, setName] = useState('');
const [isPrivate, setIsPrivate] = useState(false);
const [saving, setSaving] = useState(false);
const handleSave = useCallback(async () => {
if (!name.trim()) {
addDangerToast(t('Folder name is required'));
return;
}
setSaving(true);
try {
await SupersetClient.post({
endpoint: '/api/v1/folders/',
jsonPayload: {
name: name.trim(),
folder_type: 'analytics',
...(parentFolderUuid ? { parent_uuid: parentFolderUuid } : {}),
...(isPrivate ? { is_private: true } : {}),
},
});
addSuccessToast(t('Folder "%s" created', name.trim()));
setName('');
onSuccess();
onHide();
} catch {
addDangerToast(t('Error creating folder'));
} finally {
setSaving(false);
}
}, [
name,
parentFolderUuid,
addSuccessToast,
addDangerToast,
onSuccess,
onHide,
]);
return (
<StandardModal
title={t('Create folder')}
show={show}
onHide={onHide}
onSave={handleSave}
saveText={t('Create')}
saveDisabled={!name.trim()}
saveLoading={saving}
>
<ModalContent>
<FormGroup>
<label htmlFor="create-folder-name">{t('Name')}</label>
<Input
id="create-folder-name"
placeholder={t('Folder name')}
value={name}
onChange={e => setName(e.target.value)}
onPressEnter={handleSave}
autoFocus
/>
</FormGroup>
{!parentFolderUuid && (
<FormGroup>
<Checkbox
checked={isPrivate}
onChange={e => setIsPrivate(e.target.checked)}
>
{t('Private folder')}
</Checkbox>
</FormGroup>
)}
</ModalContent>
</StandardModal>
);
}

View File

@@ -0,0 +1,212 @@
/**
* 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 { useEffect, useRef, useState } from 'react';
import {
isFeatureEnabled,
FeatureFlag,
getChartMetadataRegistry,
} from '@superset-ui/core';
import { styled, css, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { EmptyState, Skeleton } from '@superset-ui/core/components';
import { FacePile, ModifiedInfo, TagsList, TagType } from 'src/components';
import { TagTypeEnum } from 'src/components/Tag/TagType';
import { Icons } from '@superset-ui/core/components/Icons';
import type { ContentItem } from './types';
const chartRegistry = getChartMetadataRegistry();
interface DashboardChartsProps {
dashboardId: number;
charts: ContentItem[];
loading: boolean;
onRequest: (dashboardId: number) => void;
}
const Wrapper = styled.div`
max-height: 240px;
overflow-y: auto;
/* Pull grid to the td edges so columns align with table headers */
margin: 0 -16px;
`;
const SubHeader = styled.div`
${({ theme }) => css`
display: flex;
align-items: center;
gap: ${theme.sizeUnit}px;
padding: ${theme.sizeUnit * 3}px 16px;
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorTextSecondary};
`}
`;
const Row = styled.div`
${({ theme }) => css`
display: grid;
grid-template-columns: 2fr 0.8fr 1fr 1fr 1fr 1fr 1fr;
align-items: center;
padding: ${theme.sizeUnit * 3}px 0;
font-size: ${theme.fontSize}px;
color: ${theme.colorText};
& + & {
border-top: 1px solid ${theme.colorBorderSecondary};
}
/* Match antd table cell padding so content aligns with headers */
> * {
padding: 0 16px;
}
`}
`;
const NameCell = styled.span`
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-left: 16px !important;
a {
overflow: hidden;
text-overflow: ellipsis;
}
`;
const Cell = styled.span`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`;
export default function DashboardCharts({
dashboardId,
charts,
loading,
onRequest,
}: DashboardChartsProps) {
const theme = useTheme();
const wrapperRef = useRef<HTMLDivElement>(null);
const [colWidths, setColWidths] = useState<string | undefined>();
useEffect(() => {
onRequest(dashboardId);
}, [dashboardId, onRequest]);
// Read parent table header widths to align expanded rows with table columns.
// Re-read on resize so the grid stays aligned when the window changes size.
useEffect(() => {
const readWidths = () => {
const el = wrapperRef.current;
if (!el) return;
const table = el.closest('.ant-table-wrapper')?.querySelector('thead tr');
if (!table) return;
const ths = Array.from(
table.querySelectorAll<HTMLTableCellElement>('th'),
);
const dataHeaders = ths.filter(th => th.offsetWidth > 40);
if (dataHeaders.length > 0) {
setColWidths(dataHeaders.map(th => `${th.offsetWidth}px`).join(' '));
}
};
readWidths();
window.addEventListener('resize', readWidths);
return () => window.removeEventListener('resize', readWidths);
}, [charts.length]);
if (loading) {
return <Skeleton active />;
}
if (charts.length === 0) {
return (
<EmptyState
size="small"
description={t('This dashboard has no charts')}
/>
);
}
const showTags = isFeatureEnabled(FeatureFlag.TaggingSystem);
return (
<Wrapper ref={wrapperRef}>
<SubHeader>
<Icons.LineChartOutlined iconSize="m" />
{t('Charts in the dashboard (%s)', charts.length)}
</SubHeader>
{charts.map(chart => (
<Row
key={chart.id}
style={colWidths ? { gridTemplateColumns: colWidths } : undefined}
>
<NameCell>
<Icons.LineChartOutlined
iconSize="m"
css={{ color: theme.colorErrorBorderHover, flexShrink: 0 }}
/>
{chart.url ? <a href={chart.url}>{chart.name}</a> : chart.name}
</NameCell>
<Cell />
<Cell>
{chart.viz_type
? (chartRegistry.get(chart.viz_type)?.name ?? chart.viz_type)
: null}
</Cell>
<Cell>
{chart.datasource_name && chart.datasource_url ? (
<a href={chart.datasource_url}>{chart.datasource_name}</a>
) : (
(chart.datasource_name ?? null)
)}
</Cell>
<Cell>
{showTags && chart.tags?.length ? (
<TagsList
tags={chart.tags.filter(
(tag: TagType) =>
tag.type === 'TagTypes.custom' ||
tag.type === TagTypeEnum.Custom,
)}
maxTags={3}
/>
) : null}
</Cell>
<Cell>
<FacePile users={chart.owners || []} />
</Cell>
<Cell>
{chart.changed_on_humanized ? (
<ModifiedInfo
date={chart.changed_on_humanized}
user={chart.changed_by ?? undefined}
/>
) : (
'-'
)}
</Cell>
</Row>
))}
</Wrapper>
);
}

View File

@@ -0,0 +1,127 @@
/**
* 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 { useCallback, useEffect, useState } from 'react';
import { SupersetClient } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import {
Checkbox,
DeleteModal,
Flex,
Typography,
type CheckboxChangeEvent,
} from '@superset-ui/core/components';
interface DeleteFolderModalProps {
folder: {
uuid: string;
name: string;
asset_count: number;
children_count: number;
};
show: boolean;
/** When true, suppresses the per-folder success toast (used in bulk-delete flows). */
silent?: boolean;
onHide: () => void;
onSuccess: () => void;
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
}
export default function DeleteFolderModal({
folder,
show,
silent = false,
onHide,
onSuccess,
addDangerToast,
addSuccessToast,
}: DeleteFolderModalProps) {
const theme = useTheme();
const [deleteItems, setDeleteItems] = useState(false);
// Reset checkbox when folder changes (e.g. queue processing)
useEffect(() => {
setDeleteItems(false);
}, [folder.uuid]);
const isEmpty = folder.asset_count === 0 && folder.children_count === 0;
const handleConfirm = useCallback(async () => {
try {
await SupersetClient.delete({
endpoint: `/api/v1/folders/${folder.uuid}${deleteItems ? '?archive_items=true' : ''}`,
});
if (!silent) {
addSuccessToast(t('Folder "%s" deleted', folder.name));
}
onSuccess();
onHide();
} catch {
addDangerToast(t('Error deleting folder'));
}
}, [
folder,
deleteItems,
silent,
addSuccessToast,
addDangerToast,
onSuccess,
onHide,
]);
const handleHide = useCallback(() => {
setDeleteItems(false);
onHide();
}, [onHide]);
return (
<DeleteModal
open={show}
title={t('Delete folder "%s"', folder.name)}
onHide={handleHide}
onConfirm={handleConfirm}
description={
<Flex
vertical
gap={theme.sizeUnit * 3}
style={{ marginTop: -theme.sizeUnit * 2 }}
>
<Typography.Paragraph style={{ margin: 0 }}>
{isEmpty
? t("This folder doesn't contain any items.")
: t(
'This folder contains items. Items will be moved to the parent folder or Analytics page unless you choose to delete everything together.',
)}
</Typography.Paragraph>
{!isEmpty && (
<Checkbox
checked={deleteItems}
onChange={(e: CheckboxChangeEvent) =>
setDeleteItems(e.target.checked)
}
>
{t('Delete all items in this folder')}
</Checkbox>
)}
</Flex>
}
/>
);
}

View File

@@ -0,0 +1,431 @@
/**
* 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 { useCallback, useEffect, useMemo, useState } from 'react';
import { SupersetClient } from '@superset-ui/core';
import { styled, css, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { Alert } from '@apache-superset/core/components';
import {
AsyncSelect,
FormLabel,
Input,
Select,
Table,
TableSize,
type ColumnsType,
type SelectOptionsTypePage,
Tooltip,
Typography,
type SelectValue,
} from '@superset-ui/core/components';
import { StandardModal } from 'src/components/Modal';
import { Icons } from '@superset-ui/core/components/Icons';
type Permission = 'editor' | 'viewer' | 'admin';
interface Subject {
user_id: number;
permission: Permission;
email?: string;
is_admin?: boolean;
}
interface LocalSubject {
key: string;
user_id: number;
permission: Permission;
label: string;
isCurrentUser: boolean;
isAdmin: boolean;
}
interface FolderPermissionsModalProps {
folderUuid: string;
folderName: string;
currentUserId: number;
isSubfolder?: boolean;
show: boolean;
onHide: () => void;
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
}
const PERMISSION_OPTIONS = [
{ value: 'viewer', label: t('Viewer') },
{ value: 'editor', label: t('Editor') },
];
const StyledFormLabel = styled(FormLabel)`
${({ theme }) => css`
margin-bottom: ${theme.sizeUnit * 0.5}px;
font-size: ${theme.fontSizeSM}px;
`}
`;
const ModalContent = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px;
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit * 3}px;
`}
`;
function getUserLabel(u: { email?: string | null }): string {
return u.email || t('N/A');
}
export default function FolderPermissionsModal({
folderUuid,
folderName,
currentUserId,
isSubfolder,
show,
onHide,
addDangerToast,
addSuccessToast,
}: FolderPermissionsModalProps) {
const theme = useTheme();
const [serverSubjects, setServerSubjects] = useState<LocalSubject[]>([]);
const [localSubjects, setLocalSubjects] = useState<LocalSubject[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [memberSearch, setMemberSearch] = useState('');
const [adminIds, setAdminIds] = useState<Set<number>>(new Set());
const fetchSubjects = useCallback(async () => {
setLoading(true);
try {
const { json } = await SupersetClient.get({
endpoint: `/api/v1/folders/${folderUuid}/subjects`,
});
const subjects = (json.result as Subject[]) || [];
const enriched: LocalSubject[] = subjects.map(s => ({
key: String(s.user_id),
user_id: s.user_id,
permission: s.is_admin ? 'admin' : s.permission,
label: getUserLabel(s),
isCurrentUser: s.user_id === currentUserId,
isAdmin: !!s.is_admin,
}));
setServerSubjects(enriched);
setLocalSubjects(enriched);
} catch {
addDangerToast(t('Error loading permissions'));
} finally {
setLoading(false);
}
}, [folderUuid, currentUserId, addDangerToast]);
useEffect(() => {
if (show) {
fetchSubjects();
setMemberSearch('');
}
}, [show, fetchSubjects]);
const handleAddUser = useCallback(
(selected: SelectValue) => {
if (!selected || typeof selected !== 'object') return;
const item = selected as { value: number; label: string };
const userId = Number(item.value);
if (!userId || Number.isNaN(userId)) return;
const isAdmin = adminIds.has(userId);
setLocalSubjects(prev => {
if (prev.some(s => s.user_id === userId)) return prev;
return [
...prev,
{
key: String(userId),
user_id: userId,
permission: isAdmin
? ('admin' as Permission)
: ('viewer' as Permission),
label: String(item.label),
isCurrentUser: userId === currentUserId,
isAdmin,
},
];
});
},
[currentUserId, adminIds],
);
const handleChangePermission = useCallback(
(userId: number, permission: Permission) => {
if (userId === currentUserId) return;
setLocalSubjects(prev =>
prev.map(s => (s.user_id === userId ? { ...s, permission } : s)),
);
},
[currentUserId],
);
const handleRemoveSubject = useCallback(
(userId: number) => {
if (userId === currentUserId) return;
setLocalSubjects(prev => prev.filter(s => s.user_id !== userId));
},
[currentUserId],
);
const handleSave = useCallback(async () => {
setSaving(true);
try {
const serverMap = new Map(
serverSubjects.map(s => [s.user_id, s.permission]),
);
const localMap = new Map(
localSubjects.map(s => [s.user_id, s.permission]),
);
const calls: Promise<unknown>[] = [];
for (const [userId, permission] of localMap) {
if (!serverMap.has(userId)) {
calls.push(
SupersetClient.post({
endpoint: `/api/v1/folders/${folderUuid}/subjects`,
jsonPayload: { user_id: userId, permission },
}),
);
}
}
for (const [userId] of serverMap) {
if (!localMap.has(userId)) {
calls.push(
SupersetClient.delete({
endpoint: `/api/v1/folders/${folderUuid}/subjects/${userId}`,
}),
);
}
}
for (const [userId, permission] of localMap) {
const serverPerm = serverMap.get(userId);
if (serverPerm && serverPerm !== permission) {
calls.push(
SupersetClient.put({
endpoint: `/api/v1/folders/${folderUuid}/subjects/${userId}`,
jsonPayload: { permission },
}),
);
}
}
const results = await Promise.allSettled(calls);
const failures = results.filter(r => r.status === 'rejected');
if (failures.length > 0) {
addDangerToast(t('%s permission update(s) failed', failures.length));
} else {
addSuccessToast(t('Permissions updated'));
}
onHide();
} catch {
addDangerToast(t('Error updating permissions'));
} finally {
setSaving(false);
}
}, [
folderUuid,
serverSubjects,
localSubjects,
addSuccessToast,
addDangerToast,
onHide,
]);
const hasChanges = useMemo(() => {
if (serverSubjects.length !== localSubjects.length) return true;
const serverMap = new Map(
serverSubjects.map(s => [s.user_id, s.permission]),
);
return localSubjects.some(s => serverMap.get(s.user_id) !== s.permission);
}, [serverSubjects, localSubjects]);
const fetchAvailableUsers = useCallback(
async (
search: string,
page: number,
pageSize: number,
): Promise<SelectOptionsTypePage> => {
const params = new URLSearchParams({
q: search,
page: String(page),
page_size: String(pageSize),
});
const { json } = await SupersetClient.get({
endpoint: `/api/v1/folders/${folderUuid}/available-users?${params}`,
});
const results = json?.result || [];
const newAdminIds = new Set(adminIds);
results.forEach((u: { id: number; is_admin?: boolean }) => {
if (u.is_admin) newAdminIds.add(u.id);
});
setAdminIds(newAdminIds);
return {
data: results.map(
(u: { id: number; email: string; is_admin?: boolean }) => ({
value: u.id,
label: getUserLabel(u),
}),
),
totalCount: json?.count ?? 0,
};
},
[folderUuid, adminIds],
);
const filtered = useMemo(() => {
if (!memberSearch) return localSubjects;
const q = memberSearch.toLowerCase();
return localSubjects.filter(s => s.label.toLowerCase().includes(q));
}, [localSubjects, memberSearch]);
const columns: ColumnsType<LocalSubject> = useMemo(
() => [
{
title: t('User'),
dataIndex: 'label',
key: 'label',
render: (label: string, record: LocalSubject) => (
<span>
{label}
{record.isCurrentUser && (
<span css={{ opacity: 0.5, marginLeft: theme.sizeUnit }}>
({t('you')})
</span>
)}
</span>
),
},
{
title: t('Permission'),
dataIndex: 'permission',
key: 'permission',
width: 130,
render: (permission: Permission, record: LocalSubject) =>
record.isAdmin ? (
<Tooltip
title={t(
'This user is an admin and has full access to all folders',
)}
>
<Typography.Text type="secondary">{t('Admin')}</Typography.Text>
</Tooltip>
) : (
<Select
ariaLabel={t('Permission')}
options={PERMISSION_OPTIONS}
value={permission}
disabled={record.isCurrentUser}
onChange={(val: Permission) =>
handleChangePermission(record.user_id, val)
}
getPopupContainer={trigger =>
trigger.closest('.ant-modal-content') || document.body
}
/>
),
},
{
title: '',
key: 'actions',
width: 40,
render: (_: unknown, record: LocalSubject) =>
record.isCurrentUser || record.isAdmin ? null : (
<Icons.DeleteOutlined
iconSize="m"
role="button"
tabIndex={0}
onClick={() => handleRemoveSubject(record.user_id)}
css={css`
cursor: pointer;
color: ${theme.colorIcon};
&:hover {
color: ${theme.colorError};
}
`}
/>
),
},
],
[theme, handleChangePermission, handleRemoveSubject],
);
return (
<StandardModal
title={t('Manage permissions — %s', folderName)}
show={show}
onHide={onHide}
onSave={handleSave}
saveText={t('Save')}
saveLoading={saving}
saveDisabled={!hasChanges}
contentLoading={loading}
>
<ModalContent>
{isSubfolder && (
<Alert
type="warning"
showIcon
message={t(
'Changing permissions here will stop this folder from automatically inheriting updates from its parent. You can re-sync later from the actions menu.',
)}
/>
)}
<div>
<StyledFormLabel>{t('Add user')}</StyledFormLabel>
<AsyncSelect
ariaLabel={t('Search users')}
placeholder={t('Search for a user…')}
options={fetchAvailableUsers}
onChange={handleAddUser}
value={undefined}
getPopupContainer={trigger =>
trigger.closest('.ant-modal-content') || document.body
}
/>
</div>
<div>
<StyledFormLabel>
{t('Members')} ({localSubjects.length})
</StyledFormLabel>
<Input
placeholder={t('Search members…')}
value={memberSearch}
onChange={e => setMemberSearch(e.target.value)}
allowClear
css={{ marginBottom: theme.sizeUnit * 2 }}
/>
<Table<LocalSubject>
data={filtered}
columns={columns}
usePagination
defaultPageSize={10}
size={TableSize.Small}
height={300}
/>
</div>
</ModalContent>
</StandardModal>
);
}

View File

@@ -0,0 +1,112 @@
/**
* 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 { useCallback, useEffect, useState } from 'react';
import { SupersetClient } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { Input } from '@superset-ui/core/components';
import { StandardModal } from 'src/components/Modal';
interface RenameFolderModalProps {
folderUuid: string;
currentName: string;
show: boolean;
onHide: () => void;
onSuccess: () => void;
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
}
import { ModalContent, FormGroup } from './styles';
export default function RenameFolderModal({
folderUuid,
currentName,
show,
onHide,
onSuccess,
addDangerToast,
addSuccessToast,
}: RenameFolderModalProps) {
const [name, setName] = useState(currentName);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (show) setName(currentName);
}, [show, currentName]);
const handleSave = useCallback(async () => {
const trimmed = name.trim();
if (!trimmed) {
addDangerToast(t('Folder name is required'));
return;
}
if (trimmed === currentName) {
onHide();
return;
}
setSaving(true);
try {
await SupersetClient.put({
endpoint: `/api/v1/folders/${folderUuid}`,
jsonPayload: { name: trimmed },
});
addSuccessToast(t('Folder renamed to "%s"', trimmed));
onSuccess();
onHide();
} catch {
addDangerToast(t('Error renaming folder'));
} finally {
setSaving(false);
}
}, [
name,
currentName,
folderUuid,
addSuccessToast,
addDangerToast,
onSuccess,
onHide,
]);
return (
<StandardModal
title={t('Rename folder')}
show={show}
onHide={onHide}
onSave={handleSave}
saveText={t('Save')}
saveDisabled={!name.trim() || name.trim() === currentName}
saveLoading={saving}
>
<ModalContent>
<FormGroup>
<label htmlFor="rename-folder-name">{t('Name')}</label>
<Input
id="rename-folder-name"
placeholder={t('Folder name')}
value={name}
onChange={e => setName(e.target.value)}
onPressEnter={handleSave}
autoFocus
/>
</FormGroup>
</ModalContent>
</StandardModal>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
/**
* 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 { styled, css } from '@apache-superset/core/theme';
export const ModalContent = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px;
`}
`;
export const FormGroup = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 2}px 0;
label {
display: block;
font-weight: ${theme.fontWeightStrong};
margin-bottom: ${theme.sizeUnit}px;
}
`}
`;

View File

@@ -0,0 +1,52 @@
/**
* 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 type { TagType } from 'src/components';
export type ItemType = 'folder' | 'chart' | 'dashboard';
export interface Owner {
id: number;
first_name?: string;
last_name?: string;
}
/** A contents row (folder or asset) from /api/v1/folders/[<uuid>/]assets. */
export interface ContentItem {
type: ItemType;
id: number;
uuid: string | null;
name: string;
url?: string | null;
viz_type?: string | null;
datasource_name?: string | null;
datasource_url?: string | null;
owners?: Owner[];
changed_on?: string;
changed_on_humanized?: string | null;
changed_by?: Owner | null;
tags?: TagType[];
asset_count?: number;
children_count?: number;
user_permission?: 'editor' | 'viewer' | null;
parent_uuid?: string | null;
inherits_permissions?: boolean;
is_private?: boolean;
is_only_me?: boolean;
folder_path?: Array<{ uuid: string; name: string }>;
}

View File

@@ -37,6 +37,11 @@ const ChartCreation = lazy(
import(/* webpackChunkName: "ChartCreation" */ 'src/pages/ChartCreation'),
);
const AnalyticsList = lazy(
() =>
import(/* webpackChunkName: "AnalyticsList" */ 'src/pages/AnalyticsList'),
);
const AnnotationLayerList = lazy(
() =>
import(
@@ -217,6 +222,87 @@ export const routes: Routes = [
{ path: RoutePaths.ANNOTATION_LIST, Component: AnnotationList },
{ path: RoutePaths.QUERY_HISTORY, Component: QueryHistoryList },
{ path: RoutePaths.ALERTS, Component: AlertReportList },
{
path: '/redirect/',
Component: RedirectWarning,
},
{
path: '/login/',
Component: Login,
},
{
path: '/register/activation/:activationHash',
Component: Register,
},
{
path: '/register/',
Component: Register,
},
{
path: '/logout/',
Component: Login,
},
{
path: '/superset/welcome/',
Component: Home,
},
{
path: '/superset/file-handler',
Component: FileHandler,
},
{
path: '/dashboard/list/',
Component: DashboardList,
},
{
path: '/superset/dashboard/:idOrSlug/',
Component: Dashboard,
},
{
path: '/chart/add',
Component: ChartCreation,
},
{
path: '/chart/list/',
Component: ChartList,
},
{
path: '/tablemodelview/list/',
Component: DatasetList,
},
{
path: '/databaseview/list/',
Component: DatabaseList,
},
{
path: '/savedqueryview/list/',
Component: SavedQueryList,
},
{
path: '/csstemplatemodelview/list/',
Component: CssTemplateList,
},
{
path: '/theme/list/',
Component: ThemeList,
},
{
path: '/annotationlayer/list/',
Component: AnnotationLayerList,
},
{
path: '/annotationlayer/:annotationLayerId/annotation/',
Component: AnnotationList,
},
{
path: '/sqllab/history/',
Component: QueryHistoryList,
},
{
path: '/alert/list/',
Component: AlertReportList,
props: { isReportEnabled: true },
},
{
path: RoutePaths.REPORTS,
Component: AlertReportList,
@@ -245,6 +331,13 @@ if (isFeatureEnabled(FeatureFlag.TaggingSystem)) {
routes.push({ path: RoutePaths.TAGS, Component: Tags });
}
if (isFeatureEnabled('FOLDERS' as FeatureFlag)) {
routes.push({
path: '/analytics/:folderUuid?/',
Component: AnalyticsList,
});
}
const user = getBootstrapData()?.user;
const authRegistrationEnabled =
getBootstrapData()?.common.conf.AUTH_USER_REGISTRATION;

View File

@@ -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.

View File

@@ -0,0 +1,135 @@
# 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.
from __future__ import annotations
from functools import partial
from typing import Any
from marshmallow import ValidationError
from superset import security_manager
from superset.commands.base import BaseCommand
from superset.utils.core import get_user_id
from superset.utils.decorators import on_error, transaction
from superset.commands.folder.exceptions import (
FolderAssetNotFoundValidationError,
FolderAssetTypeValidationError,
FolderForbiddenError,
FolderInvalidError,
FolderNotFoundError,
FolderUpdateFailedError,
)
from superset.folders.constants import ASSET_TYPE_CONFIGS, asset_types_for_folder_type
from superset.daos.folder import FolderDAO
from superset.folders.models import Folder
from superset.daos.folder_permissions import FolderPermissionDAO
def _validate_folder_assets(
folder_id_or_uuid: str, assets: list[dict[str, Any]]
) -> Folder:
"""Validate folder existence, editor permissions, and asset references."""
model = FolderDAO.find_by_id_or_uuid(folder_id_or_uuid)
if not model:
raise FolderNotFoundError()
if not security_manager.is_admin():
user_id = get_user_id()
if not user_id or not FolderPermissionDAO.user_is_folder_editor(
user_id, model.id
):
raise FolderForbiddenError()
exceptions: list[ValidationError] = []
allowed = set(asset_types_for_folder_type(model.folder_type))
is_admin = security_manager.is_admin()
for asset in assets:
asset_type = asset["type"]
if asset_type not in allowed:
exceptions.append(
FolderAssetTypeValidationError(asset_type, model.folder_type)
)
elif not FolderDAO.asset_exists(asset_type, asset["id"]):
exceptions.append(
FolderAssetNotFoundValidationError(asset_type, asset["id"])
)
elif not is_admin:
from superset.extensions import db
config = ASSET_TYPE_CONFIGS[asset_type]
asset_obj = db.session.get(config.model, asset["id"])
if asset_obj:
try:
if asset_type == "dashboard":
security_manager.raise_for_access(dashboard=asset_obj)
elif asset_type == "chart":
security_manager.raise_for_access(chart=asset_obj)
except Exception as ex:
raise FolderForbiddenError() from ex
if exceptions:
raise FolderInvalidError(exceptions=exceptions)
return model
class UpdateFolderAssetsCommand(BaseCommand):
"""Set a folder's asset membership.
``assets`` is the full desired membership: listed assets are moved into the
folder (from wherever they were), and any of the folder's current assets not
in the list are moved back to the root. An empty list empties the folder.
"""
def __init__(self, folder_id_or_uuid: str, assets: list[dict[str, Any]]):
self._id = folder_id_or_uuid
self._assets = assets
self._model: Folder | None = None
@transaction(on_error=partial(on_error, reraise=FolderUpdateFailedError))
def run(self) -> Folder:
self.validate()
assert self._model
FolderDAO.set_assets(self._model, self._assets)
return self._model
def validate(self) -> None:
self._model = _validate_folder_assets(self._id, self._assets)
class AddFolderAssetsCommand(BaseCommand):
"""Add assets to a folder (upsert).
Assets already in another folder are moved. Assets already in this folder
are left as-is. Unlike ``UpdateFolderAssetsCommand``, existing assets in
the folder that are not listed are **not** removed.
"""
def __init__(self, folder_id_or_uuid: str, assets: list[dict[str, Any]]):
self._id = folder_id_or_uuid
self._assets = assets
self._model: Folder | None = None
@transaction(on_error=partial(on_error, reraise=FolderUpdateFailedError))
def run(self) -> Folder:
self.validate()
assert self._model
FolderDAO.assign_assets(self._model, self._assets)
return self._model
def validate(self) -> None:
self._model = _validate_folder_assets(self._id, self._assets)

View File

@@ -0,0 +1,102 @@
# 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.
from __future__ import annotations
from functools import partial
from typing import Any
from flask import g
from marshmallow import ValidationError
from superset import security_manager
from superset.commands.base import BaseCommand
from superset.extensions import db
from superset.utils.decorators import on_error, transaction
from superset.commands.folder.exceptions import (
FolderCreateFailedError,
FolderForbiddenError,
FolderInvalidError,
FolderNameUniquenessValidationError,
FolderParentNotFoundValidationError,
FolderParentTypeMismatchValidationError,
FolderTypeValidationError,
)
from superset.folders.constants import FOLDER_TYPE_ASSETS
from superset.daos.folder import FolderDAO
from superset.folders.models import Folder
from superset.daos.folder_permissions import FolderPermissionDAO
class CreateFolderCommand(BaseCommand):
def __init__(self, data: dict[str, Any]):
self._properties = data.copy()
self._parent: Folder | None = None
@transaction(on_error=partial(on_error, reraise=FolderCreateFailedError))
def run(self) -> Folder:
self.validate()
folder = FolderDAO.create(
attributes={
"name": self._properties["name"],
"description": self._properties.get("description"),
"parent_id": self._parent.id if self._parent else None,
"folder_type": self._properties["folder_type"],
"is_private": self._properties.get("is_private", False),
}
)
db.session.flush()
if (
hasattr(g, "user")
and not g.user.is_anonymous
and not security_manager.is_admin()
):
FolderDAO.add_subject(folder.id, g.user.id, "editor")
if self._parent:
FolderPermissionDAO.copy_permissions_to_subfolder(
self._parent.id, folder.id
)
return folder
def validate(self) -> None:
exceptions: list[ValidationError] = []
name = self._properties["name"]
folder_type = self._properties["folder_type"]
if folder_type not in FOLDER_TYPE_ASSETS:
exceptions.append(FolderTypeValidationError(folder_type))
if parent_uuid := self._properties.get("parent_uuid"):
self._parent = FolderDAO.find_by_id_or_uuid(parent_uuid)
if self._parent is None:
exceptions.append(FolderParentNotFoundValidationError())
elif self._parent.folder_type != folder_type:
exceptions.append(FolderParentTypeMismatchValidationError())
elif not security_manager.is_admin():
from superset.utils.core import get_user_id
user_id = get_user_id()
if not FolderPermissionDAO.user_is_folder_editor(
user_id, self._parent.id
):
raise FolderForbiddenError()
parent_id = self._parent.id if self._parent else None
if not FolderDAO.validate_name_uniqueness(name, parent_id, folder_type):
exceptions.append(FolderNameUniquenessValidationError())
if exceptions:
raise FolderInvalidError(exceptions=exceptions)

View File

@@ -0,0 +1,65 @@
# 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.
from __future__ import annotations
from functools import partial
from superset import security_manager
from superset.commands.base import BaseCommand
from superset.utils.core import get_user_id
from superset.utils.decorators import on_error, transaction
from superset.commands.folder.exceptions import (
FolderDeleteFailedError,
FolderForbiddenError,
FolderNotFoundError,
)
from superset.daos.folder import FolderDAO
from superset.folders.models import Folder
from superset.daos.folder_permissions import FolderPermissionDAO
class DeleteFolderCommand(BaseCommand):
def __init__(self, folder_id_or_uuid: str, archive_items: bool = False):
self._id = folder_id_or_uuid
self._archive_items = archive_items
self._model: Folder | None = None
@transaction(on_error=partial(on_error, reraise=FolderDeleteFailedError))
def run(self) -> None:
self.validate()
assert self._model
FolderDAO.delete_folder(self._model, self._archive_items)
def validate(self) -> None:
from superset.utils import json as json_utils
self._model = FolderDAO.find_by_id_or_uuid(self._id)
if not self._model:
raise FolderNotFoundError()
# "Only Me" folder cannot be deleted
extra = json_utils.loads(self._model.extra) if self._model.extra else {}
if extra.get("only_me"):
raise FolderForbiddenError()
if not security_manager.is_admin():
user_id = get_user_id()
if not user_id or not FolderPermissionDAO.user_is_folder_editor(
user_id, self._model.id
):
raise FolderForbiddenError()

View File

@@ -0,0 +1,125 @@
# 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.
from flask_babel import lazy_gettext as _
from superset.commands.exceptions import (
CommandException,
CommandInvalidError,
CreateFailedError,
DeleteFailedError,
ForbiddenError,
UpdateFailedError,
ValidationError,
)
class FolderForbiddenError(ForbiddenError):
message = _("You don't have permission to access this folder.")
class FolderInvalidError(CommandInvalidError):
message = _("Folder parameters are invalid.")
class FolderNotFoundError(CommandException):
status = 404
message = _("Folder not found.")
class FolderCreateFailedError(CreateFailedError):
message = _("Folder could not be created.")
class FolderUpdateFailedError(UpdateFailedError):
message = _("Folder could not be updated.")
class FolderDeleteFailedError(DeleteFailedError):
message = _("Folder could not be deleted.")
class FolderNameUniquenessValidationError(ValidationError):
"""A sibling folder of the same type already uses this name."""
def __init__(self) -> None:
super().__init__(
[_("A folder with this name already exists in this location")],
field_name="name",
)
class FolderParentNotFoundValidationError(ValidationError):
"""The referenced parent folder does not exist."""
def __init__(self) -> None:
super().__init__([_("Parent folder not found")], field_name="parent_uuid")
class FolderParentTypeMismatchValidationError(ValidationError):
"""The parent folder belongs to a different folder type."""
def __init__(self) -> None:
super().__init__(
[_("Parent folder belongs to a different folder type")],
field_name="parent_uuid",
)
class FolderCycleValidationError(ValidationError):
"""A move would place a folder inside itself or a descendant."""
def __init__(self) -> None:
super().__init__(
[_("Cannot move a folder into itself or one of its descendants")],
field_name="parent_uuid",
)
class FolderTypeValidationError(ValidationError):
"""The folder_type is not a recognised namespace."""
def __init__(self, folder_type: str) -> None:
super().__init__(
[_("Unknown folder type: '%(type)s'", type=folder_type)],
field_name="folder_type",
)
class FolderAssetNotFoundValidationError(ValidationError):
"""A referenced asset does not exist."""
def __init__(self, asset_type: str, asset_id: int) -> None:
super().__init__(
[_("%(type)s %(id)s not found", type=asset_type, id=asset_id)],
field_name="assets",
)
class FolderAssetTypeValidationError(ValidationError):
"""An asset kind cannot be placed in a folder of this type."""
def __init__(self, asset_type: str, folder_type: str) -> None:
super().__init__(
[
_(
"Assets of type '%(type)s' cannot be added to a "
"'%(folder_type)s' folder",
type=asset_type,
folder_type=folder_type,
)
],
field_name="assets",
)

View File

@@ -0,0 +1,151 @@
# 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.
from __future__ import annotations
from functools import partial
from typing import Any
from marshmallow import ValidationError
from superset import db, security_manager
from superset.commands.base import BaseCommand
from superset.utils.core import get_user_id
from superset.utils.decorators import on_error, transaction
from superset.commands.folder.exceptions import (
FolderCycleValidationError,
FolderForbiddenError,
FolderInvalidError,
FolderNameUniquenessValidationError,
FolderNotFoundError,
FolderParentNotFoundValidationError,
FolderParentTypeMismatchValidationError,
FolderUpdateFailedError,
)
from superset.daos.folder import FolderDAO
from superset.folders.models import Folder
from superset.daos.folder_permissions import FolderPermissionDAO
class UpdateFolderCommand(BaseCommand):
def __init__(self, folder_id_or_uuid: str, data: dict[str, Any]):
self._id = folder_id_or_uuid
self._properties = data.copy()
self._model: Folder | None = None
self._parent_changed = False
self._new_parent_id: int | None = None
@transaction(on_error=partial(on_error, reraise=FolderUpdateFailedError))
def run(self) -> Folder:
self.validate()
assert self._model
attributes: dict[str, Any] = {}
if "name" in self._properties:
attributes["name"] = self._properties["name"]
if "description" in self._properties:
attributes["description"] = self._properties["description"]
if "is_private" in self._properties:
attributes["is_private"] = self._properties["is_private"]
if self._parent_changed:
attributes["parent_id"] = self._new_parent_id
folder = FolderDAO.update(self._model, attributes)
# When moving to a new parent: copy permissions and remove root pins
if self._parent_changed and self._new_parent_id is not None:
FolderPermissionDAO.copy_permissions_to_subfolder(
self._new_parent_id, folder.id
)
from superset.folders.models import FolderPin
db.session.query(FolderPin).filter(
FolderPin.folder_id == folder.id,
).delete()
# Sync permissions from parent when requested
if self._properties.get("sync_permissions") and folder.parent_id:
FolderPermissionDAO.copy_permissions_to_subfolder(
folder.parent_id, folder.id
)
FolderPermissionDAO.push_down_permissions(folder.id)
return folder
def _validate_parent(self, exceptions: list[ValidationError]) -> int | None:
"""Validate and resolve parent_uuid, returning the new parent_id."""
new_parent_id = self._model.parent_id # type: ignore[union-attr]
if "parent_uuid" not in self._properties:
return new_parent_id
self._parent_changed = True
parent: Folder | None = None
if parent_uuid := self._properties["parent_uuid"]:
parent = FolderDAO.find_by_id_or_uuid(parent_uuid)
if parent is None:
exceptions.append(FolderParentNotFoundValidationError())
elif parent.folder_type != self._model.folder_type: # type: ignore[union-attr]
exceptions.append(FolderParentTypeMismatchValidationError())
elif FolderDAO.is_descendant(parent, self._model): # type: ignore[arg-type]
exceptions.append(FolderCycleValidationError())
elif not security_manager.is_admin():
user_id = get_user_id()
if not user_id or not FolderPermissionDAO.user_is_folder_editor(user_id, parent.id):
raise FolderForbiddenError()
new_parent_id = parent.id if parent else None
self._new_parent_id = new_parent_id
return new_parent_id
def validate(self) -> None:
from superset.utils import json as json_utils
self._model = FolderDAO.find_by_id_or_uuid(self._id)
if not self._model:
raise FolderNotFoundError()
if not security_manager.is_admin():
user_id = get_user_id()
if not user_id or not FolderPermissionDAO.user_is_folder_editor(
user_id, self._model.id
):
raise FolderForbiddenError()
# "Only Me" folder: reject name/parent changes (sync_permissions is allowed)
extra = json_utils.loads(self._model.extra) if self._model.extra else {}
if extra.get("only_me"):
if "name" in self._properties or "parent_uuid" in self._properties:
raise FolderForbiddenError()
# Private folders cannot be moved into other folders
if self._model.is_private and "parent_uuid" in self._properties:
raise FolderForbiddenError()
exceptions: list[ValidationError] = []
new_parent_id = self._validate_parent(exceptions)
new_name = self._properties.get("name", self._model.name)
if new_name != self._model.name or new_parent_id != self._model.parent_id:
resolved = FolderDAO.resolve_name_conflict(
new_name,
new_parent_id,
self._model.folder_type,
exclude_id=self._model.id,
)
if resolved != new_name:
self._properties["name"] = resolved
if exceptions:
raise FolderInvalidError(exceptions=exceptions)

View File

@@ -648,6 +648,9 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# Allow metrics and columns to be grouped into folders in the chart builder
# @lifecycle: development
"DATASET_FOLDERS": False,
# Enable the folder-based analytics view (replaces Charts/Dashboards nav)
# @lifecycle: development
"FOLDERS": False,
# Enable support for date range timeshifts (e.g., "2015-01-03 : 2015-01-04")
# in addition to relative timeshifts (e.g., "1 day ago")
# @lifecycle: development
@@ -2852,6 +2855,10 @@ EXTRA_RAISE_FOR_ACCESS_BYPASS: Callable[..., bool] | None = None
EXTRA_OWNERS_RESOLVER: Callable[..., list[Any]] | None = None
# Post-create hook for charts/dashboards. Receives (model, asset_type).
AFTER_ASSET_CREATE: Callable[[Any, str], None] | None = None
# When True, folder viewers/editors can access all charts and datasources on a
# dashboard that is in their folder, even if those charts are not individually
# in any folder. Disabled by default.
FOLDER_DASHBOARD_TRANSITIVE_ACCESS: bool = False
# The migrations that add catalog permissions might take a considerably long time

963
superset/daos/folder.py Normal file
View File

@@ -0,0 +1,963 @@
# 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.
"""Data access for folders and the assets they contain.
Validation and orchestration live in ``preset.folders.commands``; this DAO is
limited to queries and persistence.
"""
from __future__ import annotations
from collections import defaultdict
from datetime import datetime
from typing import Any
from flask_appbuilder.security.sqla.models import User
from sqlalchemy import and_, case, func, literal, or_, select, union_all
from sqlalchemy.orm import joinedload
from superset.daos.base import BaseDAO
from superset.extensions import db
from superset.folders.constants import ASSET_TYPE_CONFIGS, asset_types_for_folder_type
from superset.folders.models import (
Folder,
folder_editors,
folder_viewers,
FolderObject,
FolderPin,
)
# A resolved asset: its type label paired with the underlying model instance.
ResolvedAsset = tuple[str, Any]
class FolderDAO(BaseDAO[Folder]):
"""Queries and persistence for folders and their asset links."""
# ------------------------------------------------------------------ #
# Lookups
# ------------------------------------------------------------------ #
@classmethod
def get_by_uuid(cls, folder_uuid: str) -> Folder | None:
"""Find a folder by UUID (or numeric id)."""
return cls.find_by_id_or_uuid(folder_uuid)
@classmethod
def get_folders(cls, folder_type: str | None = None) -> list[Folder]:
"""List folders, optionally filtered by ``folder_type``."""
query = db.session.query(Folder)
if folder_type:
query = query.filter(Folder.folder_type == folder_type)
return query.order_by(Folder.changed_on.desc()).all()
@classmethod
def get_roots(cls, folder_type: str) -> list[Folder]:
"""Top-level folders (no parent) for a folder type."""
return (
db.session.query(Folder)
.filter(Folder.parent_id.is_(None), Folder.folder_type == folder_type)
.order_by(Folder.changed_on.desc())
.all()
)
@classmethod
def get_children(cls, folder: Folder) -> list[Folder]:
"""Direct subfolders of ``folder``."""
return (
db.session.query(Folder)
.filter(Folder.parent_id == folder.id)
.order_by(Folder.changed_on.desc())
.all()
)
# ------------------------------------------------------------------ #
# Asset queries
# ------------------------------------------------------------------ #
@classmethod
def get_folder_assets(cls, folder: Folder) -> list[ResolvedAsset]:
"""Resolve the assets linked directly to ``folder`` into model objects."""
ids_by_type: dict[str, list[int]] = {name: [] for name in ASSET_TYPE_CONFIGS}
for link in folder.objects:
for name, config in ASSET_TYPE_CONFIGS.items():
asset_id = getattr(link, config.fk_column)
if asset_id is not None:
ids_by_type[name].append(asset_id)
return cls._load_assets(ids_by_type)
@classmethod
def get_contents( # noqa: C901
cls,
folder: Folder | None,
folder_type: str,
*,
search: str | None = None,
types: list[str] | None = None,
viz_types: list[str] | None = None,
datasets: list[int] | None = None,
tags: list[int] | None = None,
owners: list[int] | None = None,
editors: list[int] | None = None,
viewers: list[int] | None = None,
modified_start: datetime | None = None,
modified_end: datetime | None = None,
page: int = 0,
page_size: int = 25,
sort_column: str = "changed_on",
sort_order: str = "desc",
) -> tuple[list[ResolvedAsset], int]:
"""Paginated, filtered contents of a folder (or the root).
Returns ``(rows, total)`` where each row is ``(kind, model_instance)`` with
``kind`` in ``{"folder", <asset types>}``. Pagination happens in the DB via a
``UNION ALL`` over the included sources ordered by (folders first, then most
recently changed), so callers get exactly one page plus the total count.
"""
from superset import security_manager
from superset.connectors.sqla.models import Database as ConnDatabase, SqlaTable
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.utils.core import get_user_id
folder_id = folder.id if folder else None
requested = set(types) if types else None
asset_only_filter = bool(viz_types or datasets or owners)
is_admin = security_manager.is_admin()
user_id = get_user_id()
# Escape LIKE wildcards in search input
if search:
search = (
search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
)
# When searching, find ALL descendant folders via recursive CTE.
child_folder_ids_sub = None
if search:
base = (
select(Folder.id)
.where(
Folder.parent_id == folder_id
if folder_id is not None
else Folder.parent_id.is_(None),
Folder.folder_type == folder_type,
)
.cte(recursive=True, name="descendants")
)
descendants = base.union_all(
select(Folder.id).where(
Folder.parent_id == base.c.id,
Folder.folder_type == folder_type,
)
)
child_folder_ids_sub = select(descendants.c.id)
def included(kind: str) -> bool:
return requested is None or kind in requested
selects = []
# --- Folders (subfolders / top-level) ---
if included("folder") and not asset_only_filter:
fq = select(
literal(0).label("kind_order"),
literal("folder").label("item_type"),
Folder.id.label("item_id"),
Folder.changed_on.label("changed_on"),
Folder.name.label("sort_name"),
literal("").label("sort_datasource"),
).where(Folder.folder_type == folder_type)
if search and child_folder_ids_sub is not None:
fq = fq.where(
or_(
Folder.parent_id == folder_id
if folder_id is not None
else Folder.parent_id.is_(None),
Folder.parent_id.in_(child_folder_ids_sub),
)
)
fq = fq.where(Folder.name.ilike(f"%{search}%", escape="\\"))
else:
fq = fq.where(
Folder.parent_id.is_(None)
if folder_id is None
else Folder.parent_id == folder_id
)
if modified_start:
fq = fq.where(Folder.changed_on >= modified_start)
if modified_end:
fq = fq.where(Folder.changed_on <= modified_end)
# Hide other users' private folders from everyone (including admins)
if user_id:
own_private_ids = (
select(folder_editors.c.folder_id).where(
folder_editors.c.user_id == user_id
)
)
fq = fq.where(
or_(
Folder.is_private.is_(False),
Folder.id.in_(own_private_ids),
)
)
# Access filter: non-admins only see folders they are a member of
if not is_admin and user_id:
from superset.folders.utils import user_accessible_folder_ids
fq = fq.where(Folder.id.in_(user_accessible_folder_ids(user_id)))
if editors:
editor_folder_ids = select(folder_editors.c.folder_id).where(
folder_editors.c.user_id.in_(editors)
)
fq = fq.where(Folder.id.in_(editor_folder_ids))
if viewers:
viewer_folder_ids = select(folder_viewers.c.folder_id).where(
folder_viewers.c.user_id.in_(viewers)
)
fq = fq.where(Folder.id.in_(viewer_folder_ids))
selects.append(fq)
# --- Assets (charts / dashboards for this folder type) ---
title_attrs = {
"chart": Slice.slice_name,
"dashboard": Dashboard.dashboard_title,
}
for name in asset_types_for_folder_type(folder_type):
if not included(name):
continue
# viz/dataset filters only make sense for charts.
if (viz_types or datasets) and name != "chart":
continue
model = ASSET_TYPE_CONFIGS[name].model
fk_col = getattr(FolderObject, ASSET_TYPE_CONFIGS[name].fk_column)
if name == "chart":
aq = (
select(
literal(1).label("kind_order"),
literal(name).label("item_type"),
model.id.label("item_id"),
model.changed_on.label("changed_on"),
title_attrs[name].label("sort_name"),
func.coalesce(
func.concat(
func.coalesce(SqlaTable.schema, ""),
case(
(SqlaTable.schema.isnot(None), "."),
else_="",
),
func.coalesce(SqlaTable.table_name, ""),
),
"",
).label("sort_datasource"),
)
.outerjoin(SqlaTable, Slice.datasource_id == SqlaTable.id)
)
else:
aq = select(
literal(1).label("kind_order"),
literal(name).label("item_type"),
model.id.label("item_id"),
model.changed_on.label("changed_on"),
title_attrs[name].label("sort_name"),
literal("").label("sort_datasource"),
)
if folder_id is None:
if search and child_folder_ids_sub is not None:
used = select(fk_col).where(fk_col.isnot(None))
in_child = select(fk_col).where(
FolderObject.folder_id.in_(child_folder_ids_sub),
fk_col.isnot(None),
)
aq = aq.where(or_(model.id.notin_(used), model.id.in_(in_child)))
else:
used = select(fk_col).where(fk_col.isnot(None))
aq = aq.where(model.id.notin_(used))
else:
if search and child_folder_ids_sub is not None:
in_folder = select(fk_col).where(
or_(
FolderObject.folder_id == folder_id,
FolderObject.folder_id.in_(child_folder_ids_sub),
),
fk_col.isnot(None),
)
else:
in_folder = select(fk_col).where(
FolderObject.folder_id == folder_id, fk_col.isnot(None)
)
aq = aq.where(model.id.in_(in_folder))
if search:
aq = aq.where(title_attrs[name].ilike(f"%{search}%", escape="\\"))
if modified_start:
aq = aq.where(model.changed_on >= modified_start)
if modified_end:
aq = aq.where(model.changed_on <= modified_end)
if owners:
aq = aq.where(model.owners.any(cls._user_model().id.in_(owners)))
if name == "chart":
if viz_types:
aq = aq.where(Slice.viz_type.in_(viz_types))
if datasets:
aq = aq.where(Slice.datasource_id.in_(datasets))
if tags:
from superset.tags.models import ObjectType, TaggedObject
object_type = (
ObjectType.chart if name == "chart" else ObjectType.dashboard
)
tagged = select(TaggedObject.object_id).where(
TaggedObject.object_type == object_type,
TaggedObject.tag_id.in_(tags),
)
aq = aq.where(model.id.in_(tagged))
if editors:
fk_col_filter = getattr(FolderObject, ASSET_TYPE_CONFIGS[name].fk_column)
editor_asset_ids = (
select(fk_col_filter)
.where(
FolderObject.folder_id.in_(
select(folder_editors.c.folder_id).where(
folder_editors.c.user_id.in_(editors)
)
),
fk_col_filter.isnot(None),
)
)
aq = aq.where(model.id.in_(editor_asset_ids))
if viewers:
fk_col_filter = getattr(FolderObject, ASSET_TYPE_CONFIGS[name].fk_column)
viewer_asset_ids = (
select(fk_col_filter)
.where(
FolderObject.folder_id.in_(
select(folder_viewers.c.folder_id).where(
folder_viewers.c.user_id.in_(viewers)
)
),
fk_col_filter.isnot(None),
)
)
aq = aq.where(model.id.in_(viewer_asset_ids))
# Access filter: non-admins only see assets they can access
if not is_admin and user_id:
from superset.utils.filters import get_dataset_access_filters
access_conditions = []
# 1. User owns the asset
access_conditions.append(
model.owners.any(cls._user_model().id == user_id)
)
# 2. User has folder membership for the asset
from superset.folders.utils import user_accessible_folder_ids
user_folder_ids = user_accessible_folder_ids(user_id)
folder_accessible = (
select(fk_col)
.select_from(FolderObject)
.where(
fk_col.isnot(None),
FolderObject.folder_id.in_(user_folder_ids),
)
)
access_conditions.append(model.id.in_(folder_accessible))
# 3. Chart: user has datasource access
can_access_all = security_manager.can_access_all_datasources()
if name == "chart" and not can_access_all:
ds_accessible = (
select(Slice.id)
.join(
SqlaTable,
Slice.datasource_id == SqlaTable.id,
)
.join(
ConnDatabase,
SqlaTable.database_id == ConnDatabase.id,
)
.where(get_dataset_access_filters(Slice))
)
access_conditions.append(model.id.in_(ds_accessible))
# 4. Dashboard: published + user has datasource access
if name == "dashboard":
if can_access_all:
access_conditions.append(Dashboard.published.is_(True))
else:
published_accessible = (
select(Dashboard.id)
.join(Dashboard.slices, isouter=True)
.join(
SqlaTable,
Slice.datasource_id == SqlaTable.id,
)
.join(
ConnDatabase,
SqlaTable.database_id == ConnDatabase.id,
)
.where(
Dashboard.published.is_(True),
get_dataset_access_filters(Slice, can_access_all),
)
)
access_conditions.append(model.id.in_(published_accessible))
aq = aq.where(or_(*access_conditions))
selects.append(aq)
if not selects:
return [], 0
unioned = union_all(*selects).subquery()
total = (
db.session.execute(select(func.count()).select_from(unioned)).scalar() or 0
)
sort_map = {
"name": unioned.c.sort_name,
"type": unioned.c.item_type,
"changed_on": unioned.c.changed_on,
"datasource": unioned.c.sort_datasource,
}
sort_col = sort_map.get(sort_column, unioned.c.changed_on)
order = sort_col.asc() if sort_order == "asc" else sort_col.desc()
# Sort priority at root: Only Me (0) → pinned (1-3) → unpinned (4)
pin_order = (literal(4) + literal(0)).label("pin_order")
if folder_id is None and user_id:
only_me_order = (
select(literal(0) + literal(0))
.where(
unioned.c.item_type == "folder",
Folder.id == unioned.c.item_id,
Folder.is_private.is_(True),
)
.correlate(unioned)
.scalar_subquery()
)
pin_position = (
select(FolderPin.position)
.where(
FolderPin.user_id == user_id,
or_(
and_(
unioned.c.item_type == "folder",
FolderPin.folder_id == unioned.c.item_id,
),
and_(
unioned.c.item_type == "dashboard",
FolderPin.dashboard_id == unioned.c.item_id,
),
and_(
unioned.c.item_type == "chart",
FolderPin.chart_id == unioned.c.item_id,
),
),
)
.correlate(unioned)
.scalar_subquery()
)
pin_order = func.coalesce(
only_me_order, pin_position, literal(4) + literal(0)
).label("pin_order")
page_rows = db.session.execute(
select(unioned.c.item_type, unioned.c.item_id)
.order_by(pin_order, order)
.limit(page_size)
.offset(page * page_size)
).all()
# Hydrate the page's ids into model instances, preserving order.
ids_by_type: dict[str, list[int]] = defaultdict(list)
for item_type, item_id in page_rows:
ids_by_type[item_type].append(item_id)
objects: dict[tuple[str, int], Any] = {}
if ids_by_type.get("folder"):
for obj in (
db.session.query(Folder)
.options(
joinedload(Folder.parent),
joinedload(Folder.created_by),
joinedload(Folder.changed_by),
)
.filter(Folder.id.in_(ids_by_type["folder"]))
.all()
):
objects[("folder", obj.id)] = obj
for name in asset_types_for_folder_type(folder_type):
if not ids_by_type.get(name):
continue
model = ASSET_TYPE_CONFIGS[name].model
for obj in (
db.session.query(model).filter(model.id.in_(ids_by_type[name])).all()
):
objects[(name, obj.id)] = obj
rows: list[ResolvedAsset] = [
(item_type, objects[(item_type, item_id)])
for item_type, item_id in page_rows
if (item_type, item_id) in objects
]
return rows, total
@classmethod
def get_folder_path(cls, folder_id: int) -> list[dict[str, str]]:
"""Build the breadcrumb path from root to the given folder."""
path: list[dict[str, str]] = []
folder = db.session.get(Folder, folder_id)
while folder:
path.append({"uuid": str(folder.uuid), "name": folder.name})
folder = folder.parent
path.reverse()
return path
@classmethod
def get_asset_folder_paths(
cls, asset_ids: dict[str, list[int]]
) -> dict[tuple[str, int], list[dict[str, str]]]:
"""Get folder paths for a batch of assets. Returns {(type, id): path}."""
result: dict[tuple[str, int], list[dict[str, str]]] = {}
path_cache: dict[int, list[dict[str, str]]] = {}
for asset_type, ids in asset_ids.items():
config = ASSET_TYPE_CONFIGS.get(asset_type)
if not config:
continue
fk_col = getattr(FolderObject, config.fk_column)
links = (
db.session.query(FolderObject.folder_id, fk_col)
.filter(fk_col.in_(ids), fk_col.isnot(None))
.all()
)
for folder_id, asset_id in links:
if folder_id not in path_cache:
path_cache[folder_id] = cls.get_folder_path(folder_id)
result[(asset_type, asset_id)] = path_cache[folder_id]
return result
@staticmethod
def _user_model() -> Any:
from superset import security_manager
return security_manager.user_model
@classmethod
def _load_assets(cls, ids_by_type: dict[str, list[int]]) -> list[ResolvedAsset]:
resolved: list[ResolvedAsset] = []
for name, ids in ids_by_type.items():
if not ids:
continue
model = ASSET_TYPE_CONFIGS[name].model
rows = db.session.query(model).filter(model.id.in_(ids)).all()
resolved.extend((name, row) for row in rows)
return resolved
# ------------------------------------------------------------------ #
# Persistence
# ------------------------------------------------------------------ #
@classmethod
def delete_folder(cls, folder: Folder, archive_items: bool = False) -> None:
"""Delete a folder, re-parenting children to its parent.
When ``archive_items`` is False (default), assets linked to the folder
become unfoldered. When True, the assets themselves are also deleted.
"""
for child in list(folder.children):
child.parent_id = folder.parent_id
child.name = cls.resolve_name_conflict(
child.name, folder.parent_id, folder.folder_type, exclude_id=child.id
)
db.session.flush()
if archive_items:
for link in list(folder.objects):
for _name, config in ASSET_TYPE_CONFIGS.items():
asset_id = getattr(link, config.fk_column)
if asset_id is not None:
asset = db.session.get(config.model, asset_id)
if asset:
db.session.delete(asset)
break
db.session.delete(folder)
@classmethod
def assign_assets(cls, folder: Folder, assets: list[dict[str, Any]]) -> None:
"""Assign/move assets into ``folder`` (upsert).
An asset can live in only one folder, so an existing link is moved rather
than duplicated. Callers are expected to have validated the assets.
"""
for asset in assets:
config = ASSET_TYPE_CONFIGS[asset["type"]]
link_column = getattr(FolderObject, config.fk_column)
link = (
db.session.query(FolderObject)
.filter(link_column == asset["id"])
.one_or_none()
)
if link:
link.folder_id = folder.id
else:
link = FolderObject(folder_id=folder.id, created_on=datetime.utcnow())
setattr(link, config.fk_column, asset["id"])
db.session.add(link)
# Remove any pins for this asset since it's no longer at root
pin_col = {
"folder": FolderPin.folder_id,
"dashboard": FolderPin.dashboard_id,
"chart": FolderPin.chart_id,
}[asset["type"]]
db.session.query(FolderPin).filter(
pin_col == asset["id"],
).delete()
@classmethod
def set_assets(cls, folder: Folder, assets: list[dict[str, Any]]) -> None:
"""Set ``folder``'s membership to exactly ``assets``.
Listed assets are moved into the folder; the folder's current assets that
are not listed have their link removed (back to the root). An empty list
empties the folder. Callers are expected to have validated the assets.
"""
desired = {(asset["type"], asset["id"]) for asset in assets}
for link in list(folder.objects):
for name, config in ASSET_TYPE_CONFIGS.items():
asset_id = getattr(link, config.fk_column)
if asset_id is not None and (name, asset_id) not in desired:
db.session.delete(link)
break
db.session.flush()
cls.assign_assets(folder, assets)
@classmethod
def remove_assets(cls, folder: Folder, assets: list[dict[str, Any]]) -> None:
"""Remove specific assets from ``folder`` (back to root)."""
for asset in assets:
config = ASSET_TYPE_CONFIGS[asset["type"]]
link_column = getattr(FolderObject, config.fk_column)
link = (
db.session.query(FolderObject)
.filter(
FolderObject.folder_id == folder.id,
link_column == asset["id"],
)
.one_or_none()
)
if link:
db.session.delete(link)
# ------------------------------------------------------------------ #
# Validation helpers (used by commands)
# ------------------------------------------------------------------ #
@classmethod
def validate_name_uniqueness(
cls,
name: str,
parent_id: int | None,
folder_type: str,
exclude_id: int | None = None,
) -> bool:
"""True if no sibling of the same type already uses ``name``."""
query = db.session.query(Folder.id).filter(
Folder.parent_id.is_(None)
if parent_id is None
else Folder.parent_id == parent_id,
Folder.name == name,
Folder.folder_type == folder_type,
)
if exclude_id is not None:
query = query.filter(Folder.id != exclude_id)
return query.first() is None
@classmethod
def resolve_name_conflict(
cls,
name: str,
parent_id: int | None,
folder_type: str,
exclude_id: int | None = None,
) -> str:
"""Return a unique name by appending a numeric suffix if needed."""
if cls.validate_name_uniqueness(name, parent_id, folder_type, exclude_id):
return name
for counter in range(1, 1001):
candidate = f"{name} ({counter})"
if cls.validate_name_uniqueness(
candidate, parent_id, folder_type, exclude_id
):
return candidate
raise ValueError(f"Cannot resolve name conflict for '{name}'")
@classmethod
def is_descendant(cls, candidate: Folder, ancestor: Folder) -> bool:
"""True if ``candidate`` is ``ancestor`` itself or below it in the tree."""
node: Folder | None = candidate
while node is not None:
if node.id == ancestor.id:
return True
node = node.parent
return False
@classmethod
def asset_exists(cls, asset_type: str, asset_id: int) -> bool:
"""True if the referenced asset exists."""
model = ASSET_TYPE_CONFIGS[asset_type].model
return (
db.session.query(model.id).filter(model.id == asset_id).first() is not None
)
# ------------------------------------------------------------------ #
# Private / "Only Me" folders
# ------------------------------------------------------------------ #
@classmethod
def get_or_create_only_me_folder(cls, user_id: int) -> Folder:
"""Return the user's 'Only Me' folder, creating it if it doesn't exist."""
from superset.utils import json as json_utils
folder = (
db.session.query(Folder)
.join(folder_editors, folder_editors.c.folder_id == Folder.id)
.filter(
folder_editors.c.user_id == user_id,
Folder.is_private.is_(True),
Folder.parent_id.is_(None),
)
.all()
)
for f in folder:
extra = json_utils.loads(f.extra) if f.extra else {}
if extra.get("only_me"):
return f
new_folder = cls.create(
attributes={
"name": "Only Me",
"is_private": True,
"parent_id": None,
"folder_type": "analytics",
"extra": json_utils.dumps({"only_me": True, "inherits_permissions": False}),
}
)
db.session.flush()
db.session.execute(
folder_editors.insert().values(folder_id=new_folder.id, user_id=user_id)
)
db.session.flush()
return new_folder
# ------------------------------------------------------------------ #
# Pins
# ------------------------------------------------------------------ #
@classmethod
def get_pins(cls, user_id: int) -> list[FolderPin]:
return (
db.session.query(FolderPin)
.filter(FolderPin.user_id == user_id)
.order_by(FolderPin.position)
.all()
)
@classmethod
def create_pin(
cls,
user_id: int,
object_id: int,
object_type: str,
position: int,
) -> FolderPin:
pin = FolderPin(
user_id=user_id,
folder_id=object_id if object_type == "folder" else None,
dashboard_id=object_id if object_type == "dashboard" else None,
chart_id=object_id if object_type == "chart" else None,
position=position,
created_on=datetime.utcnow(),
)
db.session.add(pin)
db.session.flush()
return pin
@classmethod
def delete_pin(cls, pin_id: int, user_id: int) -> bool:
pin = (
db.session.query(FolderPin)
.filter(FolderPin.id == pin_id, FolderPin.user_id == user_id)
.first()
)
if not pin:
return False
db.session.delete(pin)
db.session.flush()
return True
# ------------------------------------------------------------------ #
# Subjects (permissions)
# ------------------------------------------------------------------ #
@classmethod
def get_subjects(cls, folder_id: int) -> list[dict[str, Any]]:
from superset import security_manager
subjects: list[dict[str, Any]] = []
admin_role = security_manager.find_role("Admin")
editors = db.session.execute(
folder_editors.select().where(folder_editors.c.folder_id == folder_id)
).fetchall()
viewers = db.session.execute(
folder_viewers.select().where(folder_viewers.c.folder_id == folder_id)
).fetchall()
all_user_ids = [r.user_id for r in editors] + [r.user_id for r in viewers]
users_by_id = (
{
u.id: u
for u in db.session.query(User).filter(User.id.in_(all_user_ids)).all()
}
if all_user_ids
else {}
)
def _is_admin(user: User | None) -> bool:
if not user or not admin_role:
return False
return admin_role in user.roles
for row in editors:
user = users_by_id.get(row.user_id)
is_admin = _is_admin(user)
subjects.append(
{
"user_id": row.user_id,
"permission": "admin" if is_admin else "editor",
"email": user.email if user else None,
"is_admin": is_admin,
}
)
for row in viewers:
user = users_by_id.get(row.user_id)
is_admin = _is_admin(user)
subjects.append(
{
"user_id": row.user_id,
"permission": "admin" if is_admin else "viewer",
"email": user.email if user else None,
"is_admin": is_admin,
}
)
return subjects
@classmethod
def add_subject(cls, folder_id: int, user_id: int, permission: str) -> None:
from flask_appbuilder.security.sqla.models import User
if not db.session.get(User, user_id):
raise ValueError(f"User {user_id} does not exist")
existing = (
db.session.execute(
folder_editors.select().where(
and_(
folder_editors.c.folder_id == folder_id,
folder_editors.c.user_id == user_id,
)
)
).first()
or db.session.execute(
folder_viewers.select().where(
and_(
folder_viewers.c.folder_id == folder_id,
folder_viewers.c.user_id == user_id,
)
)
).first()
)
if existing:
raise ValueError(f"User {user_id} is already a member of this folder")
if permission == "editor":
db.session.execute(
folder_editors.insert().values(folder_id=folder_id, user_id=user_id)
)
else:
db.session.execute(
folder_viewers.insert().values(folder_id=folder_id, user_id=user_id)
)
db.session.flush()
@classmethod
def update_subject(cls, folder_id: int, user_id: int, permission: str) -> None:
existing = (
db.session.execute(
folder_editors.select().where(
and_(
folder_editors.c.folder_id == folder_id,
folder_editors.c.user_id == user_id,
)
)
).first()
or db.session.execute(
folder_viewers.select().where(
and_(
folder_viewers.c.folder_id == folder_id,
folder_viewers.c.user_id == user_id,
)
)
).first()
)
if not existing:
raise ValueError(f"User {user_id} is not a member of this folder")
db.session.execute(
folder_editors.delete().where(
and_(
folder_editors.c.folder_id == folder_id,
folder_editors.c.user_id == user_id,
)
)
)
db.session.execute(
folder_viewers.delete().where(
and_(
folder_viewers.c.folder_id == folder_id,
folder_viewers.c.user_id == user_id,
)
)
)
cls.add_subject(folder_id, user_id, permission)
@classmethod
def remove_subject(cls, folder_id: int, user_id: int) -> None:
db.session.execute(
folder_editors.delete().where(
and_(
folder_editors.c.folder_id == folder_id,
folder_editors.c.user_id == user_id,
)
)
)
db.session.execute(
folder_viewers.delete().where(
and_(
folder_viewers.c.folder_id == folder_id,
folder_viewers.c.user_id == user_id,
)
)
)
db.session.flush()

View File

@@ -0,0 +1,457 @@
# 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.
"""Data access for folder permission operations."""
from __future__ import annotations
from typing import Any
from sqlalchemy import and_
from superset import db
from superset.utils import json as json_utils
from superset.folders.models import (
Folder,
folder_editors,
folder_viewers,
FolderObject,
)
class FolderPermissionDAO:
"""Queries and persistence for folder permissions (editors/viewers)."""
# ------------------------------------------------------------------ #
# Subject queries
# ------------------------------------------------------------------ #
@staticmethod
def get_subjects(folder_id: int) -> list[dict[str, Any]]:
"""Get all editors and viewers for a folder."""
subjects: list[dict[str, Any]] = []
editors = db.session.execute(
folder_editors.select().where(folder_editors.c.folder_id == folder_id)
).fetchall()
for row in editors:
subjects.append({"user_id": row.user_id, "permission": "editor"})
viewers = db.session.execute(
folder_viewers.select().where(folder_viewers.c.folder_id == folder_id)
).fetchall()
for row in viewers:
subjects.append({"user_id": row.user_id, "permission": "viewer"})
return subjects
# ------------------------------------------------------------------ #
# Subject mutations
# ------------------------------------------------------------------ #
@staticmethod
def add_editor(folder_id: int, user_id: int) -> None:
db.session.execute(
folder_editors.insert().values(folder_id=folder_id, user_id=user_id)
)
db.session.flush()
@staticmethod
def add_viewer(folder_id: int, user_id: int) -> None:
db.session.execute(
folder_viewers.insert().values(folder_id=folder_id, user_id=user_id)
)
db.session.flush()
@staticmethod
def remove_editor(folder_id: int, user_id: int) -> None:
db.session.execute(
folder_editors.delete().where(
and_(
folder_editors.c.folder_id == folder_id,
folder_editors.c.user_id == user_id,
)
)
)
db.session.flush()
@staticmethod
def remove_viewer(folder_id: int, user_id: int) -> None:
db.session.execute(
folder_viewers.delete().where(
and_(
folder_viewers.c.folder_id == folder_id,
folder_viewers.c.user_id == user_id,
)
)
)
db.session.flush()
@staticmethod
def update_subject_permission(
folder_id: int, user_id: int, permission: str
) -> None:
"""Move a user between editors and viewers."""
if permission not in ("editor", "viewer"):
raise ValueError(
f"Invalid permission: {permission!r}. Must be 'editor' or 'viewer'."
)
if permission == "editor":
FolderPermissionDAO.remove_viewer(folder_id, user_id)
FolderPermissionDAO.add_editor(folder_id, user_id)
else:
FolderPermissionDAO.remove_editor(folder_id, user_id)
FolderPermissionDAO.add_viewer(folder_id, user_id)
@staticmethod
def remove_subject(folder_id: int, user_id: int) -> None:
"""Remove a user from both editors and viewers."""
FolderPermissionDAO.remove_editor(folder_id, user_id)
FolderPermissionDAO.remove_viewer(folder_id, user_id)
# ------------------------------------------------------------------ #
# Permission inheritance
# ------------------------------------------------------------------ #
@staticmethod
def copy_permissions_to_subfolder(
parent_folder_id: int, child_folder_id: int
) -> None:
"""Replace child folder permissions with parent's.
Clears existing editors/viewers on the child, then copies the parent's.
Sets extra.inherits_permissions = true on the child.
Skips private folders — their permissions are owner-only.
"""
child = db.session.query(Folder).get(child_folder_id)
if child and child.is_private:
return
# Clear child's existing permissions
db.session.execute(
folder_editors.delete().where(
folder_editors.c.folder_id == child_folder_id
)
)
db.session.execute(
folder_viewers.delete().where(
folder_viewers.c.folder_id == child_folder_id
)
)
# Copy from parent
parent_editors = db.session.execute(
folder_editors.select().where(
folder_editors.c.folder_id == parent_folder_id
)
).fetchall()
for row in parent_editors:
db.session.execute(
folder_editors.insert().values(
folder_id=child_folder_id, user_id=row.user_id
)
)
parent_viewers = db.session.execute(
folder_viewers.select().where(
folder_viewers.c.folder_id == parent_folder_id
)
).fetchall()
for row in parent_viewers:
db.session.execute(
folder_viewers.insert().values(
folder_id=child_folder_id, user_id=row.user_id
)
)
# Set inherits flag on child
if child := db.session.query(Folder).get(child_folder_id):
extra = json_utils.loads(child.extra) if child.extra else {}
extra["inherits_permissions"] = True
child.extra = json_utils.dumps(extra)
db.session.flush()
@staticmethod
def push_down_permissions(folder_id: int) -> None:
"""Re-sync permissions to all descendant folders still inheriting.
Called when a folder's permissions change. Only affects descendants
where extra.inherits_permissions = true.
"""
folder = db.session.query(Folder).get(folder_id)
if not folder:
return
# Get current folder's permissions
current_subjects = FolderPermissionDAO.get_subjects(folder_id)
# Find all descendants that still inherit
def _get_inheriting_descendants(parent_id: int) -> list[int]:
children = (
db.session.query(Folder).filter(Folder.parent_id == parent_id).all()
)
result = []
for child in children:
if child.is_private:
continue
extra = json_utils.loads(child.extra) if child.extra else {}
if extra.get("inherits_permissions", True):
result.append(child.id)
result.extend(_get_inheriting_descendants(child.id))
return result
descendant_ids = _get_inheriting_descendants(folder_id)
for desc_id in descendant_ids:
# Clear existing permissions
db.session.execute(
folder_editors.delete().where(folder_editors.c.folder_id == desc_id)
)
db.session.execute(
folder_viewers.delete().where(folder_viewers.c.folder_id == desc_id)
)
# Copy from parent
for subject in current_subjects:
if subject["permission"] == "editor":
db.session.execute(
folder_editors.insert().values(
folder_id=desc_id, user_id=subject["user_id"]
)
)
else:
db.session.execute(
folder_viewers.insert().values(
folder_id=desc_id, user_id=subject["user_id"]
)
)
db.session.flush()
@staticmethod
def mark_permissions_explicit(folder_id: int) -> None:
"""Mark a folder as having explicitly set permissions (no longer inheriting)."""
if folder := db.session.query(Folder).get(folder_id):
extra = json_utils.loads(folder.extra) if folder.extra else {}
extra["inherits_permissions"] = False
folder.extra = json_utils.dumps(extra)
db.session.flush()
# ------------------------------------------------------------------ #
# Access checks
# ------------------------------------------------------------------ #
@staticmethod
def user_has_folder_access(user_id: int, folder_id: int) -> bool:
"""Check if a user has viewer or editor access to a folder."""
is_editor = db.session.execute(
folder_editors.select().where(
and_(
folder_editors.c.folder_id == folder_id,
folder_editors.c.user_id == user_id,
)
)
).first()
if is_editor:
return True
is_viewer = db.session.execute(
folder_viewers.select().where(
and_(
folder_viewers.c.folder_id == folder_id,
folder_viewers.c.user_id == user_id,
)
)
).first()
return is_viewer is not None
@staticmethod
def user_is_folder_editor(user_id: int, folder_id: int) -> bool:
"""Check if a user is an editor of a specific folder."""
return (
db.session.execute(
folder_editors.select().where(
and_(
folder_editors.c.folder_id == folder_id,
folder_editors.c.user_id == user_id,
)
)
).first()
is not None
)
@staticmethod
def user_has_any_folder_access(user_id: int) -> bool:
"""Check if user has any folder-level access (editor or viewer)."""
has_access = (
db.session.query(folder_editors.c.id)
.filter(folder_editors.c.user_id == user_id)
.first()
) or (
db.session.query(folder_viewers.c.id)
.filter(folder_viewers.c.user_id == user_id)
.first()
)
return has_access is not None
@staticmethod
def user_has_any_folder_editor_access(user_id: int) -> bool:
"""Check if user is an editor on any folder."""
return (
db.session.query(folder_editors.c.id)
.filter(folder_editors.c.user_id == user_id)
.first()
) is not None
@staticmethod
def _check_folder_object_access(user_id: int, filter_condition: Any) -> bool:
"""Check if a matching FolderObject is in an accessible folder."""
fo = db.session.query(FolderObject).filter(filter_condition).first()
return bool(
fo and FolderPermissionDAO.user_has_folder_access(user_id, fo.folder_id)
)
@staticmethod
def user_has_folder_access_for_asset(
user_id: int,
dashboard_id: int | None = None,
chart_id: int | None = None,
datasource_id: int | None = None,
) -> bool:
"""Check if user has folder access that covers this asset.
Checks multiple paths:
1. Dashboard/chart is directly in a folder the user has access to
2. Datasource is used by a chart in a folder the user has access to
"""
check = FolderPermissionDAO._check_folder_object_access
if dashboard_id and check(user_id, FolderObject.dashboard_id == dashboard_id):
return True
if chart_id and check(user_id, FolderObject.chart_id == chart_id):
return True
if datasource_id:
from superset.models.slice import Slice as SliceModel
folder_objects = (
db.session.query(FolderObject)
.join(SliceModel, SliceModel.id == FolderObject.chart_id)
.filter(SliceModel.datasource_id == datasource_id)
.all()
)
for fo in folder_objects:
if FolderPermissionDAO.user_has_folder_access(user_id, fo.folder_id):
return True
return False
@staticmethod
def user_has_transitive_dashboard_access(
user_id: int,
datasource_id: int,
) -> bool:
"""Check if a datasource is reachable via: datasource → chart → dashboard → folder.
Grants access when a dashboard in the user's folder contains a chart
that uses this datasource, even if the chart itself is not in any folder.
"""
from superset.models.dashboard import dashboard_slices
from superset.models.slice import Slice as SliceModel
# Find dashboards that contain a chart using this datasource
# and are in a folder the user has access to.
rows = (
db.session.query(FolderObject.folder_id)
.join(
dashboard_slices,
dashboard_slices.c.dashboard_id == FolderObject.dashboard_id,
)
.join(SliceModel, SliceModel.id == dashboard_slices.c.slice_id)
.filter(
FolderObject.dashboard_id.isnot(None),
SliceModel.datasource_id == datasource_id,
)
.all()
)
return any(
FolderPermissionDAO.user_has_folder_access(user_id, row.folder_id)
for row in rows
)
@staticmethod
def user_is_folder_editor_for_asset(
user_id: int,
dashboard_id: int | None = None,
chart_id: int | None = None,
) -> bool:
"""Check if user is a folder editor for an asset."""
if dashboard_id:
fo = (
db.session.query(FolderObject)
.filter(FolderObject.dashboard_id == dashboard_id)
.first()
)
if fo:
return FolderPermissionDAO.user_is_folder_editor(user_id, fo.folder_id)
if chart_id:
fo = (
db.session.query(FolderObject)
.filter(FolderObject.chart_id == chart_id)
.first()
)
if fo:
return FolderPermissionDAO.user_is_folder_editor(user_id, fo.folder_id)
return False
@staticmethod
def get_folder_editors_as_owners(
dashboard_id: int | None = None,
chart_id: int | None = None,
) -> list[dict[str, Any]]:
"""Return folder editors for an asset as owner-shaped dicts.
Used to populate the `extra_owners` field in API responses.
"""
from superset.folders.utils import get_folder_editor_users
if dashboard_id:
fo = (
db.session.query(FolderObject)
.filter(FolderObject.dashboard_id == dashboard_id)
.first()
)
elif chart_id:
fo = (
db.session.query(FolderObject)
.filter(FolderObject.chart_id == chart_id)
.first()
)
else:
return []
if not fo:
return []
return [
{
"id": u.id,
"first_name": u.first_name,
"last_name": u.last_name,
}
for u in get_folder_editor_users(fo.folder_id)
]

View File

@@ -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.

1420
superset/folders/api.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
# 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.
"""Asset-type configuration shared by the folders DAO, schemas, and API.
A ``FolderObject`` links a folder to exactly one asset via a dedicated FK column
(``dashboard_id``/``chart_id``/``dataset_id``). This module maps each asset type
to its FK column and owning model, and defines which asset kinds belong to each
folder namespace (``folder_type``).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
DEFAULT_FOLDER_TYPE = "analytics"
@dataclass(frozen=True)
class AssetType:
"""Describes how an asset kind maps onto ``FolderObject`` and its model."""
name: str
fk_column: str
title_attr: str
model_loader: Callable[[], type[Any]]
@property
def model(self) -> type[Any]:
return self.model_loader()
def _load_dashboard() -> type[Any]:
from superset.models.dashboard import Dashboard
return Dashboard
def _load_chart() -> type[Any]:
from superset.models.slice import Slice
return Slice
def _load_dataset() -> type[Any]:
from superset.connectors.sqla.models import SqlaTable
return SqlaTable
ASSET_TYPE_CONFIGS: dict[str, AssetType] = {
"dashboard": AssetType(
"dashboard", "dashboard_id", "dashboard_title", _load_dashboard
),
"chart": AssetType("chart", "chart_id", "slice_name", _load_chart),
"dataset": AssetType("dataset", "dataset_id", "table_name", _load_dataset),
}
ASSET_TYPES = set(ASSET_TYPE_CONFIGS)
# Which asset kinds surface under each folder namespace.
FOLDER_TYPE_ASSETS: dict[str, list[str]] = {
"analytics": ["dashboard", "chart"],
}
def asset_types_for_folder_type(folder_type: str) -> list[str]:
"""Return the asset kinds shown for a given ``folder_type``."""
return FOLDER_TYPE_ASSETS.get(folder_type, [])

183
superset/folders/hooks.py Normal file
View File

@@ -0,0 +1,183 @@
"""Extension hook implementations for folder-based access control."""
from __future__ import annotations
from typing import Any
from superset import db
from superset.utils.core import get_user_id
def _user_folder_ids(user_id: int) -> Any:
"""Subquery of folder IDs the user has access to."""
from superset.folders.utils import user_accessible_folder_ids
return user_accessible_folder_ids(user_id)
def folder_access_charts(user_id: int) -> Any:
"""Return subquery of chart IDs accessible via folder membership."""
from superset.folders.models import FolderObject
return (
db.session.query(FolderObject.chart_id)
.filter(
FolderObject.chart_id.isnot(None),
FolderObject.folder_id.in_(_user_folder_ids(user_id)),
)
.subquery()
)
def folder_access_dashboards(user_id: int) -> Any:
"""Return subquery of dashboard IDs accessible via folder membership."""
from superset.folders.models import FolderObject
return (
db.session.query(FolderObject.dashboard_id)
.filter(
FolderObject.dashboard_id.isnot(None),
FolderObject.folder_id.in_(_user_folder_ids(user_id)),
)
.subquery()
)
def folder_raise_for_access_bypass(**kwargs: Any) -> bool:
"""Bypass raise_for_access if user has folder access to the asset."""
from flask import current_app
from superset.daos.folder_permissions import FolderPermissionDAO
user_id = get_user_id()
if not user_id:
return False
dashboard = kwargs.get("dashboard")
chart = kwargs.get("chart")
# Never bypass for assets in private folders unless the user is the owner
if is_asset_in_private_folder(
dashboard_id=dashboard.id if dashboard else None,
chart_id=chart.id if chart else None,
):
from superset.daos.folder_permissions import FolderPermissionDAO as _PDAO
has_access = _PDAO.user_has_folder_access_for_asset(
user_id=user_id,
dashboard_id=dashboard.id if dashboard else None,
chart_id=chart.id if chart else None,
)
return has_access
query_context = kwargs.get("query_context")
viz = kwargs.get("viz")
datasource = kwargs.get("datasource")
if not datasource and query_context:
datasource = query_context.datasource
if not datasource and viz:
datasource = viz.datasource
if FolderPermissionDAO.user_has_folder_access_for_asset(
user_id=user_id,
dashboard_id=dashboard.id if dashboard else None,
chart_id=chart.id if chart else None,
datasource_id=datasource.id if datasource else None,
):
return True
# Transitive access: datasource → chart → dashboard → folder.
# Grants access to datasources used by charts on dashboards in the
# user's folders, even if the chart is not directly in any folder.
if (
datasource
and current_app.config.get("FOLDER_DASHBOARD_TRANSITIVE_ACCESS")
and FolderPermissionDAO.user_has_transitive_dashboard_access(
user_id=user_id,
datasource_id=datasource.id,
)
):
return True
return False
def folder_extra_owners(resource: Any) -> list[Any]:
"""Return folder editors as additional owners."""
from superset.folders.models import FolderObject
from superset.folders.utils import get_folder_editor_users
tablename = resource.__tablename__
if tablename == "slices":
fk_col = FolderObject.chart_id
elif tablename == "dashboards":
fk_col = FolderObject.dashboard_id
else:
return []
fo = db.session.query(FolderObject).filter(fk_col == resource.id).first()
if not fo:
return []
return [
{
"id": u.id,
"first_name": u.first_name,
"last_name": u.last_name,
"username": u.username,
}
for u in get_folder_editor_users(fo.folder_id)
]
def after_asset_create(asset: Any, asset_type: str) -> None:
"""Auto-assign newly created charts/dashboards to the user's 'Only Me' folder."""
import logging
logger = logging.getLogger(__name__)
logger.info("[after_asset_create] called with asset_type=%s, asset_id=%s", asset_type, asset.id)
from superset.daos.folder import FolderDAO
from superset.folders.utils import can_manage_folders
user_id = get_user_id()
logger.info("[after_asset_create] user_id=%s", user_id)
if not user_id:
logger.info("[after_asset_create] no user_id, returning")
return
from flask import g
if not hasattr(g, "user"):
logger.info("[after_asset_create] no g.user, returning")
return
if not can_manage_folders(g.user):
logger.info("[after_asset_create] user cannot manage folders, roles=%s", [r.name for r in g.user.roles])
return
logger.info("[after_asset_create] creating/getting Only Me folder")
folder = FolderDAO.get_or_create_only_me_folder(user_id)
logger.info("[after_asset_create] assigning asset to folder %s", folder.id)
FolderDAO.assign_assets(folder, [{"type": asset_type, "id": asset.id}])
logger.info("[after_asset_create] done")
def is_asset_in_private_folder(
dashboard_id: int | None = None,
chart_id: int | None = None,
) -> bool:
"""Check if an asset is in a private folder."""
from superset.folders.models import Folder, FolderObject
query = db.session.query(FolderObject).join(
Folder, Folder.id == FolderObject.folder_id
).filter(Folder.is_private.is_(True))
if dashboard_id:
if query.filter(FolderObject.dashboard_id == dashboard_id).first():
return True
if chart_id:
if query.filter(FolderObject.chart_id == chart_id).first():
return True
return False

259
superset/folders/models.py Normal file
View File

@@ -0,0 +1,259 @@
# 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.
"""Preset-owned folder models.
These tables live in the Superset metadata DB but belong to Preset's separate
``MetaData``/Alembic history (see ``preset/migrations_preset``). Columns that
reference Superset-owned tables (``ab_user``, ``dashboards``, ``slices``,
``tables``) are plain integer columns *without* a declarative ``ForeignKey``:
the DB-level constraints are created by the Preset migration, not the ORM. This
avoids ``NoReferencedTableError``, which fires when SQLAlchemy tries to resolve
a ``ForeignKey`` target that doesn't exist in Preset's ``MetaData``. Only
``folders``-internal references (which *are* in Preset's ``MetaData``) keep a
declarative ``ForeignKey``.
"""
from __future__ import annotations
import uuid
from flask_appbuilder.security.sqla.models import User
from sqlalchemy import (
Boolean,
CheckConstraint,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Table,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from sqlalchemy_utils import UUIDType
from flask_appbuilder import Model
from superset.models.helpers import AuditMixinNullable
metadata = Model.metadata # pylint: disable=no-member
# Junction table: folder editors (users who can manage folder contents).
folder_editors = Table(
"folder_editors",
metadata,
Column("id", Integer, primary_key=True),
Column(
"folder_id",
Integer,
ForeignKey("folders.id", ondelete="CASCADE"),
nullable=False,
),
Column(
"user_id",
Integer,
ForeignKey("ab_user.id", ondelete="CASCADE"),
nullable=False,
),
UniqueConstraint("folder_id", "user_id"),
Index("ix_folder_editors_user_id", "user_id"),
)
# Junction table: folder viewers (users who can see folder and its assets).
folder_viewers = Table(
"folder_viewers",
metadata,
Column("id", Integer, primary_key=True),
Column(
"folder_id",
Integer,
ForeignKey("folders.id", ondelete="CASCADE"),
nullable=False,
),
Column(
"user_id",
Integer,
ForeignKey("ab_user.id", ondelete="CASCADE"),
nullable=False,
),
UniqueConstraint("folder_id", "user_id"),
Index("ix_folder_viewers_user_id", "user_id"),
)
class Folder(AuditMixinNullable, Model):
"""A folder for organizing dashboards, charts, and datasets."""
__tablename__ = "folders"
__table_args__ = (UniqueConstraint("parent_id", "name", "folder_type"),)
id = Column(Integer, primary_key=True)
uuid = Column(
UUIDType(binary=True), unique=True, nullable=False, default=uuid.uuid4
)
name = Column(String(250), nullable=False)
description = Column(Text, nullable=True)
parent_id = Column(Integer, ForeignKey("folders.id"), nullable=True)
folder_type = Column(String(50), nullable=False, default="analytics")
is_private = Column(Boolean, nullable=False, default=False)
extra = Column(Text, nullable=True)
# Relationships
parent = relationship(
"Folder",
remote_side=[id],
back_populates="children",
)
children = relationship(
"Folder",
back_populates="parent",
)
objects = relationship(
"FolderObject",
back_populates="folder",
cascade="all, delete-orphan",
)
editors = relationship(
User,
secondary=folder_editors,
primaryjoin=lambda: Folder.id == folder_editors.c.folder_id,
secondaryjoin=lambda: folder_editors.c.user_id == User.id,
viewonly=True,
)
viewers = relationship(
User,
secondary=folder_viewers,
primaryjoin=lambda: Folder.id == folder_viewers.c.folder_id,
secondaryjoin=lambda: folder_viewers.c.user_id == User.id,
viewonly=True,
)
def __repr__(self) -> str:
return f"Folder<{self.id or self.name}>"
class FolderObject(Model):
"""Links a folder to an asset (dashboard, chart, or dataset).
Uses separate FK columns instead of polymorphic object_id/object_type
so that ON DELETE CASCADE works at the DB level.
Only one of dashboard_id, chart_id, dataset_id is set per row.
``dashboard_id``/``chart_id``/``dataset_id`` reference Superset-owned tables;
their FK constraints are created by the Preset migration (no declarative
``ForeignKey`` here, see module docstring).
"""
__tablename__ = "folder_objects"
__table_args__ = (
CheckConstraint(
"(CASE WHEN dashboard_id IS NOT NULL THEN 1 ELSE 0 END"
" + CASE WHEN chart_id IS NOT NULL THEN 1 ELSE 0 END"
" + CASE WHEN dataset_id IS NOT NULL THEN 1 ELSE 0 END) = 1",
name="ck_folder_objects_exactly_one_fk",
),
Index("ix_folder_objects_folder_id", "folder_id"),
Index("ix_folder_objects_chart_id", "chart_id"),
Index("ix_folder_objects_dashboard_id", "dashboard_id"),
)
id = Column(Integer, primary_key=True)
folder_id = Column(
Integer,
ForeignKey("folders.id", ondelete="CASCADE"),
nullable=False,
)
dashboard_id = Column(Integer, nullable=True)
chart_id = Column(Integer, nullable=True)
dataset_id = Column(Integer, nullable=True)
created_on = Column(DateTime, nullable=True)
# Relationships
folder = relationship("Folder", back_populates="objects")
def __repr__(self) -> str:
asset = (
f"dashboard={self.dashboard_id}"
if self.dashboard_id
else f"chart={self.chart_id}"
if self.chart_id
else f"dataset={self.dataset_id}"
)
return f"FolderObject<folder={self.folder_id}, {asset}>"
class FolderPin(Model):
"""Per-user pinned items on the Analytics root view.
Max 3 pins per user (enforced by UNIQUE on user_id + position).
Pins are user-specific and only shown on the main Analytics view.
Uses separate FK columns instead of polymorphic object_id/object_type
so that ON DELETE CASCADE works at the DB level.
Only one of folder_id, dashboard_id, chart_id is set per row.
``user_id``/``dashboard_id``/``chart_id`` reference Superset-owned tables;
their FK constraints are created by the migration.
"""
__tablename__ = "folder_pins"
__table_args__ = (
UniqueConstraint("user_id", "position"),
CheckConstraint(
"position >= 1 AND position <= 3",
name="ck_folder_pins_position_range",
),
CheckConstraint(
"(CASE WHEN folder_id IS NOT NULL THEN 1 ELSE 0 END"
" + CASE WHEN dashboard_id IS NOT NULL THEN 1 ELSE 0 END"
" + CASE WHEN chart_id IS NOT NULL THEN 1 ELSE 0 END) = 1",
name="ck_folder_pins_exactly_one_fk",
),
Index("ix_folder_pins_folder_id", "folder_id"),
Index("ix_folder_pins_dashboard_id", "dashboard_id"),
Index("ix_folder_pins_chart_id", "chart_id"),
)
id = Column(Integer, primary_key=True)
user_id = Column(Integer, nullable=False)
folder_id = Column(
Integer,
ForeignKey("folders.id", ondelete="CASCADE"),
nullable=True,
)
dashboard_id = Column(Integer, nullable=True)
chart_id = Column(Integer, nullable=True)
position = Column(Integer, nullable=False)
created_on = Column(DateTime, nullable=True)
@property
def object_type(self) -> str:
if self.folder_id is not None:
return "folder"
if self.dashboard_id is not None:
return "dashboard"
return "chart"
@property
def object_id(self) -> int:
if self.folder_id is not None:
return self.folder_id
if self.dashboard_id is not None:
return self.dashboard_id
return self.chart_id

242
superset/folders/schemas.py Normal file
View File

@@ -0,0 +1,242 @@
# 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.
"""Marshmallow schemas for the folders REST API."""
from __future__ import annotations
from marshmallow import fields, Schema, validate
from superset.folders.constants import ASSET_TYPES, DEFAULT_FOLDER_TYPE
folder_type_description = "Namespace the folder lives in (e.g. 'analytics')."
parent_uuid_description = (
"UUID of the parent folder. Omit or set to null to place at the root."
)
asset_type_description = "Kind of asset: one of " + ", ".join(sorted(ASSET_TYPES)) + "."
class FolderAssetRefSchema(Schema):
"""Reference to a single asset to assign to a folder."""
type = fields.String(
required=True,
validate=validate.OneOf(sorted(ASSET_TYPES)),
metadata={"description": asset_type_description},
)
id = fields.Integer(
required=True, metadata={"description": "Primary key of the asset."}
)
class FolderPostSchema(Schema):
"""Payload to create a folder."""
name = fields.String(required=True, validate=validate.Length(min=1, max=250))
description = fields.String(allow_none=True, load_default=None)
parent_uuid = fields.String(
allow_none=True,
load_default=None,
metadata={"description": parent_uuid_description},
)
folder_type = fields.String(
load_default=DEFAULT_FOLDER_TYPE,
validate=validate.Length(min=1, max=50),
metadata={"description": folder_type_description},
)
is_private = fields.Boolean(load_default=False)
class FolderPutSchema(Schema):
"""Payload to update a folder. All fields optional (partial update)."""
name = fields.String(validate=validate.Length(min=1, max=250))
description = fields.String(allow_none=True)
parent_uuid = fields.String(
allow_none=True, metadata={"description": parent_uuid_description}
)
is_private = fields.Boolean()
sync_permissions = fields.Boolean(load_default=False)
class FolderAssetsPutSchema(Schema):
"""Payload setting a folder's full asset membership.
``assets`` is the complete desired set: listed assets are moved into the
folder, and the folder's current assets not listed are moved back to the
root. An empty list empties the folder.
"""
assets = fields.List(fields.Nested(FolderAssetRefSchema), load_default=list)
class FolderUserSchema(Schema):
id = fields.Integer()
first_name = fields.String()
last_name = fields.String()
class FolderSchema(Schema):
"""Folder metadata in responses."""
id = fields.Integer()
uuid = fields.String()
name = fields.String()
description = fields.String(allow_none=True)
parent_uuid = fields.String(allow_none=True)
folder_type = fields.String()
is_private = fields.Boolean()
is_only_me = fields.Boolean()
children_count = fields.Integer()
asset_count = fields.Integer()
created_on = fields.DateTime()
changed_on = fields.DateTime()
created_by = fields.Nested(FolderUserSchema, allow_none=True)
changed_by = fields.Nested(FolderUserSchema, allow_none=True)
class FolderAssetSchema(Schema):
"""An asset (dashboard/chart/dataset) in responses."""
type = fields.String()
id = fields.Integer()
uuid = fields.String(allow_none=True)
name = fields.String()
url = fields.String(allow_none=True)
changed_on = fields.DateTime(allow_none=True)
changed_by = fields.Nested(FolderUserSchema, allow_none=True)
owners = fields.List(fields.Nested(FolderUserSchema))
# Chart-only columns; null for other asset kinds.
viz_type = fields.String(allow_none=True)
database = fields.String(allow_none=True)
schema = fields.String(allow_none=True)
class FolderContentItemSchema(Schema):
"""A single contents row — a folder or an asset (superset of both shapes)."""
type = fields.String()
id = fields.Integer()
uuid = fields.String(allow_none=True)
name = fields.String()
url = fields.String(allow_none=True)
parent_uuid = fields.String(allow_none=True)
folder_type = fields.String(allow_none=True)
is_private = fields.Boolean(allow_none=True)
is_only_me = fields.Boolean(allow_none=True)
children_count = fields.Integer(allow_none=True)
asset_count = fields.Integer(allow_none=True)
viz_type = fields.String(allow_none=True)
database = fields.String(allow_none=True)
schema = fields.String(allow_none=True)
changed_on = fields.DateTime(allow_none=True)
created_on = fields.DateTime(allow_none=True)
changed_by = fields.Nested(FolderUserSchema, allow_none=True)
created_by = fields.Nested(FolderUserSchema, allow_none=True)
owners = fields.List(fields.Nested(FolderUserSchema))
class FolderListResponseSchema(Schema):
count = fields.Integer()
result = fields.List(fields.Nested(FolderSchema))
class FolderResponseSchema(Schema):
result = fields.Nested(FolderSchema)
class FolderContentsResponseSchema(Schema):
"""A page of a folder's contents (subfolders + assets), filtered/paginated."""
folder = fields.Nested(FolderSchema)
result = fields.List(fields.Nested(FolderContentItemSchema))
count = fields.Integer()
page = fields.Integer()
page_size = fields.Integer()
class FolderRootResponseSchema(Schema):
"""A page of the root view (top-level folders + unfoldered assets)."""
result = fields.List(fields.Nested(FolderContentItemSchema))
count = fields.Integer()
page = fields.Integer()
page_size = fields.Integer()
# --- Pin schemas ---
PIN_OBJECT_TYPES = {"folder", "chart", "dashboard"}
class FolderPinPostSchema(Schema):
"""Payload to pin an item."""
object_id = fields.Integer(required=True)
object_type = fields.String(
required=True,
validate=validate.OneOf(sorted(PIN_OBJECT_TYPES)),
)
position = fields.Integer(
required=True,
validate=validate.Range(min=1, max=3),
)
class FolderPinSchema(Schema):
"""A pinned item in responses."""
id = fields.Integer()
object_id = fields.Integer()
object_type = fields.String()
position = fields.Integer()
created_on = fields.DateTime(allow_none=True)
class FolderPinListResponseSchema(Schema):
count = fields.Integer()
result = fields.List(fields.Nested(FolderPinSchema))
# --- Subject schemas ---
SUBJECT_PERMISSIONS = {"editor", "viewer", "admin"}
class FolderSubjectPostSchema(Schema):
"""Payload to add a user to a folder."""
user_id = fields.Integer(required=True)
permission = fields.String(
required=True,
validate=validate.OneOf(sorted(SUBJECT_PERMISSIONS)),
)
class FolderSubjectPutSchema(Schema):
"""Payload to update a subject's permission."""
permission = fields.String(
required=True,
validate=validate.OneOf(sorted(SUBJECT_PERMISSIONS)),
)
class FolderSubjectSchema(Schema):
"""A user (editor/viewer) in responses."""
user_id = fields.Integer()
permission = fields.String()

52
superset/folders/utils.py Normal file
View File

@@ -0,0 +1,52 @@
"""Folder utility functions."""
from __future__ import annotations
from typing import Any
from sqlalchemy import select
FOLDER_MANAGEMENT_ROLES = {"Admin", "Alpha", "Gamma"}
def can_manage_folders(user: Any) -> bool:
"""Check if user can create, delete, and manage folders.
Allowed for Admin, Alpha, and Gamma roles.
"""
if not user or not getattr(user, "roles", None):
return False
return any(role.name in FOLDER_MANAGEMENT_ROLES for role in user.roles)
def get_folder_editor_users(folder_id: int) -> list[Any]:
"""Load User objects that are editors of the given folder."""
from flask_appbuilder.security.sqla.models import User
from superset import db
from superset.folders.models import folder_editors
editor_user_ids = [
row.user_id
for row in db.session.execute(
folder_editors.select().where(folder_editors.c.folder_id == folder_id)
).fetchall()
]
if not editor_user_ids:
return []
return db.session.query(User).filter(User.id.in_(editor_user_ids)).all()
def user_accessible_folder_ids(user_id: int) -> Any:
"""Subquery of folder IDs the user has access to (editor or viewer)."""
from superset.folders.models import folder_editors, folder_viewers
return (
select(folder_viewers.c.folder_id)
.where(folder_viewers.c.user_id == user_id)
.union(
select(folder_editors.c.folder_id).where(
folder_editors.c.user_id == user_id
)
)
)

View File

@@ -326,22 +326,39 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
category="Data",
category_label=_("Data"),
)
appbuilder.add_view(
DashboardModelView,
"Dashboards",
label=_("Dashboards"),
icon="fa-dashboard",
category="",
category_icon="",
)
appbuilder.add_view(
SliceModelView,
"Charts",
label=_("Charts"),
icon="fa-bar-chart",
category="",
category_icon="",
)
if feature_flag_manager.is_feature_enabled("FOLDERS"):
from superset.folders.api import FolderRestApi
from superset.views.folders import FolderView
appbuilder.add_api(FolderRestApi)
appbuilder.add_view_no_menu(FolderView)
appbuilder.add_link(
"Analytics",
label=_("Analytics"),
href="/analytics/",
icon="fa-folder-open",
category="",
category_icon="",
)
if not feature_flag_manager.is_feature_enabled("FOLDERS"):
appbuilder.add_view(
DashboardModelView,
"Dashboards",
label=_("Dashboards"),
icon="fa-dashboard",
category="",
category_icon="",
)
appbuilder.add_view(
SliceModelView,
"Charts",
label=_("Charts"),
icon="fa-bar-chart",
category="",
category_icon="",
)
appbuilder.add_link(
"Datasets",

View File

@@ -0,0 +1,332 @@
# 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.
"""add folder tables
Revision ID: 4567cf3d03cc
Revises: 33d7e0e21daa
Create Date: 2026-05-27 16:01:44.222914
"""
from sqlalchemy import (
Boolean,
CheckConstraint,
Column,
DateTime,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy_utils import UUIDType
from superset.migrations.shared.utils import (
create_fks_for_table,
create_index,
create_table,
drop_fks_for_table,
drop_index,
drop_table,
)
# revision identifiers, used by Alembic.
revision = "4567cf3d03cc"
down_revision = "3a8e6f2c1b95"
FOLDERS_TABLE = "folders"
FOLDER_OBJECTS_TABLE = "folder_objects"
FOLDER_EDITORS_TABLE = "folder_editors"
FOLDER_VIEWERS_TABLE = "folder_viewers"
FOLDER_PINS_TABLE = "folder_pins"
def upgrade():
# --- folders ---
create_table(
FOLDERS_TABLE,
Column("id", Integer, primary_key=True),
Column("uuid", UUIDType(binary=True), nullable=False, unique=True),
Column("name", String(250), nullable=False),
Column("description", Text, nullable=True),
Column("parent_id", Integer, nullable=True),
Column("folder_type", String(50), nullable=False, server_default="analytics"),
Column("is_private", Boolean, nullable=False, server_default="0"),
Column("extra", Text, nullable=True),
Column("created_on", DateTime, nullable=True),
Column("changed_on", DateTime, nullable=True),
Column("created_by_fk", Integer, nullable=True),
Column("changed_by_fk", Integer, nullable=True),
UniqueConstraint(
"parent_id", "name", "folder_type", name="uq_folder_parent_name_type"
),
)
create_index(FOLDERS_TABLE, "idx_folders_uuid", ["uuid"], unique=True)
create_index(FOLDERS_TABLE, "idx_folders_parent_id", ["parent_id"])
create_index(FOLDERS_TABLE, "idx_folders_folder_type", ["folder_type"])
create_fks_for_table(
foreign_key_name="fk_folders_parent_id_folders",
table_name=FOLDERS_TABLE,
referenced_table=FOLDERS_TABLE,
local_cols=["parent_id"],
remote_cols=["id"],
)
create_fks_for_table(
foreign_key_name="fk_folders_created_by_fk_ab_user",
table_name=FOLDERS_TABLE,
referenced_table="ab_user",
local_cols=["created_by_fk"],
remote_cols=["id"],
ondelete="SET NULL",
)
create_fks_for_table(
foreign_key_name="fk_folders_changed_by_fk_ab_user",
table_name=FOLDERS_TABLE,
referenced_table="ab_user",
local_cols=["changed_by_fk"],
remote_cols=["id"],
ondelete="SET NULL",
)
# --- folder_objects ---
create_table(
FOLDER_OBJECTS_TABLE,
Column("id", Integer, primary_key=True),
Column("folder_id", Integer, nullable=False),
Column("dashboard_id", Integer, nullable=True),
Column("chart_id", Integer, nullable=True),
Column("dataset_id", Integer, nullable=True),
Column("created_on", DateTime, nullable=True),
CheckConstraint(
"(CASE WHEN dashboard_id IS NOT NULL THEN 1 ELSE 0 END"
" + CASE WHEN chart_id IS NOT NULL THEN 1 ELSE 0 END"
" + CASE WHEN dataset_id IS NOT NULL THEN 1 ELSE 0 END) = 1",
name="ck_folder_objects_exactly_one_fk",
),
)
create_fks_for_table(
foreign_key_name="fk_folder_objects_folder_id_folders",
table_name=FOLDER_OBJECTS_TABLE,
referenced_table=FOLDERS_TABLE,
local_cols=["folder_id"],
remote_cols=["id"],
ondelete="CASCADE",
)
create_fks_for_table(
foreign_key_name="fk_folder_objects_dashboard_id_dashboards",
table_name=FOLDER_OBJECTS_TABLE,
referenced_table="dashboards",
local_cols=["dashboard_id"],
remote_cols=["id"],
ondelete="CASCADE",
)
create_fks_for_table(
foreign_key_name="fk_folder_objects_chart_id_slices",
table_name=FOLDER_OBJECTS_TABLE,
referenced_table="slices",
local_cols=["chart_id"],
remote_cols=["id"],
ondelete="CASCADE",
)
create_fks_for_table(
foreign_key_name="fk_folder_objects_dataset_id_tables",
table_name=FOLDER_OBJECTS_TABLE,
referenced_table="tables",
local_cols=["dataset_id"],
remote_cols=["id"],
ondelete="CASCADE",
)
create_index(
FOLDER_OBJECTS_TABLE,
"uq_folder_objects_dashboard",
["dashboard_id"],
unique=True,
)
create_index(
FOLDER_OBJECTS_TABLE, "uq_folder_objects_chart", ["chart_id"], unique=True
)
create_index(
FOLDER_OBJECTS_TABLE, "uq_folder_objects_dataset", ["dataset_id"], unique=True
)
# --- folder_editors ---
create_table(
FOLDER_EDITORS_TABLE,
Column("id", Integer, primary_key=True),
Column("folder_id", Integer, nullable=False),
Column("user_id", Integer, nullable=False),
UniqueConstraint(
"folder_id", "user_id", name="uq_folder_editors_folder_user"
),
)
create_fks_for_table(
foreign_key_name="fk_folder_editors_folder_id_folders",
table_name=FOLDER_EDITORS_TABLE,
referenced_table=FOLDERS_TABLE,
local_cols=["folder_id"],
remote_cols=["id"],
ondelete="CASCADE",
)
create_fks_for_table(
foreign_key_name="fk_folder_editors_user_id_ab_user",
table_name=FOLDER_EDITORS_TABLE,
referenced_table="ab_user",
local_cols=["user_id"],
remote_cols=["id"],
ondelete="CASCADE",
)
# --- folder_viewers ---
create_table(
FOLDER_VIEWERS_TABLE,
Column("id", Integer, primary_key=True),
Column("folder_id", Integer, nullable=False),
Column("user_id", Integer, nullable=False),
UniqueConstraint(
"folder_id", "user_id", name="uq_folder_viewers_folder_user"
),
)
create_fks_for_table(
foreign_key_name="fk_folder_viewers_folder_id_folders",
table_name=FOLDER_VIEWERS_TABLE,
referenced_table=FOLDERS_TABLE,
local_cols=["folder_id"],
remote_cols=["id"],
ondelete="CASCADE",
)
create_fks_for_table(
foreign_key_name="fk_folder_viewers_user_id_ab_user",
table_name=FOLDER_VIEWERS_TABLE,
referenced_table="ab_user",
local_cols=["user_id"],
remote_cols=["id"],
ondelete="CASCADE",
)
# --- folder_pins ---
create_table(
FOLDER_PINS_TABLE,
Column("id", Integer, primary_key=True),
Column("user_id", Integer, nullable=False),
Column("folder_id", Integer, nullable=True),
Column("dashboard_id", Integer, nullable=True),
Column("chart_id", Integer, nullable=True),
Column("position", Integer, nullable=False),
Column("created_on", DateTime, nullable=True),
UniqueConstraint("user_id", "position", name="uq_folder_pins_user_position"),
CheckConstraint(
"position >= 1 AND position <= 3",
name="ck_folder_pins_position_range",
),
CheckConstraint(
"(CASE WHEN folder_id IS NOT NULL THEN 1 ELSE 0 END"
" + CASE WHEN dashboard_id IS NOT NULL THEN 1 ELSE 0 END"
" + CASE WHEN chart_id IS NOT NULL THEN 1 ELSE 0 END) = 1",
name="ck_folder_pins_exactly_one_fk",
),
)
create_fks_for_table(
foreign_key_name="fk_folder_pins_user_id_ab_user",
table_name=FOLDER_PINS_TABLE,
referenced_table="ab_user",
local_cols=["user_id"],
remote_cols=["id"],
ondelete="CASCADE",
)
create_fks_for_table(
foreign_key_name="fk_folder_pins_folder_id_folders",
table_name=FOLDER_PINS_TABLE,
referenced_table=FOLDERS_TABLE,
local_cols=["folder_id"],
remote_cols=["id"],
ondelete="CASCADE",
)
create_fks_for_table(
foreign_key_name="fk_folder_pins_dashboard_id_dashboards",
table_name=FOLDER_PINS_TABLE,
referenced_table="dashboards",
local_cols=["dashboard_id"],
remote_cols=["id"],
ondelete="CASCADE",
)
create_fks_for_table(
foreign_key_name="fk_folder_pins_chart_id_slices",
table_name=FOLDER_PINS_TABLE,
referenced_table="slices",
local_cols=["chart_id"],
remote_cols=["id"],
ondelete="CASCADE",
)
create_index(FOLDER_PINS_TABLE, "idx_folder_pins_user_id", ["user_id"])
create_index(FOLDER_PINS_TABLE, "ix_folder_pins_folder_id", ["folder_id"])
create_index(FOLDER_PINS_TABLE, "ix_folder_pins_dashboard_id", ["dashboard_id"])
create_index(FOLDER_PINS_TABLE, "ix_folder_pins_chart_id", ["chart_id"])
def downgrade():
drop_index(FOLDER_PINS_TABLE, "ix_folder_pins_chart_id")
drop_index(FOLDER_PINS_TABLE, "ix_folder_pins_dashboard_id")
drop_index(FOLDER_PINS_TABLE, "ix_folder_pins_folder_id")
drop_index(FOLDER_PINS_TABLE, "idx_folder_pins_user_id")
drop_fks_for_table(
FOLDER_PINS_TABLE,
[
"fk_folder_pins_user_id_ab_user",
"fk_folder_pins_folder_id_folders",
"fk_folder_pins_dashboard_id_dashboards",
"fk_folder_pins_chart_id_slices",
],
)
drop_table(FOLDER_PINS_TABLE)
drop_fks_for_table(
FOLDER_VIEWERS_TABLE,
["fk_folder_viewers_folder_id_folders", "fk_folder_viewers_user_id_ab_user"],
)
drop_table(FOLDER_VIEWERS_TABLE)
drop_fks_for_table(
FOLDER_EDITORS_TABLE,
["fk_folder_editors_folder_id_folders", "fk_folder_editors_user_id_ab_user"],
)
drop_table(FOLDER_EDITORS_TABLE)
drop_index(FOLDER_OBJECTS_TABLE, "uq_folder_objects_dashboard")
drop_index(FOLDER_OBJECTS_TABLE, "uq_folder_objects_chart")
drop_index(FOLDER_OBJECTS_TABLE, "uq_folder_objects_dataset")
drop_fks_for_table(
FOLDER_OBJECTS_TABLE,
[
"fk_folder_objects_folder_id_folders",
"fk_folder_objects_dashboard_id_dashboards",
"fk_folder_objects_chart_id_slices",
"fk_folder_objects_dataset_id_tables",
],
)
drop_table(FOLDER_OBJECTS_TABLE)
drop_index(FOLDERS_TABLE, "idx_folders_uuid")
drop_index(FOLDERS_TABLE, "idx_folders_parent_id")
drop_index(FOLDERS_TABLE, "idx_folders_folder_type")
drop_fks_for_table(
FOLDERS_TABLE,
[
"fk_folders_parent_id_folders",
"fk_folders_created_by_fk_ab_user",
"fk_folders_changed_by_fk_ab_user",
],
)
drop_table(FOLDERS_TABLE)

View File

@@ -17,7 +17,7 @@
import builtins
from typing import Callable, Union
from flask import g, redirect, Response, url_for
from flask import current_app, g, redirect, Response, url_for
from flask_appbuilder import expose
from flask_appbuilder.actions import action
from flask_appbuilder.models.sqla.interface import SQLAInterface
@@ -84,6 +84,9 @@ class Dashboard(BaseSupersetView):
)
db.session.add(new_dashboard)
db.session.commit() # pylint: disable=consider-using-transaction
if after_create := current_app.config.get("AFTER_ASSET_CREATE"):
after_create(new_dashboard, "dashboard")
db.session.commit()
return redirect(
url_for(
"Superset.dashboard", dashboard_id_or_slug=new_dashboard.id, edit="true"

47
superset/views/folders.py Normal file
View File

@@ -0,0 +1,47 @@
# 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.
from flask_appbuilder import permission_name
from flask_appbuilder.api import expose
from flask_appbuilder.security.decorators import has_access
from superset import event_logger
from superset.superset_typing import FlaskResponse
from superset.views.base import BaseSupersetView
class FolderView(BaseSupersetView):
"""Serves the SPA for the folder-based Analytics page."""
route_base = "/analytics"
class_permission_name = "Folder"
method_permission_name = {
"root": "read",
"folder": "read",
}
@has_access
@permission_name("read")
@expose("/")
@event_logger.log_this
def root(self) -> FlaskResponse:
return super().render_app_template()
@has_access
@permission_name("read")
@expose("/<folder_uuid>/")
@event_logger.log_this
def folder(self, folder_uuid: str) -> FlaskResponse:
return super().render_app_template()

View File

@@ -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.

View File

@@ -0,0 +1,442 @@
# 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.
"""Integration tests for the folders REST API and DAO."""
from typing import Any
import pytest
import tests.integration_tests.test_app # noqa: F401
from superset import db
from superset.daos.folder import FolderDAO
from superset.folders.models import Folder, FolderObject
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from tests.integration_tests.base_tests import SupersetTestCase
from tests.integration_tests.constants import ADMIN_USERNAME
DASH_PREFIX = "folders_test_dash_"
CHART_PREFIX = "folders_test_chart_"
class TestFolderApi(SupersetTestCase):
@pytest.fixture(autouse=True)
def cleanup_folders(self):
yield
db.session.query(FolderObject).delete()
db.session.query(Folder).delete()
for dashboard in (
db.session.query(Dashboard)
.filter(Dashboard.dashboard_title.like(f"{DASH_PREFIX}%"))
.all()
):
db.session.delete(dashboard)
for chart in (
db.session.query(Slice)
.filter(Slice.slice_name.like(f"{CHART_PREFIX}%"))
.all()
):
db.session.delete(chart)
db.session.commit()
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _create_folder(self, **payload: Any) -> dict[str, Any]:
payload.setdefault("name", "Folder")
response = self.client.post("/api/v1/folders/", json=payload)
assert response.status_code == 201, response.json
return response.json["result"]
def _create_dashboard(self, title: str) -> Dashboard:
admin = self.get_user(ADMIN_USERNAME)
dashboard = Dashboard(
dashboard_title=f"{DASH_PREFIX}{title}",
created_by=admin,
changed_by=admin,
)
db.session.add(dashboard)
db.session.commit()
return dashboard
def _create_chart(
self, name: str, viz_type: str = "bar", datasource_id: int = 1
) -> Slice:
admin = self.get_user(ADMIN_USERNAME)
chart = Slice(
slice_name=f"{CHART_PREFIX}{name}",
viz_type=viz_type,
datasource_id=datasource_id,
datasource_type="table",
owners=[admin],
created_by=admin,
changed_by=admin,
)
db.session.add(chart)
db.session.commit()
return chart
# ------------------------------------------------------------------ #
# CRUD
# ------------------------------------------------------------------ #
def test_create_get_update_delete(self):
self.login(ADMIN_USERNAME)
created = self._create_folder(name="Quarterly", description="Q reports")
assert created["name"] == "Quarterly"
assert created["folder_type"] == "analytics"
assert created["parent_uuid"] is None
uuid = created["uuid"]
# get
response = self.client.get(f"/api/v1/folders/{uuid}")
assert response.status_code == 200
assert response.json["result"]["description"] == "Q reports"
# update (rename + describe)
response = self.client.put(
f"/api/v1/folders/{uuid}", json={"name": "Yearly", "description": None}
)
assert response.status_code == 200
assert response.json["result"]["name"] == "Yearly"
assert response.json["result"]["description"] is None
# delete
response = self.client.delete(f"/api/v1/folders/{uuid}")
assert response.status_code == 200
assert FolderDAO.get_by_uuid(uuid) is None
def test_get_missing_returns_404(self):
self.login(ADMIN_USERNAME)
response = self.client.get("/api/v1/folders/does-not-exist")
assert response.status_code == 404
def test_duplicate_sibling_name_rejected(self):
self.login(ADMIN_USERNAME)
self._create_folder(name="Dup")
response = self.client.post("/api/v1/folders/", json={"name": "Dup"})
assert response.status_code == 422
def test_create_with_parent(self):
self.login(ADMIN_USERNAME)
parent = self._create_folder(name="Parent")
child = self._create_folder(name="Child", parent_uuid=parent["uuid"])
assert child["parent_uuid"] == parent["uuid"]
def test_create_with_unknown_parent_rejected(self):
self.login(ADMIN_USERNAME)
response = self.client.post(
"/api/v1/folders/", json={"name": "Orphan", "parent_uuid": "missing"}
)
assert response.status_code == 422
# ------------------------------------------------------------------ #
# Move / tree integrity
# ------------------------------------------------------------------ #
def test_move_into_own_descendant_rejected(self):
self.login(ADMIN_USERNAME)
parent = self._create_folder(name="P")
child = self._create_folder(name="C", parent_uuid=parent["uuid"])
# moving the parent under its own child would create a cycle
response = self.client.put(
f"/api/v1/folders/{parent['uuid']}",
json={"parent_uuid": child["uuid"]},
)
assert response.status_code == 422
def test_move_to_root(self):
self.login(ADMIN_USERNAME)
parent = self._create_folder(name="P")
child = self._create_folder(name="C", parent_uuid=parent["uuid"])
response = self.client.put(
f"/api/v1/folders/{child['uuid']}", json={"parent_uuid": None}
)
assert response.status_code == 200
assert response.json["result"]["parent_uuid"] is None
def test_delete_reparents_children(self):
self.login(ADMIN_USERNAME)
grandparent = self._create_folder(name="GP")
parent = self._create_folder(name="P", parent_uuid=grandparent["uuid"])
child = self._create_folder(name="C", parent_uuid=parent["uuid"])
# delete the middle folder -> child should re-parent to grandparent
response = self.client.delete(f"/api/v1/folders/{parent['uuid']}")
assert response.status_code == 200
moved = FolderDAO.get_by_uuid(child["uuid"])
assert moved is not None
assert moved.parent_id == grandparent["id"]
# ------------------------------------------------------------------ #
# Listing
# ------------------------------------------------------------------ #
def test_list_filtered_by_type(self):
self.login(ADMIN_USERNAME)
self._create_folder(name="Analytics one")
self._create_folder(name="Other one", folder_type="custom")
response = self.client.get("/api/v1/folders/?folder_type=analytics")
assert response.status_code == 200
names = {folder["name"] for folder in response.json["result"]}
assert "Analytics one" in names
assert "Other one" not in names
# ------------------------------------------------------------------ #
# Assets
# ------------------------------------------------------------------ #
def test_root_view_lists_unfoldered_assets(self):
self.login(ADMIN_USERNAME)
dashboard = self._create_dashboard("root")
response = self.client.get("/api/v1/folders/assets")
assert response.status_code == 200
asset_ids = {(a["type"], a["id"]) for a in response.json["result"]}
assert ("dashboard", dashboard.id) in asset_ids
def test_assign_asset_moves_it_out_of_root(self):
self.login(ADMIN_USERNAME)
folder = self._create_folder(name="Bucket")
dashboard = self._create_dashboard("assign")
response = self.client.put(
f"/api/v1/folders/{folder['uuid']}/assets",
json={"assets": [{"type": "dashboard", "id": dashboard.id}]},
)
assert response.status_code == 200
contents_ids = {(a["type"], a["id"]) for a in response.json["result"]}
assert ("dashboard", dashboard.id) in contents_ids
# no longer unfoldered
root = self.client.get("/api/v1/folders/assets").json
assert ("dashboard", dashboard.id) not in {
(a["type"], a["id"]) for a in root["result"]
}
# contents endpoint agrees
contents = self.client.get(f"/api/v1/folders/{folder['uuid']}/assets").json
assert ("dashboard", dashboard.id) in {
(a["type"], a["id"]) for a in contents["result"]
}
def test_assign_moves_between_folders(self):
self.login(ADMIN_USERNAME)
first = self._create_folder(name="First")
second = self._create_folder(name="Second")
dashboard = self._create_dashboard("move")
body = {"assets": [{"type": "dashboard", "id": dashboard.id}]}
self.client.put(f"/api/v1/folders/{first['uuid']}/assets", json=body)
self.client.put(f"/api/v1/folders/{second['uuid']}/assets", json=body)
first_contents = self.client.get(f"/api/v1/folders/{first['uuid']}/assets").json
second_contents = self.client.get(
f"/api/v1/folders/{second['uuid']}/assets"
).json
assert first_contents["result"] == []
assert ("dashboard", dashboard.id) in {
(a["type"], a["id"]) for a in second_contents["result"]
}
def test_set_membership_removes_unlisted_assets(self):
self.login(ADMIN_USERNAME)
folder = self._create_folder(name="Bucket")
kept = self._create_dashboard("kept")
dropped = self._create_dashboard("dropped")
# Put both in the folder.
self.client.put(
f"/api/v1/folders/{folder['uuid']}/assets",
json={
"assets": [
{"type": "dashboard", "id": kept.id},
{"type": "dashboard", "id": dropped.id},
]
},
)
# Re-set membership to only ``kept`` -> ``dropped`` moves back to root.
response = self.client.put(
f"/api/v1/folders/{folder['uuid']}/assets",
json={"assets": [{"type": "dashboard", "id": kept.id}]},
)
assert response.status_code == 200
folder_ids = {(a["type"], a["id"]) for a in response.json["result"]}
assert ("dashboard", kept.id) in folder_ids
assert ("dashboard", dropped.id) not in folder_ids
root_ids = {
(a["type"], a["id"])
for a in self.client.get("/api/v1/folders/assets").json["result"]
}
assert ("dashboard", dropped.id) in root_ids
# An empty list empties the folder entirely.
self.client.put(
f"/api/v1/folders/{folder['uuid']}/assets", json={"assets": []}
)
contents = self.client.get(f"/api/v1/folders/{folder['uuid']}/assets").json
assert all(a["type"] == "folder" for a in contents["result"])
def test_assign_unknown_asset_rejected(self):
self.login(ADMIN_USERNAME)
folder = self._create_folder(name="Bucket")
response = self.client.put(
f"/api/v1/folders/{folder['uuid']}/assets",
json={"assets": [{"type": "dashboard", "id": 999999}]},
)
assert response.status_code == 422
def test_assign_unknown_asset_type_rejected(self):
self.login(ADMIN_USERNAME)
folder = self._create_folder(name="Bucket")
response = self.client.put(
f"/api/v1/folders/{folder['uuid']}/assets",
json={"assets": [{"type": "report", "id": 1}]},
)
# unknown type -> schema validation error
assert response.status_code == 400
def test_assign_disallowed_asset_type_rejected(self):
self.login(ADMIN_USERNAME)
folder = self._create_folder(name="Bucket")
response = self.client.put(
f"/api/v1/folders/{folder['uuid']}/assets",
json={"assets": [{"type": "dataset", "id": 1}]},
)
# valid type, but datasets do not belong in an analytics folder
assert response.status_code == 422
def test_delete_folder_unfolders_assets(self):
self.login(ADMIN_USERNAME)
folder = self._create_folder(name="Bucket")
dashboard = self._create_dashboard("unfolder")
self.client.put(
f"/api/v1/folders/{folder['uuid']}/assets",
json={"assets": [{"type": "dashboard", "id": dashboard.id}]},
)
self.client.delete(f"/api/v1/folders/{folder['uuid']}")
# the dashboard survives and is unfoldered again
assert db.session.get(Dashboard, dashboard.id) is not None
root = self.client.get("/api/v1/folders/assets").json
assert ("dashboard", dashboard.id) in {
(a["type"], a["id"]) for a in root["result"]
}
# ------------------------------------------------------------------ #
# Filtering & pagination (scoped to a folder for deterministic counts)
# ------------------------------------------------------------------ #
def _assign(self, folder_uuid: str, assets: list[dict[str, Any]]) -> None:
resp = self.client.put(
f"/api/v1/folders/{folder_uuid}/assets", json={"assets": assets}
)
assert resp.status_code == 200, resp.json
def test_contents_response_shape(self):
self.login(ADMIN_USERNAME)
folder = self._create_folder(name="Shape")
resp = self.client.get(f"/api/v1/folders/{folder['uuid']}/assets").json
for key in ("folder", "result", "count", "page", "page_size"):
assert key in resp
def test_contents_search_by_name(self):
self.login(ADMIN_USERNAME)
folder = self._create_folder(name="Box")
alpha = self._create_dashboard("Alpha")
beta = self._create_dashboard("Beta")
self._assign(
folder["uuid"],
[
{"type": "dashboard", "id": alpha.id},
{"type": "dashboard", "id": beta.id},
],
)
resp = self.client.get(
f"/api/v1/folders/{folder['uuid']}/assets?search={DASH_PREFIX}Alpha"
).json
names = {r["name"] for r in resp["result"]}
assert any("Alpha" in n for n in names)
assert not any("Beta" in n for n in names)
def test_contents_type_filter(self):
self.login(ADMIN_USERNAME)
folder = self._create_folder(name="Mix")
self._create_folder(name="Sub", parent_uuid=folder["uuid"])
dash = self._create_dashboard("d")
self._assign(folder["uuid"], [{"type": "dashboard", "id": dash.id}])
resp = self.client.get(
f"/api/v1/folders/{folder['uuid']}/assets?types=dashboard"
).json
assert {r["type"] for r in resp["result"]} == {"dashboard"}
def test_contents_pagination(self):
self.login(ADMIN_USERNAME)
folder = self._create_folder(name="Paged")
dashboards = [self._create_dashboard(f"p{i}") for i in range(3)]
self._assign(
folder["uuid"],
[{"type": "dashboard", "id": d.id} for d in dashboards],
)
page0 = self.client.get(
f"/api/v1/folders/{folder['uuid']}/assets?page=0&page_size=2"
).json
assert page0["count"] == 3
assert page0["page_size"] == 2
assert len(page0["result"]) == 2
page1 = self.client.get(
f"/api/v1/folders/{folder['uuid']}/assets?page=1&page_size=2"
).json
assert len(page1["result"]) == 1
def test_contents_viz_type_filter_and_columns(self):
self.login(ADMIN_USERNAME)
folder = self._create_folder(name="Viz")
bar = self._create_chart("bar", viz_type="bar")
area = self._create_chart("area", viz_type="area")
self._assign(
folder["uuid"],
[{"type": "chart", "id": bar.id}, {"type": "chart", "id": area.id}],
)
resp = self.client.get(
f"/api/v1/folders/{folder['uuid']}/assets?viz_types=area"
).json
rows = {(r["type"], r["id"]): r for r in resp["result"]}
assert ("chart", area.id) in rows
assert ("chart", bar.id) not in rows
# column data is serialized on asset rows
row = rows[("chart", area.id)]
assert row["viz_type"] == "area"
assert any(o["id"] for o in row["owners"])
def test_asset_only_filter_excludes_folders(self):
self.login(ADMIN_USERNAME)
folder = self._create_folder(name="Excl")
self._create_folder(name="SubExcl", parent_uuid=folder["uuid"])
chart = self._create_chart("bar2", viz_type="bar")
self._assign(folder["uuid"], [{"type": "chart", "id": chart.id}])
resp = self.client.get(
f"/api/v1/folders/{folder['uuid']}/assets?viz_types=bar"
).json
types = {r["type"] for r in resp["result"]}
assert "folder" not in types
assert ("chart", chart.id) in {(r["type"], r["id"]) for r in resp["result"]}
# ------------------------------------------------------------------ #
# Auth
# ------------------------------------------------------------------ #
def test_requires_authentication(self):
response = self.client.get("/api/v1/folders/")
assert response.status_code == 401

View File

@@ -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.

View File

@@ -0,0 +1,91 @@
# 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 pytest
from marshmallow import ValidationError
from superset.folders.schemas import (
FolderAssetsPutSchema,
FolderPostSchema,
FolderPutSchema,
)
def test_post_schema_applies_defaults() -> None:
data = FolderPostSchema().load({"name": "Marketing"})
assert data == {
"name": "Marketing",
"description": None,
"parent_uuid": None,
"folder_type": "analytics",
"is_private": False,
}
def test_post_schema_requires_name() -> None:
with pytest.raises(ValidationError) as excinfo:
FolderPostSchema().load({})
assert "name" in excinfo.value.messages
def test_post_schema_rejects_blank_name() -> None:
with pytest.raises(ValidationError):
FolderPostSchema().load({"name": ""})
def test_post_schema_rejects_overlong_name() -> None:
with pytest.raises(ValidationError):
FolderPostSchema().load({"name": "x" * 251})
def test_put_schema_is_partial() -> None:
# Only provided keys appear, so the DAO leaves other fields untouched.
assert FolderPutSchema().load({}) == {}
assert FolderPutSchema().load({"name": "Renamed"}) == {"name": "Renamed"}
def test_put_schema_allows_clearing_parent_and_description() -> None:
data = FolderPutSchema().load({"parent_uuid": None, "description": None})
assert data == {"parent_uuid": None, "description": None}
def test_assets_schema_accepts_known_types() -> None:
data = FolderAssetsPutSchema().load(
{"assets": [{"type": "dashboard", "id": 1}, {"type": "chart", "id": 2}]}
)
assert data["assets"] == [
{"type": "dashboard", "id": 1},
{"type": "chart", "id": 2},
]
def test_assets_schema_rejects_unknown_type() -> None:
with pytest.raises(ValidationError):
FolderAssetsPutSchema().load({"assets": [{"type": "report", "id": 1}]})
def test_assets_schema_allows_empty_list() -> None:
# Membership-set semantics: an empty list empties the folder.
assert FolderAssetsPutSchema().load({"assets": []}) == {"assets": []}
def test_assets_schema_defaults_missing_to_empty() -> None:
assert FolderAssetsPutSchema().load({}) == {"assets": []}
def test_assets_schema_requires_id() -> None:
with pytest.raises(ValidationError):
FolderAssetsPutSchema().load({"assets": [{"type": "dashboard"}]})

View File

@@ -0,0 +1,117 @@
# 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.
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from uuid import UUID
from superset.folders.api import serialize_asset, serialize_assets, serialize_folder
def _user(uid: int) -> SimpleNamespace:
return SimpleNamespace(id=uid, first_name="Ada", last_name="Lovelace")
# Returns ``Any`` so the lightweight stubs satisfy the ``Folder``-typed helpers.
def _folder(**kwargs: Any) -> Any:
defaults = {
"id": 1,
"uuid": UUID("00000000-0000-0000-0000-000000000001"),
"name": "Folder",
"description": None,
"parent": None,
"folder_type": "analytics",
"is_private": False,
"children": [],
"objects": [],
"created_on": datetime(2026, 1, 1),
"changed_on": datetime(2026, 1, 2),
"created_by": _user(1),
"changed_by": _user(2),
}
defaults.update(kwargs)
return SimpleNamespace(**defaults)
def test_serialize_folder_at_root() -> None:
result = serialize_folder(_folder(children=[1, 2], objects=[1]))
assert result["uuid"] == "00000000-0000-0000-0000-000000000001"
assert result["parent_uuid"] is None
assert result["children_count"] == 2
assert result["asset_count"] == 1
assert result["created_by"] == {
"id": 1,
"first_name": "Ada",
"last_name": "Lovelace",
}
def test_serialize_folder_includes_parent_uuid() -> None:
parent = SimpleNamespace(uuid=UUID("00000000-0000-0000-0000-0000000000ff"))
result = serialize_folder(_folder(parent=parent))
assert result["parent_uuid"] == "00000000-0000-0000-0000-0000000000ff"
def test_serialize_folder_without_audit_user() -> None:
result = serialize_folder(_folder(created_by=None, changed_by=None))
assert result["created_by"] is None
assert result["changed_by"] is None
def test_serialize_asset_uses_type_specific_title() -> None:
dashboard = SimpleNamespace(
id=7,
uuid=UUID("00000000-0000-0000-0000-00000000000a"),
dashboard_title="Sales",
url="/superset/dashboard/7/",
changed_on=datetime(2026, 1, 1),
)
chart = SimpleNamespace(
id=9,
uuid=None,
slice_name="Revenue",
url="/explore/?slice_id=9",
changed_on=datetime(2026, 1, 1),
)
assert serialize_asset("dashboard", dashboard)["name"] == "Sales"
assert serialize_asset("dashboard", dashboard)["type"] == "dashboard"
assert serialize_asset("chart", chart)["name"] == "Revenue"
assert serialize_asset("chart", chart)["uuid"] is None
def test_serialize_assets_sorts_by_changed_on_desc() -> None:
older = SimpleNamespace(
id=1,
uuid=None,
dashboard_title="Old",
url=None,
changed_on=datetime(2025, 1, 1),
)
newer = SimpleNamespace(
id=2,
uuid=None,
dashboard_title="New",
url=None,
changed_on=datetime(2026, 1, 1),
)
undated = SimpleNamespace(
id=3, uuid=None, dashboard_title="None", url=None, changed_on=None
)
result = serialize_assets(
[("dashboard", older), ("dashboard", undated), ("dashboard", newer)]
)
assert [item["name"] for item in result] == ["New", "Old", "None"]