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 <[email protected]> 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 <[email protected]> --- 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 "",
