You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by en...@apache.org on 2026/05/28 10:23:52 UTC
(superset) 02/14: feat(extensions): port import/delete UI and backend endpoints to test branch
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch test/chatbot-local
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 97ba65b0cd161e3e41b1df062d1fc2aeeca9b914
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Tue May 26 21:30:32 2026 +0200
feat(extensions): port import/delete UI and backend endpoints to test branch
- POST /api/v1/extensions/ — admin upload of .supx bundles
- DELETE /api/v1/extensions/<publisher>/<name> — admin removal
- Import button in ExtensionsList SubMenu (file picker, .supx only)
- Per-row delete action with confirmation dialog
- Keeps existing Select (default chatbot) and Switch (enabled) UI
- Add publisher to build_extension_data response
- Add subscribeToLocation / getRegisteredViewIds / getViewProvider to
src/core/views/index.ts so ExtensionsList can detect chatbot extensions
- Fix scripts/oxlint.sh set -e / [ -n "" ] false-positive exit
- Fix settings.py MySQL fallback: use read-then-update instead of
try/except/rollback to satisfy the custom consider-using-transaction rule
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
scripts/oxlint.sh | 2 +-
.../src/extensions/ExtensionsList.tsx | 145 +++++++++++++++++-
superset/extensions/api.py | 169 ++++++++++++++++++++-
superset/extensions/settings.py | 48 +++---
superset/extensions/utils.py | 1 +
5 files changed, 327 insertions(+), 38 deletions(-)
diff --git a/scripts/oxlint.sh b/scripts/oxlint.sh
index 95f48afabb6..9baf026ddf2 100755
--- a/scripts/oxlint.sh
+++ b/scripts/oxlint.sh
@@ -55,7 +55,7 @@ if [ ${#js_ts_files[@]} -gt 0 ]; then
echo "$output" >&2
exit 1
}
- [ -n "$output" ] && echo "$output"
+ if [ -n "$output" ]; then echo "$output"; fi
else
echo "No JavaScript/TypeScript files to lint"
fi
diff --git a/superset-frontend/src/extensions/ExtensionsList.tsx b/superset-frontend/src/extensions/ExtensionsList.tsx
index e7974c76817..e31b14a87f2 100644
--- a/superset-frontend/src/extensions/ExtensionsList.tsx
+++ b/superset-frontend/src/extensions/ExtensionsList.tsx
@@ -18,11 +18,20 @@
*/
import { t } from '@apache-superset/core/translation';
import { css } from '@apache-superset/core/theme';
-import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ FunctionComponent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import { SupersetClient } from '@superset-ui/core';
-import { Select } from '@superset-ui/core/components';
+import { ConfirmStatusChange, Select, Tooltip } from '@superset-ui/core/components';
import { Switch } from '@superset-ui/core/components/Switch';
+import { Icons } from '@superset-ui/core/components/Icons';
import { useListViewResource } from 'src/views/CRUD/hooks';
+import { createErrorHandler } from 'src/views/CRUD/utils';
import { ListView } from 'src/components';
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
import withToasts from 'src/components/MessageToasts/withToasts';
@@ -34,6 +43,7 @@ const PAGE_SIZE = 25;
type Extension = {
id: string;
name: string;
+ publisher: string;
enabled: boolean;
};
@@ -51,6 +61,9 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
addDangerToast,
addSuccessToast,
}) => {
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const [uploading, setUploading] = useState(false);
+
const {
state: { loading, resourceCount, resourceCollection },
fetchData,
@@ -107,8 +120,67 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
const chatbotExtensions = useMemo(() => {
const chatbotIds = new Set(getRegisteredViewIds(CHATBOT_LOCATION));
return resourceCollection.filter(ext => chatbotIds.has(ext.id));
+ // chatbotRegistryVersion is intentionally in deps to re-evaluate when views register
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [resourceCollection, chatbotRegistryVersion]);
+ const handleUploadClick = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ if (!file.name.endsWith('.supx')) {
+ addDangerToast(t('File must have a .supx extension.'));
+ e.target.value = '';
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append('bundle', file);
+
+ setUploading(true);
+ SupersetClient.post({
+ endpoint: '/api/v1/extensions/',
+ body: formData,
+ headers: { Accept: 'application/json' },
+ })
+ .then(() => {
+ addSuccessToast(t('Extension installed successfully.'));
+ refreshData();
+ })
+ .catch(
+ createErrorHandler(errMsg =>
+ addDangerToast(
+ t('There was an issue installing the extension: %s', errMsg),
+ ),
+ ),
+ )
+ .finally(() => {
+ setUploading(false);
+ e.target.value = '';
+ });
+ };
+
+ const handleDelete = (extension: Extension) => {
+ const { publisher, name } = extension;
+ SupersetClient.delete({
+ endpoint: `/api/v1/extensions/${publisher}/${name}`,
+ }).then(
+ () => {
+ addSuccessToast(t('Deleted: %s', extension.name));
+ refreshData();
+ },
+ createErrorHandler(errMsg =>
+ addDangerToast(
+ t('There was an issue deleting %s: %s', extension.name, errMsg),
+ ),
+ ),
+ );
+ };
+
const columns = useMemo(
() => [
{
@@ -117,7 +189,9 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
size: 'lg',
id: 'name',
Cell: ({
- row: { original: { name } },
+ row: {
+ original: { name },
+ },
}: any) => name,
},
{
@@ -126,7 +200,9 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
size: 'sm',
id: 'enabled',
Cell: ({
- row: { original: { id } },
+ row: {
+ original: { id },
+ },
}: any) => (
<Switch
data-test="toggle-enabled"
@@ -136,6 +212,39 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
/>
),
},
+ {
+ Header: t('Actions'),
+ id: 'actions',
+ disableSortBy: true,
+ Cell: ({ row: { original } }: any) => (
+ <ConfirmStatusChange
+ title={t('Please confirm')}
+ description={
+ <>
+ {t('Are you sure you want to delete')} <b>{original.name}</b>?
+ </>
+ }
+ onConfirm={() => handleDelete(original)}
+ >
+ {(confirmDelete: () => void) => (
+ <Tooltip
+ id="delete-extension-tooltip"
+ title={t('Delete')}
+ placement="bottom"
+ >
+ <span
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={confirmDelete}
+ >
+ <Icons.DeleteOutlined iconSize="l" />
+ </span>
+ </Tooltip>
+ )}
+ </ConfirmStatusChange>
+ ),
+ },
],
[loading, settings, toggleEnabled],
);
@@ -148,11 +257,33 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
const menuData: SubMenuProps = {
activeChild: 'Extensions',
name: t('Extensions'),
- buttons: [],
+ buttons: [
+ {
+ name: (
+ <Tooltip
+ id="import-extension-tooltip"
+ title={t('Import extension (.supx)')}
+ placement="bottomRight"
+ >
+ <Icons.DownloadOutlined iconSize="l" />
+ </Tooltip>
+ ),
+ buttonStyle: 'link',
+ onClick: handleUploadClick,
+ loading: uploading,
+ },
+ ],
};
return (
<>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".supx"
+ style={{ display: 'none' }}
+ onChange={handleFileChange}
+ />
<SubMenu {...menuData} />
{chatbotOptions.length > 1 && (
<div style={{ padding: '16px 24px' }}>
@@ -167,7 +298,9 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
saveSettings({ active_chatbot_id: (value as string) ?? null })
}
placeholder={t('First registered (automatic)')}
- css={css`width: 280px;`}
+ css={css`
+ width: 280px;
+ `}
/>
</div>
)}
diff --git a/superset/extensions/api.py b/superset/extensions/api.py
index d116c6ca70e..380e2c94165 100644
--- a/superset/extensions/api.py
+++ b/superset/extensions/api.py
@@ -16,9 +16,11 @@
# under the License.
import mimetypes
from io import BytesIO
+from pathlib import Path
from typing import Any
+from zipfile import is_zipfile, ZipFile
-from flask import request, send_file
+from flask import current_app, request, send_file
from flask.wrappers import Response
from flask_appbuilder.api import BaseApi, expose, protect, safe
@@ -29,13 +31,28 @@ from superset.extensions.settings import (
)
from superset.extensions.utils import (
build_extension_data,
+ get_bundle_files_from_zip,
get_extensions,
+ get_loaded_extension,
)
+from superset.utils.core import check_is_safe_zip
class ExtensionsRestApi(BaseApi):
allow_browser_login = True
resource_name = "extensions"
+ class_permission_name = "Extensions"
+ base_permissions = [
+ "can_get_list",
+ "can_get",
+ "can_put",
+ "can_post",
+ "can_delete",
+ "can_content",
+ "can_info",
+ "can_get_settings",
+ "can_put_settings",
+ ]
def response(self, status_code: int, **kwargs: Any) -> Response:
"""Helper method to create JSON responses."""
@@ -172,6 +189,156 @@ class ExtensionsRestApi(BaseApi):
extension_data = build_extension_data(extension)
return self.response(200, result=extension_data)
+ @protect()
+ @safe
+ @expose("/", methods=("POST",))
+ def post(self, **kwargs: Any) -> Response:
+ """Upload and install an extension bundle (.supx file).
+ ---
+ post:
+ summary: Upload a .supx extension bundle.
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ properties:
+ bundle:
+ type: string
+ format: binary
+ description: The .supx extension bundle file.
+ responses:
+ 201:
+ description: Extension installed successfully.
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ result:
+ type: object
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 403:
+ $ref: '#/components/responses/403'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ if not security_manager.is_admin():
+ return self.response(403, message="Admin access required.")
+
+ extensions_path = current_app.config.get("EXTENSIONS_PATH")
+ if not extensions_path:
+ return self.response(
+ 400,
+ message=(
+ "EXTENSIONS_PATH is not configured. Set it in superset_config.py "
+ "to enable extension uploads."
+ ),
+ )
+
+ upload = request.files.get("bundle")
+ if not upload:
+ return self.response(
+ 400, message="No file provided. Send a 'bundle' field."
+ )
+
+ if not upload.filename or not upload.filename.endswith(".supx"):
+ return self.response(400, message="File must have a .supx extension.")
+
+ stream = BytesIO(upload.read())
+ if not is_zipfile(stream):
+ return self.response(400, message="File is not a valid ZIP archive.")
+
+ stream.seek(0)
+ try:
+ with ZipFile(stream, "r") as zip_file:
+ check_is_safe_zip(zip_file)
+ files = list(get_bundle_files_from_zip(zip_file))
+ extension = get_loaded_extension(files, source_base_path="upload://")
+ except Exception as ex: # pylint: disable=broad-except
+ return self.response(400, message=f"Invalid extension bundle: {ex}")
+
+ # Persist to EXTENSIONS_PATH so the extension survives restarts.
+ dest_dir = Path(extensions_path)
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ dest_file = dest_dir / f"{extension.manifest.id}.supx"
+
+ stream.seek(0)
+ dest_file.write_bytes(stream.read())
+
+ return self.response(201, result=build_extension_data(extension))
+
+ @protect()
+ @safe
+ @expose("/<publisher>/<name>", methods=("DELETE",))
+ def delete(self, publisher: str, name: str, **kwargs: Any) -> Response:
+ """Delete an uploaded extension bundle.
+ ---
+ delete:
+ summary: Delete an extension by its publisher and name.
+ parameters:
+ - in: path
+ schema:
+ type: string
+ name: publisher
+ - in: path
+ schema:
+ type: string
+ name: name
+ responses:
+ 200:
+ description: Extension deleted.
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 403:
+ $ref: '#/components/responses/403'
+ 404:
+ $ref: '#/components/responses/404'
+ """
+ if not security_manager.is_admin():
+ return self.response(403, message="Admin access required.")
+
+ composite_id = f"{publisher}.{name}"
+ extensions = get_extensions()
+ extension = extensions.get(composite_id)
+ if not extension:
+ return self.response_404()
+
+ # LOCAL_EXTENSIONS are managed via config — cannot be deleted through the UI.
+ local_paths = {
+ str((Path(p) / "dist").resolve())
+ for p in current_app.config.get("LOCAL_EXTENSIONS", [])
+ }
+ if extension.source_base_path in local_paths:
+ return self.response(
+ 400,
+ message=(
+ "Local extensions configured via LOCAL_EXTENSIONS cannot be "
+ "deleted through the UI. Remove them from your configuration."
+ ),
+ )
+
+ # Locate and remove the .supx file from EXTENSIONS_PATH.
+ extensions_path = current_app.config.get("EXTENSIONS_PATH")
+ if not extensions_path:
+ return self.response(
+ 400,
+ message="EXTENSIONS_PATH is not configured; cannot remove bundle file.",
+ )
+
+ supx_file = Path(extensions_path) / f"{composite_id}.supx"
+ if not supx_file.exists():
+ return self.response_404()
+
+ supx_file.unlink()
+ return self.response(200, message="Extension deleted.")
+
@protect()
@safe
@expose("/settings", methods=("GET",))
diff --git a/superset/extensions/settings.py b/superset/extensions/settings.py
index 1224c9da9fa..367ab4aef44 100644
--- a/superset/extensions/settings.py
+++ b/superset/extensions/settings.py
@@ -21,7 +21,6 @@ from typing import Any
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
-from sqlalchemy.exc import IntegrityError
from superset import db
from superset.models.core import ExtensionEnabled, ExtensionSettings
@@ -65,20 +64,15 @@ def _upsert_settings_row(
)
db.session.execute(stmt)
else:
- # MySQL/MariaDB and other dialects: INSERT then UPDATE on conflict.
- try:
- db.session.execute(
- ExtensionSettings.__table__.insert().values(
- id=_SETTINGS_ROW_ID, active_chatbot_id=active_chatbot_id
- )
- )
- except IntegrityError:
- db.session.rollback()
- db.session.execute(
- ExtensionSettings.__table__.update()
- .where(ExtensionSettings.__table__.c.id == _SETTINGS_ROW_ID)
- .values(active_chatbot_id=active_chatbot_id)
+ # MySQL/MariaDB: session.merge handles INSERT-or-UPDATE without rollback.
+ obj = db.session.get(ExtensionSettings, _SETTINGS_ROW_ID)
+ if obj is None:
+ obj = ExtensionSettings(
+ id=_SETTINGS_ROW_ID, active_chatbot_id=active_chatbot_id
)
+ db.session.add(obj)
+ else:
+ obj.active_chatbot_id = active_chatbot_id
def _upsert_enabled_flag(extension_id: str, enabled: bool) -> None:
@@ -105,22 +99,16 @@ def _upsert_enabled_flag(extension_id: str, enabled: bool) -> None:
)
db.session.execute(stmt)
else:
- # MySQL/MariaDB and other dialects: INSERT then UPDATE on conflict.
- try:
- db.session.execute(
- ExtensionEnabled.__table__.insert().values(
- extension_id=extension_id, enabled=enabled
- )
- )
- except IntegrityError:
- db.session.rollback()
- db.session.execute(
- ExtensionEnabled.__table__.update()
- .where(
- ExtensionEnabled.__table__.c.extension_id == extension_id
- )
- .values(enabled=enabled)
- )
+ # MySQL/MariaDB: read-then-update without rollback.
+ obj = (
+ db.session.query(ExtensionEnabled)
+ .filter_by(extension_id=extension_id)
+ .first()
+ )
+ if obj is None:
+ db.session.add(ExtensionEnabled(extension_id=extension_id, enabled=enabled))
+ else:
+ obj.enabled = enabled
@transaction()
diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py
index 6332c97435d..878fbf0bb75 100644
--- a/superset/extensions/utils.py
+++ b/superset/extensions/utils.py
@@ -234,6 +234,7 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
manifest = extension.manifest
extension_data: dict[str, Any] = {
"id": manifest.id,
+ "publisher": manifest.publisher,
"name": extension.name,
"version": extension.version,
"description": manifest.description or "",