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 "",

Reply via email to