Compare commits

...

1 Commits

Author SHA1 Message Date
Beto Dealmeida
7083d09777 feat: file handler for CSV/XSL 2025-12-19 09:47:32 -05:00
15 changed files with 734 additions and 15 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

View File

@@ -67,6 +67,7 @@ interface UploadDataModalProps {
show: boolean;
allowedExtensions: string[];
type: UploadType;
fileListOverride?: File[];
}
const CSVSpecificFields = [
@@ -215,6 +216,7 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({
show,
allowedExtensions,
type = 'csv',
fileListOverride,
}) => {
const [form] = Form.useForm();
const [currentDatabaseId, setCurrentDatabaseId] = useState<number>(0);
@@ -524,10 +526,26 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({
await loadFileMetadata(info.file.originFileObj);
};
useEffect(() => {
if (fileListOverride?.length) {
setFileList(
fileListOverride.map(file => ({
uid: file.name,
name: file.name,
originFileObj: file as UploadFile['originFileObj'],
status: 'done' as const,
})),
);
if (previewUploadedFile) {
loadFileMetadata(fileListOverride[0]).then(r => r);
}
}
}, [fileListOverride, previewUploadedFile]);
useEffect(() => {
if (
columns.length > 0 &&
fileList[0].originFileObj &&
fileList.length > 0 &&
fileList[0].originFileObj instanceof File
) {
if (!previewUploadedFile) {

View File

@@ -0,0 +1,368 @@
/**
* 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 { ComponentType } from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import { MemoryRouter, Route } from 'react-router-dom';
import FileHandler from './index';
const mockAddDangerToast = jest.fn();
const mockAddSuccessToast = jest.fn();
const mockHistoryPush = jest.fn();
type ToastInjectedProps = {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
};
// Mock the withToasts HOC
jest.mock('src/components/MessageToasts/withToasts', () => ({
__esModule: true,
default: (Component: ComponentType<ToastInjectedProps>) =>
function MockedWithToasts(props: Record<string, unknown>) {
return (
<Component
{...props}
addDangerToast={mockAddDangerToast}
addSuccessToast={mockAddSuccessToast}
/>
);
},
}));
interface UploadDataModalProps {
show: boolean;
onHide: () => void;
type: string;
allowedExtensions: string[];
fileListOverride?: File[];
}
// Mock the UploadDataModal
jest.mock('src/features/databases/UploadDataModel', () => ({
__esModule: true,
default: ({
show,
onHide,
type,
allowedExtensions,
fileListOverride,
}: UploadDataModalProps) => (
<div data-test="upload-modal">
<div data-test="modal-show">{show.toString()}</div>
<div data-test="modal-type">{type}</div>
<div data-test="modal-extensions">{allowedExtensions.join(',')}</div>
<div data-test="modal-file">{fileListOverride?.[0]?.name ?? ''}</div>
<button onClick={onHide}>Close</button>
</div>
),
}));
// Mock react-router-dom's useHistory
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: () => ({
push: mockHistoryPush,
}),
}));
// Mock the File API
type MockFileHandle = {
kind: 'file';
name: string;
getFile: () => Promise<File>;
isSameEntry: () => Promise<boolean>;
queryPermission: () => Promise<PermissionState>;
requestPermission: () => Promise<PermissionState>;
};
const createMockFileHandle = (fileName: string): MockFileHandle => ({
kind: 'file',
name: fileName,
getFile: async () => new File(['test'], fileName),
isSameEntry: async () => false,
queryPermission: async () => 'granted',
requestPermission: async () => 'granted',
});
type LaunchQueue = {
setConsumer: (
consumer: (params: { files?: MockFileHandle[] }) => void,
) => void;
};
const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
let savedConsumer:
| ((params: { files?: MockFileHandle[] }) => void | Promise<void>)
| null = null;
(window as unknown as Window & { launchQueue: LaunchQueue }).launchQueue = {
setConsumer: (consumer: (params: { files?: MockFileHandle[] }) => void) => {
savedConsumer = consumer;
if (fileHandle) {
setTimeout(() => {
consumer({
files: [fileHandle],
});
}, 0);
}
},
};
return {
triggerConsumer: async (params: { files?: MockFileHandle[] }) => {
await savedConsumer?.(params);
},
};
};
beforeEach(() => {
jest.clearAllMocks();
delete (window as any).launchQueue;
});
test('shows error when launchQueue is not supported', async () => {
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
'File handling is not supported in this browser. Please use a modern browser like Chrome or Edge.',
);
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
});
});
test('redirects when no files are provided', async () => {
const { triggerConsumer } = setupLaunchQueue();
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
// Trigger the consumer with no files
await triggerConsumer({ files: [] });
await waitFor(() => {
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
});
});
test('handles CSV file correctly', async () => {
const fileHandle = createMockFileHandle('test.csv');
setupLaunchQueue(fileHandle);
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
const modal = await screen.findByTestId('upload-modal');
expect(modal).toBeInTheDocument();
expect(screen.getByTestId('modal-show')).toHaveTextContent('true');
expect(screen.getByTestId('modal-type')).toHaveTextContent('csv');
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('csv');
expect(screen.getByTestId('modal-file')).toHaveTextContent('test.csv');
});
test('handles Excel (.xls) file correctly', async () => {
const fileHandle = createMockFileHandle('test.xls');
setupLaunchQueue(fileHandle);
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
const modal = await screen.findByTestId('upload-modal');
expect(modal).toBeInTheDocument();
expect(screen.getByTestId('modal-type')).toHaveTextContent('excel');
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('xls,xlsx');
});
test('handles Excel (.xlsx) file correctly', async () => {
const fileHandle = createMockFileHandle('test.xlsx');
setupLaunchQueue(fileHandle);
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
const modal = await screen.findByTestId('upload-modal');
expect(modal).toBeInTheDocument();
expect(screen.getByTestId('modal-type')).toHaveTextContent('excel');
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('xls,xlsx');
});
test('handles Parquet file correctly', async () => {
const fileHandle = createMockFileHandle('test.parquet');
setupLaunchQueue(fileHandle);
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
const modal = await screen.findByTestId('upload-modal');
expect(modal).toBeInTheDocument();
expect(screen.getByTestId('modal-type')).toHaveTextContent('columnar');
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('parquet');
});
test('shows error for unsupported file type', async () => {
const { triggerConsumer } = setupLaunchQueue();
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
// Trigger with unsupported file
const fileHandle = createMockFileHandle('test.pdf');
await triggerConsumer({ files: [fileHandle] });
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
'Unsupported file type. Please use CSV, Excel, or Columnar files.',
);
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
});
});
test('handles file with uppercase extension', async () => {
const fileHandle = createMockFileHandle('test.CSV');
setupLaunchQueue(fileHandle);
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
const modal = await screen.findByTestId('upload-modal');
expect(modal).toBeInTheDocument();
expect(screen.getByTestId('modal-type')).toHaveTextContent('csv');
});
test('handles errors during file processing', async () => {
const { triggerConsumer } = setupLaunchQueue();
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
// Trigger with a file handle that throws an error
const errorFileHandle: MockFileHandle = {
kind: 'file',
name: 'error.csv',
getFile: async () => {
throw new Error('File access denied');
},
isSameEntry: async () => false,
queryPermission: async () => 'granted',
requestPermission: async () => 'granted',
};
await triggerConsumer({ files: [errorFileHandle] });
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
'Failed to open file. Please try again.',
);
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
});
});
test('modal close redirects to welcome page', async () => {
const fileHandle = createMockFileHandle('test.csv');
setupLaunchQueue(fileHandle);
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
const modal = await screen.findByTestId('upload-modal');
expect(modal).toBeInTheDocument();
// Click the close button in the mocked modal
const closeButton = screen.getByRole('button', { name: 'Close' });
closeButton.click();
await waitFor(() => {
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
});
});
test('shows loading state while waiting for file', () => {
setupLaunchQueue();
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
// Should show loading initially before file is processed
expect(screen.getByRole('status')).toBeInTheDocument();
});

View File

@@ -0,0 +1,138 @@
/**
* 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, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { t } from '@superset-ui/core';
import { Loading } from '@superset-ui/core/components';
import UploadDataModal from 'src/features/databases/UploadDataModel';
import withToasts from 'src/components/MessageToasts/withToasts';
interface FileLaunchParams {
readonly files?: readonly FileSystemFileHandle[];
}
interface LaunchQueue {
setConsumer: (consumer: (params: FileLaunchParams) => void) => void;
}
interface WindowWithLaunchQueue extends Window {
launchQueue?: LaunchQueue;
}
interface FileHandlerProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
}
const FileHandler = ({ addDangerToast, addSuccessToast }: FileHandlerProps) => {
const history = useHistory();
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadType, setUploadType] = useState<
'csv' | 'excel' | 'columnar' | null
>(null);
const [showModal, setShowModal] = useState(false);
const [allowedExtensions, setAllowedExtensions] = useState<string[]>([]);
useEffect(() => {
const handleFileLaunch = async () => {
const { launchQueue } = window as WindowWithLaunchQueue;
if (!launchQueue) {
addDangerToast(
t(
'File handling is not supported in this browser. Please use a modern browser like Chrome or Edge.',
),
);
history.push('/superset/welcome/');
return;
}
launchQueue.setConsumer(async (launchParams: FileLaunchParams) => {
if (!launchParams.files || launchParams.files.length === 0) {
history.push('/superset/welcome/');
return;
}
try {
const fileHandle = launchParams.files[0];
const file = await fileHandle.getFile();
const fileName = file.name.toLowerCase();
let type: 'csv' | 'excel' | 'columnar' | null = null;
let extensions: string[] = [];
if (fileName.endsWith('.csv')) {
type = 'csv';
extensions = ['csv'];
} else if (fileName.endsWith('.xls') || fileName.endsWith('.xlsx')) {
type = 'excel';
extensions = ['xls', 'xlsx'];
} else if (fileName.endsWith('.parquet')) {
type = 'columnar';
extensions = ['parquet'];
} else {
addDangerToast(
t(
'Unsupported file type. Please use CSV, Excel, or Columnar files.',
),
);
history.push('/superset/welcome/');
return;
}
setUploadFile(file);
setUploadType(type);
setAllowedExtensions(extensions);
setShowModal(true);
} catch (error) {
console.error('Error handling file launch:', error);
addDangerToast(t('Failed to open file. Please try again.'));
history.push('/superset/welcome/');
}
});
};
handleFileLaunch();
}, [history, addDangerToast]);
const handleModalClose = () => {
setShowModal(false);
setUploadFile(null);
setUploadType(null);
history.push('/superset/welcome/');
};
if (!uploadFile || !uploadType) {
return <Loading />;
}
return (
<UploadDataModal
show={showModal}
onHide={handleModalClose}
fileListOverride={[uploadFile]}
allowedExtensions={allowedExtensions}
type={uploadType}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
/>
);
};
export default withToasts(FileHandler);

View File

@@ -0,0 +1,65 @@
{
"name": "Apache Superset",
"short_name": "Superset",
"description": "Modern data exploration and visualization platform",
"start_url": "/superset/welcome/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#20a7c9",
"icons": [
{
"src": "/static/assets/images/pwa/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/assets/images/pwa/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/assets/images/pwa/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/assets/images/pwa/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/static/assets/images/pwa/screenshot-wide.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "Apache Superset Dashboard"
},
{
"src": "/static/assets/images/pwa/screenshot-narrow.png",
"sizes": "540x720",
"type": "image/png",
"form_factor": "narrow",
"label": "Apache Superset Mobile View"
}
],
"file_handlers": [
{
"action": "/superset/file-handler",
"accept": {
"text/csv": [".csv"],
"application/vnd.ms-excel": [".xls"],
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
".xlsx"
],
"application/vnd.apache.parquet": [".parquet"]
}
}
]
}

View File

@@ -0,0 +1,38 @@
/**
* 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.
*/
// Service Worker types (declared locally to avoid polluting global scope)
declare const self: {
skipWaiting(): Promise<void>;
clients: { claim(): Promise<void> };
addEventListener(
type: 'install' | 'activate',
listener: (event: { waitUntil(promise: Promise<unknown>): void }) => void,
): void;
};
self.addEventListener('install', event => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});
export {};

View File

@@ -171,6 +171,10 @@ const UserRegistrations = lazy(
),
);
const FileHandler = lazy(
() => import(/* webpackChunkName: "FileHandler" */ 'src/pages/FileHandler'),
);
type Routes = {
path: string;
Component: ComponentType;
@@ -199,6 +203,10 @@ export const routes: Routes = [
path: '/superset/welcome/',
Component: Home,
},
{
path: '/superset/file-handler',
Component: FileHandler,
},
{
path: '/dashboard/list/',
Component: DashboardList,

View File

@@ -71,20 +71,30 @@ const isDevServer = process.argv[1]?.includes('webpack-dev-server') ?? false;
// TypeScript checker memory limit (in MB)
const TYPESCRIPT_MEMORY_LIMIT = 4096;
const defaultEntryFilename = isDevMode
? '[name].[contenthash:8].entry.js'
: nameChunks
? '[name].[chunkhash].entry.js'
: '[name].[chunkhash].entry.js';
const defaultChunkFilename = isDevMode
? '[name].[contenthash:8].chunk.js'
: nameChunks
? '[name].[chunkhash].chunk.js'
: '[chunkhash].chunk.js';
const output = {
path: BUILD_DIR,
publicPath: '/static/assets/',
filename: pathData =>
pathData.chunk?.name === 'service-worker'
? '../service-worker.js'
: defaultEntryFilename,
chunkFilename: pathData =>
pathData.chunk?.name === 'service-worker'
? '../service-worker.js'
: defaultChunkFilename,
};
if (isDevMode) {
output.filename = '[name].[contenthash:8].entry.js';
output.chunkFilename = '[name].[contenthash:8].chunk.js';
} else if (nameChunks) {
output.filename = '[name].[chunkhash].entry.js';
output.chunkFilename = '[name].[chunkhash].chunk.js';
} else {
output.filename = '[name].[chunkhash].entry.js';
output.chunkFilename = '[chunkhash].chunk.js';
}
if (!isDevMode) {
output.clean = true;
@@ -139,7 +149,11 @@ const plugins = [
}),
new CopyPlugin({
patterns: ['package.json', { from: 'src/assets/images', to: 'images' }],
patterns: [
'package.json',
{ from: 'src/assets/images', to: 'images' },
{ from: 'src/pwa-manifest.json', to: 'pwa-manifest.json' },
],
}),
// static pages
@@ -184,7 +198,13 @@ if (!process.env.CI) {
// Add React Refresh plugin for development mode
if (isDevMode) {
plugins.push(new ReactRefreshWebpackPlugin());
plugins.push(
new ReactRefreshWebpackPlugin({
// Exclude service worker from React Refresh - it runs in a worker context
// without DOM/window and doesn't need HMR
exclude: /service-worker/,
}),
);
}
if (!isDevMode) {
@@ -300,6 +320,7 @@ const config = {
menu: addPreamble('src/views/menu.tsx'),
spa: addPreamble('/src/views/index.tsx'),
embedded: addPreamble('/src/embedded/index.tsx'),
'service-worker': path.join(APP_DIR, 'src/service-worker.ts'),
},
cache: {
type: 'filesystem', // Enable filesystem caching

View File

@@ -634,7 +634,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
def register_request_handlers(self) -> None:
"""Register app-level request handlers"""
from flask import Response
from flask import request, Response
@self.superset_app.after_request
def apply_http_headers(response: Response) -> Response:
@@ -650,6 +650,14 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
for k, v in self.superset_app.config["DEFAULT_HTTP_HEADERS"].items():
if k not in response.headers:
response.headers[k] = v
# Allow service worker to control the root scope for PWA file handling
if (
request.path.endswith("service-worker.js")
and "Service-Worker-Allowed" not in response.headers
):
response.headers["Service-Worker-Allowed"] = "/"
return response
@self.superset_app.after_request

View File

@@ -0,0 +1,27 @@
/**
* 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.
*/
// Minimal service worker for PWA file handling support
self.addEventListener('install', event => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});

View File

@@ -28,7 +28,9 @@
{% endblock %}
</title>
{% block head_meta %}{% endblock %}
{% block head_meta %}
<link rel="manifest" href="{{ assets_prefix }}/static/assets/pwa-manifest.json?v=4">
{% endblock %}
<style>
body {
@@ -73,6 +75,17 @@
/>
{% block head_js %}
{% include "head_custom_extra.html" %}
<script nonce="{{ macros.get_nonce() }}">
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker
.register('{{ assets_prefix }}/static/service-worker.js')
.catch(function(err) {
console.error('Service Worker registration failed:', err);
});
});
}
</script>
{% endblock %}
</head>

View File

@@ -908,6 +908,21 @@ class Superset(BaseSupersetView):
return self.render_app_template(extra_bootstrap_data=payload)
@has_access
@event_logger.log_this
@expose("/file-handler")
def file_handler(self) -> FlaskResponse:
"""File handler page for PWA file handling"""
if not g.user or not get_user_id():
return redirect_to_login()
payload = {
"user": bootstrap_user_data(g.user, include_perms=True),
"common": common_bootstrap_payload(),
}
return self.render_app_template(extra_bootstrap_data=payload)
@has_access
@event_logger.log_this
@expose("/sqllab/history/", methods=("GET",))