diff --git a/.vscode/launch.json b/.vscode/launch.json index f3eff9d04..e53308cd9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,7 +3,7 @@ "configurations": [ { "name": "Django: Run server", - "type": "python", + "type": "debugpy", "request": "launch", "program": "${workspaceFolder}/django_project/manage.py", "args": [ @@ -43,4 +43,4 @@ "request": "attach" } ] -} +} \ No newline at end of file diff --git a/deployment/docker/Dockerfile b/deployment/docker/Dockerfile index dc39ad4cf..973187e6a 100644 --- a/deployment/docker/Dockerfile +++ b/deployment/docker/Dockerfile @@ -44,9 +44,8 @@ ENV LANGUAGE en_ZA:en ENV LC_ALL en_ZA.UTF-8 ADD deployment/docker/requirements.txt /requirements.txt -RUN python3 -m pip install --upgrade pip setuptools wheel -RUN python3 -m pip install -r /requirements.txt - +RUN pip install --upgrade pip setuptools wheel Cython +RUN pip install --no-build-isolation -r /requirements.txt RUN ln -s /usr/bin/python3 /usr/local/bin/python # setup node diff --git a/deployment/docker/requirements.txt b/deployment/docker/requirements.txt index 76e06a59c..cd42cbf42 100644 --- a/deployment/docker/requirements.txt +++ b/deployment/docker/requirements.txt @@ -10,7 +10,7 @@ # __author__ = 'irwan@kartoza.com' # __date__ = '13/06/2023' # __copyright__ = ('Copyright 2023, Unicef') - +numpy==1.23.5 Django==3.2.16 django-braces==1.15.0 django-celery-beat==2.4.0 diff --git a/django_project/frontend/src/components/Input/ImageInput/index.js b/django_project/frontend/src/components/Input/ImageInput/index.js index b7078e35e..acfbf4b11 100644 --- a/django_project/frontend/src/components/Input/ImageInput/index.js +++ b/django_project/frontend/src/components/Input/ImageInput/index.js @@ -13,7 +13,7 @@ * __copyright__ = ('Copyright 2023, Unicef') */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { ThemeButton } from "../../Elements/Button"; import './style.scss' @@ -27,6 +27,10 @@ export function ImageInput( const [iconSrc, setIconSrc] = useState(image); + useEffect(() => { + setIconSrc(image); + }, [image]); + /** Image changed */ const imageChanged = (event) => { const [file] = event.target.files @@ -35,7 +39,7 @@ export function ImageInput( } else { setIconSrc(image); } - props.onChange() + props.onChange?.(event) } return
diff --git a/django_project/frontend/src/pages/Admin/Dashboard/Form/DashboardContent.tsx b/django_project/frontend/src/pages/Admin/Dashboard/Form/DashboardContent.tsx index 84f29e9b7..2b2bf3321 100644 --- a/django_project/frontend/src/pages/Admin/Dashboard/Form/DashboardContent.tsx +++ b/django_project/frontend/src/pages/Admin/Dashboard/Form/DashboardContent.tsx @@ -25,6 +25,7 @@ import FiltersForm from "./Filters"; import WidgetForm from "./Widgets"; import RelatedTableForm from "./RelatedTable"; import ToolsForm from "./Tools"; +import StoryMapForm from "./StoryMap"; import ShareForm from "./Share"; import { PAGES } from "./types.d"; import IndicatorLayersControl from "./IndicatorLayers/Control"; @@ -57,6 +58,7 @@ export const DashboardFormContent = memo( page == PAGES.RELATED_TABLES ? : page == PAGES.TOOLS ? : + page == PAGES.STORY_MAP ? : page == PAGES.SHARE && user_permission.share ? : null @@ -76,4 +78,4 @@ export const DashboardFormContent = memo( } ) -export default DashboardFormContent; \ No newline at end of file +export default DashboardFormContent; diff --git a/django_project/frontend/src/pages/Admin/Dashboard/Form/DashboardFormHeader.tsx b/django_project/frontend/src/pages/Admin/Dashboard/Form/DashboardFormHeader.tsx index 36cb74a26..65aba0c80 100644 --- a/django_project/frontend/src/pages/Admin/Dashboard/Form/DashboardFormHeader.tsx +++ b/django_project/frontend/src/pages/Admin/Dashboard/Form/DashboardFormHeader.tsx @@ -171,6 +171,13 @@ export const DashboardFormHeader = memo(({ page, setPage }: Props) => { targetPage={PAGES.TOOLS} title={"Tools"} /> + {user_permission?.share && ( { control={} label={t("Indicator layers tab only")} /> + } + label={t("Hide both tabs")} + />
diff --git a/django_project/frontend/src/pages/Admin/Dashboard/Form/StoryMap/index.tsx b/django_project/frontend/src/pages/Admin/Dashboard/Form/StoryMap/index.tsx new file mode 100644 index 000000000..d773190e8 --- /dev/null +++ b/django_project/frontend/src/pages/Admin/Dashboard/Form/StoryMap/index.tsx @@ -0,0 +1,393 @@ +/** + * GeoSight is UNICEF's geospatial web-based business intelligence platform. + * + * Contact : geosight-no-reply@unicef.org + * + * .. note:: This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * __author__ = 'ishaan.jain@emory.edu' + * __date__ = '15/04/2026' + * __copyright__ = ('Copyright 2026, Unicef') + */ + +import React, { Suspense, useEffect, useMemo, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import Checkbox from "@mui/material/Checkbox"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import FormGroup from "@mui/material/FormGroup"; +import TextField from "@mui/material/TextField"; +import { debounce } from "@mui/material/utils"; +import { + BlockTypeSelect, + BoldItalicUnderlineToggles, + CodeToggle, + CreateLink, + headingsPlugin, + InsertTable, + InsertThematicBreak, + linkDialogPlugin, + linkPlugin, + listsPlugin, + ListsToggle, + MDXEditor, + quotePlugin, + tablePlugin, + thematicBreakPlugin, + toolbarPlugin, + UndoRedo, +} from "@mdxeditor/editor"; + +import "@mdxeditor/editor/style.css"; + +import { fetchingData } from "../../../../../Requests"; +import { Actions } from "../../../../../store/dashboard"; +import ListForm from "../ListForm"; +import Modal, { + ModalContent, + ModalFooter, + ModalHeader, +} from "../../../../../components/Modal"; +import { + SaveButton, + ThemeButton, +} from "../../../../../components/Elements/Button"; +import { ImageInput } from "../../../../../components/Input/ImageInput"; +import { DashboardStory } from "../../../../../types/Story"; + +interface BookmarkOption { + id: number; + name: string; +} + +interface StoryEditorProps { + open: boolean; + bookmarks: BookmarkOption[]; + story: DashboardStory | null; + setOpen: (open: boolean) => void; + onApply: (story: DashboardStory) => void; +} + +const StoryEditor = ({ + open, + bookmarks, + story, + setOpen, + onApply, +}: StoryEditorProps) => { + const [data, setData] = useState(story); + + useEffect(() => { + setData(story); + }, [story]); + + if (!data) { + return null; + } + + return ( + { + setOpen(false); + }} + > + { + setOpen(false); + }} + > + {data.id ? `Edit ${data.name || "story page"}` : "Add story page"} + + +
+
+ { + setData({ + ...data, + name: event.target.value, + }); + }} + /> +
+
+ + Loading...
}> + ( + <> + +
+ + +
+ +
+ +
+ +
+ + + + ), + }), + ]} + onChange={(val) => { + setData({ + ...data, + description: val, + }); + }} + /> + +
+
+ + ) => { + const file = event?.target?.files?.[0] || null; + setData({ + ...data, + iconFile: file, + icon: file ? URL.createObjectURL(file) : story?.icon || null, + }); + }} + /> +
+
+ + + {!bookmarks.length ? ( +
+ Save the dashboard first, then create bookmarks to attach them to + Story Map pages. +
+ ) : null} +
+
+ + { + setData({ + ...data, + visible_by_default: !(data.visible_by_default !== false), + }); + }} + /> + } + label="Visible in story" + /> + +
+
+ + + { + setOpen(false); + }} + > + Cancel + + { + onApply(data); + setOpen(false); + }} + /> + + + ); +}; + +export default function StoryMapForm() { + const dispatch = useDispatch(); + const { slug, stories, storiesStructure, story_map_enabled } = useSelector( + (state: any) => state.dashboard.data, + ); + const ListFormComponent: any = ListForm; + + const [bookmarks, setBookmarks] = useState([]); + const [open, setOpen] = useState(false); + const [selectedStory, setSelectedStory] = useState(null); + + const fetchBookmarks = useMemo( + () => + debounce((targetSlug: string) => { + if (!targetSlug || targetSlug.startsWith(":")) { + setBookmarks([]); + return; + } + fetchingData( + `/api/dashboard/${targetSlug}/bookmarks`, + {}, + {}, + (data: BookmarkOption[]) => { + setBookmarks( + data.map((bookmark: BookmarkOption) => ({ + id: bookmark.id, + name: bookmark.name, + })), + ); + }, + ); + }, 200), + [], + ); + + useEffect(() => { + fetchBookmarks(slug); + return () => { + fetchBookmarks.clear(); + }; + }, [fetchBookmarks, slug]); + + const updateStory = (story: DashboardStory) => { + if (!story.id) { + dispatch( + Actions.Stories.add({ + ...story, + group: "", + visible_by_default: story.visible_by_default !== false, + config: story.config || {}, + }), + ); + } else { + dispatch( + Actions.Stories.update({ + ...story, + visible_by_default: story.visible_by_default !== false, + config: story.config || {}, + }), + ); + } + setSelectedStory(null); + }; + + return ( +
+ +
+
+ + { + dispatch( + Actions.Dashboard.updateProps({ + story_map_enabled: !story_map_enabled, + }), + ); + }} + /> + } + label="Enable Story Map in the dashboard" + /> + +
+
+ { + dispatch(Actions.Dashboard.updateStructure("storiesStructure", structure)); + }} + addLayerAction={(story: DashboardStory) => { + dispatch(Actions.Stories.add(story)); + }} + removeLayerAction={(story: DashboardStory) => { + dispatch(Actions.Stories.remove(story)); + }} + changeLayerAction={(story: DashboardStory) => { + dispatch(Actions.Stories.update(story)); + }} + addLayerInGroupAction={() => { + setSelectedStory({ + id: 0, + name: "", + description: "", + icon: null, + iconFile: null, + bookmark_id: null, + visible_by_default: true, + config: {}, + }); + setOpen(true); + }} + editLayerInGroupAction={(story: DashboardStory) => { + setSelectedStory({ + ...story, + iconFile: null, + }); + setOpen(true); + }} + hasGroup={false} + otherActionsFunction={(story: DashboardStory) => { + const bookmark = bookmarks.find( + (bookmark) => bookmark.id === story.bookmark_id, + ); + return ( +
+ {bookmark ? `Bookmark: ${bookmark.name}` : "No bookmark"} +
+ ); + }} + /> +
+ ); +} diff --git a/django_project/frontend/src/pages/Admin/Dashboard/Form/index.jsx b/django_project/frontend/src/pages/Admin/Dashboard/Form/index.jsx index 869439ee1..2326b46e1 100644 --- a/django_project/frontend/src/pages/Admin/Dashboard/Form/index.jsx +++ b/django_project/frontend/src/pages/Admin/Dashboard/Form/index.jsx @@ -203,6 +203,8 @@ export function DashboardSaveForm() { relatedTables, widgets, widgetsStructure, + stories, + storiesStructure, extent, minZoom, maxZoom, @@ -218,6 +220,7 @@ export function DashboardSaveForm() { truncate_indicator_layer_name, layer_tabs_visibility, show_map_toolbar, + story_map_enabled, // Filter configurations filters, @@ -346,8 +349,22 @@ export function DashboardSaveForm() { max_zoom: maxZoom, widgets: widgets, widgets_structure: widgetsStructure, + stories: stories.map(function (model) { + return { + id: model.id, + name: model.name, + description: model.description, + icon: model.icon, + bookmark_id: model.bookmark_id, + visible_by_default: model.visible_by_default, + order: model.order, + config: model.config, + }; + }), + stories_structure: storiesStructure, permission: permission, tools: tools, + story_map_enabled: story_map_enabled, // Filter configurations filters: filters, @@ -369,6 +386,11 @@ export function DashboardSaveForm() { formData.append("group", category); formData.append("data", JSON.stringify(dashboardData)); formData.append("geoField", geoField); + stories.forEach((story) => { + if (story.iconFile) { + formData.append(`story_icon_${story.id}`, story.iconFile); + } + }); // Check featured const $projectFeatured = $("#ProjectFeatured"); diff --git a/django_project/frontend/src/pages/Admin/Dashboard/Form/style.scss b/django_project/frontend/src/pages/Admin/Dashboard/Form/style.scss index f9ca6125e..748c90ebf 100644 --- a/django_project/frontend/src/pages/Admin/Dashboard/Form/style.scss +++ b/django_project/frontend/src/pages/Admin/Dashboard/Form/style.scss @@ -221,6 +221,12 @@ main { } } + &.StoryMap { + .StoryMap { + display: block; + } + } + .Share { display: block; height: 0; @@ -333,4 +339,4 @@ main { font-size: 0.8rem; white-space: nowrap; } -} \ No newline at end of file +} diff --git a/django_project/frontend/src/pages/Admin/Dashboard/Form/types.d.tsx b/django_project/frontend/src/pages/Admin/Dashboard/Form/types.d.tsx index 445464fad..959a20c9c 100644 --- a/django_project/frontend/src/pages/Admin/Dashboard/Form/types.d.tsx +++ b/django_project/frontend/src/pages/Admin/Dashboard/Form/types.d.tsx @@ -8,5 +8,6 @@ export const PAGES = { WIDGETS: 'Widgets', RELATED_TABLES: 'RelatedTables', TOOLS: 'Tools', + STORY_MAP: 'Story Map', SHARE: 'Share', -} \ No newline at end of file +} diff --git a/django_project/frontend/src/pages/Dashboard/LeftPanel/StoryMap/index.tsx b/django_project/frontend/src/pages/Dashboard/LeftPanel/StoryMap/index.tsx new file mode 100644 index 000000000..6a65565a3 --- /dev/null +++ b/django_project/frontend/src/pages/Dashboard/LeftPanel/StoryMap/index.tsx @@ -0,0 +1,264 @@ +/** + * GeoSight is UNICEF's geospatial web-based business intelligence platform. + * + * Contact : geosight-no-reply@unicef.org + * + * .. note:: This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * __author__ = 'ishaan.jain@emory.edu' + * __date__ = '15/04/2026' + * __copyright__ = ('Copyright 2026, Unicef') + */ + +import React, { useEffect, useMemo, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import PauseIcon from "@mui/icons-material/Pause"; +import SkipNextIcon from "@mui/icons-material/SkipNext"; +import SkipPreviousIcon from "@mui/icons-material/SkipPrevious"; +import Markdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import remarkGfm from "remark-gfm"; + +import { fetchingData } from "../../../../Requests"; +import { Actions } from "../../../../store/dashboard"; +import { dataStructureToListData } from "../../../../components/SortableTreeForm/utilities"; +import { DashboardStory } from "../../../../types/Story"; + +import "./style.scss"; + +interface BookmarkOption { + id: number; + name: string; + [key: string]: any; +} + +interface Props { + isActive: boolean; +} + +const AUTO_PLAY_DELAY = 4000; + +const getStoryTitle = (story: DashboardStory, index: number) => { + return ( + story.name?.trim() || + story.title?.trim() || + story.label?.trim() || + `Story ${index + 1}` + ); +}; + +export default function StoryMap({ isActive }: Props) { + const dispatch = useDispatch(); + const selectedBookmark = useSelector((state: any) => state.selectedBookmark); + const { + slug, + stories, + storiesStructure, + indicatorLayers, + relatedTables, + } = useSelector((state: any) => state.dashboard.data); + const indicatorsMetadata = useSelector((state: any) => state.indicatorsMetadata); + const relatedTableData = useSelector((state: any) => state.relatedTableData); + + const [bookmarks, setBookmarks] = useState([]); + const [activeIndex, setActiveIndex] = useState(null); + const [autoPlay, setAutoPlay] = useState(false); + + const orderedStories = useMemo(() => { + return dataStructureToListData(stories || [], storiesStructure || { children: [] }) + .filter((item: any) => !item.isGroup && item.data?.visible_by_default !== false) + .map((item: any) => item.data as DashboardStory); + }, [stories, storiesStructure]); + + const isDataLoaded = useMemo(() => { + let total = 0; + let currentProgress = 0; + + (indicatorLayers || []).forEach((indicatorLayer: any) => { + const relatedTable = (relatedTables || []).find( + (row: any) => row.id === indicatorLayer.related_tables?.[0]?.id, + ); + if (!relatedTable) { + return; + } + total += 1; + if (!relatedTableData[relatedTable.id + "-og"]) { + currentProgress += 1; + } + if (relatedTableData[relatedTable.id + "-og"]?.fetched) { + currentProgress += 1; + } + }); + + Object.entries(indicatorsMetadata || {}).forEach(([key, value]: [string, any]) => { + if (!key.includes("layer") && value.progress?.total_page) { + total += value.progress.total_page; + currentProgress += value.progress.page; + } + }); + + if (total === 0) { + return true; + } + return currentProgress * 100 >= total * 100; + }, [indicatorLayers, indicatorsMetadata, relatedTableData, relatedTables]); + + const activateBookmark = (story: DashboardStory | undefined) => { + if (!story?.bookmark_id) { + return; + } + const bookmark = bookmarks.find((row) => row.id === story.bookmark_id); + if (bookmark && bookmark.id !== selectedBookmark?.id) { + dispatch(Actions.SelectedBookmark.change(bookmark)); + } + }; + + const applyStory = (index: number) => { + const story = orderedStories[index]; + if (!story) { + return; + } + setActiveIndex(index); + activateBookmark(story); + }; + + useEffect(() => { + if (!slug || slug.startsWith(":")) { + setBookmarks([]); + return; + } + fetchingData(`/api/dashboard/${slug}/bookmarks`, {}, {}, (data: BookmarkOption[]) => { + setBookmarks(data); + }); + }, [slug]); + + useEffect(() => { + if (autoPlay) { + return; + } + const foundIndex = orderedStories.findIndex( + (story) => story.bookmark_id && story.bookmark_id === selectedBookmark?.id, + ); + if (foundIndex >= 0) { + setActiveIndex(foundIndex); + } + }, [autoPlay, selectedBookmark?.id, orderedStories]); + + useEffect(() => { + if (isActive && activeIndex === null && orderedStories.length) { + applyStory(0); + } + }, [isActive, orderedStories.length]); + + useEffect(() => { + if (activeIndex !== null) { + activateBookmark(orderedStories[activeIndex]); + } + }, [activeIndex, bookmarks, orderedStories, selectedBookmark?.id]); + + useEffect(() => { + if (!autoPlay || activeIndex === null || orderedStories.length < 2) { + return; + } + const timer = window.setTimeout(() => { + setActiveIndex((currentIndex) => { + if (currentIndex === null) { + return 0; + } + return (currentIndex + 1) % orderedStories.length; + }); + }, AUTO_PLAY_DELAY); + return () => window.clearTimeout(timer); + }, [autoPlay, activeIndex, orderedStories.length]); + + if (!orderedStories.length) { + return
No Story Map pages configured yet.
; + } + + return ( +
+
+ + + +
+ {!isDataLoaded ? ( +
Waiting for map data to finish loading...
+ ) : null} +
+ {orderedStories.map((story, index) => ( + + ))} +
+
+ {(activeIndex !== null ? activeIndex : 0) + 1} / {orderedStories.length} +
+
+ ); +} diff --git a/django_project/frontend/src/pages/Dashboard/LeftPanel/StoryMap/style.scss b/django_project/frontend/src/pages/Dashboard/LeftPanel/StoryMap/style.scss new file mode 100644 index 000000000..fc0c52e8a --- /dev/null +++ b/django_project/frontend/src/pages/Dashboard/LeftPanel/StoryMap/style.scss @@ -0,0 +1,95 @@ +.StoryMapPanel { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + + &.Empty { + color: #666; + } + + .StoryMapControls { + display: flex; + gap: 0.5rem; + + button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid #d7d7d7; + border-radius: 8px; + background: #fff; + color: #111; + cursor: pointer; + + svg { + color: currentColor; + font-size: 1.35rem; + } + + &:hover { + background: #f3f6fb; + } + } + } + + .StoryMapStatus, + .StoryMapCounter { + color: #666; + font-size: 0.9rem; + } + + .StoryMapList { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .StoryMapItem { + display: flex; + gap: 0.75rem; + width: 100%; + text-align: left; + border: 1px solid #dcdcdc; + border-radius: 12px; + padding: 0.75rem; + background: #fff; + cursor: pointer; + color: #111; + + &.Active { + border-color: #2f80ed; + box-shadow: 0 0 0 1px #2f80ed; + } + } + + .StoryMapItemImage { + width: 88px; + height: 88px; + object-fit: cover; + border-radius: 8px; + flex-shrink: 0; + } + + .StoryMapItemContent { + min-width: 0; + flex: 1; + } + + .StoryMapItemTitle { + display: block; + color: #111; + font-weight: 600; + font-size: 1rem; + line-height: 1.35; + margin-bottom: 0.35rem; + white-space: normal; + } + + .StoryMapItemDescription { + color: #555; + font-size: 0.9rem; + } +} diff --git a/django_project/frontend/src/pages/Dashboard/LeftPanel/index.jsx b/django_project/frontend/src/pages/Dashboard/LeftPanel/index.jsx index 2af8fc5d6..9edeb44da 100644 --- a/django_project/frontend/src/pages/Dashboard/LeftPanel/index.jsx +++ b/django_project/frontend/src/pages/Dashboard/LeftPanel/index.jsx @@ -22,6 +22,7 @@ import { useDispatch, useSelector } from "react-redux"; import Box from "@mui/material/Box"; import Tabs from "@mui/material/Tabs"; import Tab from "@mui/material/Tab"; +import MenuBookIcon from "@mui/icons-material/MenuBook"; import { useTranslation } from "react-i18next"; import { Actions } from "../../../store/dashboard"; @@ -31,6 +32,7 @@ import Indicators from "./Indicators"; import IndicatorLayersAccordion from "./IndicatorLayers"; import RelatedTables from "./RelatedTable"; import FiltersAccordion from "./Filters"; +import StoryMap from "./StoryMap"; import { LayerIcon, TuneIcon, @@ -40,9 +42,8 @@ import { import TabPanel, { tabProps } from "../../../components/Tabs/index"; import { EmbedConfig } from "../../../utils/embed"; import { - isContextLayerContentVisible, - isFilterContentVisible, - isIndicatorLayerContentVisible, + isContextLayerContentVisible, isFilterContentVisible, + isIndicatorLayerContentVisible, isStoryMapEnabled, } from "../../../selectors/dashboard"; import "./style.scss"; @@ -95,29 +96,63 @@ export function IndicatorsVisibility() { /** * Left panel. */ -export default function LeftPanel({ leftExpanded }) { +export default function LeftPanel({ + leftExpanded, + selectedTab, + setSelectedTab, +}) { + const { t } = useTranslation(); const indicatorLayerVisible = useSelector(isIndicatorLayerContentVisible()); const contextLayerContentVisible = useSelector( isContextLayerContentVisible(), ); - const filterVisible = useSelector(isFilterContentVisible()); + const storyVisible = useSelector(isStoryMapEnabled()); const state = leftExpanded ? LEFT : RIGHT; - const showLayerTab = !!EmbedConfig().layer_tab; - const showFilterTab = !!EmbedConfig().filter_tab; - const [tabValue, setTabValue] = React.useState(showLayerTab ? 0 : 1); + const showLayerTab = + !!EmbedConfig().layer_tab && + (indicatorLayerVisible || contextLayerContentVisible); + const showFilterTab = !!EmbedConfig().filter_tab && filterVisible; const [tab2Value, setTab2Value] = React.useState( indicatorLayerVisible ? 1 : 0, ); - const { t } = useTranslation(); - - const handleChangeTab = (event, newValue) => { - setTabValue(newValue); - }; + const tabs = []; + if (showLayerTab) { + tabs.push({ + key: "layers", + label: t("dashboardPage.layers"), + icon: , + }); + } + if (showFilterTab) { + tabs.push({ + key: "filters", + label: t("dashboardPage.filters"), + icon: , + }); + } + if (storyVisible) { + tabs.push({ + key: "story", + label: "Story", + icon: , + }); + } const handleChangeTab2 = (event, newValue) => { setTab2Value(newValue); }; + + React.useEffect(() => { + if (!tabs.find((tab) => tab.key === selectedTab) && tabs.length) { + setSelectedTab(tabs[0].key); + } + }, [selectedTab, tabs]); + + if (!tabs.length) { + return null; + } + const className = `dashboard__panel dashboard__left_side ${state}`; const classNameWrapper = `dashboard__content-wrapper`; return ( @@ -130,90 +165,95 @@ export default function LeftPanel({ leftExpanded }) { >
- {filterVisible && ( + {tabs.length > 1 && ( setSelectedTab(newValue)} aria-label="basic tabs example" > - - iconPosition="start" - {...tabProps("Layers")} - /> - - iconPosition="start" - {...tabProps("Filters")} - /> + {tabs.map((tab) => ( + + ))} )} - - {indicatorLayerVisible && contextLayerContentVisible && ( - - + + {indicatorLayerVisible && contextLayerContentVisible && ( + + + } + iconPosition="end" + {...tabProps(0)} + /> + } + iconPosition="end" + {...tabProps(1)} + /> + + + )} + {contextLayerContentVisible && ( + + + + )} + - } - iconPosition="end" - {...tabProps("context-layer")} - /> - } - iconPosition="end" - {...tabProps("indicator")} - /> - + + - )} - {contextLayerContentVisible && ( - - - - )} - - - - - - + + + + ) : null} - + {showFilterTab ? : null} + + + {storyVisible ? : null}
diff --git a/django_project/frontend/src/pages/Dashboard/MapLibre/index.jsx b/django_project/frontend/src/pages/Dashboard/MapLibre/index.jsx index 26807eb8c..d09f46251 100644 --- a/django_project/frontend/src/pages/Dashboard/MapLibre/index.jsx +++ b/django_project/frontend/src/pages/Dashboard/MapLibre/index.jsx @@ -42,6 +42,7 @@ import { LabelToggler, PopupToolbars, SearchGeometryInput, + StoryMapToolbar, TiltControl, ToggleSidePanel, } from "../Toolbars"; @@ -77,7 +78,13 @@ let previousLayerIds = []; /** * MapLibre component. */ -export default function MapLibre({ leftPanelProps, rightPanelProps }) { +export default function MapLibre({ + leftPanelProps, + rightPanelProps, + storyMapEnabled, + storyMapActive, + onToggleStoryPanel, +}) { const dispatch = useDispatch(); const [map, setMap] = useState(null); const [deckgl, setDeckGl] = useState(null); @@ -375,6 +382,11 @@ export default function MapLibre({ leftPanelProps, rightPanelProps }) { + {rightPanelProps ? ( void; +} + +export default function StoryMapToolbar({ enabled, active, onToggle }: Props) { + if (!enabled) { + return null; + } + + return ( + +
+ + + +
+
+ ); +} diff --git a/django_project/frontend/src/pages/Dashboard/Toolbars/index.jsx b/django_project/frontend/src/pages/Dashboard/Toolbars/index.jsx index 423512637..670e5fcd8 100644 --- a/django_project/frontend/src/pages/Dashboard/Toolbars/index.jsx +++ b/django_project/frontend/src/pages/Dashboard/Toolbars/index.jsx @@ -25,4 +25,5 @@ export { default as EmbedControl } from "./Embed"; export { default as LabelToggler } from "./LabelToggler"; export { default as ProjectOverview } from "./ProjectOverview"; export { default as SearchGeometryInput } from "./SearchGeometryInput"; +export { default as StoryMapToolbar } from "./StoryMap"; export { default as ToggleSidePanel } from "./ToggleSidePanel"; diff --git a/django_project/frontend/src/pages/Dashboard/index.jsx b/django_project/frontend/src/pages/Dashboard/index.jsx index 0f7ef4d17..f08262a5d 100644 --- a/django_project/frontend/src/pages/Dashboard/index.jsx +++ b/django_project/frontend/src/pages/Dashboard/index.jsx @@ -26,35 +26,58 @@ import { EmbedConfig } from "../../utils/embed"; import { LEFT, RIGHT } from "../../components/ToggleButton"; import { ProjectOverview } from "./Toolbars"; import { useTranslation } from "react-i18next"; -import { isDashboardToolEnabled } from "../../selectors/dashboard"; +import { + isContextLayerContentVisible, + isDashboardToolEnabled, + isFilterContentVisible, + isIndicatorLayerContentVisible, +} from "../../selectors/dashboard"; import { Variables } from "../../utils/Variables"; import "./style.scss"; -export default function Dashboard({ children }) { +function isToolbarVisible(value) { + return value !== false && value !== "false"; +} + +export default function Dashboard({ children, dashboardUrlAPI = null }) { const dispatch = useDispatch(); const widgets = useSelector((state) => state.dashboard.data?.widgets); + const indicatorLayerVisible = useSelector(isIndicatorLayerContentVisible()); + const contextLayerContentVisible = useSelector( + isContextLayerContentVisible(), + ); + const filterVisible = useSelector(isFilterContentVisible()); + const storyMapEnabled = useSelector( + (state) => state.dashboard.data?.story_map_enabled, + ); const user_permission = useSelector( (state) => state.dashboard.data?.user_permission, ); - const show_map_toolbar = useSelector( - (state) => state.dashboard.data.show_map_toolbar, + const show_map_toolbar = useSelector((state) => + isToolbarVisible(state.dashboard.data.show_map_toolbar), ); const entitySearchEnable = useSelector( isDashboardToolEnabled(Variables.DASHBOARD.TOOL.ENTITY_SEARCH_BOX), ); - const showLayerTab = !!EmbedConfig().layer_tab; - const showFilterTab = !!EmbedConfig().filter_tab; + const showLayerTab = + !!EmbedConfig().layer_tab && + (indicatorLayerVisible || contextLayerContentVisible); + const showFilterTab = !!EmbedConfig().filter_tab && filterVisible; const showWidget = EmbedConfig().widget_tab; + const showLeftPanel = showLayerTab || showFilterTab || storyMapEnabled; const [leftExpanded, setLeftExpanded] = useState( - showLayerTab || showFilterTab, + showLeftPanel, + ); + const [leftPanelTab, setLeftPanelTab] = useState( + showLayerTab ? "layers" : showFilterTab ? "filters" : "story", ); const [rightExpanded, setRightExpanded] = useState(showWidget); const { t } = useTranslation(); const leftPanelProps = - showLayerTab || showFilterTab + showLeftPanel ? { className: "LeftButton", initState: leftExpanded ? LEFT : RIGHT, @@ -85,8 +108,10 @@ export default function Dashboard({ children }) { // Fetch data of dashboard useEffect(() => { - dispatch(Actions.Dashboard.fetch(dispatch)); - }, []); + dispatch( + Actions.Dashboard.fetch(dispatch, dashboardUrlAPI || urls.dashboardData), + ); + }, [dispatch, dashboardUrlAPI]); return (
{ + if (leftExpanded && leftPanelTab === "story") { + if (showLayerTab) { + setLeftPanelTab("layers"); + setLeftExpanded(true); + } else if (showFilterTab) { + setLeftPanelTab("filters"); + setLeftExpanded(true); + } else { + setLeftExpanded(false); + } + } else { + setLeftExpanded(true); + setLeftPanelTab("story"); + } + }} + /> + - (state: any): DashboardTool => { @@ -16,7 +20,7 @@ export const getDashboardTool = export const isDashboardToolEnabled = (name: string) => (state: any): boolean => { - if (!state.dashboard.data?.show_map_toolbar) { + if (!isToolbarVisible(state.dashboard.data?.show_map_toolbar)) { return false; } return ( @@ -61,3 +65,9 @@ export const isContextLayerContentVisible = layer_tabs_visibility.includes("context_layers") ); }; + +export const isStoryMapEnabled = + () => + (state: any): boolean => { + return !!state?.dashboard?.data?.story_map_enabled; + }; diff --git a/django_project/frontend/src/store/dashboard/index.js b/django_project/frontend/src/store/dashboard/index.js index f4a12c415..92da7a3e3 100644 --- a/django_project/frontend/src/store/dashboard/index.js +++ b/django_project/frontend/src/store/dashboard/index.js @@ -50,6 +50,7 @@ import SelectedGlobalTimeConfig from "./reducers/selectedGlobalTimeConfig/action import SelectedRelatedTableLayer from "./reducers/selectedRelatedTableLayer/actions"; import SelectedDynamicIndicatorLayer from "./reducers/selectedDynamicIndicatorLayer/actions"; import SelectionState from "./reducers/selectionState/actions"; +import Stories from "./reducers/stories/actions"; import Widgets from "./reducers/widgets/actions"; const Actions = { @@ -86,6 +87,7 @@ const Actions = { SelectedDynamicIndicatorLayer, SelectedRelatedTableLayer, SelectionState, + Stories, Widgets, }; diff --git a/django_project/frontend/src/store/dashboard/reducers/dashboard/actions.jsx b/django_project/frontend/src/store/dashboard/reducers/dashboard/actions.jsx index f31742dd6..7bec2172f 100644 --- a/django_project/frontend/src/store/dashboard/reducers/dashboard/actions.jsx +++ b/django_project/frontend/src/store/dashboard/reducers/dashboard/actions.jsx @@ -137,6 +137,14 @@ function receive(data, error = null) { data.widgetsStructure = data.widgets_structure; } delete data.widgets_structure; + if (!data.stories) { + data.stories = []; + } + data.storiesStructure = dictDeepCopy(groupDefault); + if (data.stories_structure) { + data.storiesStructure = data.stories_structure; + } + delete data.stories_structure; if (!data.relatedTables) { data.relatedTables = data.related_tables; delete data.related_tables; @@ -219,8 +227,8 @@ function receive(data, error = null) { /** * Fetching dashboard data. */ -export function fetch(dispatch) { - fetchingData(urls.dashboardData, {}, {}, async function (response, error) { +export function fetch(dispatch, dashboardUrlAPI = urls.dashboardData) { + fetchingData(dashboardUrlAPI, {}, {}, async function (response, error) { await updateColorPaletteData() .then((palettes) => { dispatch(receive(response, null)); diff --git a/django_project/frontend/src/store/dashboard/reducers/dashboard/index.js b/django_project/frontend/src/store/dashboard/reducers/dashboard/index.js index 38f4b7e3e..51d5065e5 100644 --- a/django_project/frontend/src/store/dashboard/reducers/dashboard/index.js +++ b/django_project/frontend/src/store/dashboard/reducers/dashboard/index.js @@ -34,6 +34,7 @@ import contextLayersReducer, { import dashboardToolReducer, { DASHBOARD_TOOL_ACTION_NAME, } from "../dashboardTool"; +import storiesReducer, { STORY_ACTION_NAME } from "../stories"; /** * DASHBOARD REQUEST reducer @@ -204,6 +205,24 @@ export default function dashboardReducer( return state; } + /** STORIES REDUCER **/ + case STORY_ACTION_NAME: { + const data = storiesReducer( + state.data.stories, + action, + state.data, + ); + if (data !== state.data.stories) { + const newState = { ...state }; + newState.data = { + ...newState.data, + stories: data, + }; + return newState; + } + return state; + } + /** BASEMAP REDUCER **/ case BASEMAP_ACTION_NAME: { const data = basemapsReducer( diff --git a/django_project/frontend/src/store/dashboard/reducers/stories/actions.jsx b/django_project/frontend/src/store/dashboard/reducers/stories/actions.jsx new file mode 100644 index 000000000..8d82b2fb7 --- /dev/null +++ b/django_project/frontend/src/store/dashboard/reducers/stories/actions.jsx @@ -0,0 +1,51 @@ +/** + * GeoSight is UNICEF's geospatial web-based business intelligence platform. + * + * Contact : geosight-no-reply@unicef.org + * + * .. note:: This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * __author__ = 'ishaan.jain@emory.edu' + * __date__ = '15/04/2026' + * __copyright__ = ('Copyright 2026, Unicef') + */ + +import { + STORY_ACTION_NAME, + STORY_ACTION_TYPE_ADD, + STORY_ACTION_TYPE_REMOVE, + STORY_ACTION_TYPE_UPDATE, +} from "./index"; + +export function add(payload) { + return { + name: STORY_ACTION_NAME, + type: STORY_ACTION_TYPE_ADD, + payload: payload, + }; +} + +export function remove(payload) { + return { + name: STORY_ACTION_NAME, + type: STORY_ACTION_TYPE_REMOVE, + payload: payload, + }; +} + +export function update(payload) { + return { + name: STORY_ACTION_NAME, + type: STORY_ACTION_TYPE_UPDATE, + payload: payload, + }; +} + +export default { + add, + remove, + update, +}; diff --git a/django_project/frontend/src/store/dashboard/reducers/stories/index.jsx b/django_project/frontend/src/store/dashboard/reducers/stories/index.jsx new file mode 100644 index 000000000..7a8cfe7b8 --- /dev/null +++ b/django_project/frontend/src/store/dashboard/reducers/stories/index.jsx @@ -0,0 +1,73 @@ +/** + * GeoSight is UNICEF's geospatial web-based business intelligence platform. + * + * Contact : geosight-no-reply@unicef.org + * + * .. note:: This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * __author__ = 'ishaan.jain@emory.edu' + * __date__ = '15/04/2026' + * __copyright__ = ('Copyright 2026, Unicef') + */ + +import { + addChildToGroupInStructure, + removeChildInGroupInStructure, +} from "../../../../components/SortableTreeForm/utilities"; + +export const STORY_ACTION_NAME = "STORY"; +export const STORY_ACTION_TYPE_ADD = "STORY/ADD"; +export const STORY_ACTION_TYPE_REMOVE = "STORY/REMOVE"; +export const STORY_ACTION_TYPE_UPDATE = "STORY/UPDATE"; + +const initialState = []; + +export default function storiesReducer( + state = initialState, + action, + dashboardState, +) { + switch (action.type) { + case STORY_ACTION_TYPE_ADD: { + action.payload.id = + state.length === 0 ? 1 : Math.max(...state.map((story) => story.id)) + 1; + addChildToGroupInStructure( + action.payload.group, + action.payload.id, + dashboardState.storiesStructure, + ); + return [...state, action.payload]; + } + case STORY_ACTION_TYPE_REMOVE: { + const newState = []; + state.forEach(function (story) { + if (story.id !== action.payload.id) { + newState.push(story); + } + }); + const story = action.payload; + removeChildInGroupInStructure( + story.group, + story.id, + dashboardState.storiesStructure, + ); + return newState; + } + case STORY_ACTION_TYPE_UPDATE: { + const newState = []; + state.forEach(function (story) { + if (story.id === action.payload.id) { + newState.push(action.payload); + } else { + newState.push(story); + } + }); + return newState; + } + default: + return state; + } +} diff --git a/django_project/frontend/src/types/Story.ts b/django_project/frontend/src/types/Story.ts new file mode 100644 index 000000000..6322c55e8 --- /dev/null +++ b/django_project/frontend/src/types/Story.ts @@ -0,0 +1,29 @@ +/** + * GeoSight is UNICEF's geospatial web-based business intelligence platform. + * + * Contact : geosight-no-reply@unicef.org + * + * .. note:: This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * __author__ = 'ishaan.jain@emory.edu' + * __date__ = '15/04/2026' + * __copyright__ = ('Copyright 2026, Unicef') + */ + +export interface DashboardStory { + id: number; + name: string; + title?: string; + label?: string; + description?: string; + icon?: string | null; + iconFile?: File | null; + bookmark_id?: number | null; + visible_by_default: boolean; + order?: number; + group?: string; + config?: Record | null; +} diff --git a/django_project/frontend/views/admin/dashboard/create.py b/django_project/frontend/views/admin/dashboard/create.py index 9d13d933c..40c13d707 100644 --- a/django_project/frontend/views/admin/dashboard/create.py +++ b/django_project/frontend/views/admin/dashboard/create.py @@ -66,7 +66,11 @@ def save(self, data, user, files): if origin: dashboard.icon = origin.icon dashboard.save() - dashboard.save_relations(data, is_create=True) + dashboard.save_relations( + data, files=files, is_create=True + ) + if origin: + dashboard.clone_story_bookmarks_from(origin) dashboard.increase_version() return redirect( reverse( diff --git a/django_project/frontend/views/admin/dashboard/edit.py b/django_project/frontend/views/admin/dashboard/edit.py index 61f7926e1..bf3b861b4 100644 --- a/django_project/frontend/views/admin/dashboard/edit.py +++ b/django_project/frontend/views/admin/dashboard/edit.py @@ -130,7 +130,7 @@ def post(self, request, slug, **kwargs): if form.is_valid(): try: dashboard = form.save() - dashboard.save_relations(data) + dashboard.save_relations(data, files=request.FILES) dashboard.increase_version() return redirect( reverse( diff --git a/django_project/geosight/data/api/dashboard.py b/django_project/geosight/data/api/dashboard.py index 1b28ae6eb..ff57c575f 100644 --- a/django_project/geosight/data/api/dashboard.py +++ b/django_project/geosight/data/api/dashboard.py @@ -236,6 +236,7 @@ def get(self, request, slug): 'permission', ).prefetch_related( 'dashboardwidget_set', + 'dashboardstory_set', 'dashboardindicator_set__object', 'dashboardindicator_set__object__style', 'dashboardindicator_set__object__indicatorrule_set', diff --git a/django_project/geosight/data/forms/dashboard.py b/django_project/geosight/data/forms/dashboard.py index 7f0af2291..9f119a69b 100644 --- a/django_project/geosight/data/forms/dashboard.py +++ b/django_project/geosight/data/forms/dashboard.py @@ -181,6 +181,13 @@ def update_data(data, user: User): # noqa DOC503 ) data['widgets'] = other_data['widgets'] data['widgets_structure'] = other_data['widgets_structure'] + data['stories'] = other_data.get('stories', []) + data['stories_structure'] = other_data.get( + 'stories_structure', {'children': []} + ) + data['story_map_enabled'] = other_data.get( + 'story_map_enabled', False + ) data['related_tables'] = other_data.get('related_tables', []) diff --git a/django_project/geosight/data/migrations/0142_dashboard_story.py b/django_project/geosight/data/migrations/0142_dashboard_story.py new file mode 100644 index 000000000..412d6fd50 --- /dev/null +++ b/django_project/geosight/data/migrations/0142_dashboard_story.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2.16 on 2026-03-27 12:00 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('geosight_data', '0141_dashboard_auto_zoom_to_filter'), + ] + + operations = [ + migrations.AddField( + model_name='dashboard', + name='stories_structure', + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name='dashboard', + name='story_map_enabled', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='DashboardStory', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=512)), + ('description', models.TextField(blank=True, null=True)), + ('icon', models.ImageField(blank=True, null=True, upload_to='icons')), + ('order', models.IntegerField(default=0)), + ('visible_by_default', models.BooleanField(default=False)), + ('group', models.CharField(blank=True, max_length=512, null=True)), + ('relation_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='geosight_data.dashboardrelationgroup')), + ('config', models.JSONField(blank=True, null=True)), + ('bookmark', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='geosight_data.dashboardbookmark')), + ('dashboard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='geosight_data.dashboard')), + ], + options={ + 'ordering': ('order',), + }, + ), + ] diff --git a/django_project/geosight/data/models/dashboard/__init__.py b/django_project/geosight/data/models/dashboard/__init__.py index 665a05594..906828b0b 100644 --- a/django_project/geosight/data/models/dashboard/__init__.py +++ b/django_project/geosight/data/models/dashboard/__init__.py @@ -21,6 +21,7 @@ from .dashboard_indicator_layer import * from .dashboard_related_table import * from .dashboard_relation import * +from .dashboard_story import * from .dashboard_tool import * from .dashboard_widget import * from .deprecated import * diff --git a/django_project/geosight/data/models/dashboard/dashboard.py b/django_project/geosight/data/models/dashboard/dashboard.py index 9c0642345..1a8c1e00a 100644 --- a/django_project/geosight/data/models/dashboard/dashboard.py +++ b/django_project/geosight/data/models/dashboard/dashboard.py @@ -110,6 +110,7 @@ class Dashboard( context_layers_structure = models.JSONField(null=True, blank=True) basemaps_layers_structure = models.JSONField(null=True, blank=True) widgets_structure = models.JSONField(null=True, blank=True) + stories_structure = models.JSONField(null=True, blank=True) level_config = models.JSONField(null=True, blank=True, default=dict) # ------------------------------ @@ -150,6 +151,7 @@ class Dashboard( null=True, blank=True, default=True, help_text=_('Show map toolbars on the map.') ) + story_map_enabled = models.BooleanField(default=False) # TransparencySlider transparency_config = models.JSONField( @@ -222,7 +224,7 @@ def save(self, *args, **kwargs): # noqa DOC101 # If it does not have icon, just save* super().save(*args, **kwargs) - def save_relations(self, data, is_create=False): # noqa DOC202 + def save_relations(self, data, files=None, is_create=False): # noqa DOC202 """ Save all related data and relationships for a dashboard instance. @@ -277,6 +279,19 @@ def save_relations(self, data, is_create=False): # noqa DOC202 widgets_structure = data['widgets_structure'] self.widgets_new = update_structure(widgets_structure, widgets_new) + # STORIES + stories_new = self.save_stories( + data.get('stories', []), + files=files, + allow_missing_bookmark=is_create + ) + stories_structure = data.get('stories_structure', {'children': []}) + self.story_ids_new = stories_new + self.stories_structure = update_structure( + stories_structure, stories_new + ) + self.story_map_enabled = data.get('story_map_enabled', False) + # INDICATORS self.save_relation( DashboardIndicator, Indicator, self.dashboardindicator_set.all(), @@ -787,6 +802,153 @@ def save_widgets(self, widget_data): pass return ids_new + def save_stories( + self, story_data, files=None, allow_missing_bookmark=False): + """ + Save or update dashboard stories from the provided data. + + Stories are dashboard-owned content like widgets, but can optionally + point to a dashboard bookmark to restore a map state. + + :param story_data: + A list of dictionaries containing story data. + :type story_data: list[dict] + :param files: + Uploaded files keyed by story-specific field names. + :type files: MultiValueDict or dict or None + :param allow_missing_bookmark: + Allow stories to be saved without their bookmark when the bookmark + will be cloned and reattached in a later step, such as dashboard + duplication. + :type allow_missing_bookmark: bool + :return: + A mapping of original story IDs (or 0 for new ones) + to actual saved story IDs. + :rtype: dict[int, int] + :raises ValueError: + If a referenced bookmark does not belong to this dashboard and + missing bookmarks are not allowed. + """ + from .bookmark import DashboardBookmark + from .dashboard_story import DashboardStory + + ids = [] + ids_new = {} + + for data in story_data: + if 'id' in data: + ids.append(data['id']) + + self.dashboardstory_set.exclude(id__in=ids).delete() + + for data in story_data: + try: + try: + story = self.dashboardstory_set.get(id=data['id']) + except (KeyError, DashboardStory.DoesNotExist): + story = DashboardStory(dashboard=self) + + bookmark_id = data.get('bookmark') or data.get('bookmark_id') + bookmark = None + if bookmark_id: + try: + bookmark = DashboardBookmark.objects.get( + id=bookmark_id, + dashboard=self + ) + except DashboardBookmark.DoesNotExist: + if not allow_missing_bookmark: + raise ValueError( + f"DashboardBookmark with id {bookmark_id} " + f"does not exist for dashboard {self.id}" + ) + + story.name = data['name'] + story.description = data.get('description', '') + order = data.get('order', 0) + story.order = order if order else 0 + story.visible_by_default = data.get('visible_by_default', True) + story.bookmark = bookmark + story.config = data.get('config', None) + icon_key = ( + f"story_icon_{story.id or data.get('id', 'new')}" + ) + temp_icon_key = f"story_icon_{data.get('id', 'new')}" + if files: + story_icon = ( + files.get(icon_key) or files.get(temp_icon_key) + ) + if story_icon: + story.icon = story_icon + story.save() + ids_new[data.get('id', 0)] = story.id + except KeyError: + pass + return ids_new + + def clone_story_bookmarks_from(self, origin): + """ + Clone bookmarks referenced by stories from another dashboard. + + This is primarily used during dashboard duplication so that newly + created stories reference bookmarks owned by the duplicated dashboard. + + :param origin: Source dashboard to clone story bookmarks from. + :type origin: Dashboard + """ + from .dashboard_story import DashboardStory + + story_ids_new = getattr(self, 'story_ids_new', {}) + cloned_bookmarks = {} + + for origin_story in origin.dashboardstory_set.select_related( + 'bookmark').all(): + if not origin_story.bookmark: + continue + + origin_bookmark = origin_story.bookmark + if origin_bookmark.id not in cloned_bookmarks: + bookmark = self.dashboardbookmark_set.create( + name=origin_bookmark.name, + dashboard=self, + creator=self.creator, + extent=origin_bookmark.extent, + filters=origin_bookmark.filters, + selected_basemap=origin_bookmark.selected_basemap, + selected_indicator_layers= + origin_bookmark.selected_indicator_layers, + indicator_layer_show=origin_bookmark.indicator_layer_show, + selected_admin_level=origin_bookmark.selected_admin_level, + is_3d_mode=origin_bookmark.is_3d_mode, + position=origin_bookmark.position, + context_layer_show=origin_bookmark.context_layer_show, + context_layers_config=( + origin_bookmark.context_layers_config + ), + transparency_config=origin_bookmark.transparency_config, + ) + bookmark.selected_context_layers.set( + origin_bookmark.selected_context_layers.all() + ) + cloned_bookmarks[origin_bookmark.id] = bookmark + + story_id = story_ids_new.get(origin_story.id) + if story_id: + try: + story = self.dashboardstory_set.get(id=story_id) + except DashboardStory.DoesNotExist: + story = None + else: + story = self.dashboardstory_set.filter( + name=origin_story.name, order=origin_story.order + ).first() + + if story: + story.bookmark = cloned_bookmarks[ + origin_bookmark.id + ] + story.save(update_fields=['bookmark']) + @staticmethod def check_data(data, user: User): """ diff --git a/django_project/geosight/data/models/dashboard/dashboard_story.py b/django_project/geosight/data/models/dashboard/dashboard_story.py new file mode 100644 index 000000000..8087a3bc3 --- /dev/null +++ b/django_project/geosight/data/models/dashboard/dashboard_story.py @@ -0,0 +1,46 @@ +# coding=utf-8 +""" +GeoSight is UNICEF's geospatial web-based business intelligence platform. + +Contact : geosight-no-reply@unicef.org + +.. note:: This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation; either version 3 of the License, or + (at your option) any later version. + +""" +__author__ = 'ishaan.jain@emory.edu' +__date__ = '22/01/2026' +__copyright__ = ('Copyright 2026, Unicef') + +from django.contrib.gis.db import models + +from core.models.general import AbstractTerm, IconTerm +from geosight.data.models.dashboard.bookmark import DashboardBookmark +from geosight.data.models.dashboard.dashboard_relation import ( + DashboardRelationWithLimit +) + + +class DashboardStory(AbstractTerm, IconTerm, DashboardRelationWithLimit): + """Dashboard story model.""" + + bookmark = models.ForeignKey( + DashboardBookmark, + on_delete=models.CASCADE, + null=True, + blank=True + ) + + config = models.JSONField(null=True, blank=True) + + content_limitation_description = ( + 'Limit the number of stories per project' + ) + + def __str__(self): + return self.name + + class Meta: # noqa: D106 + ordering = ('order',) diff --git a/django_project/geosight/data/serializer/dashboard.py b/django_project/geosight/data/serializer/dashboard.py index 771de4a1d..2346f4abc 100644 --- a/django_project/geosight/data/serializer/dashboard.py +++ b/django_project/geosight/data/serializer/dashboard.py @@ -32,6 +32,7 @@ DashboardContextLayerSerializer, DashboardRelatedTableSerializer, DashboardToolSerializer ) +from geosight.data.serializer.dashboard_story import DashboardStorySerializer from geosight.data.serializer.dashboard_widget import DashboardWidgetSerializer from geosight.data.serializer.indicator import IndicatorSerializer from geosight.data.serializer.related_table import RelatedTableSerializer @@ -46,6 +47,7 @@ class DashboardSerializer(serializers.ModelSerializer): category = serializers.SerializerMethodField() group = serializers.SerializerMethodField() widgets = serializers.SerializerMethodField() + stories = serializers.SerializerMethodField() extent = serializers.SerializerMethodField() min_zoom = serializers.SerializerMethodField() max_zoom = serializers.SerializerMethodField() @@ -120,6 +122,22 @@ def get_widgets(self, obj: Dashboard): else: return [] + def get_stories(self, obj: Dashboard): + """ + Return serialized stories for the dashboard. + + :param obj: The dashboard instance. + :type obj: Dashboard + :return: A list of serialized stories. + :rtype: list[dict] + """ + if obj.id: + return DashboardStorySerializer( + obj.dashboardstory_set.all(), many=True + ).data + else: + return [] + def get_extent(self, obj: Dashboard): """ Return the extent (bounding box) for the dashboard. @@ -489,6 +507,7 @@ class Meta: # noqa: D106 'context_layers', 'context_layers_structure', 'basemaps_layers', 'basemaps_layers_structure', 'widgets', 'widgets_structure', + 'stories', 'stories_structure', 'related_tables', 'user_permission', 'geo_field', @@ -507,7 +526,7 @@ class Meta: # noqa: D106 'show_splash_first_open', 'truncate_indicator_layer_name', 'layer_tabs_visibility', 'transparency_config', - 'show_map_toolbar', 'featured' + 'show_map_toolbar', 'story_map_enabled', 'featured' ) diff --git a/django_project/geosight/data/serializer/dashboard_story.py b/django_project/geosight/data/serializer/dashboard_story.py new file mode 100644 index 000000000..f7219c8bb --- /dev/null +++ b/django_project/geosight/data/serializer/dashboard_story.py @@ -0,0 +1,40 @@ +# coding=utf-8 +""" +GeoSight is UNICEF's geospatial web-based business intelligence platform. + +Contact : geosight-no-reply@unicef.org + +.. note:: This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation; either version 3 of the License, or + (at your option) any later version. + +""" +__author__ = 'ishaan.jain@emory.edu' +__date__ = '27/03/2026' +__copyright__ = ('Copyright 2026, Unicef') + +from rest_framework import serializers + +from geosight.data.models.dashboard.dashboard_story import DashboardStory + + +class DashboardStorySerializer(serializers.ModelSerializer): + """Serializer for Dashboard Story.""" + + bookmark_id = serializers.SerializerMethodField() + + def get_bookmark_id(self, obj: DashboardStory): + """ + Return the related bookmark identifier. + + :param obj: Dashboard story instance being serialized. + :type obj: DashboardStory + :return: The related bookmark ID, if any. + :rtype: int or None + """ + return obj.bookmark.id if obj.bookmark else None + + class Meta: # noqa: D106 + model = DashboardStory + exclude = ('dashboard', 'bookmark') diff --git a/django_project/geosight/data/tests/api/dashboard.py b/django_project/geosight/data/tests/api/dashboard.py index f6c84f54b..3e8d1b9c7 100644 --- a/django_project/geosight/data/tests/api/dashboard.py +++ b/django_project/geosight/data/tests/api/dashboard.py @@ -19,7 +19,9 @@ from django.urls import reverse from core.models.preferences import SitePreferences -from geosight.data.models.dashboard import Dashboard +from geosight.data.models.dashboard import ( + Dashboard, DashboardBookmark, DashboardStory +) from geosight.permission.models.factory import PERMISSIONS from geosight.permission.models.manager import PermissionException from geosight.permission.tests._base import BasePermissionTest @@ -205,6 +207,9 @@ def test_data_content_api(self): data = response.json() self.assertEqual(data['name'], 'Dashboard test 1') self.assertEqual(data['slug'], resource.slug) + self.assertEqual(data['stories'], []) + self.assertEqual(data['stories_structure'], None) + self.assertEqual(data['story_map_enabled'], False) pref = SitePreferences.preferences() self.assertEqual(data['default_time_mode'], { 'use_only_last_known_value': True, @@ -256,6 +261,64 @@ def test_data_content_api(self): 'default_interval': 'Daily', }) + def test_story_map_data_and_duplicate_api(self): + """Test story map data is serialized and preserved on duplicate.""" + resource = self.create_resource(self.creator, 'Dashboard with story') + bookmark = DashboardBookmark.objects.create( + dashboard=resource, + creator=self.creator, + name='Story bookmark' + ) + story = DashboardStory.objects.create( + dashboard=resource, + name='Story page 1', + description='Narrative step', + bookmark=bookmark, + visible_by_default=True, + order=1, + config={'auto_play': False} + ) + resource.story_map_enabled = True + resource.stories_structure = { + 'children': [ + { + 'id': story.id, + 'children': [] + } + ] + } + resource.save() + + url = reverse('dashboard-data-api', kwargs={'slug': resource.slug}) + response = self.assertRequestGetView(url, 200, self.creator) + data = response.json() + self.assertEqual(data['story_map_enabled'], True) + self.assertEqual(len(data['stories']), 1) + self.assertEqual(data['stories'][0]['name'], 'Story page 1') + self.assertEqual(data['stories'][0]['bookmark_id'], bookmark.id) + self.assertEqual(data['stories'][0]['config'], {'auto_play': False}) + self.assertEqual( + data['stories_structure'], + {'children': [{'id': story.id, 'children': []}]} + ) + + duplicate_url = reverse( + 'dashboard-duplicate-api', kwargs={'slug': resource.slug} + ) + self.assertRequestPostView( + duplicate_url, 302, data={}, user=self.creator + ) + duplicated = Dashboard.objects.get(slug=resource.slug + '-1') + duplicated_story = duplicated.dashboardstory_set.get(name='Story page 1') + self.assertEqual(duplicated.story_map_enabled, True) + self.assertEqual( + duplicated.stories_structure, + {'children': [{'id': duplicated_story.id, 'children': []}]} + ) + self.assertEqual(duplicated_story.description, 'Narrative step') + self.assertEqual(duplicated_story.config, {'auto_play': False}) + self.assertEqual(duplicated_story.bookmark.name, 'Story bookmark') + def test_delete_api(self): """Test list API.""" resource = self.create_resource(self.creator, 'name 2') diff --git a/vscode.sh b/vscode.sh index 1df65ad88..164f5c1aa 100755 --- a/vscode.sh +++ b/vscode.sh @@ -23,7 +23,6 @@ REQUIRED_EXTENSIONS=( ms-python.vscode-pylance@2025.5.1 rpinski.shebang-snippets@1.1.0 joffreykern.markdown-toc@1.4.0 - GitHub.copilot@1.326.0 bpruitt-goddard.mermaid-markdown-syntax-highlighting@1.7.1 DavidAnson.vscode-markdownlint@0.60.0 shd101wyy.markdown-preview-enhanced@0.8.18