This is an automated email from the ASF dual-hosted git repository. kgabryje pushed a commit to branch what-if in repository https://gitbox.apache.org/repos/asf/superset.git
commit 106baa67e5eb960c6ffe20171271cf685de6c42a Author: Kamil Gabryjelski <[email protected]> AuthorDate: Fri Dec 19 10:03:35 2025 +0100 Saving simulations --- .../src/components/Icons/AntdEnhanced.tsx | 2 + .../WhatIfDrawer/SaveSimulationModal.tsx | 170 +++++++++ .../components/WhatIfDrawer/WhatIfAIInsights.tsx | 17 + .../components/WhatIfDrawer/WhatIfHeaderMenu.tsx | 252 ++++++++++++++ .../dashboard/components/WhatIfDrawer/index.tsx | 108 +++++- .../dashboard/components/WhatIfDrawer/styles.ts | 6 + .../components/WhatIfDrawer/useWhatIfApply.ts | 80 ++++- .../dashboard/components/WhatIfDrawer/whatIfApi.ts | 189 ++++++++++ .../src/pages/WhatIfSimulationList/index.tsx | 385 +++++++++++++++++++++ superset-frontend/src/views/routes.tsx | 11 + superset/daos/what_if_simulation.py | 106 ++++++ ...9_10-00_b8f3a2c9d1e5_add_what_if_simulations.py | 116 +++++++ superset/what_if/api.py | 288 ++++++++++++++- superset/what_if/commands/interpret.py | 2 +- superset/what_if/commands/simulation_create.py | 68 ++++ superset/what_if/commands/simulation_delete.py | 60 ++++ superset/what_if/commands/simulation_update.py | 82 +++++ superset/what_if/commands/suggest_related.py | 2 +- superset/what_if/exceptions.py | 62 ++++ superset/what_if/models.py | 85 +++++ superset/what_if/schemas.py | 79 +++++ 21 files changed, 2161 insertions(+), 9 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx index 8ff6302d3b..012777f5f7 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx @@ -77,6 +77,7 @@ import { FileOutlined, FileTextOutlined, FireOutlined, + FolderOpenOutlined, FormOutlined, FullscreenExitOutlined, FullscreenOutlined, @@ -217,6 +218,7 @@ const AntdIcons = { FileOutlined, FileTextOutlined, FireOutlined, + FolderOpenOutlined, FormOutlined, FullscreenExitOutlined, FullscreenOutlined, diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/SaveSimulationModal.tsx b/superset-frontend/src/dashboard/components/WhatIfDrawer/SaveSimulationModal.tsx new file mode 100644 index 0000000000..da66a1b83d --- /dev/null +++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/SaveSimulationModal.tsx @@ -0,0 +1,170 @@ +/** + * 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 { useCallback, useEffect } from 'react'; +import { t } from '@superset-ui/core'; +import { styled } from '@apache-superset/core/ui'; +import { Form, Input } from '@superset-ui/core/components'; +import { StandardModal } from 'src/components/Modal'; +import { WhatIfModification } from './types'; +import { + createSimulation, + updateSimulation, + WhatIfSimulation, +} from './whatIfApi'; + +const ModalContent = styled.div` + padding: ${({ theme }) => theme.sizeUnit * 4}px; + + .ant-form-item { + margin-bottom: ${({ theme }) => theme.sizeUnit * 6}px; + + &:last-child { + margin-bottom: 0; + } + } + + .ant-form-item-label { + padding-bottom: ${({ theme }) => theme.sizeUnit}px; + } +`; + +const { TextArea } = Input; + +interface SaveSimulationModalProps { + show: boolean; + onHide: () => void; + onSaved: (simulation: WhatIfSimulation) => void; + dashboardId: number; + modifications: WhatIfModification[]; + cascadingEffectsEnabled: boolean; + existingSimulation?: WhatIfSimulation | null; + addSuccessToast: (msg: string) => void; + addDangerToast: (msg: string) => void; +} + +const SaveSimulationModal = ({ + show, + onHide, + onSaved, + dashboardId, + modifications, + cascadingEffectsEnabled, + existingSimulation, + addSuccessToast, + addDangerToast, +}: SaveSimulationModalProps) => { + const [form] = Form.useForm(); + + const isUpdate = Boolean(existingSimulation); + + useEffect(() => { + if (show) { + form.setFieldsValue({ + name: existingSimulation?.name || '', + description: existingSimulation?.description || '', + }); + } + }, [show, existingSimulation, form]); + + const handleSave = useCallback(async () => { + try { + const values = await form.validateFields(); + + if (isUpdate && existingSimulation) { + await updateSimulation(existingSimulation.id, { + name: values.name, + description: values.description, + modifications, + cascadingEffectsEnabled, + }); + const updatedSimulation: WhatIfSimulation = { + ...existingSimulation, + name: values.name, + description: values.description, + modifications, + cascadingEffectsEnabled, + }; + onSaved(updatedSimulation); + addSuccessToast(t('Simulation updated successfully')); + } else { + const simulation = await createSimulation({ + name: values.name, + description: values.description, + dashboardId, + modifications, + cascadingEffectsEnabled, + }); + onSaved(simulation); + addSuccessToast(t('Simulation saved successfully')); + } + + onHide(); + } catch (error) { + addDangerToast(t('Failed to save simulation')); + } + }, [ + form, + isUpdate, + existingSimulation, + modifications, + cascadingEffectsEnabled, + dashboardId, + onSaved, + onHide, + addSuccessToast, + addDangerToast, + ]); + + const handleCancel = useCallback(() => { + form.resetFields(); + onHide(); + }, [form, onHide]); + + return ( + <StandardModal + show={show} + onHide={handleCancel} + onSave={handleSave} + title={isUpdate ? t('Update Simulation') : t('Save Simulation')} + width={500} + saveText={isUpdate ? t('Update') : t('Save')} + > + <ModalContent> + <Form form={form} layout="vertical"> + <Form.Item + name="name" + label={t('Name')} + rules={[{ required: true, message: t('Please enter a name') }]} + > + <Input placeholder={t('My What-If Scenario')} /> + </Form.Item> + <Form.Item name="description" label={t('Description')}> + <TextArea + placeholder={t('Optional description of this simulation')} + rows={3} + /> + </Form.Item> + </Form> + </ModalContent> + </StandardModal> + ); +}; + +export default SaveSimulationModal; diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/WhatIfAIInsights.tsx b/superset-frontend/src/dashboard/components/WhatIfDrawer/WhatIfAIInsights.tsx index 8778017172..db1fe6c44e 100644 --- a/superset-frontend/src/dashboard/components/WhatIfDrawer/WhatIfAIInsights.tsx +++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/WhatIfAIInsights.tsx @@ -188,11 +188,14 @@ const CollapsePanelHeader = styled.div<{ insightType: WhatIfInsightType }>` interface WhatIfAIInsightsProps { affectedChartIds: number[]; modifications: WhatIfModification[]; + /** Ref to register the abort function for external control */ + abortRef?: React.MutableRefObject<(() => void) | null>; } const WhatIfAIInsights = ({ affectedChartIds, modifications, + abortRef, }: WhatIfAIInsightsProps) => { const [status, setStatus] = useState<WhatIfAIStatus>('idle'); const [response, setResponse] = useState<WhatIfInterpretResponse | null>( @@ -219,6 +222,20 @@ const WhatIfAIInsights = ({ [], ); + // Register abort function with external ref for parent control + useEffect(() => { + if (abortRef) { + abortRef.current = () => { + abortControllerRef.current?.abort(); + }; + } + return () => { + if (abortRef) { + abortRef.current = null; + } + }; + }, [abortRef]); + // Track modification changes to reset status when user adjusts the slider const modificationsKey = getModificationsKey(modifications); const prevModificationsKeyRef = useRef<string>(modificationsKey); diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/WhatIfHeaderMenu.tsx b/superset-frontend/src/dashboard/components/WhatIfDrawer/WhatIfHeaderMenu.tsx new file mode 100644 index 0000000000..1b0a7d5de7 --- /dev/null +++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/WhatIfHeaderMenu.tsx @@ -0,0 +1,252 @@ +/** + * 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 { useState, useEffect, useCallback, Key } from 'react'; +import { t } from '@superset-ui/core'; +import { css, useTheme } from '@apache-superset/core/ui'; +import { Menu } from '@superset-ui/core/components/Menu'; +import { + NoAnimationDropdown, + Button, + Tooltip, +} from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { Link } from 'react-router-dom'; +import { fetchSimulations, WhatIfSimulation } from './whatIfApi'; + +enum MenuKeys { + LoadSimulation = 'load-simulation', + SaveSimulation = 'save-simulation', + SaveAsNew = 'save-as-new', + ManageSimulations = 'manage-simulations', +} + +interface WhatIfHeaderMenuProps { + dashboardId: number; + selectedSimulation: WhatIfSimulation | null; + onSelectSimulation: (simulation: WhatIfSimulation | null) => void; + onSaveClick: () => void; + onSaveAsNewClick: () => void; + hasModifications: boolean; + refreshTrigger?: number; + addDangerToast: (msg: string) => void; +} + +const VerticalDotsTrigger = () => { + const theme = useTheme(); + return ( + <Icons.EllipsisOutlined + css={css` + transform: rotate(90deg); + &:hover { + cursor: pointer; + } + `} + iconSize="xl" + iconColor={theme.colorTextLabel} + /> + ); +}; + +const WhatIfHeaderMenu = ({ + dashboardId, + selectedSimulation, + onSelectSimulation, + onSaveClick, + onSaveAsNewClick, + hasModifications, + refreshTrigger, + addDangerToast, +}: WhatIfHeaderMenuProps) => { + const theme = useTheme(); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + const [simulations, setSimulations] = useState<WhatIfSimulation[]>([]); + const [loading, setLoading] = useState(false); + + const loadSimulations = useCallback(async () => { + setLoading(true); + try { + const result = await fetchSimulations(dashboardId); + setSimulations(result); + } catch (error) { + addDangerToast(t('Failed to load saved simulations')); + } finally { + setLoading(false); + } + }, [dashboardId, addDangerToast]); + + useEffect(() => { + loadSimulations(); + }, [loadSimulations, refreshTrigger]); + + const handleMenuClick = useCallback( + ({ key }: { key: Key }) => { + const keyStr = String(key); + + if (keyStr === MenuKeys.SaveSimulation) { + onSaveClick(); + setIsDropdownVisible(false); + } else if (keyStr === MenuKeys.SaveAsNew) { + onSaveAsNewClick(); + setIsDropdownVisible(false); + } else if (keyStr.startsWith('load-sim-')) { + const simId = parseInt(keyStr.replace('load-sim-', ''), 10); + const sim = simulations.find(s => s.id === simId); + if (sim) { + onSelectSimulation(sim); + } + setIsDropdownVisible(false); + } else if (keyStr === 'clear-simulation') { + onSelectSimulation(null); + setIsDropdownVisible(false); + } + }, + [simulations, onSelectSimulation, onSaveClick, onSaveAsNewClick], + ); + + const simulationMenuItems = + simulations.length > 0 + ? [ + ...(selectedSimulation + ? [ + { + key: 'clear-simulation', + label: t('Clear current simulation'), + icon: <Icons.CloseOutlined />, + }, + { type: 'divider' as const }, + ] + : []), + ...simulations.map(sim => ({ + key: `load-sim-${sim.id}`, + label: ( + <div + css={css` + display: flex; + justify-content: space-between; + align-items: center; + gap: ${theme.sizeUnit * 2}px; + `} + > + <span + css={css` + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `} + > + {sim.name} + {selectedSimulation?.id === sim.id && ( + <Icons.CheckOutlined + css={css` + margin-left: ${theme.sizeUnit}px; + color: ${theme.colorSuccess}; + `} + /> + )} + </span> + {sim.description && ( + <Tooltip title={sim.description}> + <Icons.InfoCircleOutlined + onClick={e => e.stopPropagation()} + css={css` + color: ${theme.colorTextSecondary}; + font-size: ${theme.fontSizeSM}px; + `} + /> + </Tooltip> + )} + </div> + ), + })), + ] + : [ + { + key: 'no-simulations', + label: t('No saved simulations'), + disabled: true, + }, + ]; + + const menuItems = [ + { + type: 'submenu' as const, + key: MenuKeys.LoadSimulation, + label: loading ? t('Loading...') : t('Load simulation'), + icon: <Icons.FolderOpenOutlined />, + children: simulationMenuItems, + }, + { + key: MenuKeys.SaveSimulation, + label: selectedSimulation ? t('Update simulation') : t('Save simulation'), + icon: <Icons.SaveOutlined />, + disabled: !hasModifications, + }, + ...(selectedSimulation + ? [ + { + key: MenuKeys.SaveAsNew, + label: t('Save as new'), + icon: <Icons.PlusOutlined />, + disabled: !hasModifications, + }, + ] + : []), + { type: 'divider' as const }, + { + key: MenuKeys.ManageSimulations, + label: <Link to="/whatif/simulations/">{t('Manage simulations')}</Link>, + icon: <Icons.SettingOutlined />, + }, + ]; + + return ( + <NoAnimationDropdown + popupRender={() => ( + <Menu + onClick={handleMenuClick} + data-test="what-if-header-menu" + selectable={false} + items={menuItems} + /> + )} + trigger={['click']} + placement="bottomRight" + open={isDropdownVisible} + onOpenChange={visible => setIsDropdownVisible(visible)} + > + <Button + buttonStyle="link" + aria-label={t('More Options')} + aria-haspopup="true" + css={css` + padding: ${theme.sizeUnit}px; + display: flex; + align-items: center; + justify-content: center; + `} + > + <VerticalDotsTrigger /> + </Button> + </NoAnimationDropdown> + ); +}; + +export default WhatIfHeaderMenu; diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx b/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx index 5718de1fc2..ed3cbf9812 100644 --- a/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx +++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx @@ -30,14 +30,18 @@ import { } from '@superset-ui/core/components'; import Slider from '@superset-ui/core/components/Slider'; import { Icons } from '@superset-ui/core/components/Icons'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; import { useNumericColumns } from 'src/dashboard/util/useNumericColumns'; import { RootState, Datasource } from 'src/dashboard/types'; import WhatIfAIInsights from './WhatIfAIInsights'; import HarryPotterWandLoader from './HarryPotterWandLoader'; import FilterButton from './FilterButton'; import ModificationsDisplay from './ModificationsDisplay'; +import WhatIfHeaderMenu from './WhatIfHeaderMenu'; +import SaveSimulationModal from './SaveSimulationModal'; import { useWhatIfFilters } from './useWhatIfFilters'; import { useWhatIfApply } from './useWhatIfApply'; +import { WhatIfSimulation } from './whatIfApi'; import { SLIDER_MIN, SLIDER_MAX, @@ -61,6 +65,7 @@ import { ColumnSelectWrapper, FiltersSection, FilterTagsContainer, + HeaderButtonsContainer, } from './styles'; export { WHAT_IF_PANEL_WIDTH }; @@ -72,6 +77,10 @@ interface WhatIfPanelProps { const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { const theme = useTheme(); + const { addSuccessToast, addDangerToast } = useToasts(); + + // Get dashboard ID from Redux + const dashboardId = useSelector((state: RootState) => state.dashboardInfo.id); // Local state for column selection and slider const [selectedColumn, setSelectedColumn] = useState<string | undefined>( @@ -80,6 +89,12 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { const [sliderValue, setSliderValue] = useState<number>(SLIDER_DEFAULT); const [enableCascadingEffects, setEnableCascadingEffects] = useState(false); + // Simulation state + const [selectedSimulation, setSelectedSimulation] = + useState<WhatIfSimulation | null>(null); + const [saveModalVisible, setSaveModalVisible] = useState(false); + const [simulationRefreshTrigger, setSimulationRefreshTrigger] = useState(0); + // Custom hook for filter management const { filters, @@ -105,6 +120,9 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { handleApply, handleDismissLoader, aiInsightsModifications, + loadModificationsDirectly, + clearModifications, + interpretAbortRef, } = useWhatIfApply({ selectedColumn, sliderValue, @@ -167,6 +185,65 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { setEnableCascadingEffects(e.target.checked); }, []); + // Handle loading a saved simulation + const handleLoadSimulation = useCallback( + (simulation: WhatIfSimulation | null) => { + setSelectedSimulation(simulation); + if (simulation && simulation.modifications.length > 0) { + const firstMod = simulation.modifications[0]; + setSelectedColumn(firstMod.column); + setSliderValue((firstMod.multiplier - 1) * 100); + setEnableCascadingEffects(simulation.cascadingEffectsEnabled); + + // Convert to extended modifications with isAISuggested flag + // First modification is the user's, rest are AI-suggested + const extendedModifications = simulation.modifications.map( + (mod, index) => ({ + column: mod.column, + multiplier: mod.multiplier, + filters: mod.filters, + isAISuggested: index > 0, + }), + ); + + // Load all modifications directly and trigger chart queries + /interpret + loadModificationsDirectly(extendedModifications); + } else if (!simulation) { + // Clear state when deselecting + setSelectedColumn(undefined); + setSliderValue(SLIDER_DEFAULT); + setEnableCascadingEffects(false); + clearFilters(); + clearModifications(); + } + }, + [clearFilters, loadModificationsDirectly, clearModifications], + ); + + // Handle saving a simulation + const handleSaveSimulation = useCallback((simulation: WhatIfSimulation) => { + setSelectedSimulation(simulation); + setSimulationRefreshTrigger(prev => prev + 1); + }, []); + + // Track if we're saving as new (vs updating existing) + const [isSavingAsNew, setIsSavingAsNew] = useState(false); + + const handleOpenSaveModal = useCallback(() => { + setIsSavingAsNew(false); + setSaveModalVisible(true); + }, []); + + const handleOpenSaveAsNewModal = useCallback(() => { + setIsSavingAsNew(true); + setSaveModalVisible(true); + }, []); + + const handleCloseSaveModal = useCallback(() => { + setSaveModalVisible(false); + setIsSavingAsNew(false); + }, []); + const isApplyDisabled = !selectedColumn || sliderValue === SLIDER_DEFAULT || isLoadingSuggestions; @@ -182,9 +259,21 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { /> {t('What-if playground')} </PanelTitle> - <CloseButton onClick={onClose} aria-label={t('Close')}> - <Icons.CloseOutlined iconSize="m" /> - </CloseButton> + <HeaderButtonsContainer> + <WhatIfHeaderMenu + dashboardId={dashboardId} + selectedSimulation={selectedSimulation} + onSelectSimulation={handleLoadSimulation} + onSaveClick={handleOpenSaveModal} + onSaveAsNewClick={handleOpenSaveAsNewModal} + hasModifications={appliedModifications.length > 0} + refreshTrigger={simulationRefreshTrigger} + addDangerToast={addDangerToast} + /> + <CloseButton onClick={onClose} aria-label={t('Close')}> + <Icons.CloseOutlined iconSize="m" /> + </CloseButton> + </HeaderButtonsContainer> </PanelHeader> <PanelContent> @@ -313,6 +402,7 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { key={applyCounter} affectedChartIds={affectedChartIds} modifications={aiInsightsModifications} + abortRef={interpretAbortRef} /> )} </PanelContent> @@ -320,6 +410,18 @@ const WhatIfPanel = ({ onClose, topOffset }: WhatIfPanelProps) => { {isLoadingSuggestions && ( <HarryPotterWandLoader onDismiss={handleDismissLoader} /> )} + + <SaveSimulationModal + show={saveModalVisible} + onHide={handleCloseSaveModal} + onSaved={handleSaveSimulation} + dashboardId={dashboardId} + modifications={appliedModifications} + cascadingEffectsEnabled={enableCascadingEffects} + existingSimulation={isSavingAsNew ? null : selectedSimulation} + addSuccessToast={addSuccessToast} + addDangerToast={addDangerToast} + /> </PanelContainer> ); }; diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/styles.ts b/superset-frontend/src/dashboard/components/WhatIfDrawer/styles.ts index 07b19317f0..4f43c41e30 100644 --- a/superset-frontend/src/dashboard/components/WhatIfDrawer/styles.ts +++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/styles.ts @@ -205,3 +205,9 @@ export const FilterTagsContainer = styled.div` flex-wrap: wrap; gap: ${({ theme }) => theme.sizeUnit}px; `; + +export const HeaderButtonsContainer = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.sizeUnit}px; +`; diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/useWhatIfApply.ts b/superset-frontend/src/dashboard/components/WhatIfDrawer/useWhatIfApply.ts index 7587b6dc14..b595f21e88 100644 --- a/superset-frontend/src/dashboard/components/WhatIfDrawer/useWhatIfApply.ts +++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/useWhatIfApply.ts @@ -45,6 +45,12 @@ export interface UseWhatIfApplyReturn { handleApply: () => Promise<void>; handleDismissLoader: () => void; aiInsightsModifications: WhatIfModification[]; + loadModificationsDirectly: ( + modifications: ExtendedWhatIfModification[], + ) => void; + clearModifications: () => void; + /** Ref to register an abort function from WhatIfAIInsights */ + interpretAbortRef: React.MutableRefObject<(() => void) | null>; } /** @@ -74,6 +80,9 @@ export function useWhatIfApply({ // AbortController for cancelling in-flight /suggest_related requests const suggestionsAbortControllerRef = useRef<AbortController | null>(null); + // Ref to hold the abort function from WhatIfAIInsights for /interpret requests + const interpretAbortRef = useRef<(() => void) | null>(null); + const { numericColumns, columnToChartIds } = useNumericColumns(); const dashboardInfo = useSelector((state: RootState) => state.dashboardInfo); @@ -88,8 +97,9 @@ export function useWhatIfApply({ const handleApply = useCallback(async () => { if (!selectedColumn) return; - // Cancel any in-flight suggestions request + // Cancel any in-flight requests suggestionsAbortControllerRef.current?.abort(); + interpretAbortRef.current?.(); // Immediately clear previous results and increment counter to reset AI insights component setAppliedModifications([]); @@ -208,6 +218,71 @@ export function useWhatIfApply({ setIsLoadingSuggestions(false); }, []); + /** + * Load modifications directly without fetching AI suggestions. + * Used when loading a saved simulation. + */ + const loadModificationsDirectly = useCallback( + (modifications: ExtendedWhatIfModification[]) => { + // Cancel any in-flight requests + suggestionsAbortControllerRef.current?.abort(); + interpretAbortRef.current?.(); + setIsLoadingSuggestions(false); + + // Increment counter to reset AI insights component + setApplyCounter(c => c + 1); + + setAppliedModifications(modifications); + + // Collect all affected chart IDs from all modifications + const allAffectedChartIds = new Set<number>(); + modifications.forEach(mod => { + const chartIds = columnToChartIds.get(mod.column) || []; + chartIds.forEach(id => allAffectedChartIds.add(id)); + }); + const chartIdsArray = Array.from(allAffectedChartIds); + + // Save original chart data before applying what-if modifications + chartIdsArray.forEach(chartId => { + dispatch(saveOriginalChartData(chartId)); + }); + + // Set the what-if modifications in Redux state + dispatch( + setWhatIfModifications( + modifications.map(mod => ({ + column: mod.column, + multiplier: mod.multiplier, + filters: mod.filters, + })), + ), + ); + + // Trigger queries for all affected charts + chartIdsArray.forEach(chartId => { + dispatch(triggerQuery(true, chartId)); + }); + + // Set affected chart IDs to enable AI insights + setAffectedChartIds(chartIdsArray); + }, + [dispatch, columnToChartIds], + ); + + /** + * Clear all modifications and reset state. + */ + const clearModifications = useCallback(() => { + // Cancel any in-flight requests + suggestionsAbortControllerRef.current?.abort(); + interpretAbortRef.current?.(); + setIsLoadingSuggestions(false); + + setAppliedModifications([]); + setAffectedChartIds([]); + dispatch(setWhatIfModifications([])); + }, [dispatch]); + // Memoize modifications array for WhatIfAIInsights to prevent unnecessary re-renders const aiInsightsModifications = useMemo( () => @@ -227,6 +302,9 @@ export function useWhatIfApply({ handleApply, handleDismissLoader, aiInsightsModifications, + loadModificationsDirectly, + clearModifications, + interpretAbortRef, }; } diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/whatIfApi.ts b/superset-frontend/src/dashboard/components/WhatIfDrawer/whatIfApi.ts index cc1b247d93..40dcf724ab 100644 --- a/superset-frontend/src/dashboard/components/WhatIfDrawer/whatIfApi.ts +++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/whatIfApi.ts @@ -23,11 +23,43 @@ import { WhatIfInterpretResponse, ChartComparison, WhatIfFilter, + WhatIfModification, WhatIfSuggestRelatedRequest, WhatIfSuggestRelatedResponse, SuggestedModification, } from './types'; +// ============================================================================= +// Simulation CRUD Types +// ============================================================================= + +export interface WhatIfSimulation { + id: number; + uuid: string; + name: string; + description?: string | null; + dashboardId: number; + modifications: WhatIfModification[]; + cascadingEffectsEnabled: boolean; + createdOn?: string | null; + changedOn?: string | null; +} + +export interface CreateSimulationRequest { + name: string; + description?: string; + dashboardId: number; + modifications: WhatIfModification[]; + cascadingEffectsEnabled: boolean; +} + +export interface UpdateSimulationRequest { + name?: string; + description?: string; + modifications?: WhatIfModification[]; + cascadingEffectsEnabled?: boolean; +} + interface ApiResponse { result: { summary: string; @@ -137,3 +169,160 @@ export async function fetchRelatedColumnSuggestions( explanation: result.explanation, }; } + +// ============================================================================= +// Simulation CRUD API Functions +// ============================================================================= + +interface SimulationListResponse { + result: Array<{ + id: number; + uuid: string; + name: string; + description?: string | null; + dashboard_id?: number; + modifications: Array<{ + column: string; + multiplier: number; + filters?: Array<{ + col: string; + op: string; + val: string | number | boolean | Array<string | number>; + }>; + }>; + cascading_effects_enabled: boolean; + created_on?: string | null; + changed_on?: string | null; + }>; +} + +interface SimulationCreateResponse { + id: number; + uuid: string; +} + +export async function fetchAllSimulations(): Promise<WhatIfSimulation[]> { + const response = await SupersetClient.get({ + endpoint: '/api/v1/what_if/simulations', + }); + + const data = response.json as SimulationListResponse; + return data.result.map(sim => ({ + id: sim.id, + uuid: sim.uuid, + name: sim.name, + description: sim.description, + dashboardId: sim.dashboard_id ?? 0, + modifications: sim.modifications.map(mod => ({ + column: mod.column, + multiplier: mod.multiplier, + filters: mod.filters?.map(f => ({ + col: f.col, + op: f.op as WhatIfFilter['op'], + val: f.val, + })), + })), + cascadingEffectsEnabled: sim.cascading_effects_enabled, + createdOn: sim.created_on, + changedOn: sim.changed_on, + })); +} + +export async function fetchSimulations( + dashboardId: number, +): Promise<WhatIfSimulation[]> { + const response = await SupersetClient.get({ + endpoint: `/api/v1/what_if/simulations/dashboard/${dashboardId}`, + }); + + const data = response.json as SimulationListResponse; + return data.result.map(sim => ({ + id: sim.id, + uuid: sim.uuid, + name: sim.name, + description: sim.description, + dashboardId, + modifications: sim.modifications.map(mod => ({ + column: mod.column, + multiplier: mod.multiplier, + filters: mod.filters?.map(f => ({ + col: f.col, + op: f.op as WhatIfFilter['op'], + val: f.val, + })), + })), + cascadingEffectsEnabled: sim.cascading_effects_enabled, + createdOn: sim.created_on, + changedOn: sim.changed_on, + })); +} + +export async function createSimulation( + request: CreateSimulationRequest, +): Promise<WhatIfSimulation> { + const response = await SupersetClient.post({ + endpoint: '/api/v1/what_if/simulations', + jsonPayload: { + name: request.name, + description: request.description, + dashboard_id: request.dashboardId, + modifications: request.modifications.map(mod => ({ + column: mod.column, + multiplier: mod.multiplier, + filters: mod.filters?.map(f => ({ + col: f.col, + op: f.op, + val: f.val, + })), + })), + cascading_effects_enabled: request.cascadingEffectsEnabled, + }, + }); + + const data = response.json as SimulationCreateResponse; + return { + id: data.id, + uuid: data.uuid, + name: request.name, + description: request.description, + dashboardId: request.dashboardId, + modifications: request.modifications, + cascadingEffectsEnabled: request.cascadingEffectsEnabled, + }; +} + +export async function updateSimulation( + simulationId: number, + request: UpdateSimulationRequest, +): Promise<void> { + const payload: Record<string, unknown> = {}; + + if (request.name !== undefined) payload.name = request.name; + if (request.description !== undefined) + payload.description = request.description; + if (request.cascadingEffectsEnabled !== undefined) { + payload.cascading_effects_enabled = request.cascadingEffectsEnabled; + } + if (request.modifications !== undefined) { + payload.modifications = request.modifications.map(mod => ({ + column: mod.column, + multiplier: mod.multiplier, + filters: mod.filters?.map(f => ({ + col: f.col, + op: f.op, + val: f.val, + })), + })); + } + + await SupersetClient.put({ + endpoint: `/api/v1/what_if/simulations/${simulationId}`, + jsonPayload: payload, + }); +} + +export async function deleteSimulation(simulationId: number): Promise<void> { + await SupersetClient.delete({ + endpoint: `/api/v1/what_if/simulations/${simulationId}`, + }); +} diff --git a/superset-frontend/src/pages/WhatIfSimulationList/index.tsx b/superset-frontend/src/pages/WhatIfSimulationList/index.tsx new file mode 100644 index 0000000000..102f92bbd2 --- /dev/null +++ b/superset-frontend/src/pages/WhatIfSimulationList/index.tsx @@ -0,0 +1,385 @@ +/** + * 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 { useMemo, useState, useEffect, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { t, SupersetClient } from '@superset-ui/core'; +import { css, styled } from '@apache-superset/core/ui'; +import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates'; + +import { + ConfirmStatusChange, + DeleteModal, + Empty, + Skeleton, +} from '@superset-ui/core/components'; +import { + ListView, + ListViewActionsBar, + type ListViewProps, + type ListViewActionProps, +} from 'src/components'; +import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; +import withToasts from 'src/components/MessageToasts/withToasts'; + +import { + fetchAllSimulations, + deleteSimulation, + WhatIfSimulation, +} from 'src/dashboard/components/WhatIfDrawer/whatIfApi'; + +const PAGE_SIZE = 25; + +interface WhatIfSimulationListProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; +} + +interface DashboardInfo { + id: number; + dashboard_title: string; + slug: string | null; +} + +const PageContainer = styled.div` + padding: ${({ theme }) => theme.gridUnit * 4}px; +`; + +const EmptyContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: ${({ theme }) => theme.gridUnit * 16}px; +`; + +function WhatIfSimulationList({ + addDangerToast, + addSuccessToast, +}: WhatIfSimulationListProps) { + const [simulations, setSimulations] = useState<WhatIfSimulation[]>([]); + const [dashboards, setDashboards] = useState<Record<number, DashboardInfo>>( + {}, + ); + const [loading, setLoading] = useState(true); + const [simulationCurrentlyDeleting, setSimulationCurrentlyDeleting] = + useState<WhatIfSimulation | null>(null); + + const loadSimulations = useCallback(async () => { + setLoading(true); + try { + const result = await fetchAllSimulations(); + setSimulations(result); + + // Fetch dashboard info for all unique dashboard IDs + const dashboardIds = [...new Set(result.map(sim => sim.dashboardId))]; + if (dashboardIds.length > 0) { + const dashboardInfos: Record<number, DashboardInfo> = {}; + await Promise.all( + dashboardIds.map(async id => { + try { + const response = await SupersetClient.get({ + endpoint: `/api/v1/dashboard/${id}`, + }); + dashboardInfos[id] = { + id, + dashboard_title: response.json.result.dashboard_title, + slug: response.json.result.slug, + }; + } catch { + dashboardInfos[id] = { + id, + dashboard_title: `Dashboard ${id}`, + slug: null, + }; + } + }), + ); + setDashboards(dashboardInfos); + } + } catch (error) { + addDangerToast(t('Failed to load simulations')); + } finally { + setLoading(false); + } + }, [addDangerToast]); + + useEffect(() => { + loadSimulations(); + }, [loadSimulations]); + + const handleDelete = useCallback( + async (simulation: WhatIfSimulation) => { + try { + await deleteSimulation(simulation.id); + setSimulationCurrentlyDeleting(null); + addSuccessToast(t('Deleted: %s', simulation.name)); + loadSimulations(); + } catch (error) { + addDangerToast(t('Failed to delete simulation')); + } + }, + [addSuccessToast, addDangerToast, loadSimulations], + ); + + const handleBulkDelete = useCallback( + async (simulationsToDelete: WhatIfSimulation[]) => { + try { + await Promise.all( + simulationsToDelete.map(sim => deleteSimulation(sim.id)), + ); + addSuccessToast( + t('Deleted %s simulation(s)', simulationsToDelete.length), + ); + loadSimulations(); + } catch (error) { + addDangerToast(t('Failed to delete simulations')); + } + }, + [addSuccessToast, addDangerToast, loadSimulations], + ); + + const menuData: SubMenuProps = { + name: t('What-If Simulations'), + }; + + const initialSort = [{ id: 'changedOn', desc: true }]; + + const columns = useMemo( + () => [ + { + accessor: 'name', + Header: t('Name'), + size: 'xxl', + id: 'name', + }, + { + accessor: 'description', + Header: t('Description'), + size: 'xl', + id: 'description', + Cell: ({ + row: { + original: { description }, + }, + }: { + row: { original: WhatIfSimulation }; + }) => description || '-', + }, + { + accessor: 'dashboardId', + Header: t('Dashboard'), + size: 'lg', + id: 'dashboardId', + Cell: ({ + row: { + original: { dashboardId }, + }, + }: { + row: { original: WhatIfSimulation }; + }) => { + const dashboard = dashboards[dashboardId]; + if (!dashboard) return `Dashboard ${dashboardId}`; + const url = dashboard.slug + ? `/superset/dashboard/${dashboard.slug}/` + : `/superset/dashboard/${dashboardId}/`; + return <Link to={url}>{dashboard.dashboard_title}</Link>; + }, + }, + { + accessor: 'modifications', + Header: t('Modifications'), + size: 'md', + id: 'modifications', + disableSortBy: true, + Cell: ({ + row: { + original: { modifications }, + }, + }: { + row: { original: WhatIfSimulation }; + }) => modifications.length, + }, + { + accessor: 'changedOn', + Header: t('Last modified'), + size: 'lg', + id: 'changedOn', + Cell: ({ + row: { + original: { changedOn }, + }, + }: { + row: { original: WhatIfSimulation }; + }) => (changedOn ? dayjs(changedOn).format('ll') : '-'), + }, + { + Cell: ({ + row: { original }, + }: { + row: { original: WhatIfSimulation }; + }) => { + const dashboard = dashboards[original.dashboardId]; + const dashboardUrl = dashboard?.slug + ? `/superset/dashboard/${dashboard.slug}/` + : `/superset/dashboard/${original.dashboardId}/`; + + const handleOpen = () => { + window.location.href = dashboardUrl; + }; + const handleDelete = () => setSimulationCurrentlyDeleting(original); + + const actions = [ + { + label: 'open-action', + tooltip: t('Open in Dashboard'), + placement: 'bottom', + icon: 'ExportOutlined', + onClick: handleOpen, + }, + { + label: 'delete-action', + tooltip: t('Delete simulation'), + placement: 'bottom', + icon: 'DeleteOutlined', + onClick: handleDelete, + }, + ]; + + return ( + <ListViewActionsBar actions={actions as ListViewActionProps[]} /> + ); + }, + Header: t('Actions'), + id: 'actions', + disableSortBy: true, + }, + ], + [dashboards], + ); + + const emptyState = { + title: t('No simulations yet'), + image: 'filter-results.svg', + description: t( + 'Create your first What-If simulation from the What-If panel in a dashboard.', + ), + }; + + if (loading) { + return ( + <> + <SubMenu {...menuData} /> + <PageContainer> + <Skeleton active /> + </PageContainer> + </> + ); + } + + if (simulations.length === 0) { + return ( + <> + <SubMenu {...menuData} /> + <EmptyContainer> + <Empty + image="simple" + description={ + <> + <div + css={css` + font-weight: 600; + margin-bottom: 8px; + `} + > + {t('No simulations yet')} + </div> + <div> + {t( + 'Create your first What-If simulation from the What-If panel in a dashboard.', + )} + </div> + </> + } + /> + </EmptyContainer> + </> + ); + } + + return ( + <> + <SubMenu {...menuData} /> + {simulationCurrentlyDeleting && ( + <DeleteModal + description={t( + 'Are you sure you want to delete %s?', + simulationCurrentlyDeleting.name, + )} + onConfirm={() => { + if (simulationCurrentlyDeleting) { + handleDelete(simulationCurrentlyDeleting); + } + }} + onHide={() => setSimulationCurrentlyDeleting(null)} + open + title={t('Delete Simulation?')} + /> + )} + <ConfirmStatusChange + title={t('Please confirm')} + description={t( + 'Are you sure you want to delete the selected simulations?', + )} + onConfirm={handleBulkDelete} + > + {confirmDelete => { + const bulkActions: ListViewProps['bulkActions'] = [ + { + key: 'delete', + name: t('Delete'), + onSelect: confirmDelete, + type: 'danger', + }, + ]; + + return ( + <ListView<WhatIfSimulation> + bulkActions={bulkActions} + bulkSelectEnabled={false} + columns={columns} + count={simulations.length} + data={simulations} + emptyState={emptyState} + fetchData={() => {}} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + refreshData={loadSimulations} + initialSort={initialSort} + loading={loading} + pageSize={PAGE_SIZE} + /> + ); + }} + </ConfirmStatusChange> + </> + ); +} + +export default withToasts(WhatIfSimulationList); diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index db4fff7214..e88014a1c5 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -138,6 +138,13 @@ const RowLevelSecurityList = lazy( ), ); +const WhatIfSimulationList = lazy( + () => + import( + /* webpackChunkName: "WhatIfSimulationList" */ 'src/pages/WhatIfSimulationList' + ), +); + const RolesList = lazy( () => import(/* webpackChunkName: "RolesList" */ 'src/pages/RolesList'), ); @@ -289,6 +296,10 @@ export const routes: Routes = [ path: '/rowlevelsecurity/list', Component: RowLevelSecurityList, }, + { + path: '/whatif/simulations/', + Component: WhatIfSimulationList, + }, { path: '/sqllab/', Component: SqlLab, diff --git a/superset/daos/what_if_simulation.py b/superset/daos/what_if_simulation.py new file mode 100644 index 0000000000..cd478d9fe9 --- /dev/null +++ b/superset/daos/what_if_simulation.py @@ -0,0 +1,106 @@ +# 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. +"""DAO for What-If Simulation persistence.""" + +from __future__ import annotations + +import logging +from typing import Optional + +from superset.daos.base import BaseDAO +from superset.extensions import db +from superset.utils.core import get_user_id +from superset.what_if.models import WhatIfSimulation + +logger = logging.getLogger(__name__) + + +class WhatIfSimulationDAO(BaseDAO[WhatIfSimulation]): + """Data access object for What-If Simulations.""" + + @classmethod + def find_by_dashboard_and_user( + cls, + dashboard_id: int, + user_id: Optional[int] = None, + ) -> list[WhatIfSimulation]: + """ + Find all simulations for a dashboard owned by a specific user. + + :param dashboard_id: The dashboard ID + :param user_id: The user ID (defaults to current user) + :returns: List of simulations + """ + if user_id is None: + user_id = get_user_id() + + return ( + db.session.query(WhatIfSimulation) + .filter( + WhatIfSimulation.dashboard_id == dashboard_id, + WhatIfSimulation.user_id == user_id, + ) + .order_by(WhatIfSimulation.changed_on.desc()) + .all() + ) + + @classmethod + def find_all_for_user( + cls, + user_id: Optional[int] = None, + ) -> list[WhatIfSimulation]: + """ + Find all simulations owned by a user across all dashboards. + + :param user_id: The user ID (defaults to current user) + :returns: List of simulations + """ + if user_id is None: + user_id = get_user_id() + + return ( + db.session.query(WhatIfSimulation) + .filter(WhatIfSimulation.user_id == user_id) + .order_by(WhatIfSimulation.changed_on.desc()) + .all() + ) + + @classmethod + def validate_name_uniqueness( + cls, + name: str, + dashboard_id: int, + user_id: int, + simulation_id: Optional[int] = None, + ) -> bool: + """ + Validate if simulation name is unique for this dashboard/user combo. + + :param name: The simulation name + :param dashboard_id: The dashboard ID + :param user_id: The user ID + :param simulation_id: Optional simulation ID (for updates) + :returns: True if unique, False otherwise + """ + query = db.session.query(WhatIfSimulation).filter( + WhatIfSimulation.name == name, + WhatIfSimulation.dashboard_id == dashboard_id, + WhatIfSimulation.user_id == user_id, + ) + if simulation_id: + query = query.filter(WhatIfSimulation.id != simulation_id) + return not db.session.query(query.exists()).scalar() diff --git a/superset/migrations/versions/2025-12-19_10-00_b8f3a2c9d1e5_add_what_if_simulations.py b/superset/migrations/versions/2025-12-19_10-00_b8f3a2c9d1e5_add_what_if_simulations.py new file mode 100644 index 0000000000..7c361ceea7 --- /dev/null +++ b/superset/migrations/versions/2025-12-19_10-00_b8f3a2c9d1e5_add_what_if_simulations.py @@ -0,0 +1,116 @@ +# 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_what_if_simulations + +Revision ID: b8f3a2c9d1e5 +Revises: a9c01ec10479 +Create Date: 2025-12-19 10:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import mysql +from sqlalchemy_utils import UUIDType + +from superset.migrations.shared.utils import ( + create_fks_for_table, + create_table, + drop_table, +) + +# revision identifiers, used by Alembic. +revision = "b8f3a2c9d1e5" +down_revision = "a9c01ec10479" + + +def upgrade(): + create_table( + "what_if_simulations", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("uuid", UUIDType(binary=True), nullable=False), + sa.Column("name", sa.String(length=256), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("dashboard_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column( + "modifications_json", + sa.Text().with_variant(mysql.MEDIUMTEXT(), "mysql"), + nullable=False, + ), + sa.Column( + "cascading_effects_enabled", + sa.Boolean(), + server_default=sa.false(), + nullable=False, + ), + sa.Column("created_on", sa.DateTime(), nullable=True), + sa.Column("changed_on", sa.DateTime(), nullable=True), + sa.Column("created_by_fk", sa.Integer(), nullable=True), + sa.Column("changed_by_fk", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("uuid"), + ) + + # Create index for fast lookup by dashboard_id + user_id + op.create_index( + "ix_what_if_simulations_dashboard_user", + "what_if_simulations", + ["dashboard_id", "user_id"], + ) + + # Create foreign key constraints + create_fks_for_table( + "fk_what_if_simulations_dashboard_id_dashboards", + "what_if_simulations", + "dashboards", + ["dashboard_id"], + ["id"], + ondelete="CASCADE", + ) + + create_fks_for_table( + "fk_what_if_simulations_user_id_ab_user", + "what_if_simulations", + "ab_user", + ["user_id"], + ["id"], + ) + + create_fks_for_table( + "fk_what_if_simulations_created_by_fk_ab_user", + "what_if_simulations", + "ab_user", + ["created_by_fk"], + ["id"], + ) + + create_fks_for_table( + "fk_what_if_simulations_changed_by_fk_ab_user", + "what_if_simulations", + "ab_user", + ["changed_by_fk"], + ["id"], + ) + + +def downgrade(): + op.drop_index( + "ix_what_if_simulations_dashboard_user", + table_name="what_if_simulations", + ) + drop_table("what_if_simulations") diff --git a/superset/what_if/api.py b/superset/what_if/api.py index 5ed560b0cf..6c67086b74 100644 --- a/superset/what_if/api.py +++ b/superset/what_if/api.py @@ -25,14 +25,30 @@ from flask_appbuilder.api import expose, protect, safe from marshmallow import ValidationError from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP +from superset.daos.what_if_simulation import WhatIfSimulationDAO from superset.extensions import event_logger +from superset.utils import json from superset.views.base_api import BaseSupersetApi, statsd_metrics from superset.what_if.commands.interpret import WhatIfInterpretCommand +from superset.what_if.commands.simulation_create import CreateWhatIfSimulationCommand +from superset.what_if.commands.simulation_delete import DeleteWhatIfSimulationCommand +from superset.what_if.commands.simulation_update import UpdateWhatIfSimulationCommand from superset.what_if.commands.suggest_related import WhatIfSuggestRelatedCommand -from superset.what_if.exceptions import OpenRouterAPIError, OpenRouterConfigError +from superset.what_if.exceptions import ( + OpenRouterAPIError, + OpenRouterConfigError, + WhatIfSimulationCreateFailedError, + WhatIfSimulationDeleteFailedError, + WhatIfSimulationForbiddenError, + WhatIfSimulationInvalidError, + WhatIfSimulationNotFoundError, + WhatIfSimulationUpdateFailedError, +) from superset.what_if.schemas import ( WhatIfInterpretRequestSchema, WhatIfInterpretResponseSchema, + WhatIfSimulationPostSchema, + WhatIfSimulationPutSchema, WhatIfSuggestRelatedRequestSchema, WhatIfSuggestRelatedResponseSchema, ) @@ -52,9 +68,9 @@ class WhatIfRestApi(BaseSupersetApi): method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP @expose("/interpret", methods=("POST",)) - @event_logger.log_this @protect() @safe + @event_logger.log_this @statsd_metrics def interpret(self) -> Response: """Generate AI interpretation of what-if analysis results. @@ -121,9 +137,9 @@ class WhatIfRestApi(BaseSupersetApi): return self.response_400(message=str(ex)) @expose("/suggest_related", methods=("POST",)) - @event_logger.log_this @protect() @safe + @event_logger.log_this @statsd_metrics def suggest_related(self) -> Response: """Get AI suggestions for related column modifications. @@ -189,3 +205,269 @@ class WhatIfRestApi(BaseSupersetApi): except ValueError as ex: logger.warning("Invalid request: %s", ex) return self.response_400(message=str(ex)) + + # ========================================================================= + # Simulation CRUD Endpoints + # ========================================================================= + + @expose("/simulations", methods=("GET",)) + @protect() + @safe + @event_logger.log_this + @statsd_metrics + def list_all_simulations(self) -> Response: + """List all saved simulations for the current user across all dashboards. + --- + get: + summary: List all What-If simulations for current user + responses: + 200: + description: List of simulations + content: + application/json: + schema: + type: object + properties: + result: + type: array + 401: + $ref: '#/components/responses/401' + security: + - jwt: [] + """ + simulations = WhatIfSimulationDAO.find_all_for_user() + result = [ + { + "id": sim.id, + "uuid": str(sim.uuid), + "name": sim.name, + "description": sim.description, + "dashboard_id": sim.dashboard_id, + "modifications": json.loads(sim.modifications_json), + "cascading_effects_enabled": sim.cascading_effects_enabled, + "created_on": sim.created_on.isoformat() if sim.created_on else None, + "changed_on": sim.changed_on.isoformat() if sim.changed_on else None, + } + for sim in simulations + ] + return self.response(200, result=result) + + @expose("/simulations/dashboard/<int:dashboard_id>", methods=("GET",)) + @protect() + @safe + @event_logger.log_this + @statsd_metrics + def list_simulations(self, dashboard_id: int) -> Response: + """List all saved simulations for a dashboard (current user only). + --- + get: + summary: List What-If simulations for a dashboard + parameters: + - in: path + name: dashboard_id + schema: + type: integer + required: true + responses: + 200: + description: List of simulations + content: + application/json: + schema: + type: object + properties: + result: + type: array + 401: + $ref: '#/components/responses/401' + security: + - jwt: [] + """ + simulations = WhatIfSimulationDAO.find_by_dashboard_and_user(dashboard_id) + result = [ + { + "id": sim.id, + "uuid": str(sim.uuid), + "name": sim.name, + "description": sim.description, + "modifications": json.loads(sim.modifications_json), + "cascading_effects_enabled": sim.cascading_effects_enabled, + "created_on": sim.created_on.isoformat() if sim.created_on else None, + "changed_on": sim.changed_on.isoformat() if sim.changed_on else None, + } + for sim in simulations + ] + return self.response(200, result=result) + + @expose("/simulations", methods=("POST",)) + @protect() + @safe + @event_logger.log_this + @statsd_metrics + def create_simulation(self) -> Response: + """Create a new What-If simulation. + --- + post: + summary: Save a new What-If simulation + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WhatIfSimulationPostSchema' + responses: + 201: + description: Simulation created + content: + application/json: + schema: + type: object + properties: + id: + type: integer + uuid: + type: string + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 422: + $ref: '#/components/responses/422' + security: + - jwt: [] + """ + try: + data = WhatIfSimulationPostSchema().load(request.json) + except ValidationError as ex: + logger.warning("Invalid request data: %s", ex.messages) + return self.response_400(message=str(ex.messages)) + + # Serialize modifications to JSON + data["modifications_json"] = json.dumps(data.pop("modifications")) + + try: + simulation = CreateWhatIfSimulationCommand(data).run() + return self.response( + 201, + id=simulation.id, + uuid=str(simulation.uuid), + ) + except WhatIfSimulationInvalidError as ex: + return self.response_422(message=ex.normalized_messages()) + except WhatIfSimulationCreateFailedError as ex: + logger.error("Error creating simulation: %s", ex) + return self.response_422(message=str(ex)) + + @expose("/simulations/<int:pk>", methods=("PUT",)) + @protect() + @safe + @event_logger.log_this + @statsd_metrics + def update_simulation(self, pk: int) -> Response: + """Update a What-If simulation. + --- + put: + summary: Update a What-If simulation + parameters: + - in: path + name: pk + schema: + type: integer + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WhatIfSimulationPutSchema' + responses: + 200: + description: Simulation updated + content: + application/json: + schema: + type: object + properties: + id: + type: integer + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + security: + - jwt: [] + """ + try: + data = WhatIfSimulationPutSchema().load(request.json) + except ValidationError as ex: + logger.warning("Invalid request data: %s", ex.messages) + return self.response_400(message=str(ex.messages)) + + # Serialize modifications to JSON if present + if "modifications" in data: + data["modifications_json"] = json.dumps(data.pop("modifications")) + + try: + simulation = UpdateWhatIfSimulationCommand(pk, data).run() + return self.response(200, id=simulation.id) + except WhatIfSimulationNotFoundError: + return self.response_404() + except WhatIfSimulationForbiddenError: + return self.response_403() + except WhatIfSimulationInvalidError as ex: + return self.response_422(message=ex.normalized_messages()) + except WhatIfSimulationUpdateFailedError as ex: + logger.error("Error updating simulation: %s", ex) + return self.response_422(message=str(ex)) + + @expose("/simulations/<int:pk>", methods=("DELETE",)) + @protect() + @safe + @event_logger.log_this + @statsd_metrics + def delete_simulation(self, pk: int) -> Response: + """Delete a What-If simulation. + --- + delete: + summary: Delete a What-If simulation + parameters: + - in: path + name: pk + schema: + type: integer + required: true + responses: + 200: + description: Simulation deleted + content: + application/json: + schema: + type: object + properties: + message: + type: string + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 404: + $ref: '#/components/responses/404' + security: + - jwt: [] + """ + try: + DeleteWhatIfSimulationCommand([pk]).run() + return self.response(200, message="OK") + except WhatIfSimulationNotFoundError: + return self.response_404() + except WhatIfSimulationForbiddenError: + return self.response_403() + except WhatIfSimulationDeleteFailedError as ex: + logger.error("Error deleting simulation: %s", ex) + return self.response_422(message=str(ex)) diff --git a/superset/what_if/commands/interpret.py b/superset/what_if/commands/interpret.py index 1611c83d28..fd0fdc6730 100644 --- a/superset/what_if/commands/interpret.py +++ b/superset/what_if/commands/interpret.py @@ -18,7 +18,6 @@ from __future__ import annotations -import json import logging from typing import Any @@ -26,6 +25,7 @@ import httpx from flask import current_app from superset.commands.base import BaseCommand +from superset.utils import json from superset.what_if.exceptions import OpenRouterAPIError, OpenRouterConfigError logger = logging.getLogger(__name__) diff --git a/superset/what_if/commands/simulation_create.py b/superset/what_if/commands/simulation_create.py new file mode 100644 index 0000000000..e84606bb38 --- /dev/null +++ b/superset/what_if/commands/simulation_create.py @@ -0,0 +1,68 @@ +# 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. +"""Create What-If Simulation command.""" + +from __future__ import annotations + +import logging +from functools import partial +from typing import Any + +from flask_appbuilder.models.sqla import Model +from marshmallow import ValidationError + +from superset.commands.base import BaseCommand +from superset.daos.what_if_simulation import WhatIfSimulationDAO +from superset.utils.core import get_user_id +from superset.utils.decorators import on_error, transaction +from superset.what_if.exceptions import ( + WhatIfSimulationCreateFailedError, + WhatIfSimulationInvalidError, + WhatIfSimulationNameUniquenessError, +) + +logger = logging.getLogger(__name__) + + +class CreateWhatIfSimulationCommand(BaseCommand): + """Command to create a new What-If simulation.""" + + def __init__(self, data: dict[str, Any]): + self._properties = data.copy() + + @transaction(on_error=partial(on_error, reraise=WhatIfSimulationCreateFailedError)) + def run(self) -> Model: + self.validate() + user_id = get_user_id() + self._properties["user_id"] = user_id + return WhatIfSimulationDAO.create(attributes=self._properties) + + def validate(self) -> None: + exceptions: list[ValidationError] = [] + + name = self._properties.get("name", "") + dashboard_id = self._properties.get("dashboard_id") + user_id = get_user_id() + + # Validate name uniqueness for this dashboard/user + if not WhatIfSimulationDAO.validate_name_uniqueness( + name, dashboard_id, user_id + ): + exceptions.append(WhatIfSimulationNameUniquenessError()) + + if exceptions: + raise WhatIfSimulationInvalidError(exceptions=exceptions) diff --git a/superset/what_if/commands/simulation_delete.py b/superset/what_if/commands/simulation_delete.py new file mode 100644 index 0000000000..c26ac43bd0 --- /dev/null +++ b/superset/what_if/commands/simulation_delete.py @@ -0,0 +1,60 @@ +# 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. +"""Delete What-If Simulation command.""" + +from __future__ import annotations + +import logging +from functools import partial + +from superset.commands.base import BaseCommand +from superset.daos.what_if_simulation import WhatIfSimulationDAO +from superset.utils.core import get_user_id +from superset.utils.decorators import on_error, transaction +from superset.what_if.exceptions import ( + WhatIfSimulationDeleteFailedError, + WhatIfSimulationForbiddenError, + WhatIfSimulationNotFoundError, +) +from superset.what_if.models import WhatIfSimulation + +logger = logging.getLogger(__name__) + + +class DeleteWhatIfSimulationCommand(BaseCommand): + """Command to delete What-If simulation(s).""" + + def __init__(self, simulation_ids: list[int]): + self._simulation_ids = simulation_ids + self._models: list[WhatIfSimulation] = [] + + @transaction(on_error=partial(on_error, reraise=WhatIfSimulationDeleteFailedError)) + def run(self) -> None: + self.validate() + WhatIfSimulationDAO.delete(self._models) + + def validate(self) -> None: + user_id = get_user_id() + self._models = WhatIfSimulationDAO.find_by_ids(self._simulation_ids) + + if len(self._models) != len(self._simulation_ids): + raise WhatIfSimulationNotFoundError() + + # Check ownership of all simulations + for model in self._models: + if model.user_id != user_id: + raise WhatIfSimulationForbiddenError() diff --git a/superset/what_if/commands/simulation_update.py b/superset/what_if/commands/simulation_update.py new file mode 100644 index 0000000000..49c846dac0 --- /dev/null +++ b/superset/what_if/commands/simulation_update.py @@ -0,0 +1,82 @@ +# 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 What-If Simulation command.""" + +from __future__ import annotations + +import logging +from functools import partial +from typing import Any, Optional + +from flask_appbuilder.models.sqla import Model +from marshmallow import ValidationError + +from superset.commands.base import BaseCommand +from superset.daos.what_if_simulation import WhatIfSimulationDAO +from superset.utils.core import get_user_id +from superset.utils.decorators import on_error, transaction +from superset.what_if.exceptions import ( + WhatIfSimulationForbiddenError, + WhatIfSimulationInvalidError, + WhatIfSimulationNameUniquenessError, + WhatIfSimulationNotFoundError, + WhatIfSimulationUpdateFailedError, +) +from superset.what_if.models import WhatIfSimulation + +logger = logging.getLogger(__name__) + + +class UpdateWhatIfSimulationCommand(BaseCommand): + """Command to update a What-If simulation.""" + + def __init__(self, simulation_id: int, data: dict[str, Any]): + self._simulation_id = simulation_id + self._properties = data.copy() + self._model: Optional[WhatIfSimulation] = None + + @transaction(on_error=partial(on_error, reraise=WhatIfSimulationUpdateFailedError)) + def run(self) -> Model: + self.validate() + return WhatIfSimulationDAO.update(self._model, attributes=self._properties) + + def validate(self) -> None: + exceptions: list[ValidationError] = [] + + # Fetch model + self._model = WhatIfSimulationDAO.find_by_id(self._simulation_id) + if not self._model: + raise WhatIfSimulationNotFoundError() + + # Check ownership + user_id = get_user_id() + if self._model.user_id != user_id: + raise WhatIfSimulationForbiddenError() + + # Validate name uniqueness if name is being updated + name = self._properties.get("name") + if name and name != self._model.name: + if not WhatIfSimulationDAO.validate_name_uniqueness( + name, + self._model.dashboard_id, + user_id, + self._simulation_id, + ): + exceptions.append(WhatIfSimulationNameUniquenessError()) + + if exceptions: + raise WhatIfSimulationInvalidError(exceptions=exceptions) diff --git a/superset/what_if/commands/suggest_related.py b/superset/what_if/commands/suggest_related.py index 7cd5334a70..e0341f6e3c 100644 --- a/superset/what_if/commands/suggest_related.py +++ b/superset/what_if/commands/suggest_related.py @@ -18,7 +18,6 @@ from __future__ import annotations -import json import logging from typing import Any @@ -26,6 +25,7 @@ import httpx from flask import current_app from superset.commands.base import BaseCommand +from superset.utils import json from superset.what_if.exceptions import OpenRouterAPIError, OpenRouterConfigError logger = logging.getLogger(__name__) diff --git a/superset/what_if/exceptions.py b/superset/what_if/exceptions.py index 666423f29c..621b7c93b6 100644 --- a/superset/what_if/exceptions.py +++ b/superset/what_if/exceptions.py @@ -16,6 +16,16 @@ # under the License. """What-If Analysis exceptions.""" +from flask_babel import lazy_gettext as _ +from marshmallow import ValidationError + +from superset.commands.exceptions import ( + CommandException, + CommandInvalidError, + CreateFailedError, + DeleteFailedError, + ForbiddenError, +) from superset.exceptions import SupersetException @@ -35,3 +45,55 @@ class OpenRouterAPIError(WhatIfException): status = 502 message = "Error communicating with OpenRouter API" + + +# ============================================================================= +# Simulation persistence exceptions +# ============================================================================= + + +class WhatIfSimulationNameUniquenessError(ValidationError): + """Validation error for simulation name already exists.""" + + def __init__(self) -> None: + super().__init__( + [_("Name must be unique for this dashboard")], + field_name="name", + ) + + +class WhatIfSimulationNotFoundError(CommandException): + """Raised when a simulation is not found.""" + + status = 404 + message = _("What-If simulation not found.") + + +class WhatIfSimulationInvalidError(CommandInvalidError): + """Raised when simulation parameters are invalid.""" + + message = _("What-If simulation parameters are invalid.") + + +class WhatIfSimulationCreateFailedError(CreateFailedError): + """Raised when simulation creation fails.""" + + message = _("What-If simulation could not be created.") + + +class WhatIfSimulationUpdateFailedError(CreateFailedError): + """Raised when simulation update fails.""" + + message = _("What-If simulation could not be updated.") + + +class WhatIfSimulationDeleteFailedError(DeleteFailedError): + """Raised when simulation deletion fails.""" + + message = _("What-If simulation could not be deleted.") + + +class WhatIfSimulationForbiddenError(ForbiddenError): + """Raised when user doesn't have permission to access a simulation.""" + + message = _("You do not have permission to access this simulation.") diff --git a/superset/what_if/models.py b/superset/what_if/models.py new file mode 100644 index 0000000000..5ae8224058 --- /dev/null +++ b/superset/what_if/models.py @@ -0,0 +1,85 @@ +# 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. +"""What-If Simulation persistence models.""" + +from __future__ import annotations + +import json +import uuid +from typing import Any + +from flask_appbuilder import Model +from sqlalchemy import Boolean, Column, ForeignKey, Index, Integer, String, Text +from sqlalchemy.orm import relationship +from sqlalchemy_utils import UUIDType + +from superset import security_manager +from superset.models.helpers import AuditMixinNullable + + +class WhatIfSimulation(Model, AuditMixinNullable): + """Saved What-If simulation configuration.""" + + __tablename__ = "what_if_simulations" + + id = Column(Integer, primary_key=True) + uuid = Column( + UUIDType(binary=True), default=uuid.uuid4, unique=True, nullable=False + ) + name = Column(String(256), nullable=False) + description = Column(Text, nullable=True) + dashboard_id = Column( + Integer, ForeignKey("dashboards.id", ondelete="CASCADE"), nullable=False + ) + user_id = Column(Integer, ForeignKey("ab_user.id"), nullable=False) + + # JSON column storing modifications array + # Structure: [{"column": "...", "multiplier": 1.1, "filters": [...]}] + modifications_json = Column(Text, nullable=False) + + # Whether cascading effects were enabled when saved + cascading_effects_enabled = Column(Boolean, default=False, nullable=False) + + # Relationships + dashboard = relationship( + "Dashboard", + foreign_keys=[dashboard_id], + backref="what_if_simulations", + ) + user = relationship( + security_manager.user_model, + foreign_keys=[user_id], + ) + + __table_args__ = ( + Index("ix_what_if_simulations_dashboard_user", dashboard_id, user_id), + ) + + @property + def modifications(self) -> list[dict[str, Any]]: + """Parse and return modifications from JSON.""" + if self.modifications_json: + return json.loads(self.modifications_json) + return [] + + @modifications.setter + def modifications(self, value: list[dict[str, Any]]) -> None: + """Serialize modifications to JSON.""" + self.modifications_json = json.dumps(value) + + def __repr__(self) -> str: + return f"WhatIfSimulation<{self.name}>" diff --git a/superset/what_if/schemas.py b/superset/what_if/schemas.py index 87212daede..7bd8ebba5a 100644 --- a/superset/what_if/schemas.py +++ b/superset/what_if/schemas.py @@ -248,3 +248,82 @@ class WhatIfSuggestRelatedResponseSchema(Schema): load_default=None, metadata={"description": "Overall explanation of the relationship analysis"}, ) + + +# ============================================================================= +# Simulation CRUD Schemas +# ============================================================================= + + +class WhatIfSimulationPostSchema(Schema): + """Schema for creating a What-If simulation.""" + + name = fields.String( + required=True, + metadata={"description": "Name of the saved simulation"}, + ) + description = fields.String( + required=False, + allow_none=True, + load_default=None, + metadata={"description": "Optional description"}, + ) + dashboard_id = fields.Integer( + required=True, + metadata={"description": "ID of the dashboard this simulation belongs to"}, + ) + modifications = fields.List( + fields.Nested(ModificationSchema), + required=True, + metadata={"description": "List of column modifications"}, + ) + cascading_effects_enabled = fields.Boolean( + required=False, + load_default=False, + metadata={"description": "Whether AI cascading effects were enabled"}, + ) + + +class WhatIfSimulationPutSchema(Schema): + """Schema for updating a What-If simulation.""" + + name = fields.String( + required=False, + metadata={"description": "Name of the saved simulation"}, + ) + description = fields.String( + required=False, + allow_none=True, + metadata={"description": "Optional description"}, + ) + modifications = fields.List( + fields.Nested(ModificationSchema), + required=False, + metadata={"description": "List of column modifications"}, + ) + cascading_effects_enabled = fields.Boolean( + required=False, + metadata={"description": "Whether AI cascading effects were enabled"}, + ) + + +class WhatIfSimulationResponseSchema(Schema): + """Schema for simulation response.""" + + id = fields.Integer(metadata={"description": "Simulation ID"}) + uuid = fields.String(metadata={"description": "Simulation UUID"}) + name = fields.String(metadata={"description": "Simulation name"}) + description = fields.String( + allow_none=True, + metadata={"description": "Simulation description"}, + ) + dashboard_id = fields.Integer(metadata={"description": "Dashboard ID"}) + modifications = fields.List( + fields.Nested(ModificationSchema), + metadata={"description": "Saved modifications"}, + ) + cascading_effects_enabled = fields.Boolean( + metadata={"description": "Whether cascading effects were enabled"}, + ) + created_on = fields.DateTime(metadata={"description": "Creation timestamp"}) + changed_on = fields.DateTime(metadata={"description": "Last modified timestamp"})
