This is an automated email from the ASF dual-hosted git repository. EnxDev pushed a commit to branch enxdev/chat-prototype in repository https://gitbox.apache.org/repos/asf/superset.git
commit c47fb16ebe3fc4f50087c1a6635ff884c00af6d7 Author: Enzo Martellucci <[email protected]> AuthorDate: Tue May 26 16:11:00 2026 +0200 feat(extensions): backend settings persistence and admin-only permissions Adds ExtensionSettings and ExtensionEnabled models with migration. GET /api/v1/extensions/settings is public; PUT is restricted to Admin role via security_manager.is_admin(). Uses dialect-aware ON CONFLICT DO UPDATE upserts and @transaction() for safe concurrent writes. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> --- superset/extensions/api.py | 54 ++++++++++- superset/extensions/settings.py | 108 +++++++++++++++++++++ ...25_00-00_b2c3d4e5f6a7_add_extension_settings.py | 47 +++++++++ superset/models/core.py | 16 +++ 4 files changed, 224 insertions(+), 1 deletion(-) diff --git a/superset/extensions/api.py b/superset/extensions/api.py index b1b5734979e..d116c6ca70e 100644 --- a/superset/extensions/api.py +++ b/superset/extensions/api.py @@ -18,10 +18,15 @@ import mimetypes from io import BytesIO from typing import Any -from flask import send_file +from flask import request, send_file from flask.wrappers import Response from flask_appbuilder.api import BaseApi, expose, protect, safe +from superset.extensions import security_manager +from superset.extensions.settings import ( + get_extension_settings, + update_extension_settings, +) from superset.extensions.utils import ( build_extension_data, get_extensions, @@ -167,6 +172,53 @@ class ExtensionsRestApi(BaseApi): 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. + --- + get: + summary: Get extension admin settings (active chatbot, enabled flags). + responses: + 200: + description: Extension settings + """ + return self.response(200, result=get_extension_settings()) + + @protect() + @safe + @expose("/settings", methods=("PUT",)) + def put_settings(self, **kwargs: Any) -> Response: + """Update global extension admin settings. + --- + put: + summary: Update extension admin settings. + requestBody: + content: + application/json: + schema: + type: object + properties: + active_chatbot_id: + type: string + nullable: true + enabled: + type: object + additionalProperties: + type: boolean + responses: + 200: + description: Updated settings + 403: + $ref: '#/components/responses/403' + """ + if not security_manager.is_admin(): + return self.response(403, message="Admin access required.") + body = request.get_json(silent=True) or {} + result = update_extension_settings(body) + return self.response(200, result=result) + @protect() @safe @expose("/<publisher>/<name>/<file>", methods=("GET",)) diff --git a/superset/extensions/settings.py b/superset/extensions/settings.py new file mode 100644 index 00000000000..cd04a424dea --- /dev/null +++ b/superset/extensions/settings.py @@ -0,0 +1,108 @@ +# 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. + +"""Admin settings persistence for extensions (active chatbot, enable/disable).""" + +from typing import Any + +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert + +from superset import db +from superset.models.core import ExtensionEnabled, ExtensionSettings +from superset.utils.decorators import transaction + +_SETTINGS_ROW_ID = 1 + + +def get_extension_settings() -> dict[str, Any]: + row = db.session.get(ExtensionSettings, _SETTINGS_ROW_ID) + enabled_rows = db.session.query(ExtensionEnabled).all() + return { + "active_chatbot_id": row.active_chatbot_id if row else None, + "enabled": {r.extension_id: r.enabled for r in enabled_rows}, + } + + +def _upsert_settings_row( + active_chatbot_id: str | None, +) -> None: + """Upsert the singleton settings row without a read-then-insert race.""" + bind = db.session.get_bind() + dialect = bind.dialect.name + if dialect == "postgresql": + stmt = ( + pg_insert(ExtensionSettings) + .values(id=_SETTINGS_ROW_ID, active_chatbot_id=active_chatbot_id) + .on_conflict_do_update( + index_elements=["id"], + set_={"active_chatbot_id": active_chatbot_id}, + ) + ) + db.session.execute(stmt) + else: + stmt = ( + sqlite_insert(ExtensionSettings) + .values(id=_SETTINGS_ROW_ID, active_chatbot_id=active_chatbot_id) + .on_conflict_do_update( + index_elements=["id"], + set_={"active_chatbot_id": active_chatbot_id}, + ) + ) + db.session.execute(stmt) + + +def _upsert_enabled_flag(extension_id: str, enabled: bool) -> None: + """Upsert a per-extension enabled flag without a read-then-insert race.""" + bind = db.session.get_bind() + dialect = bind.dialect.name + if dialect == "postgresql": + stmt = ( + pg_insert(ExtensionEnabled) + .values(extension_id=extension_id, enabled=enabled) + .on_conflict_do_update( + index_elements=["extension_id"], + set_={"enabled": enabled}, + ) + ) + db.session.execute(stmt) + else: + stmt = ( + sqlite_insert(ExtensionEnabled) + .values(extension_id=extension_id, enabled=enabled) + .on_conflict_do_update( + index_elements=["extension_id"], + set_={"enabled": enabled}, + ) + ) + db.session.execute(stmt) + + +@transaction() +def update_extension_settings(body: dict[str, Any]) -> dict[str, Any]: + if "active_chatbot_id" in body: + value = body["active_chatbot_id"] + active_chatbot_id = str(value) if isinstance(value, str) and value else None + _upsert_settings_row(active_chatbot_id) + + if "enabled" in body and isinstance(body["enabled"], dict): + for extension_id, enabled in body["enabled"].items(): + if not isinstance(enabled, bool): + continue + _upsert_enabled_flag(extension_id, enabled) + + return get_extension_settings() diff --git a/superset/migrations/versions/2026-05-25_00-00_b2c3d4e5f6a7_add_extension_settings.py b/superset/migrations/versions/2026-05-25_00-00_b2c3d4e5f6a7_add_extension_settings.py new file mode 100644 index 00000000000..9773a5104aa --- /dev/null +++ b/superset/migrations/versions/2026-05-25_00-00_b2c3d4e5f6a7_add_extension_settings.py @@ -0,0 +1,47 @@ +# 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. +"""Add extension_settings table for chatbot admin selection and enable/disable. + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-05-25 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +revision = "b2c3d4e5f6a7" +down_revision = "a1b2c3d4e5f6" + + +def upgrade() -> None: + op.create_table( + "extension_settings", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("active_chatbot_id", sa.String(250), nullable=True), + ) + op.create_table( + "extension_enabled", + sa.Column("extension_id", sa.String(250), primary_key=True), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"), + ) + + +def downgrade() -> None: + op.drop_table("extension_enabled") + op.drop_table("extension_settings") diff --git a/superset/models/core.py b/superset/models/core.py index 1f99630ab3d..35a36f588d5 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -108,6 +108,22 @@ class KeyValue(Model): # pylint: disable=too-few-public-methods value = Column(utils.MediumText(), nullable=False) +class ExtensionSettings(Model): # pylint: disable=too-few-public-methods + """Global admin settings for extensions (singleton row, id=1).""" + + __tablename__ = "extension_settings" + id = Column(Integer, primary_key=True) + active_chatbot_id = Column(String(250), nullable=True) + + +class ExtensionEnabled(Model): # pylint: disable=too-few-public-methods + """Per-extension enable/disable flag.""" + + __tablename__ = "extension_enabled" + extension_id = Column(String(250), primary_key=True) + enabled = Column(Boolean, nullable=False, default=True) + + class CssTemplate(AuditMixinNullable, UUIDMixin, Model): """CSS templates for dashboards"""
