Skip to content

Visualizations + dashboards

VisualizationsAccessor on Dhis2Client.visualizations + DashboardsAccessor on Dhis2Client.dashboards cover the authoring surface over /api/visualizations and /api/dashboards. VisualizationSpec is a typed builder that turns chart type + data elements + periods + org units + dimensional placement into a full Visualization that DHIS2's metadata importer accepts. DashboardSlot models the 60-unit-wide grid placement used by DashboardsAccessor.add_item.

Generic CRUD remains on client.resources.visualizations and client.resources.dashboards (the generated accessors). The helpers here layer the workflow pieces — spec-driven creation, clone, add/remove dashboard items — that the bare generated API forces callers to hand-roll.

VisualizationSpec — builder over the generated Visualization

Visualization is the generated model — emitted from DHIS2's OpenAPI schema with ~70 fields covering every knob the Data Visualizer app writes (plus DHIS2 bookkeeping: created, lastUpdated, href, access, favorites, translations). Authoring a chart by populating that model directly is tedious.

VisualizationSpec is the authoring shape — a frozen pydantic model whose fields are the tiny subset the caller actually supplies: name, viz_type, data_elements / indicators / program_indicators, periods, organisation_units, optional dimensional placement overrides, optional legend_set. VisualizationsAccessor.create_from_spec calls .build() internally to materialise the spec into the full typed Visualization that DHIS2's metadata importer accepts.

The spec exists because the wire shape needs transformation the generated Visualization doesn't carry on its own — chart-type-aware rows / columns / filters defaults driven off viz_type (see Dimensional placement below), and RelativePeriod enum fan-out into the 45 individual boolean fields DHIS2 exposes on Visualization.relativePeriods. Plain keyword args on accessor.create(...) would push both jobs onto every caller. Same pattern as MapSpec / MapLayerSpec / 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.

Dimensional placement

Every Visualization distributes the three DHIS2 analytics dimensions — dx (data), pe (period), ou (org unit) — across three slots:

  • rows — category axis on charts, left side of pivot tables
  • columns — series (legend colours) on charts, top of pivot tables
  • filters — narrows to specific items without occupying the canvas

VisualizationSpec._resolve_placement() picks sensible defaults per VisualizationType:

Type family rows columns filters
LINE / COLUMN / STACKED_COLUMN / BAR / STACKED_BAR / AREA / RADAR / PIE pe ou dx
PIVOT_TABLE ou pe dx
SINGLE_VALUE (empty) dx pe, ou

Override any slot explicitly via category_dimension / series_dimension / filter_dimension when the data shape needs a different layout.

Why everything goes through /api/metadata

A direct PUT /api/visualizations/{uid} with rowDimensions / columnDimensions / filterDimensions set does not populate the derived rows / columns / filters collections that DHIS2 renders from. DHIS2 silently accepts the PUT, stores empty axes, and the dashboard app surfaces "A end date was not specified" or an empty grid. Routing through POST /api/metadata?importStrategy=CREATE_AND_UPDATE runs the same importer the UI does and expands the dimension selectors into the axes DHIS2 reads at render time. VisualizationsAccessor.create_from_spec and DashboardsAccessor.add_item both take that path; don't bypass them.

  • Analyticsclient.analytics.query(...) hits the same dimension model (dx, pe, ou) that powers visualizations. Every saved Visualization is essentially a persisted analytics query with a chart type + axis placement attached.
  • Client tutorial — visualizations guide — end-to-end walkthrough covering spec-driven authoring, clone, dashboard composition, and the screenshot path.

visualizations

Visualization authoring helpers — Dhis2Client.visualizations.

DHIS2 Visualization is one of the richest schemas on the instance — every combination of chart type, axis placement, legend, periods, and data dimensions is one model. Most day-to-day authoring only touches a small subset: the chart type, one or more data elements, a period selection, an org-unit set, and which of those three dimensions lands on the category axis / series / filter.

This module layers three things over the generated client.resources.visualizations CRUD accessor:

  • VisualizationSpec — a typed builder covering the common authoring surface with sensible defaults per chart type. Produces a full Visualization ready to POST via /api/metadata.
  • VisualizationsAccessorDhis2Client.visualizations — provides list / get / create_from_spec / clone / delete, all round-tripping typed models.
  • DashboardSlot + helpers used by Dhis2Client.dashboards.add_item (see dashboards.py).

Dimensional placement cheat-sheet

DHIS2 visualizations have three dimensions — dx (data), pe (period), ou (org unit) — distributed across three slots:

  • rows (category axis on charts; left side of a pivot)
  • columns (series on charts; top of a pivot)
  • filters (narrow to single value(s); invisible on the canvas)

VisualizationSpec._resolve_placement() picks sensible defaults per VisualizationType:

  • LINE / COLUMN / STACKED_COLUMN / BAR / STACKED_BAR / AREA / RADAR / PIE: rows=[pe], columns=[ou], filters=[dx] — time runs along the x-axis, one series per org unit, single data element filter.
  • PIVOT_TABLE: rows=[ou], columns=[pe], filters=[dx] — the shape DHIS2 UIs ship as the default pivot layout.
  • SINGLE_VALUE: rows=[], columns=[dx], filters=[pe, ou] — grid collapses to one cell so the KPI tile renders a single number.

Callers override any of those via category_dimension / series_dimension / filter_dimension when the data shape needs a different layout (e.g. one line per data element).

Why POST through /api/metadata

A direct PUT /api/visualizations/{uid} with rowDimensions / columnDimensions / filterDimensions set does not populate the derived rows / columns / filters collections — DHIS2 silently accepts the PUT and stores empty axes, which the dashboard app surfaces as "A end date was not specified" or an empty grid. Routing through /api/metadata?importStrategy=CREATE_AND_UPDATE runs the same importer the UI does and expands the dimension selectors into the axes DHIS2 reads at render time. The accessor always takes that path; don't bypass it.

Classes

VisualizationSpec

Bases: BaseModel

Typed builder for the common visualization shapes DHIS2 exposes.

Captures the high-value authoring surface — chart type, data elements, periods, org units, and dimensional placement — and produces a full Visualization via to_visualization(). Use client.visualizations.create_from_spec(spec) to round-trip through /api/metadata (the only path that fully populates DHIS2's derived axes — see the module docstring).

Source code in packages/dhis2w-client/src/dhis2w_client/visualizations.py
class VisualizationSpec(BaseModel):
    """Typed builder for the common visualization shapes DHIS2 exposes.

    Captures the high-value authoring surface — chart type, data
    elements, periods, org units, and dimensional placement — and
    produces a full `Visualization` via `to_visualization()`. Use
    `client.visualizations.create_from_spec(spec)` to round-trip
    through `/api/metadata` (the only path that fully populates
    DHIS2's derived axes — see the module docstring).
    """

    model_config = ConfigDict(frozen=True)

    name: str = Field(..., min_length=1, max_length=230)
    viz_type: VisualizationType = VisualizationType.COLUMN
    data_elements: list[str] = Field(default_factory=list)
    indicators: list[str] = Field(default_factory=list)
    periods: list[str] = Field(default_factory=list)
    relative_periods: frozenset[RelativePeriod] = Field(default_factory=frozenset)
    organisation_units: list[str] = Field(..., min_length=1)
    description: str | None = None
    uid: str | None = None
    category_dimension: DimensionAxis | None = None
    series_dimension: DimensionAxis | None = None
    filter_dimension: DimensionAxis | None = None
    legend_set: str | None = None

    @model_validator(mode="after")
    def _require_period_selection(self) -> VisualizationSpec:
        """Enforce that at least one period slot is populated.

        A `Visualization` without any period dimension has nothing on
        the x-axis for charts / no row-or-column anchor for pivots, and
        DHIS2 resolves it to "No data available" at render time. Failing
        fast at spec construction catches the mistake before the POST.
        """
        if not self.periods and not self.relative_periods:
            raise ValueError("VisualizationSpec requires either `periods` or `relative_periods` (or both)")
        return self

    @model_validator(mode="after")
    def _require_data_dimension(self) -> VisualizationSpec:
        """Enforce that at least one data-dimension item is selected.

        DHIS2 needs at least one `DATA_ELEMENT` / `INDICATOR` /
        `PROGRAM_INDICATOR` entry on the `dx` axis — a viz with zero
        `dataDimensionItems` has nothing to plot and the importer
        rejects it opaquely. Catch it at spec construction.
        """
        if not self.data_elements and not self.indicators:
            raise ValueError("VisualizationSpec requires at least one `data_elements` or `indicators` entry")
        return self

    def to_visualization(self) -> Visualization:
        """Materialise the typed `Visualization` DHIS2's metadata importer accepts."""
        rows, columns, filters = self._resolve_placement()
        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
        )
        payload: dict[str, Any] = {
            "id": self.uid or generate_uid(),
            "name": self.name,
            "description": self.description,
            "type": self.viz_type.value,
            "dataDimensionItems": data_dimension_items,
            "periods": [{"id": pe} for pe in self.periods],
            "organisationUnits": [{"id": ou} for ou in self.organisation_units],
            "rowDimensions": rows,
            "columnDimensions": columns,
            "filterDimensions": filters,
        }
        if self.relative_periods:
            # Serialize through the typed `RelativePeriods` model so the boolean
            # field-name discipline is enforced, then dump to a plain dict —
            # the `schemas/Visualization` model stores the block via
            # `extra="allow"` rather than a first-class field, so only dicts
            # round-trip cleanly through `model_validate`.
            relative_periods_model = RelativePeriods(**{p.value: True for p in self.relative_periods})
            payload["relativePeriods"] = relative_periods_model.model_dump(exclude_none=True)
        if self.legend_set is not None:
            # Attach a LegendSet so DHIS2 bands values into the legend's
            # ranges (coverage <50% red, >=90% green). `strategy=FIXED`
            # applies the single legend to every data item; `style=FILL`
            # colours the bar/cell (not just the text); `showKey=true`
            # renders the legend key on the chart.
            payload["legend"] = {
                "set": {"id": self.legend_set},
                "strategy": "FIXED",
                "style": "FILL",
                "showKey": True,
            }
        return Visualization.model_validate(payload)

    def _resolve_placement(self) -> tuple[list[str], list[str], list[str]]:
        """Default dimension placement per `VisualizationType`.

        Returns `(rowDimensions, columnDimensions, filterDimensions)`
        as lists of `"dx" | "pe" | "ou"` tokens. Overrides from
        `category_dimension` / `series_dimension` / `filter_dimension`
        take precedence and shift the remaining dimensions to filters.
        """
        if self.viz_type == VisualizationType.SINGLE_VALUE:
            # SV: grid collapses to one cell. `dx` on columns by
            # default, everything else on filters, rows stays empty.
            series = self.series_dimension or "dx"
            remaining: list[str] = [d for d in ("dx", "pe", "ou") if d != series and d != self.category_dimension]
            category: list[str] = [self.category_dimension] if self.category_dimension is not None else []
            return category, [series], remaining
        if self.viz_type == VisualizationType.PIVOT_TABLE:
            rows = self.category_dimension or "ou"
            columns = self.series_dimension or "pe"
            filters = self.filter_dimension or _remaining_axis(rows, columns)
            return [rows], [columns], [filters]
        if self.viz_type in _CHART_TYPES:
            rows = self.category_dimension or "pe"
            columns = self.series_dimension or "ou"
            filters = self.filter_dimension or _remaining_axis(rows, columns)
            return [rows], [columns], [filters]
        # Unknown / future types fall back to the chart convention.
        rows = self.category_dimension or "pe"
        columns = self.series_dimension or "ou"
        filters = self.filter_dimension or _remaining_axis(rows, columns)
        return [rows], [columns], [filters]
Functions
to_visualization()

Materialise the typed Visualization DHIS2's metadata importer accepts.

Source code in packages/dhis2w-client/src/dhis2w_client/visualizations.py
def to_visualization(self) -> Visualization:
    """Materialise the typed `Visualization` DHIS2's metadata importer accepts."""
    rows, columns, filters = self._resolve_placement()
    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
    )
    payload: dict[str, Any] = {
        "id": self.uid or generate_uid(),
        "name": self.name,
        "description": self.description,
        "type": self.viz_type.value,
        "dataDimensionItems": data_dimension_items,
        "periods": [{"id": pe} for pe in self.periods],
        "organisationUnits": [{"id": ou} for ou in self.organisation_units],
        "rowDimensions": rows,
        "columnDimensions": columns,
        "filterDimensions": filters,
    }
    if self.relative_periods:
        # Serialize through the typed `RelativePeriods` model so the boolean
        # field-name discipline is enforced, then dump to a plain dict —
        # the `schemas/Visualization` model stores the block via
        # `extra="allow"` rather than a first-class field, so only dicts
        # round-trip cleanly through `model_validate`.
        relative_periods_model = RelativePeriods(**{p.value: True for p in self.relative_periods})
        payload["relativePeriods"] = relative_periods_model.model_dump(exclude_none=True)
    if self.legend_set is not None:
        # Attach a LegendSet so DHIS2 bands values into the legend's
        # ranges (coverage <50% red, >=90% green). `strategy=FIXED`
        # applies the single legend to every data item; `style=FILL`
        # colours the bar/cell (not just the text); `showKey=true`
        # renders the legend key on the chart.
        payload["legend"] = {
            "set": {"id": self.legend_set},
            "strategy": "FIXED",
            "style": "FILL",
            "showKey": True,
        }
    return Visualization.model_validate(payload)

VisualizationsAccessor

Dhis2Client.visualizations — workflow helpers over /api/visualizations.

Source code in packages/dhis2w-client/src/dhis2w_client/visualizations.py
class VisualizationsAccessor:
    """`Dhis2Client.visualizations` — workflow helpers over `/api/visualizations`."""

    def __init__(self, client: Dhis2Client) -> None:
        """Bind to the sharing client."""
        self._client = client

    async def list_all(
        self,
        *,
        viz_type: VisualizationType | str | None = None,
    ) -> list[Visualization]:
        """List every Visualization, optionally narrowed by type. Sorted by name."""
        filters: list[str] | None = None
        if viz_type is not None:
            value = viz_type.value if isinstance(viz_type, VisualizationType) else viz_type
            filters = [f"type:eq:{value}"]
        return cast(
            list[Visualization],
            await self._client.resources.visualizations.list(
                fields="id,name,description,type,lastUpdated",
                filters=filters,
                order=["name:asc"],
                paging=False,
            ),
        )

    async def get(self, uid: str) -> Visualization:
        """Fetch one Visualization with axes + data dimensions resolved inline."""
        raw = await self._client.get_raw(f"/api/visualizations/{uid}", params={"fields": _VIZ_FIELDS})
        return Visualization.model_validate(raw)

    async def create_from_spec(self, spec: VisualizationSpec) -> Visualization:
        """Build a Visualization from a spec and POST via `/api/metadata`.

        The metadata importer expands `rowDimensions` / `columnDimensions`
        / `filterDimensions` into the derived `rows` / `columns` /
        `filters` collections DHIS2 actually renders from. A direct
        `PUT /api/visualizations/{uid}` skips that expansion and stores
        empty axes — don't take that shortcut.
        """
        viz = spec.to_visualization()
        if viz.id is None:
            raise ValueError("VisualizationSpec did not assign a UID — check to_visualization()")
        body = {"visualizations": [viz.model_dump(by_alias=True, exclude_none=True, mode="json")]}
        raw = await self._client.post_raw(
            "/api/metadata",
            body,
            params={"importStrategy": "CREATE_AND_UPDATE", "atomicMode": "ALL"},
        )
        WebMessageResponse.model_validate(raw)
        return await self.get(viz.id)

    async def clone(
        self,
        source_uid: str,
        *,
        new_name: str,
        new_uid: str | None = None,
        new_description: str | None = None,
    ) -> Visualization:
        """Duplicate an existing Visualization with a fresh UID + new name.

        Copies the full axes / data dimensions / period / org-unit
        selection so the clone renders identically. Reset any display
        overrides (title / subtitle) by passing `new_description=...`
        explicitly — the source's description carries over otherwise.
        """
        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")
        # Strip server-owned + identity fields so the clone is treated
        # as a fresh create rather than an update of the source.
        for owned in (
            "id",
            "uid",
            "created",
            "lastUpdated",
            "createdBy",
            "lastUpdatedBy",
            "href",
            "access",
            "user",
            "favorites",
            "favorite",
            "subscribers",
            "subscribed",
            "interpretations",
            "displayName",
            "displayDescription",
            "displayFormName",
            "displayShortName",
            "displaySubtitle",
            "displayTitle",
            "translations",
        ):
            payload.pop(owned, None)
        payload["id"] = target_uid
        payload["name"] = new_name
        if new_description is not None:
            payload["description"] = new_description
        raw = await self._client.post_raw(
            "/api/metadata",
            {"visualizations": [payload]},
            params={"importStrategy": "CREATE_AND_UPDATE", "atomicMode": "ALL"},
        )
        WebMessageResponse.model_validate(raw)
        return await self.get(target_uid)

    async def delete(self, uid: str) -> None:
        """DELETE a Visualization by UID."""
        await self._client.resources.visualizations.delete(uid)
Functions
__init__(client)

Bind to the sharing client.

Source code in packages/dhis2w-client/src/dhis2w_client/visualizations.py
def __init__(self, client: Dhis2Client) -> None:
    """Bind to the sharing client."""
    self._client = client
list_all(*, viz_type=None) async

List every Visualization, optionally narrowed by type. Sorted by name.

Source code in packages/dhis2w-client/src/dhis2w_client/visualizations.py
async def list_all(
    self,
    *,
    viz_type: VisualizationType | str | None = None,
) -> list[Visualization]:
    """List every Visualization, optionally narrowed by type. Sorted by name."""
    filters: list[str] | None = None
    if viz_type is not None:
        value = viz_type.value if isinstance(viz_type, VisualizationType) else viz_type
        filters = [f"type:eq:{value}"]
    return cast(
        list[Visualization],
        await self._client.resources.visualizations.list(
            fields="id,name,description,type,lastUpdated",
            filters=filters,
            order=["name:asc"],
            paging=False,
        ),
    )
get(uid) async

Fetch one Visualization with axes + data dimensions resolved inline.

Source code in packages/dhis2w-client/src/dhis2w_client/visualizations.py
async def get(self, uid: str) -> Visualization:
    """Fetch one Visualization with axes + data dimensions resolved inline."""
    raw = await self._client.get_raw(f"/api/visualizations/{uid}", params={"fields": _VIZ_FIELDS})
    return Visualization.model_validate(raw)
create_from_spec(spec) async

Build a Visualization from a spec and POST via /api/metadata.

The metadata importer expands rowDimensions / columnDimensions / filterDimensions into the derived rows / columns / filters collections DHIS2 actually renders from. A direct PUT /api/visualizations/{uid} skips that expansion and stores empty axes — don't take that shortcut.

Source code in packages/dhis2w-client/src/dhis2w_client/visualizations.py
async def create_from_spec(self, spec: VisualizationSpec) -> Visualization:
    """Build a Visualization from a spec and POST via `/api/metadata`.

    The metadata importer expands `rowDimensions` / `columnDimensions`
    / `filterDimensions` into the derived `rows` / `columns` /
    `filters` collections DHIS2 actually renders from. A direct
    `PUT /api/visualizations/{uid}` skips that expansion and stores
    empty axes — don't take that shortcut.
    """
    viz = spec.to_visualization()
    if viz.id is None:
        raise ValueError("VisualizationSpec did not assign a UID — check to_visualization()")
    body = {"visualizations": [viz.model_dump(by_alias=True, exclude_none=True, mode="json")]}
    raw = await self._client.post_raw(
        "/api/metadata",
        body,
        params={"importStrategy": "CREATE_AND_UPDATE", "atomicMode": "ALL"},
    )
    WebMessageResponse.model_validate(raw)
    return await self.get(viz.id)
clone(source_uid, *, new_name, new_uid=None, new_description=None) async

Duplicate an existing Visualization with a fresh UID + new name.

Copies the full axes / data dimensions / period / org-unit selection so the clone renders identically. Reset any display overrides (title / subtitle) by passing new_description=... explicitly — the source's description carries over otherwise.

Source code in packages/dhis2w-client/src/dhis2w_client/visualizations.py
async def clone(
    self,
    source_uid: str,
    *,
    new_name: str,
    new_uid: str | None = None,
    new_description: str | None = None,
) -> Visualization:
    """Duplicate an existing Visualization with a fresh UID + new name.

    Copies the full axes / data dimensions / period / org-unit
    selection so the clone renders identically. Reset any display
    overrides (title / subtitle) by passing `new_description=...`
    explicitly — the source's description carries over otherwise.
    """
    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")
    # Strip server-owned + identity fields so the clone is treated
    # as a fresh create rather than an update of the source.
    for owned in (
        "id",
        "uid",
        "created",
        "lastUpdated",
        "createdBy",
        "lastUpdatedBy",
        "href",
        "access",
        "user",
        "favorites",
        "favorite",
        "subscribers",
        "subscribed",
        "interpretations",
        "displayName",
        "displayDescription",
        "displayFormName",
        "displayShortName",
        "displaySubtitle",
        "displayTitle",
        "translations",
    ):
        payload.pop(owned, None)
    payload["id"] = target_uid
    payload["name"] = new_name
    if new_description is not None:
        payload["description"] = new_description
    raw = await self._client.post_raw(
        "/api/metadata",
        {"visualizations": [payload]},
        params={"importStrategy": "CREATE_AND_UPDATE", "atomicMode": "ALL"},
    )
    WebMessageResponse.model_validate(raw)
    return await self.get(target_uid)
delete(uid) async

DELETE a Visualization by UID.

Source code in packages/dhis2w-client/src/dhis2w_client/visualizations.py
async def delete(self, uid: str) -> None:
    """DELETE a Visualization by UID."""
    await self._client.resources.visualizations.delete(uid)

Functions

dashboards

Dashboard assembly helpers — Dhis2Client.dashboards.

DHIS2 Dashboard carries an ordered list of DashboardItems, each slotted on a 60-unit-wide grid via explicit x / y / width / height. The generated CRUD accessor (client.resources.dashboards) covers basic lifecycle — this module layers workflow helpers that the dashboard app's UI exposes but the raw API forces callers to hand-roll:

  • add_item(dashboard_uid, viz_uid, ...) — append one visualization item to an existing dashboard, read-modify-write against /api/metadata. Auto-stacks below existing items when y is omitted.
  • DashboardSlot — typed x/y/width/height bundle; passed through to add_item when the caller wants explicit placement.

The "auto-stack below existing" heuristic scans the dashboard's current items, finds the largest y + height, and drops the new item at that y. Callers who want tiling layouts (two charts side-by-side) set x + width explicitly and share a y.

Classes

DashboardSlot

Bases: BaseModel

Grid placement for one dashboard item — x/y/width/height + shape.

The DHIS2 grid is 60 units wide; heights stack freely. Use NORMAL shape for blocks that sit alongside neighbours, FULL_WIDTH when the item spans the entire row and nothing else shares its row, and DOUBLE_WIDTH for 2-column layouts. The shape is rendering metadata — actual layout comes from width + x.

Source code in packages/dhis2w-client/src/dhis2w_client/dashboards.py
class DashboardSlot(BaseModel):
    """Grid placement for one dashboard item — x/y/width/height + shape.

    The DHIS2 grid is 60 units wide; heights stack freely. Use `NORMAL`
    shape for blocks that sit alongside neighbours, `FULL_WIDTH` when
    the item spans the entire row and nothing else shares its row, and
    `DOUBLE_WIDTH` for 2-column layouts. The shape is rendering metadata
    — actual layout comes from `width` + `x`.
    """

    model_config = ConfigDict(frozen=True)

    x: int = Field(default=0, ge=0, le=60)
    y: int = Field(default=0, ge=0)
    width: int = Field(default=60, ge=1, le=60)
    height: int = Field(default=20, ge=1)
    shape: DashboardItemShape = DashboardItemShape.NORMAL

DashboardsAccessor

Dhis2Client.dashboards — compose dashboards from existing visualizations.

Source code in packages/dhis2w-client/src/dhis2w_client/dashboards.py
class DashboardsAccessor:
    """`Dhis2Client.dashboards` — compose dashboards from existing visualizations."""

    def __init__(self, client: Dhis2Client) -> None:
        """Bind to the sharing client."""
        self._client = client

    async def get(self, uid: str) -> Dashboard:
        """Fetch one Dashboard with every item + its referenced resource."""
        raw = await self._client.get_raw(f"/api/dashboards/{uid}", params={"fields": _DASHBOARD_FIELDS})
        return Dashboard.model_validate(raw)

    async def list_all(self) -> list[Dashboard]:
        """List every Dashboard on the instance, sorted by name."""
        raw = await self._client.get_raw(
            "/api/dashboards",
            params={"fields": "id,name,description,lastUpdated", "order": "name:asc", "paging": "false"},
        )
        rows = raw.get("dashboards")
        if not isinstance(rows, list):
            return []
        return [Dashboard.model_validate(row) for row in rows if isinstance(row, dict)]

    async def add_item(
        self,
        dashboard_uid: str,
        target_uid: str,
        *,
        kind: DashboardItemKind = "visualization",
        slot: DashboardSlot | None = None,
        item_uid: str | None = None,
    ) -> Dashboard:
        """Append one metadata-backed item (viz / map / event chart / …) to a dashboard.

        `kind` picks which DHIS2 DashboardItem reference field carries
        the UID — `"visualization"` (default), `"map"`,
        `"eventVisualization"`, `"eventChart"`, `"eventReport"`. The
        `type` enum on the item is set automatically from `kind`.

        When `slot` is omitted the new item stacks below every existing
        item: `y` is computed as `max(existing.y + existing.height)` so
        nothing overlaps. Supply a `DashboardSlot` when you need
        side-by-side tiling (share `y`, split `x` + `width`).

        Returns the full updated Dashboard. The PUT uses `/api/metadata`
        to route through the same importer the UI does, so derived
        fields populate correctly.
        """
        if kind not in _KIND_MAP:
            raise ValueError(f"unknown dashboard item kind {kind!r}; expected one of {list(_KIND_MAP)}")
        ref_field, item_type = _KIND_MAP[kind]
        current = await self.get(dashboard_uid)
        existing_items = list(current.dashboardItems or [])
        placement = slot or _auto_stack_below(existing_items)
        item = DashboardItem.model_validate(
            {
                "id": item_uid or generate_uid(),
                "type": item_type.value,
                ref_field: {"id": target_uid},
                "x": placement.x,
                "y": placement.y,
                "width": placement.width,
                "height": placement.height,
                "shape": placement.shape.value,
            },
        )
        updated_items: list[Any] = []
        for entry in existing_items:
            if isinstance(entry, DashboardItem):
                updated_items.append(entry.model_dump(by_alias=True, exclude_none=True, mode="json"))
            elif isinstance(entry, dict):
                updated_items.append(entry)
        updated_items.append(item.model_dump(by_alias=True, exclude_none=True, mode="json"))
        payload = current.model_dump(by_alias=True, exclude_none=True, mode="json")
        # Strip read-only / server-owned fields so the metadata importer
        # treats this as a straight update of the same dashboard.
        for owned in (
            "created",
            "lastUpdated",
            "createdBy",
            "lastUpdatedBy",
            "href",
            "access",
            "user",
            "favorites",
            "favorite",
            "displayName",
            "displayDescription",
        ):
            payload.pop(owned, None)
        payload["dashboardItems"] = updated_items
        await self._client.post(
            "/api/metadata",
            {"dashboards": [payload]},
            params={"importStrategy": "CREATE_AND_UPDATE", "atomicMode": "ALL"},
            model=WebMessageResponse,
        )
        return await self.get(dashboard_uid)

    async def remove_item(self, dashboard_uid: str, item_uid: str) -> Dashboard:
        """Remove one dashboardItem by its UID and PUT the dashboard back."""
        current = await self.get(dashboard_uid)
        kept: list[Any] = []
        for entry in current.dashboardItems or []:
            entry_uid = (
                entry.id if isinstance(entry, DashboardItem) else entry.get("id") if isinstance(entry, dict) else None
            )
            if entry_uid == item_uid:
                continue
            if isinstance(entry, DashboardItem):
                kept.append(entry.model_dump(by_alias=True, exclude_none=True, mode="json"))
            elif isinstance(entry, dict):
                kept.append(entry)
        payload = current.model_dump(by_alias=True, exclude_none=True, mode="json")
        for owned in (
            "created",
            "lastUpdated",
            "createdBy",
            "lastUpdatedBy",
            "href",
            "access",
            "user",
            "favorites",
            "favorite",
            "displayName",
            "displayDescription",
        ):
            payload.pop(owned, None)
        payload["dashboardItems"] = kept
        await self._client.post(
            "/api/metadata",
            {"dashboards": [payload]},
            params={"importStrategy": "CREATE_AND_UPDATE", "atomicMode": "ALL"},
            model=WebMessageResponse,
        )
        return await self.get(dashboard_uid)
Functions
__init__(client)

Bind to the sharing client.

Source code in packages/dhis2w-client/src/dhis2w_client/dashboards.py
def __init__(self, client: Dhis2Client) -> None:
    """Bind to the sharing client."""
    self._client = client
get(uid) async

Fetch one Dashboard with every item + its referenced resource.

Source code in packages/dhis2w-client/src/dhis2w_client/dashboards.py
async def get(self, uid: str) -> Dashboard:
    """Fetch one Dashboard with every item + its referenced resource."""
    raw = await self._client.get_raw(f"/api/dashboards/{uid}", params={"fields": _DASHBOARD_FIELDS})
    return Dashboard.model_validate(raw)
list_all() async

List every Dashboard on the instance, sorted by name.

Source code in packages/dhis2w-client/src/dhis2w_client/dashboards.py
async def list_all(self) -> list[Dashboard]:
    """List every Dashboard on the instance, sorted by name."""
    raw = await self._client.get_raw(
        "/api/dashboards",
        params={"fields": "id,name,description,lastUpdated", "order": "name:asc", "paging": "false"},
    )
    rows = raw.get("dashboards")
    if not isinstance(rows, list):
        return []
    return [Dashboard.model_validate(row) for row in rows if isinstance(row, dict)]
add_item(dashboard_uid, target_uid, *, kind='visualization', slot=None, item_uid=None) async

Append one metadata-backed item (viz / map / event chart / …) to a dashboard.

kind picks which DHIS2 DashboardItem reference field carries the UID — "visualization" (default), "map", "eventVisualization", "eventChart", "eventReport". The type enum on the item is set automatically from kind.

When slot is omitted the new item stacks below every existing item: y is computed as max(existing.y + existing.height) so nothing overlaps. Supply a DashboardSlot when you need side-by-side tiling (share y, split x + width).

Returns the full updated Dashboard. The PUT uses /api/metadata to route through the same importer the UI does, so derived fields populate correctly.

Source code in packages/dhis2w-client/src/dhis2w_client/dashboards.py
async def add_item(
    self,
    dashboard_uid: str,
    target_uid: str,
    *,
    kind: DashboardItemKind = "visualization",
    slot: DashboardSlot | None = None,
    item_uid: str | None = None,
) -> Dashboard:
    """Append one metadata-backed item (viz / map / event chart / …) to a dashboard.

    `kind` picks which DHIS2 DashboardItem reference field carries
    the UID — `"visualization"` (default), `"map"`,
    `"eventVisualization"`, `"eventChart"`, `"eventReport"`. The
    `type` enum on the item is set automatically from `kind`.

    When `slot` is omitted the new item stacks below every existing
    item: `y` is computed as `max(existing.y + existing.height)` so
    nothing overlaps. Supply a `DashboardSlot` when you need
    side-by-side tiling (share `y`, split `x` + `width`).

    Returns the full updated Dashboard. The PUT uses `/api/metadata`
    to route through the same importer the UI does, so derived
    fields populate correctly.
    """
    if kind not in _KIND_MAP:
        raise ValueError(f"unknown dashboard item kind {kind!r}; expected one of {list(_KIND_MAP)}")
    ref_field, item_type = _KIND_MAP[kind]
    current = await self.get(dashboard_uid)
    existing_items = list(current.dashboardItems or [])
    placement = slot or _auto_stack_below(existing_items)
    item = DashboardItem.model_validate(
        {
            "id": item_uid or generate_uid(),
            "type": item_type.value,
            ref_field: {"id": target_uid},
            "x": placement.x,
            "y": placement.y,
            "width": placement.width,
            "height": placement.height,
            "shape": placement.shape.value,
        },
    )
    updated_items: list[Any] = []
    for entry in existing_items:
        if isinstance(entry, DashboardItem):
            updated_items.append(entry.model_dump(by_alias=True, exclude_none=True, mode="json"))
        elif isinstance(entry, dict):
            updated_items.append(entry)
    updated_items.append(item.model_dump(by_alias=True, exclude_none=True, mode="json"))
    payload = current.model_dump(by_alias=True, exclude_none=True, mode="json")
    # Strip read-only / server-owned fields so the metadata importer
    # treats this as a straight update of the same dashboard.
    for owned in (
        "created",
        "lastUpdated",
        "createdBy",
        "lastUpdatedBy",
        "href",
        "access",
        "user",
        "favorites",
        "favorite",
        "displayName",
        "displayDescription",
    ):
        payload.pop(owned, None)
    payload["dashboardItems"] = updated_items
    await self._client.post(
        "/api/metadata",
        {"dashboards": [payload]},
        params={"importStrategy": "CREATE_AND_UPDATE", "atomicMode": "ALL"},
        model=WebMessageResponse,
    )
    return await self.get(dashboard_uid)
remove_item(dashboard_uid, item_uid) async

Remove one dashboardItem by its UID and PUT the dashboard back.

Source code in packages/dhis2w-client/src/dhis2w_client/dashboards.py
async def remove_item(self, dashboard_uid: str, item_uid: str) -> Dashboard:
    """Remove one dashboardItem by its UID and PUT the dashboard back."""
    current = await self.get(dashboard_uid)
    kept: list[Any] = []
    for entry in current.dashboardItems or []:
        entry_uid = (
            entry.id if isinstance(entry, DashboardItem) else entry.get("id") if isinstance(entry, dict) else None
        )
        if entry_uid == item_uid:
            continue
        if isinstance(entry, DashboardItem):
            kept.append(entry.model_dump(by_alias=True, exclude_none=True, mode="json"))
        elif isinstance(entry, dict):
            kept.append(entry)
    payload = current.model_dump(by_alias=True, exclude_none=True, mode="json")
    for owned in (
        "created",
        "lastUpdated",
        "createdBy",
        "lastUpdatedBy",
        "href",
        "access",
        "user",
        "favorites",
        "favorite",
        "displayName",
        "displayDescription",
    ):
        payload.pop(owned, None)
    payload["dashboardItems"] = kept
    await self._client.post(
        "/api/metadata",
        {"dashboards": [payload]},
        params={"importStrategy": "CREATE_AND_UPDATE", "atomicMode": "ALL"},
        model=WebMessageResponse,
    )
    return await self.get(dashboard_uid)

Functions