No Story Map pages configured yet.
;
+ }
+
+ return (
+ 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