Maps
MapsAccessor on Dhis2Client.maps covers the authoring surface over /api/maps: list_all, get, create_from_spec, clone, delete. MapSpec is a typed builder that captures the viewport (longitude / latitude / zoom / basemap) plus an ordered list of MapLayerSpec layers and produces a full Map that DHIS2's metadata importer accepts. MapLayerSpec covers the most common layer type — thematic choropleth — with sensible defaults; drop to the generated Map / MapView models for the full knob set (event layers, earth-engine, custom rendering strategies).
Layer types
A DHIS2 Map holds one or more MapView layers rendered bottom-up:
- Thematic (
layer="thematic") — the workhorse. Choropleth (thematicMapType="CHOROPLETH") colours each org unit by a data value; graduated symbols ("BUBBLE") scale point size instead. Needs geo-referenced org units (polygons for choropleth, points for bubbles).
- Boundary (
layer="boundary") — outline-only base layer; typically sits below a thematic.
- Facility (
layer="facility") — point markers for facility-level org units.
- Earth engine / event / org unit — rarer types supported via raw
MapView construction.
Georeferenced org units are required
Thematic + boundary layers rely on OrganisationUnit.geometry being a GeoJSON-compatible polygon / multipolygon / point. Without it the Maps app falls back to a default viewport and you see a choropleth floating over a blank / wrong-continent basemap. The seed's Sierra Leonean districts carry rough bounding polygons so the demos render in the right place.
MapSpec + MapLayerSpec — builders over the generated models
Map and MapView are the generated models — pydantic emitted from DHIS2's OpenAPI schema with every viewport, basemap, rendering-strategy, and axis-placement knob the Maps app exposes, plus DHIS2 bookkeeping. Authoring a choropleth by populating those fields directly for each map is tedious + error-prone.
MapSpec + MapLayerSpec are the authoring shapes — frozen pydantic models whose fields cover the common-case knobs: viewport (longitude, latitude, zoom, basemap), ordered layers, and per-layer (data_elements / indicators, periods, organisation_units, legend_set, thematic_map_type, classes, color_low, color_high, opacity). MapsAccessor.create_from_spec materialises the spec into a full typed Map with every derived MapView row populated.
The spec exists because the wire shape branches on MapLayerSpec.layer_kind: thematic layers need dataDimensionItems[] plus rowDimensions / columnDimensions / filterDimensions populated, while boundary and facility layers leave those fields empty and DHIS2 rejects payloads that mix the two. MapLayerSpec.to_map_view() encodes that branch once; the kwargs alternative would replay it at every call site. Same pattern as VisualizationSpec / LegendSetSpec / LegendSpec / OptionSpec — see the Legend sets doc for the full spec-vs-generated-model cross-reference table and the rule for when reaching for a spec is the right call.
Same reason as Visualization: a direct PUT /api/maps/{uid} with nested mapViews silently drops the derived rows / columns / filters collections DHIS2 renders from. The accessor routes through POST /api/metadata?importStrategy=CREATE_AND_UPDATE so the importer expands every dimension selector — don't bypass it.
- Visualizations + dashboards — maps share the same dimension model (
dx, pe, ou) as visualizations; same analytics query drives both.
- Analytics — sanity-check the data path with
client.get_raw("/api/analytics", params={...}) before saving a map.
- CLI surface:
dhis2 metadata map list / get / create / clone / delete + dhis2 browser map screenshot <uid>.
maps
Map authoring helpers — Dhis2Client.maps.
A DHIS2 Map is a geographic-visualization container — a viewport
(longitude, latitude, zoom, basemap) plus an ordered list of
MapView layers rendered bottom-up. Each layer is either:
- THEMATIC (
layer="thematic") — choropleth (one fill colour per
org unit, driven by a data dimension) or graduated symbols (size
scales with the value). The most common layer type.
- BOUNDARY (
layer="boundary") — outline-only layer, typically
used as a base below thematics.
- FACILITY (
layer="facility") — point markers for facility-level
org units.
- EARTH_ENGINE / EVENT / ORG_UNIT — rarer layer types;
supported via raw
MapView construction.
Most day-to-day authoring only touches the thematic case: one data
element × one period × one org-unit level → a choropleth of Sierra
Leone's districts coloured by immunization coverage, say.
MapLayerSpec + MapSpec cover that case with sensible defaults;
drop to the generated Map / MapView models when you need the
full knob set.
Why always POST through /api/metadata
Same reason as Visualization: a direct PUT /api/maps/{uid} with
nested mapViews silently drops the derived rows / columns /
filters collections DHIS2 renders from. Route creates + updates
through POST /api/metadata?importStrategy=CREATE_AND_UPDATE so the
importer expands every dimension selector into the axes DHIS2 reads
at render time. MapsAccessor.create_from_spec takes that path.
Classes
MapLayerSpec
Bases: BaseModel
Typed builder for one MapView layer on a Map.
Covers the common thematic-choropleth case with sensible defaults.
Drop down to a raw MapView payload for the full knob surface
(event layers, earth-engine, custom rendering strategies).
Source code in packages/dhis2w-client/src/dhis2w_client/maps.py
| class MapLayerSpec(BaseModel):
"""Typed builder for one `MapView` layer on a `Map`.
Covers the common thematic-choropleth case with sensible defaults.
Drop down to a raw `MapView` payload for the full knob surface
(event layers, earth-engine, custom rendering strategies).
"""
model_config = ConfigDict(frozen=True)
layer_kind: LayerKind = "thematic"
data_elements: list[str] = Field(default_factory=list)
indicators: list[str] = Field(default_factory=list)
periods: list[str] = Field(default_factory=list)
organisation_units: list[str] = Field(default_factory=list)
organisation_unit_levels: list[int] = Field(default_factory=list)
organisation_unit_selection_mode: OrganisationUnitSelectionMode = OrganisationUnitSelectionMode.SELECTED
thematic_map_type: ThematicMapType = ThematicMapType.CHOROPLETH
classes: int = Field(default=5, ge=2, le=9)
color_low: str = "#fef0d9"
color_high: str = "#b30000"
opacity: float = Field(default=1.0, ge=0.0, le=1.0)
name: str | None = None
legend_set: str | None = None
def to_map_view(self) -> MapView:
"""Materialise this layer as a typed `MapView` the metadata importer accepts."""
base: dict[str, Any] = {
"layer": self.layer_kind,
"opacity": self.opacity,
"organisationUnits": [{"id": ou} for ou in self.organisation_units],
"organisationUnitLevels": list(self.organisation_unit_levels),
"organisationUnitSelectionMode": self.organisation_unit_selection_mode.value,
}
if self.name is not None:
base["name"] = self.name
if self.layer_kind == "thematic":
data_dimension_items: list[dict[str, Any]] = [
{"dataDimensionItemType": "DATA_ELEMENT", "dataElement": {"id": uid}} for uid in self.data_elements
]
data_dimension_items.extend(
{"dataDimensionItemType": "INDICATOR", "indicator": {"id": uid}} for uid in self.indicators
)
base.update(
{
"thematicMapType": self.thematic_map_type.value,
"classes": self.classes,
"colorLow": self.color_low,
"colorHigh": self.color_high,
"dataDimensionItems": data_dimension_items,
"periods": [{"id": pe} for pe in self.periods],
"rowDimensions": ["ou"],
"columnDimensions": ["dx"],
"filterDimensions": ["pe"],
},
)
if self.legend_set is not None:
base["legendSet"] = {"id": self.legend_set}
return MapView.model_validate(base)
|
Functions
to_map_view()
Materialise this layer as a typed MapView the metadata importer accepts.
Source code in packages/dhis2w-client/src/dhis2w_client/maps.py
| def to_map_view(self) -> MapView:
"""Materialise this layer as a typed `MapView` the metadata importer accepts."""
base: dict[str, Any] = {
"layer": self.layer_kind,
"opacity": self.opacity,
"organisationUnits": [{"id": ou} for ou in self.organisation_units],
"organisationUnitLevels": list(self.organisation_unit_levels),
"organisationUnitSelectionMode": self.organisation_unit_selection_mode.value,
}
if self.name is not None:
base["name"] = self.name
if self.layer_kind == "thematic":
data_dimension_items: list[dict[str, Any]] = [
{"dataDimensionItemType": "DATA_ELEMENT", "dataElement": {"id": uid}} for uid in self.data_elements
]
data_dimension_items.extend(
{"dataDimensionItemType": "INDICATOR", "indicator": {"id": uid}} for uid in self.indicators
)
base.update(
{
"thematicMapType": self.thematic_map_type.value,
"classes": self.classes,
"colorLow": self.color_low,
"colorHigh": self.color_high,
"dataDimensionItems": data_dimension_items,
"periods": [{"id": pe} for pe in self.periods],
"rowDimensions": ["ou"],
"columnDimensions": ["dx"],
"filterDimensions": ["pe"],
},
)
if self.legend_set is not None:
base["legendSet"] = {"id": self.legend_set}
return MapView.model_validate(base)
|
MapSpec
Bases: BaseModel
Typed builder for a full Map — viewport + ordered layers.
Source code in packages/dhis2w-client/src/dhis2w_client/maps.py
| class MapSpec(BaseModel):
"""Typed builder for a full `Map` — viewport + ordered layers."""
model_config = ConfigDict(frozen=True)
name: str = Field(..., min_length=1, max_length=230)
description: str | None = None
uid: str | None = None
title: str | None = None
longitude: float = Field(default=0.0, ge=-180.0, le=180.0)
latitude: float = Field(default=0.0, ge=-90.0, le=90.0)
zoom: int = Field(default=4, ge=0, le=20)
basemap: str = "openStreetMap"
layers: list[MapLayerSpec] = Field(..., min_length=1)
def to_map(self) -> Map:
"""Materialise the typed `Map` DHIS2's metadata importer accepts."""
map_views: list[dict[str, Any]] = [
layer.to_map_view().model_dump(by_alias=True, exclude_none=True, mode="json") for layer in self.layers
]
return Map.model_validate(
{
"id": self.uid or generate_uid(),
"name": self.name,
"description": self.description,
"title": self.title,
"longitude": self.longitude,
"latitude": self.latitude,
"zoom": self.zoom,
"basemap": self.basemap,
"mapViews": map_views,
},
)
|
Functions
to_map()
Materialise the typed Map DHIS2's metadata importer accepts.
Source code in packages/dhis2w-client/src/dhis2w_client/maps.py
| def to_map(self) -> Map:
"""Materialise the typed `Map` DHIS2's metadata importer accepts."""
map_views: list[dict[str, Any]] = [
layer.to_map_view().model_dump(by_alias=True, exclude_none=True, mode="json") for layer in self.layers
]
return Map.model_validate(
{
"id": self.uid or generate_uid(),
"name": self.name,
"description": self.description,
"title": self.title,
"longitude": self.longitude,
"latitude": self.latitude,
"zoom": self.zoom,
"basemap": self.basemap,
"mapViews": map_views,
},
)
|
MapsAccessor
Dhis2Client.maps — workflow helpers over /api/maps.
Source code in packages/dhis2w-client/src/dhis2w_client/maps.py
| class MapsAccessor:
"""`Dhis2Client.maps` — workflow helpers over `/api/maps`."""
def __init__(self, client: Dhis2Client) -> None:
"""Bind to the sharing client."""
self._client = client
async def list_all(self) -> list[Map]:
"""List every Map on the instance, sorted by name."""
raw = await self._client.get_raw(
"/api/maps",
params={"fields": "id,name,description,zoom,lastUpdated", "order": "name:asc", "paging": "false"},
)
rows = raw.get("maps")
if not isinstance(rows, list):
return []
return [Map.model_validate(row) for row in rows if isinstance(row, dict)]
async def get(self, uid: str) -> Map:
"""Fetch one Map with every `mapViews` layer resolved inline."""
raw = await self._client.get_raw(f"/api/maps/{uid}", params={"fields": _MAP_FIELDS})
return Map.model_validate(raw)
async def create_from_spec(self, spec: MapSpec) -> Map:
"""Build a Map from a spec and POST via `/api/metadata`.
Route through the metadata importer so derived axes populate.
A direct `PUT /api/maps/{uid}` with nested `mapViews` silently
drops `rows` / `columns` / `filters` — don't take that shortcut.
"""
m = spec.to_map()
if m.id is None:
raise ValueError("MapSpec did not assign a UID — check to_map()")
body = {"maps": [m.model_dump(by_alias=True, exclude_none=True, mode="json")]}
await self._client.post(
"/api/metadata",
body,
params={"importStrategy": "CREATE_AND_UPDATE", "atomicMode": "ALL"},
model=WebMessageResponse,
)
return await self.get(m.id)
async def clone(
self,
source_uid: str,
*,
new_name: str,
new_uid: str | None = None,
new_description: str | None = None,
) -> Map:
"""Duplicate an existing Map with a fresh UID + new name.
Copies the viewport + every layer so the clone renders
identically. Stripped fields: server-owned (`created`,
`lastUpdated`, `createdBy`, `lastUpdatedBy`) and display-computed
shortcuts. `mapViews` carry over with fresh UIDs assigned by
the importer.
"""
source = await self.get(source_uid)
target_uid = new_uid or generate_uid()
payload = source.model_dump(by_alias=True, exclude_none=True, mode="json")
for owned in (
"id",
"uid",
"created",
"lastUpdated",
"createdBy",
"lastUpdatedBy",
"href",
"access",
"user",
"favorites",
"favorite",
"subscribers",
"subscribed",
"interpretations",
"displayName",
"displayDescription",
"displayFormName",
"displayShortName",
"translations",
):
payload.pop(owned, None)
payload["id"] = target_uid
payload["name"] = new_name
if new_description is not None:
payload["description"] = new_description
# Strip nested MapView UIDs so the importer mints fresh ones
# (keeping the source's UIDs would collide on any re-import).
nested_views = payload.get("mapViews") or []
scrubbed_views: list[dict[str, Any]] = []
for view in nested_views:
if isinstance(view, dict):
copy = {k: v for k, v in view.items() if k not in ("id", "uid", "created", "lastUpdated")}
scrubbed_views.append(copy)
payload["mapViews"] = scrubbed_views
await self._client.post(
"/api/metadata",
{"maps": [payload]},
params={"importStrategy": "CREATE_AND_UPDATE", "atomicMode": "ALL"},
model=WebMessageResponse,
)
return await self.get(target_uid)
async def delete(self, uid: str) -> None:
"""DELETE a Map by UID."""
await self._client.resources.maps.delete(uid)
|
Functions
__init__(client)
Bind to the sharing client.
Source code in packages/dhis2w-client/src/dhis2w_client/maps.py
| def __init__(self, client: Dhis2Client) -> None:
"""Bind to the sharing client."""
self._client = client
|
list_all()
async
List every Map on the instance, sorted by name.
Source code in packages/dhis2w-client/src/dhis2w_client/maps.py
| async def list_all(self) -> list[Map]:
"""List every Map on the instance, sorted by name."""
raw = await self._client.get_raw(
"/api/maps",
params={"fields": "id,name,description,zoom,lastUpdated", "order": "name:asc", "paging": "false"},
)
rows = raw.get("maps")
if not isinstance(rows, list):
return []
return [Map.model_validate(row) for row in rows if isinstance(row, dict)]
|
get(uid)
async
Fetch one Map with every mapViews layer resolved inline.
Source code in packages/dhis2w-client/src/dhis2w_client/maps.py
| async def get(self, uid: str) -> Map:
"""Fetch one Map with every `mapViews` layer resolved inline."""
raw = await self._client.get_raw(f"/api/maps/{uid}", params={"fields": _MAP_FIELDS})
return Map.model_validate(raw)
|
create_from_spec(spec)
async
Build a Map from a spec and POST via /api/metadata.
Route through the metadata importer so derived axes populate.
A direct PUT /api/maps/{uid} with nested mapViews silently
drops rows / columns / filters — don't take that shortcut.
Source code in packages/dhis2w-client/src/dhis2w_client/maps.py
| async def create_from_spec(self, spec: MapSpec) -> Map:
"""Build a Map from a spec and POST via `/api/metadata`.
Route through the metadata importer so derived axes populate.
A direct `PUT /api/maps/{uid}` with nested `mapViews` silently
drops `rows` / `columns` / `filters` — don't take that shortcut.
"""
m = spec.to_map()
if m.id is None:
raise ValueError("MapSpec did not assign a UID — check to_map()")
body = {"maps": [m.model_dump(by_alias=True, exclude_none=True, mode="json")]}
await self._client.post(
"/api/metadata",
body,
params={"importStrategy": "CREATE_AND_UPDATE", "atomicMode": "ALL"},
model=WebMessageResponse,
)
return await self.get(m.id)
|
clone(source_uid, *, new_name, new_uid=None, new_description=None)
async
Duplicate an existing Map with a fresh UID + new name.
Copies the viewport + every layer so the clone renders
identically. Stripped fields: server-owned (created,
lastUpdated, createdBy, lastUpdatedBy) and display-computed
shortcuts. mapViews carry over with fresh UIDs assigned by
the importer.
Source code in packages/dhis2w-client/src/dhis2w_client/maps.py
| async def clone(
self,
source_uid: str,
*,
new_name: str,
new_uid: str | None = None,
new_description: str | None = None,
) -> Map:
"""Duplicate an existing Map with a fresh UID + new name.
Copies the viewport + every layer so the clone renders
identically. Stripped fields: server-owned (`created`,
`lastUpdated`, `createdBy`, `lastUpdatedBy`) and display-computed
shortcuts. `mapViews` carry over with fresh UIDs assigned by
the importer.
"""
source = await self.get(source_uid)
target_uid = new_uid or generate_uid()
payload = source.model_dump(by_alias=True, exclude_none=True, mode="json")
for owned in (
"id",
"uid",
"created",
"lastUpdated",
"createdBy",
"lastUpdatedBy",
"href",
"access",
"user",
"favorites",
"favorite",
"subscribers",
"subscribed",
"interpretations",
"displayName",
"displayDescription",
"displayFormName",
"displayShortName",
"translations",
):
payload.pop(owned, None)
payload["id"] = target_uid
payload["name"] = new_name
if new_description is not None:
payload["description"] = new_description
# Strip nested MapView UIDs so the importer mints fresh ones
# (keeping the source's UIDs would collide on any re-import).
nested_views = payload.get("mapViews") or []
scrubbed_views: list[dict[str, Any]] = []
for view in nested_views:
if isinstance(view, dict):
copy = {k: v for k, v in view.items() if k not in ("id", "uid", "created", "lastUpdated")}
scrubbed_views.append(copy)
payload["mapViews"] = scrubbed_views
await self._client.post(
"/api/metadata",
{"maps": [payload]},
params={"importStrategy": "CREATE_AND_UPDATE", "atomicMode": "ALL"},
model=WebMessageResponse,
)
return await self.get(target_uid)
|
delete(uid)
async
DELETE a Map by UID.
Source code in packages/dhis2w-client/src/dhis2w_client/maps.py
| async def delete(self, uid: str) -> None:
"""DELETE a Map by UID."""
await self._client.resources.maps.delete(uid)
|
Functions