diff --git a/docs/scripts/export_config_metadata.py b/docs/scripts/export_config_metadata.py new file mode 100644 index 00000000000..15f93dda6ab --- /dev/null +++ b/docs/scripts/export_config_metadata.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Export configuration metadata to JSON for documentation generation. + +This script extracts configuration metadata from SupersetConfig and generates +JSON files that can be imported into the documentation site. +""" + +import sys +from pathlib import Path +from typing import Any, Dict, List + +# Add the superset directory to Python path +superset_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(superset_root)) + +from superset.config_extensions import SupersetConfig # noqa: E402 +from superset.utils import json # noqa: E402 + + +def export_config_metadata() -> List[Dict[str, Any]]: + """Export configuration metadata as JSON.""" + config = SupersetConfig() + + # Get all settings metadata + settings_metadata = config.DATABASE_SETTINGS_SCHEMA + + # Transform metadata for documentation + docs_metadata = [] + + for key, schema in settings_metadata.items(): + # Skip readonly settings for user documentation + if schema.get("readonly", False): + continue + + # Build environment variable name + env_var = f"SUPERSET__{key}" + + # Extract nested example if available + nested_example = None + if schema.get("type") == "object" and "example" in schema: + nested_example = f"SUPERSET__{key}__example__nested_key=value" + + # Format type information + type_info = str(schema.get("type", "unknown")) + if type_info == "integer": + min_val = schema.get("minimum") + max_val = schema.get("maximum") + if min_val is not None or max_val is not None: + min_str = str(min_val) if min_val is not None else "N/A" + max_str = str(max_val) if max_val is not None else "N/A" + type_info += f" ({min_str} - {max_str})" + + doc_entry = { + "key": key, + "title": schema.get("title", key), + "description": schema.get("description", ""), + "type": type_info, + "category": schema.get("category", "general"), + "impact": schema.get("impact", "medium"), + "requires_restart": schema.get("requires_restart", True), + "default": schema.get("default"), + "env_var": env_var, + "nested_example": nested_example, + "documentation_url": schema.get("documentation_url"), + } + + docs_metadata.append(doc_entry) + + # Group by category + categories: Dict[str, List[Dict[str, Any]]] = {} + for entry in docs_metadata: + category = str(entry["category"]) + if category not in categories: + categories[category] = [] + categories[category].append(entry) + + # Sort entries within each category + for category in categories: + categories[category].sort(key=lambda x: x["key"]) + + # Export as JSON + output_dir = Path(__file__).parent.parent / "src" / "resources" + output_dir.mkdir(exist_ok=True) + + # Export all settings + with open(output_dir / "config_metadata.json", "w") as f: + f.write( + json.dumps( + { + "all_settings": docs_metadata, + "by_category": categories, + "categories": list(categories.keys()), + }, + indent=2, + ) + ) + + output_file = output_dir / "config_metadata.json" + print(f"Exported {len(docs_metadata)} configuration settings to {output_file}") + return docs_metadata + + +if __name__ == "__main__": + export_config_metadata() diff --git a/docs/scripts/update_docs.sh b/docs/scripts/update_docs.sh new file mode 100755 index 00000000000..d91e687c0b5 --- /dev/null +++ b/docs/scripts/update_docs.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# 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. + +# Update documentation with latest configuration metadata +# This script should be run before building the documentation + +set -e + +echo "Updating configuration metadata for documentation..." + +# Navigate to the docs directory +cd "$(dirname "$0")/.." + +# Export configuration metadata +echo "Exporting configuration metadata..." +python scripts/export_config_metadata.py + +echo "Configuration metadata updated successfully!" +echo "The following files were updated:" +echo "- src/resources/config_metadata.json" +echo "" +echo "To build the documentation with the latest metadata:" +echo " npm install" +echo " npm run build" diff --git a/docs/src/components/ConfigurationTable.tsx b/docs/src/components/ConfigurationTable.tsx new file mode 100644 index 00000000000..d2e67e81bfa --- /dev/null +++ b/docs/src/components/ConfigurationTable.tsx @@ -0,0 +1,331 @@ +/** + * 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. + */ + +import React, { useState } from 'react'; +import configMetadata from '../resources/config_metadata.json'; + +interface ConfigSetting { + key: string; + title: string; + description: string; + type: string; + category: string; + impact: string; + requires_restart: boolean; + default: any; + env_var: string; + nested_example: string | null; + documentation_url: string | null; +} + +interface ConfigurationTableProps { + category?: string; + showEnvironmentVariables?: boolean; +} + +const ImpactBadge: React.FC<{ impact: string }> = ({ impact }) => { + const colors = { + low: '#52c41a', + medium: '#faad14', + high: '#ff4d4f', + }; + + return ( + + {impact.toUpperCase()} + + ); +}; + +const RestartBadge: React.FC<{ requiresRestart: boolean }> = ({ + requiresRestart, +}) => { + if (!requiresRestart) return null; + + return ( + + RESTART + + ); +}; + +const ConfigurationTable: React.FC = ({ + category, + showEnvironmentVariables = false, +}) => { + const [selectedCategory, setSelectedCategory] = useState( + category || 'all', + ); + + // Get settings based on selected category + const getSettings = (): ConfigSetting[] => { + if (selectedCategory === 'all') { + return configMetadata.all_settings; + } + return configMetadata.by_category[selectedCategory] || []; + }; + + const settings = getSettings(); + + const formatDefault = (value: any): string => { + if (value === null || value === undefined) return 'None'; + if (typeof value === 'object') { + return JSON.stringify(value, null, 2); + } + return String(value); + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + }; + + return ( +
+ {/* Category selector */} + {!category && ( +
+ + + + Showing {settings.length} configuration settings + +
+ )} + + {/* Table */} +
+ + + + + + + + {showEnvironmentVariables && ( + + )} + + + + + {settings.map((setting: ConfigSetting) => ( + + + + + + {showEnvironmentVariables && ( + + )} + + + ))} + +
+ Setting + + Description + + Type + + Default + + Environment Variable + + Impact +
+
+ {setting.title} +
+ + {setting.key} + + {setting.documentation_url && ( + + )} +
+
+ {setting.description} + + {setting.type} + + + {formatDefault(setting.default)} + + +
+ + {setting.env_var} + + +
+ {setting.nested_example && ( +
+ + {setting.nested_example} + +
+ )} +
+ + +
+
+ + {settings.length === 0 && ( +
+ No configuration settings found for the selected category. +
+ )} +
+ ); +}; + +export default ConfigurationTable; diff --git a/docs/src/components/EnvironmentVariablesExample.tsx b/docs/src/components/EnvironmentVariablesExample.tsx new file mode 100644 index 00000000000..7de8b619522 --- /dev/null +++ b/docs/src/components/EnvironmentVariablesExample.tsx @@ -0,0 +1,181 @@ +/** + * 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. + */ + +import React, { useState } from 'react'; +import configMetadata from '../resources/config_metadata.json'; + +interface EnvironmentVariablesExampleProps { + category?: string; +} + +const EnvironmentVariablesExample: React.FC< + EnvironmentVariablesExampleProps +> = ({ category }) => { + const [showAll, setShowAll] = useState(false); + + // Get settings based on category + const getSettings = () => { + if (category && configMetadata.by_category[category]) { + return configMetadata.by_category[category]; + } + return configMetadata.all_settings; + }; + + const settings = getSettings(); + const displaySettings = showAll ? settings : settings.slice(0, 5); + + const formatDefaultForEnv = (value: any): string => { + if (value === null || value === undefined) return '""'; + if (typeof value === 'object') { + return `'${JSON.stringify(value)}'`; + } + if (typeof value === 'string') { + return `"${value}"`; + } + return String(value); + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + }; + + const generateEnvExample = (setting: any): string => { + const example = formatDefaultForEnv(setting.default); + return `export ${setting.env_var}=${example}`; + }; + + const generateAllEnvVars = (): string => { + return [ + '# Superset Configuration Environment Variables', + '# Generated from configuration metadata', + '', + ...displaySettings.map(setting => + [ + `# ${setting.title}`, + `# ${setting.description}`, + `# Type: ${setting.type}`, + `# Impact: ${setting.impact}${ + setting.requires_restart ? ' (requires restart)' : '' + }`, + generateEnvExample(setting), + '', + ].join('\n'), + ), + ].join('\n'); + }; + + return ( +
+
+
+

+ Environment Variables {category && `(${category})`} +

+ +
+ +
+          {generateAllEnvVars()}
+        
+ + {!showAll && settings.length > 5 && ( +
+ +
+ )} +
+ +
+ Usage: Save to a .env file or export + directly in your shell. + {category && ` Showing ${settings.length} ${category} settings.`} +
+
+ ); +}; + +export default EnvironmentVariablesExample; diff --git a/docs/src/resources/config_metadata.json b/docs/src/resources/config_metadata.json new file mode 100644 index 00000000000..a2cdb412819 --- /dev/null +++ b/docs/src/resources/config_metadata.json @@ -0,0 +1,225 @@ +{ + "all_settings": [ + { + "key": "ROW_LIMIT", + "title": "Row Limit", + "description": "Maximum number of rows returned from queries", + "type": "integer (1 - 1000000)", + "category": "performance", + "impact": "medium", + "requires_restart": false, + "default": 50000, + "env_var": "SUPERSET__ROW_LIMIT", + "nested_example": null, + "documentation_url": "https://superset.apache.org/docs/configuration/databases" + }, + { + "key": "SAMPLES_ROW_LIMIT", + "title": "Samples Row Limit", + "description": "Default row limit when requesting samples from datasource", + "type": "integer (1 - 10000)", + "category": "performance", + "impact": "low", + "requires_restart": false, + "default": 1000, + "env_var": "SUPERSET__SAMPLES_ROW_LIMIT", + "nested_example": null, + "documentation_url": null + }, + { + "key": "NATIVE_FILTER_DEFAULT_ROW_LIMIT", + "title": "Native Filter Default Row Limit", + "description": "Default row limit for native filters", + "type": "integer (1 - 10000)", + "category": "performance", + "impact": "low", + "requires_restart": false, + "default": 1000, + "env_var": "SUPERSET__NATIVE_FILTER_DEFAULT_ROW_LIMIT", + "nested_example": null, + "documentation_url": null + }, + { + "key": "SQLLAB_TIMEOUT", + "title": "SQL Lab Timeout", + "description": "Timeout duration for SQL Lab synchronous queries (seconds)", + "type": "integer (1 - 3600)", + "category": "performance", + "impact": "high", + "requires_restart": false, + "default": 30, + "env_var": "SUPERSET__SQLLAB_TIMEOUT", + "nested_example": null, + "documentation_url": null + }, + { + "key": "FEATURE_FLAGS", + "title": "Feature Flags", + "description": "Feature flags to enable/disable functionality", + "type": "object", + "category": "features", + "impact": "high", + "requires_restart": true, + "default": {}, + "env_var": "SUPERSET__FEATURE_FLAGS", + "nested_example": null, + "documentation_url": null + }, + { + "key": "THEME_DEFAULT", + "title": "Default Theme", + "description": "Default theme configuration (Ant Design format)", + "type": "object", + "category": "ui", + "impact": "medium", + "requires_restart": false, + "default": {}, + "env_var": "SUPERSET__THEME_DEFAULT", + "nested_example": null, + "documentation_url": null + }, + { + "key": "THEME_DARK", + "title": "Dark Theme", + "description": "Dark theme configuration (Ant Design format)", + "type": "object", + "category": "ui", + "impact": "medium", + "requires_restart": false, + "default": {}, + "env_var": "SUPERSET__THEME_DARK", + "nested_example": null, + "documentation_url": null + }, + { + "key": "THEME_SETTINGS", + "title": "Theme Settings", + "description": "Theme behavior and user preference settings", + "type": "object", + "category": "ui", + "impact": "medium", + "requires_restart": false, + "default": {}, + "env_var": "SUPERSET__THEME_SETTINGS", + "nested_example": null, + "documentation_url": null + } + ], + "by_category": { + "performance": [ + { + "key": "NATIVE_FILTER_DEFAULT_ROW_LIMIT", + "title": "Native Filter Default Row Limit", + "description": "Default row limit for native filters", + "type": "integer (1 - 10000)", + "category": "performance", + "impact": "low", + "requires_restart": false, + "default": 1000, + "env_var": "SUPERSET__NATIVE_FILTER_DEFAULT_ROW_LIMIT", + "nested_example": null, + "documentation_url": null + }, + { + "key": "ROW_LIMIT", + "title": "Row Limit", + "description": "Maximum number of rows returned from queries", + "type": "integer (1 - 1000000)", + "category": "performance", + "impact": "medium", + "requires_restart": false, + "default": 50000, + "env_var": "SUPERSET__ROW_LIMIT", + "nested_example": null, + "documentation_url": "https://superset.apache.org/docs/configuration/databases" + }, + { + "key": "SAMPLES_ROW_LIMIT", + "title": "Samples Row Limit", + "description": "Default row limit when requesting samples from datasource", + "type": "integer (1 - 10000)", + "category": "performance", + "impact": "low", + "requires_restart": false, + "default": 1000, + "env_var": "SUPERSET__SAMPLES_ROW_LIMIT", + "nested_example": null, + "documentation_url": null + }, + { + "key": "SQLLAB_TIMEOUT", + "title": "SQL Lab Timeout", + "description": "Timeout duration for SQL Lab synchronous queries (seconds)", + "type": "integer (1 - 3600)", + "category": "performance", + "impact": "high", + "requires_restart": false, + "default": 30, + "env_var": "SUPERSET__SQLLAB_TIMEOUT", + "nested_example": null, + "documentation_url": null + } + ], + "features": [ + { + "key": "FEATURE_FLAGS", + "title": "Feature Flags", + "description": "Feature flags to enable/disable functionality", + "type": "object", + "category": "features", + "impact": "high", + "requires_restart": true, + "default": {}, + "env_var": "SUPERSET__FEATURE_FLAGS", + "nested_example": null, + "documentation_url": null + } + ], + "ui": [ + { + "key": "THEME_DARK", + "title": "Dark Theme", + "description": "Dark theme configuration (Ant Design format)", + "type": "object", + "category": "ui", + "impact": "medium", + "requires_restart": false, + "default": {}, + "env_var": "SUPERSET__THEME_DARK", + "nested_example": null, + "documentation_url": null + }, + { + "key": "THEME_DEFAULT", + "title": "Default Theme", + "description": "Default theme configuration (Ant Design format)", + "type": "object", + "category": "ui", + "impact": "medium", + "requires_restart": false, + "default": {}, + "env_var": "SUPERSET__THEME_DEFAULT", + "nested_example": null, + "documentation_url": null + }, + { + "key": "THEME_SETTINGS", + "title": "Theme Settings", + "description": "Theme behavior and user preference settings", + "type": "object", + "category": "ui", + "impact": "medium", + "requires_restart": false, + "default": {}, + "env_var": "SUPERSET__THEME_SETTINGS", + "nested_example": null, + "documentation_url": null + } + ] + }, + "categories": [ + "performance", + "features", + "ui" + ] +} diff --git a/superset/commands/settings/__init__.py b/superset/commands/settings/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/superset/commands/settings/__init__.py @@ -0,0 +1,16 @@ +# 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/settings/exceptions.py b/superset/commands/settings/exceptions.py new file mode 100644 index 00000000000..d45cf712bbf --- /dev/null +++ b/superset/commands/settings/exceptions.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. +from superset.commands.exceptions import CommandException + + +class SettingsCommandException(CommandException): + """Base exception for settings commands.""" + + pass + + +class SettingNotFoundError(SettingsCommandException): + """Exception raised when a setting is not found.""" + + pass + + +class SettingNotAllowedInDatabaseError(SettingsCommandException): + """Exception raised when a setting cannot be stored in the database.""" + + pass + + +class SettingValidationError(SettingsCommandException): + """Exception raised when a setting value is invalid.""" + + pass + + +class SettingAlreadyExistsError(SettingsCommandException): + """Exception raised when trying to create a setting that already exists.""" + + pass diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 9ac70a4ad1d..1ec660c984e 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -160,6 +160,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods SecurityRestApi, UserRegistrationsRestAPI, ) + from superset.settings.api import SettingsRestApi from superset.sqllab.api import SqlLabRestApi from superset.sqllab.permalink.api import SqlLabPermalinkRestApi from superset.tags.api import TagRestApi @@ -242,6 +243,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods appbuilder.add_api(ReportExecutionLogRestApi) appbuilder.add_api(RLSRestApi) appbuilder.add_api(SavedQueryRestApi) + appbuilder.add_api(SettingsRestApi) appbuilder.add_api(TagRestApi) appbuilder.add_api(SqlLabRestApi) appbuilder.add_api(SqlLabPermalinkRestApi) diff --git a/superset/settings/__init__.py b/superset/settings/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/superset/settings/__init__.py @@ -0,0 +1,16 @@ +# 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/settings/api.py b/superset/settings/api.py new file mode 100644 index 00000000000..27f05254d30 --- /dev/null +++ b/superset/settings/api.py @@ -0,0 +1,379 @@ +# 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. +import logging +from typing import Any, Dict, Optional + +from flask import current_app, request +from flask_appbuilder.api import expose, protect, safe +from flask_appbuilder.models.sqla.interface import SQLAInterface +from marshmallow import ValidationError + +from superset.config_extensions import SupersetConfig +from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP +from superset.daos.settings import SettingsDAO +from superset.models.core import Settings +from superset.settings.schemas import ( + SettingCreateSchema, + SettingListResponseSchema, + SettingResponseSchema, + SettingUpdateSchema, +) +from superset.views.base_api import BaseSupersetModelRestApi + +logger = logging.getLogger(__name__) + + +class SettingsRestApi(BaseSupersetModelRestApi): + """REST API for configuration settings management.""" + + datamodel = SQLAInterface(Settings) + resource_name = "settings" + allow_browser_login = True + + # Only allow specific methods + include_route_methods = { + "get_list", + "get", + "post", + "put", + "delete", + "info", + } + + # Schemas for serialization/deserialization + list_schema = SettingListResponseSchema() + show_schema = SettingResponseSchema() + add_schema = SettingCreateSchema() + edit_schema = SettingUpdateSchema() + + # Permissions - only admins can read/write settings + method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP.copy() + method_permission_name.update( + { + "get_list": "can_read", + "get": "can_read", + "post": "can_write", + "put": "can_write", + "delete": "can_write", + "validate": "can_read", + "metadata": "can_read", + "effective_config": "can_read", + } + ) + + # Only Admin role can access settings + class_permission_name = "Settings" + + def _get_setting_metadata(self, key: str) -> Optional[Dict[str, Any]]: + """Get metadata for a configuration setting.""" + if isinstance(current_app.config, SupersetConfig): + return current_app.config.get_setting_metadata(key) + return None + + def _get_setting_source(self, key: str) -> str: + """Determine where a setting value comes from.""" + import os + + # Check if it's from environment variables + env_key = f"SUPERSET__{key}" + if env_key in os.environ: + return f"environment ({env_key})" + + # Check if it's from superset_config.py + try: + import superset_config + + if hasattr(superset_config, key): + return "superset_config.py" + except ImportError: + pass + + # Check if it's in database + if SettingsDAO.find_by_key(key): + return "database" + + # Otherwise it's from defaults + return "config_defaults.py" + + def _is_setting_allowed_in_database(self, key: str) -> bool: + """Check if a setting is allowed to be stored in the database.""" + metadata = self._get_setting_metadata(key) + if not metadata: + return False + + # Only allow settings that don't require restart and aren't readonly + return not metadata.get("requires_restart", True) and not metadata.get( + "readonly", False + ) + + def _validate_setting_value(self, key: str, value: Any) -> tuple[bool, list[str]]: + """Validate a setting value against its metadata.""" + metadata = self._get_setting_metadata(key) + if not metadata: + return True, [] + + if isinstance(current_app.config, SupersetConfig): + if current_app.config.validate_setting(key, value): + return True, [] + else: + return False, [ + f"Value does not match expected type or constraints for {key}" + ] + + return True, [] + + @expose("/", methods=["GET"]) + @protect() + @safe + def get_list(self) -> str: + """Get list of all database settings.""" + # Parse query parameters + args = request.args + namespace = args.get("namespace") + category = args.get("category") + include_metadata = args.get("include_metadata", "false").lower() == "true" + include_source = args.get("include_source", "false").lower() == "true" + + # Get settings from database + if namespace: + settings = SettingsDAO.get_by_namespace(namespace) + else: + settings = SettingsDAO.get_all_as_dict() + + # Build response + result = [] + for key, value in settings.items(): + setting_data = { + "key": key, + "value": value, + "namespace": namespace, # This would need to be fetched from the model + } + + if include_metadata: + setting_data["metadata"] = self._get_setting_metadata(key) + + if include_source: + setting_data["source"] = self._get_setting_source(key) + + # Filter by category if specified + if category: + metadata = self._get_setting_metadata(key) + if not metadata or metadata.get("category") != category: + continue + + result.append(setting_data) + + return self.response(200, result=result, count=len(result)) + + @expose("/", methods=["GET"]) + @protect() + @safe + def get(self, pk: str) -> str: + """Get a specific setting by key.""" + # Parse query parameters + args = request.args + include_metadata = args.get("include_metadata", "false").lower() == "true" + include_source = args.get("include_source", "false").lower() == "true" + + # Get setting value + value = SettingsDAO.get_value(pk) + if value is None: + return self.response_404() + + # Build response + setting_data = { + "key": pk, + "value": value, + } + + if include_metadata: + setting_data["metadata"] = self._get_setting_metadata(pk) + + if include_source: + setting_data["source"] = self._get_setting_source(pk) + + return self.response(200, **setting_data) + + @expose("/", methods=["POST"]) + @protect() + @safe + def post(self) -> str: + """Create a new setting.""" + try: + item = self.add_schema.load(request.json) + except ValidationError as error: + return self.response_422(message=error.messages) + + key = item["key"] + value = item["value"] + namespace = item.get("namespace") + + # Check if setting is allowed in database + if not self._is_setting_allowed_in_database(key): + return self.response_422( + message=f"Setting '{key}' cannot be stored in database. " + f"It may require a restart or be read-only." + ) + + # Validate value + is_valid, errors = self._validate_setting_value(key, value) + if not is_valid: + return self.response_422(message={"validation_errors": errors}) + + # Check if setting already exists + if SettingsDAO.find_by_key(key): + return self.response_422( + message=f"Setting '{key}' already exists. Use PUT to update." + ) + + # Create setting + try: + SettingsDAO.set_value(key, value, namespace) + return self.response(201, key=key, value=value) + except Exception as ex: + logger.exception("Error creating setting") + return self.response_422(message=str(ex)) + + @expose("/", methods=["PUT"]) + @protect() + @safe + def put(self, pk: str) -> str: + """Update an existing setting.""" + try: + item = self.edit_schema.load(request.json) + except ValidationError as error: + return self.response_422(message=error.messages) + + value = item["value"] + namespace = item.get("namespace") + + # Check if setting is allowed in database + if not self._is_setting_allowed_in_database(pk): + return self.response_422( + message=f"Setting '{pk}' cannot be stored in database. " + f"It may require a restart or be read-only." + ) + + # Validate value + is_valid, errors = self._validate_setting_value(pk, value) + if not is_valid: + return self.response_422(message={"validation_errors": errors}) + + # Update setting + try: + SettingsDAO.set_value(pk, value, namespace) + return self.response(200, key=pk, value=value) + except Exception as ex: + logger.exception("Error updating setting") + return self.response_422(message=str(ex)) + + @expose("/", methods=["DELETE"]) + @protect() + @safe + def delete(self, pk: str) -> str: + """Delete a setting.""" + # Check if setting exists + if not SettingsDAO.find_by_key(pk): + return self.response_404() + + # Delete setting + try: + SettingsDAO.delete_by_key(pk) + return self.response(200, message=f"Setting '{pk}' deleted") + except Exception as ex: + logger.exception("Error deleting setting") + return self.response_422(message=str(ex)) + + @expose("/validate", methods=["POST"]) + @protect() + @safe + def validate(self) -> str: + """Validate a setting value without saving it.""" + if not request.json: + return self.response_400() + + key = request.json.get("key") + value = request.json.get("value") + + if not key: + return self.response_422(message="Key is required") + + # Get metadata + metadata = self._get_setting_metadata(key) + + # Validate value + is_valid, errors = self._validate_setting_value(key, value) + + # Check if allowed in database + allowed_in_db = self._is_setting_allowed_in_database(key) + + response_data = { + "key": key, + "value": value, + "valid": is_valid, + "errors": errors, + "metadata": metadata, + "allowed_in_database": allowed_in_db, + } + + return self.response(200, **response_data) + + @expose("/metadata", methods=["GET"]) + @protect() + @safe + def metadata(self) -> str: + """Get metadata for all documented settings.""" + if not isinstance(current_app.config, SupersetConfig): + return self.response(200, metadata={}) + + metadata = current_app.config.DATABASE_SETTINGS_SCHEMA + + # Filter by category if specified + if category := request.args.get("category"): + metadata = current_app.config.get_settings_by_category(category) + + return self.response(200, metadata=metadata) + + @expose("/effective_config", methods=["GET"]) + @protect() + @safe + def effective_config(self) -> str: + """Get effective configuration (database + env + defaults).""" + # Get current config values + config_dict = {} + + # Get documented settings + if isinstance(current_app.config, SupersetConfig): + for key in current_app.config.DATABASE_SETTINGS_SCHEMA: + if key in current_app.config: + config_dict[key] = { + "value": current_app.config[key], + "source": self._get_setting_source(key), + "metadata": self._get_setting_metadata(key), + } + + # Add database settings + db_settings = SettingsDAO.get_all_as_dict() + for key, value in db_settings.items(): + if key not in config_dict: + config_dict[key] = { + "value": value, + "source": "database", + "metadata": self._get_setting_metadata(key), + } + + return self.response(200, config=config_dict) diff --git a/superset/settings/schemas.py b/superset/settings/schemas.py new file mode 100644 index 00000000000..dfd76fd797a --- /dev/null +++ b/superset/settings/schemas.py @@ -0,0 +1,110 @@ +# 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 marshmallow import fields, Schema, validate + + +class SettingCreateSchema(Schema): + """Schema for creating a new setting.""" + + key = fields.String(required=True, validate=validate.Length(min=1, max=255)) + value = fields.Raw(required=True) + namespace = fields.String(allow_none=True, validate=validate.Length(max=100)) + + +class SettingUpdateSchema(Schema): + """Schema for updating an existing setting.""" + + value = fields.Raw(required=True) + namespace = fields.String(allow_none=True, validate=validate.Length(max=100)) + + +class SettingResponseSchema(Schema): + """Schema for setting response.""" + + key = fields.String() + value = fields.Raw() + namespace = fields.String(allow_none=True) + created_on = fields.DateTime() + changed_on = fields.DateTime() + created_by = fields.Nested("UserSchema", only=["id", "first_name", "last_name"]) + changed_by = fields.Nested("UserSchema", only=["id", "first_name", "last_name"]) + metadata = fields.Dict(allow_none=True) # Configuration metadata if available + source = fields.String( + allow_none=True + ) # Source of the setting (database, env, defaults) + + +class SettingListResponseSchema(Schema): + """Schema for listing settings.""" + + result = fields.List(fields.Nested(SettingResponseSchema)) + count = fields.Integer() + + +class SettingMetadataSchema(Schema): + """Schema for configuration metadata.""" + + title = fields.String() + description = fields.String() + type = fields.String() + category = fields.String() + impact = fields.String() + requires_restart = fields.Boolean() + default = fields.Raw() + minimum = fields.Integer(allow_none=True) + maximum = fields.Integer(allow_none=True) + readonly = fields.Boolean() + documentation_url = fields.String(allow_none=True) + + +class SettingValidationSchema(Schema): + """Schema for setting validation.""" + + key = fields.String(required=True) + value = fields.Raw(required=True) + valid = fields.Boolean() + errors = fields.List(fields.String()) + metadata = fields.Nested(SettingMetadataSchema, allow_none=True) + + +# Query schemas for API endpoints +setting_get_schema = { + "type": "object", + "properties": { + "include_metadata": {"type": "boolean"}, + "include_source": {"type": "boolean"}, + }, +} + +setting_list_schema = { + "type": "object", + "properties": { + "namespace": {"type": "string"}, + "category": {"type": "string"}, + "include_metadata": {"type": "boolean"}, + "include_source": {"type": "boolean"}, + }, +} + +setting_validate_schema = { + "type": "object", + "properties": { + "key": {"type": "string"}, + "value": {}, # Any type + }, + "required": ["key", "value"], +}