Skip to content

Commit 47490a6

Browse files
authored
Merge branch 'master' into csv-upload
2 parents 0bdbc15 + 5847171 commit 47490a6

13 files changed

Lines changed: 587 additions & 121 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Shared helpers for Starlette Admin batch action forms."""
2+
3+
from __future__ import annotations
4+
5+
from enum import Enum
6+
from html import escape
7+
from typing import Iterable
8+
9+
# Starlette-admin batch actions take pre-rendered HTML for ``form``, not a template
10+
# path. The list page embeds this string in each action link's ``data-form``
11+
# attribute; client JS copies it into the confirmation modal on click.
12+
13+
14+
def bulk_status_select_form(
15+
choices: Iterable[Enum | str | tuple[str, str]],
16+
*,
17+
field_name: str = "status",
18+
select_id: str = "bulk-status",
19+
placeholder: str = "Select status…",
20+
) -> str:
21+
"""Return HTML for a bulk status ``<select>`` embedded in a batch action form."""
22+
option_lines = [f'<option value="">{escape(placeholder)}</option>']
23+
for choice in choices:
24+
value, label = _value_and_label(choice)
25+
option_lines.append(f'<option value="{escape(value)}">{escape(label)}</option>')
26+
options_html = "\n ".join(option_lines)
27+
return f"""
28+
<form>
29+
<div class="mt-3">
30+
<label class="form-label" for="{escape(select_id)}">Status</label>
31+
<select id="{escape(select_id)}" class="form-select" name="{escape(field_name)}" required>
32+
{options_html}
33+
</select>
34+
</div>
35+
</form>
36+
"""
37+
38+
39+
def _value_and_label(choice: Enum | str | tuple[str, str]) -> tuple[str, str]:
40+
if isinstance(choice, tuple):
41+
return str(choice[0]), str(choice[1])
42+
if isinstance(choice, Enum):
43+
return str(choice.value), str(choice.value)
44+
return str(choice), str(choice)

api/prism_app/admin_map_export.py

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from uuid import UUID
99

1010
from prism_app.admin import PrismGatedModelView, ReadOnlyModelView
11+
from prism_app.admin_bulk_actions import bulk_status_select_form
1112
from prism_app.auth.admin_request import (
1213
admin_user_from_request,
1314
request_can_manage_map_exports,
@@ -51,22 +52,7 @@
5152
_DEFAULT_EQ = OPERATORS["eq"]
5253
_DEFAULT_NEQ = OPERATORS["neq"]
5354

54-
# Starlette-admin batch actions take pre-rendered HTML for ``form``, not a template
55-
# path. The list page embeds this string in each action link's ``data-form``
56-
# attribute; client JS copies it into the confirmation modal on click. Unlike
57-
# create/edit/list pages, there is no request-time TemplateResponse hook for it.
58-
_BULK_UPDATE_STATUS_FORM = """
59-
<form>
60-
<div class="mt-3">
61-
<label class="form-label" for="bulk-status">Status</label>
62-
<select id="bulk-status" class="form-select" name="status" required>
63-
<option value="">Select status…</option>
64-
<option value="active">Active</option>
65-
<option value="stopped">Stopped</option>
66-
</select>
67-
</div>
68-
</form>
69-
"""
55+
_MAP_EXPORT_BULK_UPDATE_STATUS_FORM = bulk_status_select_form(MapExportScheduleStatus)
7056

7157
_CASE_INSENSITIVE_STRING_OPERATORS: Dict[str, Callable[..., ClauseElement]] = {
7258
**OPERATORS,
@@ -666,7 +652,7 @@ async def serialize_clone_prefill(
666652
submit_btn_text="Update status",
667653
submit_btn_class="btn-primary",
668654
icon_class="fa-solid fa-toggle-on",
669-
form=_BULK_UPDATE_STATUS_FORM,
655+
form=_MAP_EXPORT_BULK_UPDATE_STATUS_FORM,
670656
)
671657
async def update_status_action(self, request: Request, pks: List[Any]) -> str:
672658
data = await request.form()

api/prism_app/dashboard/dashboard_admin.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@
33
import json
44
from typing import Any, cast
55

6+
from prism_app.admin_bulk_actions import bulk_status_select_form
67
from prism_app.dashboard.dashboard_config_field import DashboardConfigJsonFileField
78
from prism_app.database.dashboard_model import DashboardCountry, DashboardStatus
9+
from prism_app.utils import utc_now
810
from sqlalchemy.exc import IntegrityError
11+
from sqlalchemy.orm import Session
912
from starlette.requests import Request
13+
from starlette_admin.actions import action
1014
from starlette_admin.contrib.sqla import ModelView
11-
from starlette_admin.exceptions import FormValidationError
15+
from starlette_admin.exceptions import ActionFailed, FormValidationError
1216
from starlette_admin.fields import EnumField
17+
from starlette_admin.i18n import ngettext
1318

1419
_DASHBOARD_COUNTRY_CHOICES = [
1520
(country.value, country.value) for country in DashboardCountry
@@ -20,6 +25,8 @@
2025
"Change the title in your config JSON or edit the existing dashboard."
2126
)
2227

28+
_DASHBOARD_BULK_UPDATE_STATUS_FORM = bulk_status_select_form(DashboardStatus)
29+
2330

2431
class DashboardAdminView(ModelView):
2532
"""Create / edit / delete dashboards; path is derived from title and country.
@@ -33,6 +40,7 @@ class DashboardAdminView(ModelView):
3340

3441
label = "Dashboards"
3542
name = "dashboard"
43+
list_template = "dashboard_list.html"
3644
detail_template = "detail_dashboard.html"
3745
edit_template = "edit_no_add_another.html"
3846
create_template = "create_no_add_another.html"
@@ -82,6 +90,61 @@ class DashboardAdminView(ModelView):
8290
"status",
8391
"country",
8492
]
93+
actions = ["update_status", "delete"]
94+
95+
@action(
96+
name="update_status",
97+
text="Update status",
98+
confirmation="Update the status of the selected dashboards?",
99+
submit_btn_text="Update status",
100+
submit_btn_class="btn-primary",
101+
icon_class="fa-solid fa-toggle-on",
102+
form=_DASHBOARD_BULK_UPDATE_STATUS_FORM,
103+
)
104+
async def update_status_action(self, request: Request, pks: list[Any]) -> str:
105+
data = await request.form()
106+
status_raw = data.get("status")
107+
if not status_raw:
108+
raise ActionFailed("Status is required")
109+
try:
110+
new_status = DashboardStatus(str(status_raw))
111+
except ValueError as exc:
112+
raise ActionFailed(f"Invalid status: {status_raw}") from exc
113+
114+
session: Session = request.state.session
115+
dashboards = list(await self.find_by_pks(request, pks))
116+
if not dashboards:
117+
raise ActionFailed("No accessible dashboards selected")
118+
119+
now = utc_now()
120+
for dashboard in dashboards:
121+
dashboard.status = new_status
122+
dashboard.updated_at = now
123+
session.add(dashboard)
124+
session.commit()
125+
126+
count = len(dashboards)
127+
label = new_status.value
128+
return f"Updated {count} dashboard{'s' if count != 1 else ''} to {label}."
129+
130+
@action(
131+
name="delete",
132+
text="Delete dashboards",
133+
confirmation=(
134+
"Are you sure you want to delete the selected dashboards? "
135+
"This cannot be undone."
136+
),
137+
submit_btn_text="Yes, delete",
138+
submit_btn_class="btn-danger",
139+
icon_class="fa-solid fa-trash",
140+
)
141+
async def delete_action(self, request: Request, pks: list[Any]) -> str:
142+
affected_rows = await self.delete(request, pks)
143+
return ngettext(
144+
"Dashboard was successfully deleted",
145+
"%(count)d dashboards were successfully deleted",
146+
affected_rows or 0,
147+
) % {"count": affected_rows}
85148

86149
async def validate(self, request: Request, data: dict[str, Any]) -> None:
87150
errors: dict[str, str] = {}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{% extends "list.html" %}
2+
3+
{% block modal %}
4+
{% include "modals/loading.html" %}
5+
{% include "modals/error.html" %}
6+
{% include "modals/bulk_action_confirm.html" %}
7+
{% endblock %}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Tests for shared Starlette Admin batch action form helpers."""
2+
3+
from enum import Enum
4+
5+
from prism_app.admin_bulk_actions import bulk_status_select_form
6+
from prism_app.database.dashboard_model import DashboardStatus
7+
from prism_app.database.map_export_schedule_model import MapExportScheduleStatus
8+
9+
10+
class _SampleStatus(str, Enum):
11+
on = "on"
12+
off = "off"
13+
14+
15+
def test_bulk_status_select_form_renders_enum_values() -> None:
16+
form = bulk_status_select_form(DashboardStatus)
17+
18+
assert 'name="status"' in form
19+
assert 'id="bulk-status"' in form
20+
for status in DashboardStatus:
21+
assert f'value="{status.value}"' in form
22+
assert f">{status.value}</option>" in form
23+
24+
25+
def test_bulk_status_select_form_renders_tuple_labels() -> None:
26+
form = bulk_status_select_form([("active", "Active"), ("stopped", "Stopped")])
27+
28+
assert 'value="active"' in form
29+
assert ">Active</option>" in form
30+
assert 'value="stopped"' in form
31+
assert ">Stopped</option>" in form
32+
33+
34+
def test_dashboard_view_uses_shared_bulk_status_form() -> None:
35+
from prism_app.dashboard.dashboard_admin import (
36+
_DASHBOARD_BULK_UPDATE_STATUS_FORM,
37+
DashboardAdminView,
38+
)
39+
from prism_app.database.dashboard_model import DashboardModel
40+
41+
view = DashboardAdminView(DashboardModel)
42+
assert view._actions["update_status"]["form"] == _DASHBOARD_BULK_UPDATE_STATUS_FORM
43+
44+
45+
def test_map_export_view_uses_shared_bulk_status_form() -> None:
46+
from prism_app.admin_map_export import (
47+
_MAP_EXPORT_BULK_UPDATE_STATUS_FORM,
48+
MapExportScheduleView,
49+
)
50+
from prism_app.database.map_export_schedule_model import MapExportSchedule
51+
52+
view = MapExportScheduleView(MapExportSchedule)
53+
assert view._actions["update_status"]["form"] == _MAP_EXPORT_BULK_UPDATE_STATUS_FORM
54+
55+
56+
def test_map_export_bulk_form_includes_schedule_statuses() -> None:
57+
form = bulk_status_select_form(MapExportScheduleStatus)
58+
59+
assert 'value="active"' in form
60+
assert 'value="stopped"' in form
61+
62+
63+
def test_bulk_status_select_form_supports_string_choices() -> None:
64+
form = bulk_status_select_form(_SampleStatus)
65+
66+
assert 'value="on"' in form
67+
assert 'value="off"' in form

api/prism_app/tests/test_admin_map_export.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from uuid import uuid4
55

66
import pytest
7+
from prism_app.admin_bulk_actions import bulk_status_select_form
78
from prism_app.admin_map_export import (
89
MapExportJobView,
910
MapExportScheduleView,
@@ -182,9 +183,9 @@ def test_schedule_view_bulk_actions_include_update_status_and_delete() -> None:
182183
assert "delete" in view._actions
183184
assert view._actions["update_status"]["confirmation"]
184185
assert view._actions["delete"]["confirmation"]
185-
assert 'name="status"' in view._actions["update_status"]["form"]
186-
assert 'value="active"' in view._actions["update_status"]["form"]
187-
assert 'value="stopped"' in view._actions["update_status"]["form"]
186+
assert view._actions["update_status"]["form"] == bulk_status_select_form(
187+
MapExportScheduleStatus
188+
)
188189

189190

190191
@pytest.mark.asyncio
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Admin dashboard view bulk actions."""
2+
3+
from uuid import uuid4
4+
5+
import pytest
6+
from prism_app.admin_bulk_actions import bulk_status_select_form
7+
from prism_app.dashboard.dashboard_admin import DashboardAdminView
8+
from prism_app.database.dashboard_model import (
9+
DashboardCountry,
10+
DashboardModel,
11+
DashboardStatus,
12+
)
13+
from starlette.datastructures import FormData
14+
from starlette.requests import Request
15+
from starlette_admin.exceptions import ActionFailed
16+
17+
18+
def _request() -> Request:
19+
scope = {
20+
"type": "http",
21+
"method": "GET",
22+
"path": "/",
23+
"query_string": b"",
24+
"headers": [],
25+
}
26+
return Request(scope)
27+
28+
29+
def test_dashboard_view_bulk_actions_include_update_status_and_delete() -> None:
30+
view = DashboardAdminView(DashboardModel)
31+
assert view.list_template == "dashboard_list.html"
32+
assert view.actions == ["update_status", "delete"]
33+
assert "update_status" in view._actions
34+
assert "delete" in view._actions
35+
assert view._actions["update_status"]["confirmation"]
36+
assert view._actions["delete"]["confirmation"]
37+
assert view._actions["update_status"]["form"] == bulk_status_select_form(
38+
DashboardStatus
39+
)
40+
41+
42+
@pytest.mark.asyncio
43+
async def test_update_status_action_requires_status() -> None:
44+
view = DashboardAdminView(DashboardModel)
45+
request = _request()
46+
request._form = FormData([]) # noqa: SLF001
47+
48+
async def _fake_form() -> FormData:
49+
return request._form # noqa: SLF001
50+
51+
request.form = _fake_form # type: ignore[method-assign]
52+
53+
with pytest.raises(ActionFailed, match="Status is required"):
54+
await view.update_status_action(request, [str(uuid4())])
55+
56+
57+
@pytest.mark.asyncio
58+
async def test_update_status_action_updates_selected_dashboards(
59+
monkeypatch: pytest.MonkeyPatch,
60+
) -> None:
61+
from unittest.mock import AsyncMock, MagicMock
62+
63+
view = DashboardAdminView(DashboardModel)
64+
dashboard = DashboardModel(
65+
id=uuid4(),
66+
title="Mozambique overview",
67+
path="mozambique/mozambique-overview",
68+
status=DashboardStatus.draft,
69+
country=DashboardCountry.mozambique,
70+
config={"title": "Mozambique overview", "country": "mozambique"},
71+
)
72+
request = _request()
73+
request._form = FormData([("status", "published")]) # noqa: SLF001
74+
75+
async def _fake_form() -> FormData:
76+
return request._form # noqa: SLF001
77+
78+
request.form = _fake_form # type: ignore[method-assign]
79+
80+
session = MagicMock()
81+
request.state.session = session
82+
monkeypatch.setattr(
83+
view,
84+
"find_by_pks",
85+
AsyncMock(return_value=[dashboard]),
86+
)
87+
88+
message = await view.update_status_action(request, [str(dashboard.id)])
89+
90+
assert dashboard.status == DashboardStatus.published
91+
assert dashboard.updated_at is not None
92+
session.add.assert_called_once_with(dashboard)
93+
session.commit.assert_called_once()
94+
assert "1 dashboard" in message
95+
assert "published" in message

frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/AnticipatoryActionDroughtPanel/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ function AnticipatoryActionDroughtPanel() {
205205
id={x.id}
206206
checkBoxProps={{
207207
checked: categoryFilters[x.id],
208-
disabled: x.id !== 'Mild',
208+
disabled: categories.length === 1,
209209
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
210210
const { checked } = e.target;
211211
dispatch(setAAFilters({ categories: { [x.id]: checked } }));

frontend/src/config/cambodia/layers.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1653,7 +1653,7 @@
16531653
"loader": "ews",
16541654
"type": "point_data",
16551655
"icon_shape": "point",
1656-
"data": "https://sensors.ews1294.com/api/v1",
1656+
"data": "https://ews1294.com/wp-json/api/v1",
16571657
"date_url": "active",
16581658
"feature_info_props": {
16591659
"status": {

0 commit comments

Comments
 (0)