mirror of
https://github.com/apache/superset.git
synced 2026-05-04 07:24:18 +00:00
Compare commits
9 Commits
docs/testi
...
v2021.13.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
968962a4ad | ||
|
|
81e1e6b726 | ||
|
|
258923e91f | ||
|
|
df04b66dae | ||
|
|
2347de09fc | ||
|
|
52f7a0afeb | ||
|
|
ea3d6905af | ||
|
|
3eefca3a82 | ||
|
|
3396fe26fb |
@@ -61,7 +61,7 @@ describe('DatasourceEditor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders Tabs', () => {
|
it('renders Tabs', () => {
|
||||||
expect(wrapper.find(Tabs)).toExist();
|
expect(wrapper.find('#table-tabs')).toExist();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('makes an async request', () =>
|
it('makes an async request', () =>
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import React from 'react';
|
|||||||
import configureStore from 'redux-mock-store';
|
import configureStore from 'redux-mock-store';
|
||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import { render, screen, act } from '@testing-library/react';
|
import { render, screen } from 'spec/helpers/testing-library';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import '@testing-library/jest-dom/extend-expect';
|
import '@testing-library/jest-dom/extend-expect';
|
||||||
import thunk from 'redux-thunk';
|
import thunk from 'redux-thunk';
|
||||||
@@ -56,6 +57,10 @@ describe('SqlEditorLeftBar', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it('is valid', () => {
|
it('is valid', () => {
|
||||||
expect(React.isValidElement(<SqlEditorLeftBar {...mockedProps} />)).toBe(
|
expect(React.isValidElement(<SqlEditorLeftBar {...mockedProps} />)).toBe(
|
||||||
true,
|
true,
|
||||||
@@ -68,19 +73,14 @@ describe('SqlEditorLeftBar', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Left Panel Expansion', () => {
|
describe('Left Panel Expansion', () => {
|
||||||
beforeEach(async () => {
|
it('table should be visible when expanded is true', () => {
|
||||||
await act(async () => {
|
const { container } = render(
|
||||||
render(
|
<ThemeProvider theme={supersetTheme}>
|
||||||
<ThemeProvider theme={supersetTheme}>
|
<Provider store={store}>
|
||||||
<Provider store={store}>
|
<SqlEditorLeftBar {...mockedProps} />
|
||||||
<SqlEditorLeftBar {...mockedProps} />
|
</Provider>
|
||||||
</Provider>
|
</ThemeProvider>,
|
||||||
</ThemeProvider>,
|
);
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('table should be visible when expanded is true', async () => {
|
|
||||||
const dbSelect = screen.getByText(/select a database/i);
|
const dbSelect = screen.getByText(/select a database/i);
|
||||||
const schemaSelect = screen.getByText(/select a schema \(0\)/i);
|
const schemaSelect = screen.getByText(/select a schema \(0\)/i);
|
||||||
const dropdown = screen.getByText(/Select table/i);
|
const dropdown = screen.getByText(/Select table/i);
|
||||||
@@ -89,5 +89,28 @@ describe('Left Panel Expansion', () => {
|
|||||||
expect(schemaSelect).toBeInTheDocument();
|
expect(schemaSelect).toBeInTheDocument();
|
||||||
expect(dropdown).toBeInTheDocument();
|
expect(dropdown).toBeInTheDocument();
|
||||||
expect(abUser).toBeInTheDocument();
|
expect(abUser).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
container.querySelector('.ant-collapse-content-active'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle the table when the header is clicked', async () => {
|
||||||
|
const collapseMock = jest.fn();
|
||||||
|
render(
|
||||||
|
<ThemeProvider theme={supersetTheme}>
|
||||||
|
<Provider store={store}>
|
||||||
|
<SqlEditorLeftBar
|
||||||
|
actions={{ ...mockedActions, collapseTable: collapseMock }}
|
||||||
|
tables={[table]}
|
||||||
|
queryEditor={defaultQueryEditor}
|
||||||
|
database={databases}
|
||||||
|
height={0}
|
||||||
|
/>
|
||||||
|
</Provider>
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
const header = screen.getByText(/ab_user/);
|
||||||
|
userEvent.click(header);
|
||||||
|
expect(collapseMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,22 +118,6 @@ describe('TableElement', () => {
|
|||||||
'active',
|
'active',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('calls the collapseTable action', () => {
|
|
||||||
const wrapper = mount(
|
|
||||||
<Provider store={store}>
|
|
||||||
<TableElement {...mockedProps} />
|
|
||||||
</Provider>,
|
|
||||||
{
|
|
||||||
wrappingComponent: ThemeProvider,
|
|
||||||
wrappingComponentProps: {
|
|
||||||
theme: supersetTheme,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(mockedActions.collapseTable.called).toBe(false);
|
|
||||||
wrapper.find('[data-test="collapse"]').hostNodes().simulate('click');
|
|
||||||
expect(mockedActions.collapseTable.called).toBe(true);
|
|
||||||
});
|
|
||||||
it('removes the table', () => {
|
it('removes the table', () => {
|
||||||
const wrapper = mount(
|
const wrapper = mount(
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
LOCALSTORAGE_MAX_QUERY_AGE_MS,
|
LOCALSTORAGE_MAX_QUERY_AGE_MS,
|
||||||
} from '../../constants';
|
} from '../../constants';
|
||||||
|
|
||||||
const TAB_HEIGHT = 64;
|
const TAB_HEIGHT = 90;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
editorQueries are queries executed by users passed from SqlEditor component
|
editorQueries are queries executed by users passed from SqlEditor component
|
||||||
@@ -63,7 +63,6 @@ const StyledPane = styled.div`
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.tab-content {
|
.tab-content {
|
||||||
overflow: hidden;
|
|
||||||
.alert {
|
.alert {
|
||||||
margin-top: ${({ theme }) => theme.gridUnit * 2}px;
|
margin-top: ${({ theme }) => theme.gridUnit * 2}px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export default class SqlEditorLeftBar extends React.PureComponent {
|
|||||||
this.onDbChange = this.onDbChange.bind(this);
|
this.onDbChange = this.onDbChange.bind(this);
|
||||||
this.getDbList = this.getDbList.bind(this);
|
this.getDbList = this.getDbList.bind(this);
|
||||||
this.onTableChange = this.onTableChange.bind(this);
|
this.onTableChange = this.onTableChange.bind(this);
|
||||||
|
this.onToggleTable = this.onToggleTable.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSchemaChange(schema) {
|
onSchemaChange(schema) {
|
||||||
@@ -91,6 +92,16 @@ export default class SqlEditorLeftBar extends React.PureComponent {
|
|||||||
this.props.actions.addTable(this.props.queryEditor, tableName, schemaName);
|
this.props.actions.addTable(this.props.queryEditor, tableName, schemaName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onToggleTable(tables) {
|
||||||
|
this.props.tables.forEach(table => {
|
||||||
|
if (!tables.includes(table.id.toString()) && table.expanded) {
|
||||||
|
this.props.actions.collapseTable(table);
|
||||||
|
} else if (tables.includes(table.id.toString()) && !table.expanded) {
|
||||||
|
this.props.actions.expandTable(table);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getDbList(dbs) {
|
getDbList(dbs) {
|
||||||
this.props.actions.setDatabases(dbs);
|
this.props.actions.setDatabases(dbs);
|
||||||
}
|
}
|
||||||
@@ -172,13 +183,13 @@ export default class SqlEditorLeftBar extends React.PureComponent {
|
|||||||
`}
|
`}
|
||||||
expandIconPosition="right"
|
expandIconPosition="right"
|
||||||
ghost
|
ghost
|
||||||
|
onChange={this.onToggleTable}
|
||||||
>
|
>
|
||||||
{this.props.tables.map(table => (
|
{this.props.tables.map(table => (
|
||||||
<TableElement
|
<TableElement
|
||||||
table={table}
|
table={table}
|
||||||
key={table.id}
|
key={table.id}
|
||||||
actions={this.props.actions}
|
actions={this.props.actions}
|
||||||
onClick={this.toggleTable}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|||||||
@@ -79,15 +79,6 @@ class TableElement extends React.PureComponent {
|
|||||||
this.props.actions.addQueryEditor(qe);
|
this.props.actions.addQueryEditor(qe);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleTable(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (this.props.table.expanded) {
|
|
||||||
this.props.actions.collapseTable(this.props.table);
|
|
||||||
} else {
|
|
||||||
this.props.actions.expandTable(this.props.table);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTable() {
|
removeTable() {
|
||||||
this.props.actions.removeDataPreview(this.props.table);
|
this.props.actions.removeDataPreview(this.props.table);
|
||||||
this.props.actions.removeTable(this.props.table);
|
this.props.actions.removeTable(this.props.table);
|
||||||
@@ -214,13 +205,7 @@ class TableElement extends React.PureComponent {
|
|||||||
title={table.name}
|
title={table.name}
|
||||||
trigger={['hover']}
|
trigger={['hover']}
|
||||||
>
|
>
|
||||||
<StyledSpan
|
<StyledSpan data-test="collapse" className="table-name">
|
||||||
data-test="collapse"
|
|
||||||
className="table-name"
|
|
||||||
onClick={e => {
|
|
||||||
this.toggleTable(e);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>{table.name}</strong>
|
<strong>{table.name}</strong>
|
||||||
</StyledSpan>
|
</StyledSpan>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
background-color: @lightest;
|
background-color: @lightest;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: auto;
|
||||||
|
|
||||||
> .ant-tabs-tabpane {
|
> .ant-tabs-tabpane {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -300,6 +300,7 @@ div.Workspace {
|
|||||||
.schemaPane-enter-done,
|
.schemaPane-enter-done,
|
||||||
.schemaPane-exit {
|
.schemaPane-exit {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
|
z-index: 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schemaPane-exit-active {
|
.schemaPane-exit-active {
|
||||||
|
|||||||
@@ -245,7 +245,6 @@ export default function DatabaseSelector({
|
|||||||
placeholder={t('Select a database')}
|
placeholder={t('Select a database')}
|
||||||
autoSelect
|
autoSelect
|
||||||
isDisabled={!isDatabaseSelectEnabled || readOnly}
|
isDisabled={!isDatabaseSelectEnabled || readOnly}
|
||||||
menuPosition="fixed"
|
|
||||||
/>,
|
/>,
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ const ProgressBar = styled(({ striped, ...props }: ProgressBarProps) => (
|
|||||||
<AntdProgress {...props} />
|
<AntdProgress {...props} />
|
||||||
))`
|
))`
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
|
position: static;
|
||||||
|
.ant-progress-inner {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
.ant-progress-outer {
|
.ant-progress-outer {
|
||||||
${({ percent }) => !percent && `display: none;`}
|
${({ percent }) => !percent && `display: none;`}
|
||||||
}
|
}
|
||||||
@@ -37,6 +41,7 @@ const ProgressBar = styled(({ striped, ...props }: ProgressBarProps) => (
|
|||||||
font-size: ${({ theme }) => theme.typography.sizes.s}px;
|
font-size: ${({ theme }) => theme.typography.sizes.s}px;
|
||||||
}
|
}
|
||||||
.ant-progress-bg {
|
.ant-progress-bg {
|
||||||
|
position: static;
|
||||||
${({ striped }) =>
|
${({ striped }) =>
|
||||||
striped &&
|
striped &&
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -326,7 +326,6 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
|
|||||||
optionRenderer={renderTableOption}
|
optionRenderer={renderTableOption}
|
||||||
valueRenderer={renderTableOption}
|
valueRenderer={renderTableOption}
|
||||||
isDisabled={readOnly}
|
isDisabled={readOnly}
|
||||||
menuPosition="fixed"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (formMode) {
|
} else if (formMode) {
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ const FlexRowContainer = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const StyledTableTabs = styled(Tabs)`
|
||||||
|
overflow: visible;
|
||||||
|
.ant-tabs-content-holder {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const EditLockContainer = styled.div`
|
const EditLockContainer = styled.div`
|
||||||
font-size: ${supersetTheme.typography.sizes.s}px;
|
font-size: ${supersetTheme.typography.sizes.s}px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -995,7 +1002,7 @@ class DatasourceEditor extends React.PureComponent {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Tabs
|
<StyledTableTabs
|
||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
id="table-tabs"
|
id="table-tabs"
|
||||||
data-test="edit-dataset-tabs"
|
data-test="edit-dataset-tabs"
|
||||||
@@ -1086,7 +1093,7 @@ class DatasourceEditor extends React.PureComponent {
|
|||||||
</Col>
|
</Col>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
</Tabs>
|
</StyledTableTabs>
|
||||||
</DatasourceContainer>
|
</DatasourceContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ const StyledDatasourceModal = styled(Modal)`
|
|||||||
.modal-footer {
|
.modal-footer {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-modal-body {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface DatasourceModalProps {
|
interface DatasourceModalProps {
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ function VizSupportValidation({ vizType }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nativeFilterGate = behaviors =>
|
||||||
|
!behaviors.includes(Behavior.NATIVE_FILTER) ||
|
||||||
|
(isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS) &&
|
||||||
|
behaviors.includes(Behavior.CROSS_FILTER));
|
||||||
|
|
||||||
const VizTypeControl = props => {
|
const VizTypeControl = props => {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
@@ -168,11 +173,7 @@ const VizTypeControl = props => {
|
|||||||
const filteredTypes = DEFAULT_ORDER.filter(type => registry.has(type))
|
const filteredTypes = DEFAULT_ORDER.filter(type => registry.has(type))
|
||||||
.filter(type => {
|
.filter(type => {
|
||||||
const behaviors = registry.get(type)?.behaviors || [];
|
const behaviors = registry.get(type)?.behaviors || [];
|
||||||
return (
|
return nativeFilterGate(behaviors);
|
||||||
(isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS) &&
|
|
||||||
behaviors.includes(Behavior.CROSS_FILTER)) ||
|
|
||||||
!behaviors.length
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.map(type => ({
|
.map(type => ({
|
||||||
key: type,
|
key: type,
|
||||||
@@ -183,11 +184,7 @@ const VizTypeControl = props => {
|
|||||||
.entries()
|
.entries()
|
||||||
.filter(entry => {
|
.filter(entry => {
|
||||||
const behaviors = entry.value?.behaviors || [];
|
const behaviors = entry.value?.behaviors || [];
|
||||||
return (
|
return nativeFilterGate(behaviors);
|
||||||
(isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS) &&
|
|
||||||
behaviors.includes(Behavior.CROSS_FILTER)) ||
|
|
||||||
!behaviors.length
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.filter(({ key }) => !typesWithDefaultOrder.has(key)),
|
.filter(({ key }) => !typesWithDefaultOrder.has(key)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -359,6 +359,13 @@ DEFAULT_FEATURE_FLAGS: Dict[str, bool] = {
|
|||||||
"OMNIBAR": False,
|
"OMNIBAR": False,
|
||||||
"DASHBOARD_RBAC": False,
|
"DASHBOARD_RBAC": False,
|
||||||
"ENABLE_EXPLORE_DRAG_AND_DROP": False,
|
"ENABLE_EXPLORE_DRAG_AND_DROP": False,
|
||||||
|
# Enabling ALERTS_ATTACH_REPORTS, the system sends email and slack message
|
||||||
|
# with screenshot and link
|
||||||
|
# Disables ALERTS_ATTACH_REPORTS, the system DOES NOT generate screenshot
|
||||||
|
# for report with type 'alert' and sends email and slack message with only link;
|
||||||
|
# for report with type 'report' still send with email and slack message with
|
||||||
|
# screenshot and link
|
||||||
|
"ALERTS_ATTACH_REPORTS": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Set the default view to card/grid view if thumbnail support is enabled.
|
# Set the default view to card/grid view if thumbnail support is enabled.
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ def import_dashboard(
|
|||||||
# TODO (betodealmeida): move this logic to import_from_dict
|
# TODO (betodealmeida): move this logic to import_from_dict
|
||||||
config = config.copy()
|
config = config.copy()
|
||||||
for key, new_name in JSON_KEYS.items():
|
for key, new_name in JSON_KEYS.items():
|
||||||
if config.get(key):
|
if config.get(key) is not None:
|
||||||
value = config.pop(key)
|
value = config.pop(key)
|
||||||
try:
|
try:
|
||||||
config[new_name] = json.dumps(value)
|
config[new_name] = json.dumps(value)
|
||||||
|
|||||||
@@ -314,6 +314,8 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
|||||||
changed_model = UpdateDatasetCommand(
|
changed_model = UpdateDatasetCommand(
|
||||||
g.user, pk, item, override_columns
|
g.user, pk, item, override_columns
|
||||||
).run()
|
).run()
|
||||||
|
if override_columns:
|
||||||
|
RefreshDatasetCommand(g.user, pk).run()
|
||||||
response = self.response(200, id=changed_model.id, result=item)
|
response = self.response(200, id=changed_model.id, result=item)
|
||||||
except DatasetNotFoundError:
|
except DatasetNotFoundError:
|
||||||
response = self.response_404()
|
response = self.response_404()
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ def import_dataset(
|
|||||||
# TODO (betodealmeida): move this logic to import_from_dict
|
# TODO (betodealmeida): move this logic to import_from_dict
|
||||||
config = config.copy()
|
config = config.copy()
|
||||||
for key in JSON_KEYS:
|
for key in JSON_KEYS:
|
||||||
if config.get(key):
|
if config.get(key) is not None:
|
||||||
try:
|
try:
|
||||||
config[key] = json.dumps(config[key])
|
config[key] = json.dumps(config[key])
|
||||||
except TypeError:
|
except TypeError:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from sqlalchemy.orm import Session
|
|||||||
from superset import app
|
from superset import app
|
||||||
from superset.commands.base import BaseCommand
|
from superset.commands.base import BaseCommand
|
||||||
from superset.commands.exceptions import CommandException
|
from superset.commands.exceptions import CommandException
|
||||||
|
from superset.extensions import feature_flag_manager
|
||||||
from superset.models.reports import (
|
from superset.models.reports import (
|
||||||
ReportExecutionLog,
|
ReportExecutionLog,
|
||||||
ReportSchedule,
|
ReportSchedule,
|
||||||
@@ -52,7 +53,7 @@ from superset.reports.dao import (
|
|||||||
ReportScheduleDAO,
|
ReportScheduleDAO,
|
||||||
)
|
)
|
||||||
from superset.reports.notifications import create_notification
|
from superset.reports.notifications import create_notification
|
||||||
from superset.reports.notifications.base import NotificationContent, ScreenshotData
|
from superset.reports.notifications.base import NotificationContent
|
||||||
from superset.reports.notifications.exceptions import NotificationError
|
from superset.reports.notifications.exceptions import NotificationError
|
||||||
from superset.utils.celery import session_scope
|
from superset.utils.celery import session_scope
|
||||||
from superset.utils.screenshots import (
|
from superset.utils.screenshots import (
|
||||||
@@ -153,7 +154,7 @@ class BaseReportState:
|
|||||||
raise ReportScheduleSelleniumUserNotFoundError()
|
raise ReportScheduleSelleniumUserNotFoundError()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def _get_screenshot(self) -> ScreenshotData:
|
def _get_screenshot(self) -> bytes:
|
||||||
"""
|
"""
|
||||||
Get a chart or dashboard screenshot
|
Get a chart or dashboard screenshot
|
||||||
|
|
||||||
@@ -176,7 +177,6 @@ class BaseReportState:
|
|||||||
window_size=app.config["WEBDRIVER_WINDOW"]["dashboard"],
|
window_size=app.config["WEBDRIVER_WINDOW"]["dashboard"],
|
||||||
thumb_size=app.config["WEBDRIVER_WINDOW"]["dashboard"],
|
thumb_size=app.config["WEBDRIVER_WINDOW"]["dashboard"],
|
||||||
)
|
)
|
||||||
image_url = self._get_url(user_friendly=True)
|
|
||||||
user = self._get_screenshot_user()
|
user = self._get_screenshot_user()
|
||||||
try:
|
try:
|
||||||
image_data = screenshot.get_screenshot(user=user)
|
image_data = screenshot.get_screenshot(user=user)
|
||||||
@@ -188,7 +188,7 @@ class BaseReportState:
|
|||||||
)
|
)
|
||||||
if not image_data:
|
if not image_data:
|
||||||
raise ReportScheduleScreenshotFailedError()
|
raise ReportScheduleScreenshotFailedError()
|
||||||
return ScreenshotData(url=image_url, image=image_data)
|
return image_data
|
||||||
|
|
||||||
def _get_notification_content(self) -> NotificationContent:
|
def _get_notification_content(self) -> NotificationContent:
|
||||||
"""
|
"""
|
||||||
@@ -196,7 +196,19 @@ class BaseReportState:
|
|||||||
|
|
||||||
:raises: ReportScheduleScreenshotFailedError
|
:raises: ReportScheduleScreenshotFailedError
|
||||||
"""
|
"""
|
||||||
screenshot_data = self._get_screenshot()
|
screenshot_data = None
|
||||||
|
url = self._get_url(user_friendly=True)
|
||||||
|
if (
|
||||||
|
feature_flag_manager.is_feature_enabled("ALERTS_ATTACH_REPORTS")
|
||||||
|
or self._report_schedule.type == ReportScheduleType.REPORT
|
||||||
|
):
|
||||||
|
screenshot_data = self._get_screenshot()
|
||||||
|
if not screenshot_data:
|
||||||
|
return NotificationContent(
|
||||||
|
name=self._report_schedule.name,
|
||||||
|
text="Unexpected missing screenshot",
|
||||||
|
)
|
||||||
|
|
||||||
if self._report_schedule.chart:
|
if self._report_schedule.chart:
|
||||||
name = (
|
name = (
|
||||||
f"{self._report_schedule.name}: "
|
f"{self._report_schedule.name}: "
|
||||||
@@ -207,7 +219,7 @@ class BaseReportState:
|
|||||||
f"{self._report_schedule.name}: "
|
f"{self._report_schedule.name}: "
|
||||||
f"{self._report_schedule.dashboard.dashboard_title}"
|
f"{self._report_schedule.dashboard.dashboard_title}"
|
||||||
)
|
)
|
||||||
return NotificationContent(name=name, screenshot=screenshot_data)
|
return NotificationContent(name=name, url=url, screenshot=screenshot_data)
|
||||||
|
|
||||||
def _send(self, notification_content: NotificationContent) -> None:
|
def _send(self, notification_content: NotificationContent) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -21,16 +21,11 @@ from typing import Any, List, Optional, Type
|
|||||||
from superset.models.reports import ReportRecipients, ReportRecipientType
|
from superset.models.reports import ReportRecipients, ReportRecipientType
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ScreenshotData:
|
|
||||||
url: str # url to chart/dashboard for this screenshot
|
|
||||||
image: bytes # bytes for the screenshot
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class NotificationContent:
|
class NotificationContent:
|
||||||
name: str
|
name: str
|
||||||
screenshot: Optional[ScreenshotData] = None
|
url: Optional[str] = None # url to chart/dashboard for this screenshot
|
||||||
|
screenshot: Optional[bytes] = None # bytes for the screenshot
|
||||||
text: Optional[str] = None
|
text: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -63,21 +63,21 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
|
|||||||
return EmailContent(body=self._error_template(self._content.text))
|
return EmailContent(body=self._error_template(self._content.text))
|
||||||
# Get the domain from the 'From' address ..
|
# Get the domain from the 'From' address ..
|
||||||
# and make a message id without the < > in the end
|
# and make a message id without the < > in the end
|
||||||
|
image = None
|
||||||
|
domain = self._get_smtp_domain()
|
||||||
|
msgid = make_msgid(domain)[1:-1]
|
||||||
|
body = __(
|
||||||
|
"""
|
||||||
|
<b><a href="%(url)s">Explore in Superset</a></b><p></p>
|
||||||
|
<img src="cid:%(msgid)s">
|
||||||
|
""",
|
||||||
|
url=self._content.url,
|
||||||
|
msgid=msgid,
|
||||||
|
)
|
||||||
if self._content.screenshot:
|
if self._content.screenshot:
|
||||||
domain = self._get_smtp_domain()
|
image = {msgid: self._content.screenshot}
|
||||||
msgid = make_msgid(domain)[1:-1]
|
|
||||||
|
|
||||||
image = {msgid: self._content.screenshot.image}
|
return EmailContent(body=body, images=image)
|
||||||
body = __(
|
|
||||||
"""
|
|
||||||
<b><a href="%(url)s">Explore in Superset</a></b><p></p>
|
|
||||||
<img src="cid:%(msgid)s">
|
|
||||||
""",
|
|
||||||
url=self._content.screenshot.url,
|
|
||||||
msgid=msgid,
|
|
||||||
)
|
|
||||||
return EmailContent(body=body, images=image)
|
|
||||||
return EmailContent(body=self._error_template("Unexpected missing screenshot"))
|
|
||||||
|
|
||||||
def _get_subject(self) -> str:
|
def _get_subject(self) -> str:
|
||||||
return __(
|
return __(
|
||||||
|
|||||||
@@ -57,20 +57,18 @@ class SlackNotification(BaseNotification): # pylint: disable=too-few-public-met
|
|||||||
def _get_body(self) -> str:
|
def _get_body(self) -> str:
|
||||||
if self._content.text:
|
if self._content.text:
|
||||||
return self._error_template(self._content.name, self._content.text)
|
return self._error_template(self._content.name, self._content.text)
|
||||||
if self._content.screenshot:
|
return __(
|
||||||
return __(
|
"""
|
||||||
"""
|
*%(name)s*\n
|
||||||
*%(name)s*\n
|
<%(url)s|Explore in Superset>
|
||||||
<%(url)s|Explore in Superset>
|
""",
|
||||||
""",
|
name=self._content.name,
|
||||||
name=self._content.name,
|
url=self._content.url,
|
||||||
url=self._content.screenshot.url,
|
)
|
||||||
)
|
|
||||||
return self._error_template(self._content.name, "Unexpected missing screenshot")
|
|
||||||
|
|
||||||
def _get_inline_screenshot(self) -> Optional[Union[str, IOBase, bytes]]:
|
def _get_inline_screenshot(self) -> Optional[Union[str, IOBase, bytes]]:
|
||||||
if self._content.screenshot:
|
if self._content.screenshot:
|
||||||
return self._content.screenshot.image
|
return self._content.screenshot
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@retry(SlackApiError, delay=10, backoff=2, tries=5)
|
@retry(SlackApiError, delay=10, backoff=2, tries=5)
|
||||||
|
|||||||
@@ -598,12 +598,7 @@ class TestDatasetApi(SupersetTestCase):
|
|||||||
rv = self.put_assert_metric(uri, dataset_data, "put")
|
rv = self.put_assert_metric(uri, dataset_data, "put")
|
||||||
assert rv.status_code == 200
|
assert rv.status_code == 200
|
||||||
|
|
||||||
columns = (
|
columns = db.session.query(TableColumn).filter_by(table_id=dataset.id).all()
|
||||||
db.session.query(TableColumn)
|
|
||||||
.filter_by(table_id=dataset.id)
|
|
||||||
.order_by("column_name")
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
assert columns[0].column_name == dataset_data["columns"][0]["column_name"]
|
assert columns[0].column_name == dataset_data["columns"][0]["column_name"]
|
||||||
assert columns[0].description == dataset_data["columns"][0]["description"]
|
assert columns[0].description == dataset_data["columns"][0]["description"]
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ class TestImportDatasetsCommand(SupersetTestCase):
|
|||||||
assert dataset.schema == ""
|
assert dataset.schema == ""
|
||||||
assert dataset.sql == ""
|
assert dataset.sql == ""
|
||||||
assert dataset.params is None
|
assert dataset.params is None
|
||||||
assert dataset.template_params is None
|
assert dataset.template_params == "{}"
|
||||||
assert dataset.filter_select_enabled
|
assert dataset.filter_select_enabled
|
||||||
assert dataset.fetch_values_predicate is None
|
assert dataset.fetch_values_predicate is None
|
||||||
assert dataset.extra is None
|
assert dataset.extra is None
|
||||||
|
|||||||
2
tests/fixtures/importexport.py
vendored
2
tests/fixtures/importexport.py
vendored
@@ -368,7 +368,7 @@ dataset_config: Dict[str, Any] = {
|
|||||||
"schema": "",
|
"schema": "",
|
||||||
"sql": "",
|
"sql": "",
|
||||||
"params": None,
|
"params": None,
|
||||||
"template_params": None,
|
"template_params": {},
|
||||||
"filter_select_enabled": True,
|
"filter_select_enabled": True,
|
||||||
"fetch_values_predicate": None,
|
"fetch_values_predicate": None,
|
||||||
"extra": None,
|
"extra": None,
|
||||||
|
|||||||
@@ -750,6 +750,10 @@ def test_email_dashboard_report_fails(
|
|||||||
)
|
)
|
||||||
@patch("superset.reports.notifications.email.send_email_smtp")
|
@patch("superset.reports.notifications.email.send_email_smtp")
|
||||||
@patch("superset.utils.screenshots.ChartScreenshot.get_screenshot")
|
@patch("superset.utils.screenshots.ChartScreenshot.get_screenshot")
|
||||||
|
@patch.dict(
|
||||||
|
"superset.extensions.feature_flag_manager._feature_flags",
|
||||||
|
ALERTS_ATTACH_REPORTS=True,
|
||||||
|
)
|
||||||
def test_slack_chart_alert(screenshot_mock, email_mock, create_alert_email_chart):
|
def test_slack_chart_alert(screenshot_mock, email_mock, create_alert_email_chart):
|
||||||
"""
|
"""
|
||||||
ExecuteReport Command: Test chart slack alert
|
ExecuteReport Command: Test chart slack alert
|
||||||
@@ -773,6 +777,34 @@ def test_slack_chart_alert(screenshot_mock, email_mock, create_alert_email_chart
|
|||||||
assert_log(ReportState.SUCCESS)
|
assert_log(ReportState.SUCCESS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures(
|
||||||
|
"load_birth_names_dashboard_with_slices", "create_alert_email_chart"
|
||||||
|
)
|
||||||
|
@patch("superset.reports.notifications.email.send_email_smtp")
|
||||||
|
@patch.dict(
|
||||||
|
"superset.extensions.feature_flag_manager._feature_flags",
|
||||||
|
ALERTS_ATTACH_REPORTS=False,
|
||||||
|
)
|
||||||
|
def test_slack_chart_alert_no_attachment(email_mock, create_alert_email_chart):
|
||||||
|
"""
|
||||||
|
ExecuteReport Command: Test chart slack alert
|
||||||
|
"""
|
||||||
|
# setup screenshot mock
|
||||||
|
|
||||||
|
with freeze_time("2020-01-01T00:00:00Z"):
|
||||||
|
AsyncExecuteReportScheduleCommand(
|
||||||
|
test_id, create_alert_email_chart.id, datetime.utcnow()
|
||||||
|
).run()
|
||||||
|
|
||||||
|
notification_targets = get_target_from_report_schedule(create_alert_email_chart)
|
||||||
|
# Assert the email smtp address
|
||||||
|
assert email_mock.call_args[0][0] == notification_targets[0]
|
||||||
|
# Assert the there is no attached image
|
||||||
|
assert email_mock.call_args[1]["images"] is None
|
||||||
|
# Assert logs are correct
|
||||||
|
assert_log(ReportState.SUCCESS)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures(
|
@pytest.mark.usefixtures(
|
||||||
"load_birth_names_dashboard_with_slices", "create_report_slack_chart"
|
"load_birth_names_dashboard_with_slices", "create_report_slack_chart"
|
||||||
)
|
)
|
||||||
@@ -859,6 +891,10 @@ def test_soft_timeout_alert(email_mock, create_alert_email_chart):
|
|||||||
)
|
)
|
||||||
@patch("superset.reports.notifications.email.send_email_smtp")
|
@patch("superset.reports.notifications.email.send_email_smtp")
|
||||||
@patch("superset.utils.screenshots.ChartScreenshot.get_screenshot")
|
@patch("superset.utils.screenshots.ChartScreenshot.get_screenshot")
|
||||||
|
@patch.dict(
|
||||||
|
"superset.extensions.feature_flag_manager._feature_flags",
|
||||||
|
ALERTS_ATTACH_REPORTS=True,
|
||||||
|
)
|
||||||
def test_soft_timeout_screenshot(screenshot_mock, email_mock, create_alert_email_chart):
|
def test_soft_timeout_screenshot(screenshot_mock, email_mock, create_alert_email_chart):
|
||||||
"""
|
"""
|
||||||
ExecuteReport Command: Test soft timeout on screenshot
|
ExecuteReport Command: Test soft timeout on screenshot
|
||||||
@@ -882,11 +918,11 @@ def test_soft_timeout_screenshot(screenshot_mock, email_mock, create_alert_email
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures(
|
@pytest.mark.usefixtures(
|
||||||
"load_birth_names_dashboard_with_slices", "create_alert_email_chart"
|
"load_birth_names_dashboard_with_slices", "create_report_email_chart"
|
||||||
)
|
)
|
||||||
@patch("superset.reports.notifications.email.send_email_smtp")
|
@patch("superset.reports.notifications.email.send_email_smtp")
|
||||||
@patch("superset.utils.screenshots.ChartScreenshot.get_screenshot")
|
@patch("superset.utils.screenshots.ChartScreenshot.get_screenshot")
|
||||||
def test_fail_screenshot(screenshot_mock, email_mock, create_alert_email_chart):
|
def test_fail_screenshot(screenshot_mock, email_mock, create_report_email_chart):
|
||||||
"""
|
"""
|
||||||
ExecuteReport Command: Test soft timeout on screenshot
|
ExecuteReport Command: Test soft timeout on screenshot
|
||||||
"""
|
"""
|
||||||
@@ -896,10 +932,10 @@ def test_fail_screenshot(screenshot_mock, email_mock, create_alert_email_chart):
|
|||||||
screenshot_mock.side_effect = Exception("Unexpected error")
|
screenshot_mock.side_effect = Exception("Unexpected error")
|
||||||
with pytest.raises(ReportScheduleScreenshotFailedError):
|
with pytest.raises(ReportScheduleScreenshotFailedError):
|
||||||
AsyncExecuteReportScheduleCommand(
|
AsyncExecuteReportScheduleCommand(
|
||||||
test_id, create_alert_email_chart.id, datetime.utcnow()
|
test_id, create_report_email_chart.id, datetime.utcnow()
|
||||||
).run()
|
).run()
|
||||||
|
|
||||||
notification_targets = get_target_from_report_schedule(create_alert_email_chart)
|
notification_targets = get_target_from_report_schedule(create_report_email_chart)
|
||||||
# Assert the email smtp address, asserts a notification was sent with the error
|
# Assert the email smtp address, asserts a notification was sent with the error
|
||||||
assert email_mock.call_args[0][0] == notification_targets[0]
|
assert email_mock.call_args[0][0] == notification_targets[0]
|
||||||
|
|
||||||
@@ -908,6 +944,32 @@ def test_fail_screenshot(screenshot_mock, email_mock, create_alert_email_chart):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures(
|
||||||
|
"load_birth_names_dashboard_with_slices", "create_alert_email_chart"
|
||||||
|
)
|
||||||
|
@patch("superset.reports.notifications.email.send_email_smtp")
|
||||||
|
@patch.dict(
|
||||||
|
"superset.extensions.feature_flag_manager._feature_flags",
|
||||||
|
ALERTS_ATTACH_REPORTS=False,
|
||||||
|
)
|
||||||
|
def test_email_disable_screenshot(email_mock, create_alert_email_chart):
|
||||||
|
"""
|
||||||
|
ExecuteReport Command: Test soft timeout on screenshot
|
||||||
|
"""
|
||||||
|
|
||||||
|
AsyncExecuteReportScheduleCommand(
|
||||||
|
test_id, create_alert_email_chart.id, datetime.utcnow()
|
||||||
|
).run()
|
||||||
|
|
||||||
|
notification_targets = get_target_from_report_schedule(create_alert_email_chart)
|
||||||
|
# Assert the email smtp address, asserts a notification was sent with the error
|
||||||
|
assert email_mock.call_args[0][0] == notification_targets[0]
|
||||||
|
# Assert the there is no attached image
|
||||||
|
assert email_mock.call_args[1]["images"] is None
|
||||||
|
|
||||||
|
assert_log(ReportState.SUCCESS)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("create_invalid_sql_alert_email_chart")
|
@pytest.mark.usefixtures("create_invalid_sql_alert_email_chart")
|
||||||
@patch("superset.reports.notifications.email.send_email_smtp")
|
@patch("superset.reports.notifications.email.send_email_smtp")
|
||||||
def test_invalid_sql_alert(email_mock, create_invalid_sql_alert_email_chart):
|
def test_invalid_sql_alert(email_mock, create_invalid_sql_alert_email_chart):
|
||||||
|
|||||||
Reference in New Issue
Block a user