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/06/11 08:26:49 UTC
(superset) branch enxdev/chatbot-drop-settings-layer created (now af5eca38f5b)
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a change to branch enxdev/chatbot-drop-settings-layer
in repository https://gitbox.apache.org/repos/asf/superset.git
at af5eca38f5b refactor(chatbot): drop extension settings layer; resolve last-loaded chatbot
This branch includes the following new commits:
new af5eca38f5b refactor(chatbot): drop extension settings layer; resolve last-loaded chatbot
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 01/01: refactor(chatbot): drop extension settings layer; resolve last-loaded chatbot
Posted by en...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch enxdev/chatbot-drop-settings-layer
in repository https://gitbox.apache.org/repos/asf/superset.git
commit af5eca38f5b955b73a16e0c04970d904c4cfcc76
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Thu Jun 11 10:21:36 2026 +0200
refactor(chatbot): drop extension settings layer; resolve last-loaded chatbot
Remove the extension admin-settings layer entirely. With the Extensions admin
page and the settings write path already gone, the only remaining use of
GET /api/v1/extensions/settings was reading active_chatbot_id — a pin nothing
in the product could set (DB-only). Rather than keep a read endpoint for an
unsettable value, drop the whole layer and resolve the active chatbot purely
from the view registry: the most-recently-registered (last-loaded) chatbot wins.
Frontend:
- getActiveChatbot() takes no args and returns the last-registered chatbot at
superset.chatbot (was: first-registered + optional admin pin).
- ChatbotMount resolves from the view registry alone — no settings fetch, no
loading gate, no settings store subscription.
- Remove the settings store from core/extensions (ExtensionSettings type,
SETTINGS_ENDPOINT, load/subscribe/snapshot); keep the extensions namespace.
Backend:
- Remove GET /settings route + can_get_settings permission from the API.
- Delete the settings command package, ExtensionSettingsDAO/get_extension_settings,
and the ExtensionSettings model; drop its registration import.
- Migration e2f3a4b5c6d7 drops the extension_settings table (reversible).
Behavior change: there is no admin chatbot pin anymore. When multiple chatbot
extensions load, the last one to register takes the singleton bubble.
Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
.../components/ChatbotMount/ChatbotMount.test.tsx | 41 ++++--------
.../src/components/ChatbotMount/index.tsx | 46 +++----------
superset-frontend/src/core/chatbot/index.test.ts | 55 +++-------------
superset-frontend/src/core/chatbot/index.ts | 23 ++-----
superset-frontend/src/core/extensions/index.ts | 59 -----------------
superset/commands/extension/settings/__init__.py | 16 -----
superset/commands/extension/settings/get.py | 29 --------
superset/daos/extension.py | 44 -------------
superset/extensions/api.py | 19 ------
..._00-00_e2f3a4b5c6d7_drop_extension_settings.py} | 34 +++++++---
superset/models/__init__.py | 2 -
tests/unit_tests/extensions/test_settings.py | 77 ----------------------
12 files changed, 62 insertions(+), 383 deletions(-)
diff --git a/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx
index 25a05367715..683e32b6592 100644
--- a/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx
+++ b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx
@@ -17,39 +17,24 @@
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
-import { SupersetClient } from '@superset-ui/core';
import { views } from 'src/core';
-import { loadExtensionSettings } from 'src/core/extensions';
import { CHATBOT_LOCATION } from 'src/views/contributions';
import ChatbotMount from '.';
const disposables: Array<{ dispose: () => void }> = [];
-beforeEach(async () => {
- // The settings store is a module singleton; reset it to the empty default
- // (no admin pin) before each test by loading from a mocked API response.
- jest.spyOn(SupersetClient, 'get').mockResolvedValue({
- json: { result: { active_chatbot_id: null } },
- } as any);
- await loadExtensionSettings();
-});
-
afterEach(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
- jest.restoreAllMocks();
});
-test('renders nothing when no chatbot extension is registered', async () => {
+test('renders nothing when no chatbot extension is registered', () => {
render(<ChatbotMount />);
- // Wait a tick for the settings load to resolve; the corner must stay empty
- // even after the gate opens (no chatbot registered → nothing to render).
- await Promise.resolve();
expect(screen.queryByTestId('chatbot-mount')).not.toBeInTheDocument();
});
-test('renders the registered chatbot inside the fixed mount slot', async () => {
+test('renders the registered chatbot inside the fixed mount slot', () => {
const provider = () => <div>My Chatbot Bubble</div>;
disposables.push(
views.registerView(
@@ -61,12 +46,11 @@ test('renders the registered chatbot inside the fixed mount slot', async () => {
render(<ChatbotMount />);
- // findBy* awaits the re-render after the initial settings load resolves.
- expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument();
+ expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument();
expect(screen.getByText('My Chatbot Bubble')).toBeInTheDocument();
});
-test('renders only the first-to-register chatbot when several are installed', async () => {
+test('renders the last-registered chatbot when several are installed', () => {
const firstProvider = () => <div>First Bubble</div>;
const secondProvider = () => <div>Second Bubble</div>;
disposables.push(
@@ -84,11 +68,12 @@ test('renders only the first-to-register chatbot when several are installed', as
render(<ChatbotMount />);
- expect(await screen.findByText('First Bubble')).toBeInTheDocument();
- expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
+ // Last-loaded wins: the second registration takes over the singleton bubble.
+ expect(screen.getByText('Second Bubble')).toBeInTheDocument();
+ expect(screen.queryByText('First Bubble')).not.toBeInTheDocument();
});
-test('isolates a failing chatbot so it does not crash the host', async () => {
+test('isolates a failing chatbot so it does not crash the host', () => {
const FailingChatbot = () => {
throw new Error('chatbot blew up');
};
@@ -102,12 +87,12 @@ test('isolates a failing chatbot so it does not crash the host', async () => {
// The host-owned error boundary catches the failure; render does not throw.
expect(() => render(<ChatbotMount />)).not.toThrow();
- // The mount slot still renders post-gate (the boundary lives inside it);
- // awaiting it confirms the provider was actually exercised and contained.
- expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument();
+ // The mount slot still renders (the boundary lives inside it), confirming the
+ // provider was actually exercised and contained.
+ expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument();
});
-test('isolates a chatbot whose provider function itself throws', async () => {
+test('isolates a chatbot whose provider function itself throws', () => {
disposables.push(
views.registerView(
{ id: 'core.chatbot', name: 'Superset Chatbot' },
@@ -121,5 +106,5 @@ test('isolates a chatbot whose provider function itself throws', async () => {
// ChatbotRenderer wraps provider() in a component so ErrorBoundary catches
// synchronous throws from the provider function, not just from its output.
expect(() => render(<ChatbotMount />)).not.toThrow();
- expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument();
+ expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument();
});
diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx b/superset-frontend/src/components/ChatbotMount/index.tsx
index af213baa61e..ddc430b563b 100644
--- a/superset-frontend/src/components/ChatbotMount/index.tsx
+++ b/superset-frontend/src/components/ChatbotMount/index.tsx
@@ -18,10 +18,8 @@
*/
import {
type ReactElement,
- useEffect,
useMemo,
useRef,
- useState,
useSyncExternalStore,
} from 'react';
import { t } from '@apache-superset/core/translation';
@@ -32,11 +30,6 @@ import { addDangerToast } from 'src/components/MessageToasts/actions';
import { store } from 'src/views/store';
import { getActiveChatbot } from 'src/core/chatbot';
import { subscribeToRegistry, getRegistryVersion } from 'src/core/views';
-import {
- getExtensionSettingsSnapshot,
- loadExtensionSettings,
- subscribeToExtensionSettings,
-} from 'src/core/extensions';
const CHATBOT_EDGE_MARGIN = 24;
@@ -53,41 +46,18 @@ const ChatbotMount = () => {
const theme = useTheme();
// Notify once per mount; a crash can re-render and would otherwise re-toast.
const crashNotified = useRef(false);
- // Defer chatbot resolution until the first settings load resolves. Otherwise
- // the initial empty-default snapshot (no pin) would briefly resolve the
- // first-registered chatbot even when the DB pins a different one, mounting
- // the wrong provider until the async settings response arrives.
- const [settingsLoaded, setSettingsLoaded] = useState(false);
- // The active chatbot is a function of two host-owned stores: the admin
- // settings (active chatbot id) and the view registry (which chatbots are
- // registered). Both are read via useSyncExternalStore so this re-resolves
- // when either changes — no local copy of the settings state.
- const settings = useSyncExternalStore(
- subscribeToExtensionSettings,
- getExtensionSettingsSnapshot,
- );
+ // The active chatbot is a function of the view registry alone: the
+ // most-recently-registered chatbot wins. Read via useSyncExternalStore so it
+ // re-resolves whenever a chatbot extension registers or disposes.
const registryVersion = useSyncExternalStore(
subscribeToRegistry,
getRegistryVersion,
);
- useEffect(() => {
- // Settings fetch failure is non-fatal: the store keeps its empty default,
- // which getActiveChatbot treats as "no admin pin" (falls back to the
- // first-registered chatbot). Either way, unblock rendering once the request
- // settles so a failed fetch never permanently hides the chatbot.
- loadExtensionSettings()
- .catch(() => {})
- .finally(() => setSettingsLoaded(true));
- }, []);
-
- const activeChatbot = useMemo(
- () => getActiveChatbot(settings.active_chatbot_id),
- [settings, registryVersion],
- );
+ const activeChatbot = useMemo(() => getActiveChatbot(), [registryVersion]);
- if (!settingsLoaded || !activeChatbot) {
+ if (!activeChatbot) {
return null;
}
@@ -105,9 +75,9 @@ const ChatbotMount = () => {
<ErrorBoundary
showMessage={false}
onError={(error: Error) => {
- // Fault isolation (SIP §4.5): contain the crash, log it, surface a
- // one-time notification, and leave the corner empty rather than
- // parking a persistent error card.
+ // Fault isolation: contain the crash, log it, surface a one-time
+ // notification, and leave the corner empty rather than parking a
+ // persistent error card.
logging.error('[chatbot] provider crashed', error);
if (!crashNotified.current) {
crashNotified.current = true;
diff --git a/superset-frontend/src/core/chatbot/index.test.ts b/superset-frontend/src/core/chatbot/index.test.ts
index aedbee95ed5..7b6eb00b0ac 100644
--- a/superset-frontend/src/core/chatbot/index.test.ts
+++ b/superset-frontend/src/core/chatbot/index.test.ts
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import React from 'react';
+import { createElement } from 'react';
import { views } from 'src/core/views';
import { CHATBOT_LOCATION } from 'src/views/contributions';
import { getActiveChatbot } from './index';
@@ -33,7 +33,7 @@ test('getActiveChatbot returns undefined when no chatbot is registered', () => {
});
test('getActiveChatbot resolves the single registered chatbot', () => {
- const provider = () => React.createElement('div', null, 'Chatbot');
+ const provider = () => createElement('div', null, 'Chatbot');
disposables.push(
views.registerView(
{ id: 'core.chatbot', name: 'Superset Chatbot' },
@@ -46,9 +46,9 @@ test('getActiveChatbot resolves the single registered chatbot', () => {
expect(active).toEqual({ id: 'core.chatbot', provider });
});
-test('getActiveChatbot picks the first-to-register when multiple are installed', () => {
- const firstProvider = () => React.createElement('div', null, 'First');
- const secondProvider = () => React.createElement('div', null, 'Second');
+test('getActiveChatbot picks the last-to-register when multiple are installed', () => {
+ const firstProvider = () => createElement('div', null, 'First');
+ const secondProvider = () => createElement('div', null, 'Second');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First Chatbot' },
@@ -62,13 +62,14 @@ test('getActiveChatbot picks the first-to-register when multiple are installed',
),
);
+ // Last-loaded wins: the most-recently-registered chatbot takes the slot.
const active = getActiveChatbot();
- expect(active?.id).toBe('first.chatbot');
- expect(active?.provider).toBe(firstProvider);
+ expect(active?.id).toBe('second.chatbot');
+ expect(active?.provider).toBe(secondProvider);
});
test('getActiveChatbot ignores views registered at other locations', () => {
- const provider = () => React.createElement('div', null, 'Panel');
+ const provider = () => createElement('div', null, 'Panel');
disposables.push(
views.registerView(
{ id: 'some.panel', name: 'Some Panel' },
@@ -81,7 +82,7 @@ test('getActiveChatbot ignores views registered at other locations', () => {
});
test('getActiveChatbot stops resolving a chatbot once it is disposed', () => {
- const provider = () => React.createElement('div', null, 'Chatbot');
+ const provider = () => createElement('div', null, 'Chatbot');
const disposable = views.registerView(
{ id: 'core.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
@@ -94,39 +95,3 @@ test('getActiveChatbot stops resolving a chatbot once it is disposed', () => {
expect(getActiveChatbot()).toBeUndefined();
});
-
-test('getActiveChatbot honours the admin-pinned selection', () => {
- const firstProvider = () => React.createElement('div', null, 'First');
- const secondProvider = () => React.createElement('div', null, 'Second');
- disposables.push(
- views.registerView(
- { id: 'first.chatbot', name: 'First Chatbot' },
- CHATBOT_LOCATION,
- firstProvider,
- ),
- views.registerView(
- { id: 'second.chatbot', name: 'Second Chatbot' },
- CHATBOT_LOCATION,
- secondProvider,
- ),
- );
-
- const active = getActiveChatbot('second.chatbot');
- expect(active?.id).toBe('second.chatbot');
- expect(active?.provider).toBe(secondProvider);
-});
-
-test('getActiveChatbot falls back to first-registered when pinned id is unknown', () => {
- const provider = () => React.createElement('div', null, 'First');
- disposables.push(
- views.registerView(
- { id: 'first.chatbot', name: 'First Chatbot' },
- CHATBOT_LOCATION,
- provider,
- ),
- );
-
- // 'stale.chatbot' was once the admin pin but is no longer registered.
- const active = getActiveChatbot('stale.chatbot');
- expect(active?.id).toBe('first.chatbot');
-});
diff --git a/superset-frontend/src/core/chatbot/index.ts b/superset-frontend/src/core/chatbot/index.ts
index 243c0820dbd..eded52c85a3 100644
--- a/superset-frontend/src/core/chatbot/index.ts
+++ b/superset-frontend/src/core/chatbot/index.ts
@@ -48,31 +48,22 @@ export interface ActiveChatbot {
*
* Selection policy:
* - If no chatbot is registered, returns `undefined` — the corner stays empty.
- * - If `adminSelectedId` matches a registered chatbot, that one wins.
- * - Otherwise the first-registered chatbot is used as a fallback.
- * The active chatbot pin is set only via the backend DB; when no pin is set
- * (active_chatbot_id is null), the fallback is the first-registered chatbot.
+ * - Otherwise the most-recently-registered (last-loaded) chatbot wins. When a
+ * second chatbot extension loads, it takes over the singleton bubble.
*
- * @param adminSelectedId The id stored in the DB "Default chatbot" setting, if any.
* @returns The active chatbot's id and provider, or `undefined` if none.
*/
-export const getActiveChatbot = (
- adminSelectedId?: string | null,
-): ActiveChatbot | undefined => {
+export const getActiveChatbot = (): ActiveChatbot | undefined => {
const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION);
if (registeredIds.length === 0) {
return undefined;
}
- // When the DB pin names a registered candidate, use it; otherwise fall back
- // to the first registered chatbot in registration order.
- // `getRegisteredViewIds` and `getViewProvider` read the same synchronous
- // registry maps, so a candidate id always has a live provider; the final
+ // `getRegisteredViewIds` returns ids in registration order, so the last entry
+ // is the most-recently-loaded chatbot. `getViewProvider` reads the same
+ // synchronous registry maps, so the id always has a live provider; the final
// guard is cheap defensiveness, not a fallback path.
- const selectedId =
- adminSelectedId && registeredIds.includes(adminSelectedId)
- ? adminSelectedId
- : registeredIds[0];
+ const selectedId = registeredIds[registeredIds.length - 1];
const provider = getViewProvider(CHATBOT_LOCATION, selectedId);
return provider ? { id: selectedId, provider } : undefined;
diff --git a/superset-frontend/src/core/extensions/index.ts b/superset-frontend/src/core/extensions/index.ts
index 2474e212c6b..ae49a135aed 100644
--- a/superset-frontend/src/core/extensions/index.ts
+++ b/superset-frontend/src/core/extensions/index.ts
@@ -17,7 +17,6 @@
* under the License.
*/
import { extensions as extensionsApi } from '@apache-superset/core';
-import { SupersetClient } from '@superset-ui/core';
import ExtensionsLoader from 'src/extensions/ExtensionsLoader';
const getExtension: typeof extensionsApi.getExtension = id =>
@@ -30,61 +29,3 @@ export const extensions: typeof extensionsApi = {
getExtension,
getAllExtensions,
};
-
-/**
- * Deployment-wide extension admin settings. The keys are snake_case to match
- * the `/api/v1/extensions/settings` wire shape this store loads from.
- * Settings are read-only from the frontend; the admin write path has been
- * removed in favour of direct backend configuration.
- */
-export type ExtensionSettings = {
- active_chatbot_id: string | null;
-};
-
-const SETTINGS_ENDPOINT = '/api/v1/extensions/settings';
-
-const EMPTY_SETTINGS: ExtensionSettings = {
- active_chatbot_id: null,
-};
-
-/**
- * Single module-level store for extension admin settings. The chatbot mount
- * reads this one source via `useSyncExternalStore` so it re-resolves when the
- * store is updated — no bespoke second notification channel needed.
- */
-let settings: ExtensionSettings = EMPTY_SETTINGS;
-const settingsListeners = new Set<() => void>();
-
-const emitSettingsChange = (): void => {
- settingsListeners.forEach(fn => fn());
-};
-
-/** Subscribe to settings changes (for `useSyncExternalStore`). */
-export const subscribeToExtensionSettings = (
- listener: () => void,
-): (() => void) => {
- settingsListeners.add(listener);
- return () => {
- settingsListeners.delete(listener);
- };
-};
-
-/** Current settings snapshot (for `useSyncExternalStore`). */
-export const getExtensionSettingsSnapshot = (): ExtensionSettings => settings;
-
-/** Replace the settings snapshot and notify subscribers. Module-private; only loadExtensionSettings should call this. */
-const applyExtensionSettings = (next: ExtensionSettings): void => {
- settings = next;
- emitSettingsChange();
-};
-
-/**
- * Fetch settings from the server into the store. Resolves to the loaded value;
- * on failure the store is left untouched and the error is rethrown so callers
- * can surface it.
- */
-export const loadExtensionSettings = async (): Promise<ExtensionSettings> => {
- const { json } = await SupersetClient.get({ endpoint: SETTINGS_ENDPOINT });
- applyExtensionSettings(json.result ?? EMPTY_SETTINGS);
- return settings;
-};
diff --git a/superset/commands/extension/settings/__init__.py b/superset/commands/extension/settings/__init__.py
deleted file mode 100644
index 13a83393a91..00000000000
--- a/superset/commands/extension/settings/__init__.py
+++ /dev/null
@@ -1,16 +0,0 @@
-# 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.
diff --git a/superset/commands/extension/settings/get.py b/superset/commands/extension/settings/get.py
deleted file mode 100644
index 401dbfb449e..00000000000
--- a/superset/commands/extension/settings/get.py
+++ /dev/null
@@ -1,29 +0,0 @@
-# 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.
-from typing import Any
-
-from superset.commands.base import BaseCommand
-from superset.daos.extension import get_extension_settings
-
-
-class GetExtensionSettingsCommand(BaseCommand):
- def run(self) -> dict[str, Any]:
- self.validate()
- return get_extension_settings()
-
- def validate(self) -> None:
- return None
diff --git a/superset/daos/extension.py b/superset/daos/extension.py
deleted file mode 100644
index 10557cb87b6..00000000000
--- a/superset/daos/extension.py
+++ /dev/null
@@ -1,44 +0,0 @@
-# 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.
-from typing import Any
-
-from superset import db
-from superset.daos.base import BaseDAO
-from superset.extensions.models import ExtensionSettings
-
-# The global extension settings live in a single row; id is fixed so the row
-# can be fetched without a secondary lookup.
-SETTINGS_ROW_ID = 1
-
-
-class ExtensionSettingsDAO(BaseDAO[ExtensionSettings]):
- """Persistence for the singleton global extension settings row.
-
- The row (id=1) holds global admin state such as the active chatbot id.
- """
-
- @staticmethod
- def get_settings_row() -> ExtensionSettings | None:
- return db.session.get(ExtensionSettings, SETTINGS_ROW_ID)
-
-
-def get_extension_settings() -> dict[str, Any]:
- """Read-only view of the extension settings."""
- row = ExtensionSettingsDAO.get_settings_row()
- return {
- "active_chatbot_id": row.active_chatbot_id if row else None,
- }
diff --git a/superset/extensions/api.py b/superset/extensions/api.py
index cf08a9fbe20..68ca938af1c 100644
--- a/superset/extensions/api.py
+++ b/superset/extensions/api.py
@@ -23,7 +23,6 @@ from flask import send_file
from flask.wrappers import Response
from flask_appbuilder.api import expose, protect, safe
-from superset.commands.extension.settings.get import GetExtensionSettingsCommand
from superset.extensions.utils import (
build_extension_data,
get_extensions,
@@ -50,7 +49,6 @@ class ExtensionsRestApi(BaseSupersetApi):
"can_get",
"can_content",
"can_info",
- "can_get_settings",
]
@expose("/_info", methods=("GET",))
@@ -177,23 +175,6 @@ class ExtensionsRestApi(BaseSupersetApi):
extension_data = build_extension_data(extension)
return self.response(200, result=extension_data)
- @protect()
- @safe
- @expose("/settings", methods=("GET",))
- def get_settings(self, **kwargs: Any) -> Response:
- """Get global extension admin settings.
-
- No admin gate here by design: authenticated non-admin users need these
- settings so the ChatbotMount can read active_chatbot_id on every page.
- ---
- get:
- summary: Get extension admin settings (active chatbot id).
- responses:
- 200:
- description: Extension settings
- """
- return self.response(200, result=GetExtensionSettingsCommand().run())
-
@protect()
@safe
@expose("/<publisher>/<name>/<file>", methods=("GET",))
diff --git a/superset/extensions/models.py b/superset/migrations/versions/2026-06-10_00-00_e2f3a4b5c6d7_drop_extension_settings.py
similarity index 50%
rename from superset/extensions/models.py
rename to superset/migrations/versions/2026-06-10_00-00_e2f3a4b5c6d7_drop_extension_settings.py
index 700f5b7b6b5..6a0c3f14077 100644
--- a/superset/extensions/models.py
+++ b/superset/migrations/versions/2026-06-10_00-00_e2f3a4b5c6d7_drop_extension_settings.py
@@ -14,19 +14,33 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+"""Drop extension_settings table (ExtensionSettings model removed in chatbot SIP).
-"""SQLAlchemy models for extension settings persistence."""
+The active chatbot is now resolved purely from the view registry (last-loaded
+wins), so the admin-pin settings table is no longer read or written.
-from flask_appbuilder import Model
-from sqlalchemy import Column, Integer, String
+Revision ID: e2f3a4b5c6d7
+Revises: d1e2f3a4b5c6
+Create Date: 2026-06-10 00:00:00.000000
-# Column length for extension/chatbot identifiers.
-EXTENSION_ID_MAX_LENGTH = 250
+"""
+import sqlalchemy as sa
-class ExtensionSettings(Model): # pylint: disable=too-few-public-methods
- """Global admin settings for extensions (singleton row, id=1)."""
+from superset.migrations.shared.utils import create_table, drop_table
- __tablename__ = "extension_settings"
- id = Column(Integer, primary_key=True)
- active_chatbot_id = Column(String(EXTENSION_ID_MAX_LENGTH), nullable=True)
+# revision identifiers, used by Alembic.
+revision = "e2f3a4b5c6d7"
+down_revision = "d1e2f3a4b5c6"
+
+
+def upgrade() -> None:
+ drop_table("extension_settings")
+
+
+def downgrade() -> None:
+ create_table(
+ "extension_settings",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("active_chatbot_id", sa.String(250), nullable=True),
+ )
diff --git a/superset/models/__init__.py b/superset/models/__init__.py
index 639e3c61fb9..750c61ccc23 100644
--- a/superset/models/__init__.py
+++ b/superset/models/__init__.py
@@ -14,6 +14,4 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from superset.extensions import models as extensions_models # noqa: F401
-
from . import core, dynamic_plugins, sql_lab, user_attributes # noqa: F401
diff --git a/tests/unit_tests/extensions/test_settings.py b/tests/unit_tests/extensions/test_settings.py
deleted file mode 100644
index c32d38ccdd7..00000000000
--- a/tests/unit_tests/extensions/test_settings.py
+++ /dev/null
@@ -1,77 +0,0 @@
-# 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.
-
-"""Unit tests for extension settings persistence and the settings API endpoint.
-
-Persistence is exercised through the public Command + DAO layer:
-``GetExtensionSettingsCommand`` and ``ExtensionSettingsDAO``.
-"""
-
-from __future__ import annotations
-
-from typing import Any
-
-import pytest
-
-# ---------------------------------------------------------------------------
-# Settings persistence (Command + DAO) — sqlite-backed round-trip tests
-# ---------------------------------------------------------------------------
-
-
-class TestGetExtensionSettings:
- def test_returns_defaults_when_no_rows(self, app_context: Any) -> None:
- from superset.commands.extension.settings.get import (
- GetExtensionSettingsCommand,
- )
-
- result = GetExtensionSettingsCommand().run()
- assert result["active_chatbot_id"] is None
-
-
-# ---------------------------------------------------------------------------
-# GET /api/v1/extensions/settings
-# ---------------------------------------------------------------------------
-
-# The settings routes are only registered when ENABLE_EXTENSIONS is on at
-# app-init time, so the endpoint tests parametrize the app fixture to enable it
-# (otherwise the route is absent and requests 404).
-_ENABLE_EXTENSIONS = [{"FEATURE_FLAGS": {"ENABLE_EXTENSIONS": True}}]
-
-
-@pytest.mark.parametrize("app", _ENABLE_EXTENSIONS, indirect=True)
-class TestGetSettingsEndpoint:
- def test_authenticated_user_can_read(
- self, client: Any, full_api_access: None, mocker: Any
- ) -> None:
- mocker.patch(
- "superset.extensions.api.GetExtensionSettingsCommand.run",
- return_value={"active_chatbot_id": None},
- )
- resp = client.get("/api/v1/extensions/settings")
- assert resp.status_code == 200
- assert resp.json["result"]["active_chatbot_id"] is None
-
- def test_returns_active_chatbot_id(
- self, client: Any, full_api_access: None, mocker: Any
- ) -> None:
- mocker.patch(
- "superset.extensions.api.GetExtensionSettingsCommand.run",
- return_value={"active_chatbot_id": "acme.chatbot"},
- )
- resp = client.get("/api/v1/extensions/settings")
- assert resp.status_code == 200
- assert resp.json["result"]["active_chatbot_id"] == "acme.chatbot"