Tracker schema
The authoring flip side of dhis2 tracker register / enroll / add-event. DHIS2's tracker writes need a schema on the instance: the TrackedEntityType that names the kind of subject, and the TrackedEntityAttributes that describe the fields captured per enrolled TEI. Two accessors cover the leaf half of tracker-schema CRUD:
| Accessor |
API path |
Purpose |
client.tracked_entity_attributes |
/api/trackedEntityAttributes |
Atomic fields on a TEI (National ID, Given Name, DOB, …). CRUD + rename + common toggles (unique, generated, confidential, inherit, pattern). |
client.tracked_entity_types |
/api/trackedEntityTypes |
The kind of TEI (Person, Case, Animal). CRUD + ordered attribute linkage through trackedEntityTypeAttributes[]. |
client.programs |
/api/programs |
Tracker container. Binds a TrackedEntityType, a set of TEAs on the enrollment form, a CategoryCombo, and the OUs that can capture. CRUD + add_attribute / remove_attribute for PTEA linkage + add_organisation_unit / remove_organisation_unit for OU scope. |
client.program_stages |
/api/programStages |
Inner tracker-schema layer. Each stage owns an ordered programStageDataElements[] list (a join table with compulsory / displayInReports / allowFutureDate flags). CRUD + add_element / remove_element / reorder. |
Scope
This page covers the full tracker-schema authoring chain: leaf resources (TrackedEntityAttribute + TrackedEntityType), the middle layer (Program + programTrackedEntityAttributes[]), and the inner layer (ProgramStage + programStageDataElements[]). Optional ProgramStageSection grouping (rarely used in the field) is still unauthored — reach for metadata patch if you need it.
TETA join table
Wiring a TEA onto a TET isn't a simple ref list — the link is a trackedEntityTypeAttributes[] entry that carries mandatory, searchable, and displayInList flags. The accessor's add_attribute / remove_attribute helpers round-trip the full TET, mutate the list, and PUT it back so those flags travel without a dedicated endpoint:
async with Dhis2Client(...) as client:
national_id = await client.tracked_entity_attributes.create(
name="National ID",
short_name="NatID",
unique=True,
generated=True,
pattern="RANDOM(#######)",
)
person = await client.tracked_entity_types.create(
name="Person",
short_name="Person",
allow_audit_log=True,
feature_type="NONE",
)
await client.tracked_entity_types.add_attribute(
person.id,
national_id.id,
mandatory=True,
searchable=True,
)
Self-ref stripping
DHIS2's /api/trackedEntityTypes/{uid} read embeds trackedEntityTypeAttributes[].trackedEntityType = {id: <parent>} even though that field is the inverse side the importer rejects on PUT. The accessor strips it automatically before every update, mirroring the DataSet + DataSetElement workaround (BUGS tracker parity — same shape as _strip_self_ref_from_dse).
unique + generated + pattern
DHIS2 supports auto-generated attribute values for registration:
unique=True makes the value unique across the instance (National ID, passport number).
generated=True + pattern together mean DHIS2 auto-fills the value when a new TEI is registered.
- Common patterns:
"RANDOM(#######)" for a 7-digit random suffix, "#(ORGUNIT)(RANDOM)" to prefix the TEI's OU.
No *Spec builder
Same call as every other authoring accessor — keyword args. Continues the spec-audit data point.
CLI
# TrackedEntityAttribute
dhis2 metadata tracked-entity-attributes create \
--name "National ID" --short-name NatID --value-type TEXT \
--unique --generated --pattern "RANDOM(#######)"
# TrackedEntityType + attribute linkage
dhis2 metadata tracked-entity-types create \
--name Person --short-name Person --allow-audit-log --feature-type NONE
dhis2 metadata tracked-entity-types add-attribute <TET_UID> <TEA_UID> --mandatory --searchable
Every list has an ls alias; every destructive verb accepts --yes / -y.
MCP
12 tools: metadata_tracked_entity_attribute_* (list / get / create / rename / delete), metadata_tracked_entity_type_* (list / get / create / rename / add-attribute / remove-attribute / delete).
Using them with tracker writes
The point of authoring these here is to make the tracker-write plugin usable end-to-end from CLI alone:
# 1. author the schema
dhis2 metadata tracked-entity-types create --name Person --short-name Person ...
# 2. use it
dhis2 tracker register --type <TET_UID> --ou <OU_UID> ...
See the tracker plugin for the write-side reference.
Program authoring
A Program binds everything together. Two flavours: WITH_REGISTRATION (tracker — requires a TET, enrolls individual TEIs) and WITHOUT_REGISTRATION (event program — captures anonymous events directly).
async with Dhis2Client(...) as client:
program = await client.programs.create(
name="Antenatal care",
short_name="ANC",
program_type="WITH_REGISTRATION",
tracked_entity_type_uid=person.id,
display_incident_date=True,
only_enroll_once=True,
)
await client.programs.add_attribute(
program.id,
national_id.id,
mandatory=True,
searchable=True,
sort_order=1,
)
await client.programs.add_organisation_unit(program.id, root_ou_uid)
PTEA join table + mergeMode=REPLACE quirk
programTrackedEntityAttributes[] is a nested join table (DHIS2's wire name is trackedEntityAttribute on the entry, not attribute). DHIS2 v42's PUT /api/programs/{uid} treats nested-list updates additively by default — items omitted from the payload are NOT removed. The accessor always passes ?mergeMode=REPLACE on PUT so remove_attribute behaves symmetrically.
OU scoping
add_organisation_unit / remove_organisation_unit use DHIS2's per-item shortcut (POST/DELETE /api/programs/{program}/organisationUnits/{ou}) — avoids the round-trip PUT entirely.
tracked_entity_attributes
TrackedEntityAttribute authoring — Dhis2Client.tracked_entity_attributes.
TrackedEntityAttributes are the atomic fields on a tracked entity
(National ID, Given Name, Date of Birth, …). They get wired into a
TrackedEntityType (via trackedEntityTypeAttributes[]) and/or into
programs (via programTrackedEntityAttributes[]).
This module adds the CRUD primitives — the run / write side lives on
Dhis2Client.tracker (the existing tracker write plugin).
create(...) — named kwargs over the minimal required subset
(name, short_name, value_type, aggregation_type) plus the
optional references (option_set_uid, legend_set_uids) and the
common toggles (unique, generated, confidential, inherit,
display_in_list_no_program, pattern).
update(tea) / rename(uid, ...) — standard edit pathways.
delete(uid) — DHIS2 rejects deletes on TEAs in use.
No *Spec builder — continues the spec-audit data point.
Classes
TrackedEntityAttribute
Bases: BaseModel
Generated model for DHIS2 TrackedEntityAttribute.
DHIS2 Tracked Entity Attribute - persisted metadata (generated from /api/schemas at DHIS2 v42).
API endpoint: /api/trackedEntityAttributes.
Field Field(description=...) entries flag DHIS2 semantics the bare
type can't capture: which side of a relationship owns the link
(writable) vs the inverse side (ignored by the API), uniqueness
constraints, and length bounds.
Source code in packages/dhis2w-client/src/dhis2w_client/generated/v42/schemas/tracked_entity_attribute.py
| class TrackedEntityAttribute(BaseModel):
"""Generated model for DHIS2 `TrackedEntityAttribute`.
DHIS2 Tracked Entity Attribute - persisted metadata (generated from /api/schemas at DHIS2 v42).
API endpoint: /api/trackedEntityAttributes.
Field `Field(description=...)` entries flag DHIS2 semantics the bare
type can't capture: which side of a relationship owns the link
(writable) vs the inverse side (ignored by the API), uniqueness
constraints, and length bounds.
"""
model_config = ConfigDict(extra="allow", populate_by_name=True)
access: Any | None = Field(default=None, description="Reference to Access. Read-only (inverse side).")
aggregationType: AggregationType | None = None
attributeValues: Any | None = Field(default=None, description="Reference to AttributeValues. Length/value max=255.")
code: str | None = Field(default=None, description="Unique. Length/value max=50.")
confidential: bool | None = None
created: datetime | None = None
createdBy: Reference | None = Field(default=None, description="Reference to User.")
description: str | None = Field(default=None, description="Length/value min=2, max=2147483647.")
dimensionItem: str | None = Field(default=None, description="Read-only.")
dimensionItemType: DimensionItemType | None = None
displayDescription: str | None = Field(default=None, description="Read-only.")
displayFormName: str | None = Field(default=None, description="Read-only.")
displayInListNoProgram: bool | None = None
displayName: str | None = Field(default=None, description="Read-only.")
displayOnVisitSchedule: bool | None = None
displayShortName: str | None = Field(default=None, description="Read-only.")
expression: str | None = Field(default=None, description="Length/value max=255.")
favorite: bool | None = Field(default=None, description="Read-only.")
favorites: list[Any] | None = Field(default=None, description="Collection of String. Read-only (inverse side).")
fieldMask: str | None = Field(default=None, description="Length/value max=255.")
formName: str | None = Field(default=None, description="Length/value max=2147483647.")
generated: bool | None = None
href: str | None = None
id: str | None = Field(default=None, description="Unique. Length/value min=11, max=11.")
inherit: bool | None = None
lastUpdated: datetime | None = None
lastUpdatedBy: Reference | None = Field(default=None, description="Reference to User.")
legendSet: Reference | None = Field(default=None, description="Reference to LegendSet. Read-only (inverse side).")
legendSets: list[Any] | None = Field(default=None, description="Collection of LegendSet.")
name: str | None = Field(default=None, description="Unique. Length/value min=1, max=230.")
optionSet: Reference | None = Field(default=None, description="Reference to OptionSet.")
optionSetValue: bool | None = Field(default=None, description="Read-only.")
orgunitScope: bool | None = None
pattern: str | None = Field(default=None, description="Length/value max=255.")
queryMods: Any | None = Field(default=None, description="Reference to QueryModifiers. Read-only (inverse side).")
sharing: Any | None = Field(default=None, description="Reference to Sharing. Length/value max=255.")
shortName: str | None = Field(default=None, description="Unique. Length/value min=1, max=50.")
skipSynchronization: bool | None = None
sortOrderInListNoProgram: int | None = Field(default=None, description="Length/value max=2147483647.")
sortOrderInVisitSchedule: int | None = Field(default=None, description="Length/value max=2147483647.")
style: Any | None = Field(default=None, description="Reference to ObjectStyle. Length/value max=255.")
translations: list[Any] | None = Field(default=None, description="Collection of Translation. Length/value max=255.")
unique: bool | None = None
user: Reference | None = Field(default=None, description="Reference to User. Read-only (inverse side).")
valueType: ValueType | None = None
|
TrackedEntityAttributesAccessor
Dhis2Client.tracked_entity_attributes — CRUD over /api/trackedEntityAttributes.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_attributes.py
| class TrackedEntityAttributesAccessor:
"""`Dhis2Client.tracked_entity_attributes` — CRUD over `/api/trackedEntityAttributes`."""
def __init__(self, client: Dhis2Client) -> None:
"""Bind to the sharing client."""
self._client = client
async def list_all(
self,
*,
value_type: ValueType | str | None = None,
page: int = 1,
page_size: int = 50,
) -> list[TrackedEntityAttribute]:
"""Page through TrackedEntityAttributes, optionally filtered by valueType."""
filters: list[str] | None = None
if value_type is not None:
value = value_type.value if isinstance(value_type, ValueType) else value_type
filters = [f"valueType:eq:{value}"]
return cast(
list[TrackedEntityAttribute],
await self._client.resources.tracked_entity_attributes.list(
fields=_TEA_FIELDS,
filters=filters,
page=page,
page_size=page_size,
),
)
async def get(self, uid: str) -> TrackedEntityAttribute:
"""Fetch one TrackedEntityAttribute with its optionSet + legendSet refs inline."""
return await self._client.get(
f"/api/trackedEntityAttributes/{uid}", model=TrackedEntityAttribute, params={"fields": _TEA_FIELDS}
)
async def create(
self,
*,
name: str,
short_name: str,
value_type: ValueType | str = ValueType.TEXT,
aggregation_type: AggregationType | str = AggregationType.NONE,
option_set_uid: str | None = None,
legend_set_uids: list[str] | None = None,
unique: bool = False,
generated: bool = False,
confidential: bool = False,
inherit: bool = False,
display_in_list_no_program: bool = False,
orgunit_scope: bool = False,
pattern: str | None = None,
field_mask: str | None = None,
code: str | None = None,
form_name: str | None = None,
description: str | None = None,
uid: str | None = None,
) -> TrackedEntityAttribute:
"""Create a TrackedEntityAttribute.
`value_type` defaults to `TEXT`; switch to `NUMBER`, `DATE`,
`PHONE_NUMBER`, etc. via the `ValueType` StrEnum. `unique=True`
makes the value unique across the instance (National ID,
passport number). `generated=True` + `pattern` lets DHIS2
auto-generate the value when a TEI is registered (common for
case IDs). `option_set_uid` constrains to an option-set
picklist. `confidential=True` tags the attribute as sensitive
for audit + sharing policies.
"""
payload: dict[str, Any] = {
"name": name,
"shortName": short_name,
"valueType": value_type.value if isinstance(value_type, ValueType) else value_type,
"aggregationType": (
aggregation_type.value if isinstance(aggregation_type, AggregationType) else aggregation_type
),
"unique": unique,
"generated": generated,
"confidential": confidential,
"inherit": inherit,
"displayInListNoProgram": display_in_list_no_program,
"orgunitScope": orgunit_scope,
}
if option_set_uid:
payload["optionSet"] = {"id": option_set_uid}
if legend_set_uids:
payload["legendSets"] = [{"id": uid_} for uid_ in legend_set_uids]
if uid:
payload["id"] = uid
if code:
payload["code"] = code
if form_name:
payload["formName"] = form_name
if description:
payload["description"] = description
if pattern:
payload["pattern"] = pattern
if field_mask:
payload["fieldMask"] = field_mask
envelope = await self._client.post("/api/trackedEntityAttributes", payload, model=WebMessageResponse)
created_uid = envelope.created_uid or uid
if not created_uid:
raise RuntimeError("tracked-entity-attribute create did not return a uid")
return await self.get(created_uid)
async def update(self, attribute: TrackedEntityAttribute) -> TrackedEntityAttribute:
"""PUT an edited TrackedEntityAttribute back. `attribute.id` must be set."""
if not attribute.id:
raise ValueError("update requires attribute.id to be set")
body = attribute.model_dump(by_alias=True, exclude_none=True, mode="json")
await self._client.put_raw(f"/api/trackedEntityAttributes/{attribute.id}", body=body)
return await self.get(attribute.id)
async def rename(
self,
uid: str,
*,
name: str | None = None,
short_name: str | None = None,
form_name: str | None = None,
description: str | None = None,
) -> TrackedEntityAttribute:
"""Partial-update shortcut — read, mutate the label fields, PUT."""
if name is None and short_name is None and form_name is None and description is None:
raise ValueError("rename requires at least one of name / short_name / form_name / description")
current = await self.get(uid)
if name is not None:
current.name = name
if short_name is not None:
current.shortName = short_name
if form_name is not None:
current.formName = form_name
if description is not None:
current.description = description
return await self.update(current)
async def delete(self, uid: str) -> None:
"""Delete a TrackedEntityAttribute — DHIS2 rejects deletes on TEAs wired into a TET or program."""
if not uid:
raise ValueError("delete requires a non-empty uid")
await self._client.resources.tracked_entity_attributes.delete(uid)
|
Functions
__init__(client)
Bind to the sharing client.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_attributes.py
| def __init__(self, client: Dhis2Client) -> None:
"""Bind to the sharing client."""
self._client = client
|
list_all(*, value_type=None, page=1, page_size=50)
async
Page through TrackedEntityAttributes, optionally filtered by valueType.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_attributes.py
| async def list_all(
self,
*,
value_type: ValueType | str | None = None,
page: int = 1,
page_size: int = 50,
) -> list[TrackedEntityAttribute]:
"""Page through TrackedEntityAttributes, optionally filtered by valueType."""
filters: list[str] | None = None
if value_type is not None:
value = value_type.value if isinstance(value_type, ValueType) else value_type
filters = [f"valueType:eq:{value}"]
return cast(
list[TrackedEntityAttribute],
await self._client.resources.tracked_entity_attributes.list(
fields=_TEA_FIELDS,
filters=filters,
page=page,
page_size=page_size,
),
)
|
get(uid)
async
Fetch one TrackedEntityAttribute with its optionSet + legendSet refs inline.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_attributes.py
| async def get(self, uid: str) -> TrackedEntityAttribute:
"""Fetch one TrackedEntityAttribute with its optionSet + legendSet refs inline."""
return await self._client.get(
f"/api/trackedEntityAttributes/{uid}", model=TrackedEntityAttribute, params={"fields": _TEA_FIELDS}
)
|
create(*, name, short_name, value_type=ValueType.TEXT, aggregation_type=AggregationType.NONE, option_set_uid=None, legend_set_uids=None, unique=False, generated=False, confidential=False, inherit=False, display_in_list_no_program=False, orgunit_scope=False, pattern=None, field_mask=None, code=None, form_name=None, description=None, uid=None)
async
Create a TrackedEntityAttribute.
value_type defaults to TEXT; switch to NUMBER, DATE,
PHONE_NUMBER, etc. via the ValueType StrEnum. unique=True
makes the value unique across the instance (National ID,
passport number). generated=True + pattern lets DHIS2
auto-generate the value when a TEI is registered (common for
case IDs). option_set_uid constrains to an option-set
picklist. confidential=True tags the attribute as sensitive
for audit + sharing policies.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_attributes.py
| async def create(
self,
*,
name: str,
short_name: str,
value_type: ValueType | str = ValueType.TEXT,
aggregation_type: AggregationType | str = AggregationType.NONE,
option_set_uid: str | None = None,
legend_set_uids: list[str] | None = None,
unique: bool = False,
generated: bool = False,
confidential: bool = False,
inherit: bool = False,
display_in_list_no_program: bool = False,
orgunit_scope: bool = False,
pattern: str | None = None,
field_mask: str | None = None,
code: str | None = None,
form_name: str | None = None,
description: str | None = None,
uid: str | None = None,
) -> TrackedEntityAttribute:
"""Create a TrackedEntityAttribute.
`value_type` defaults to `TEXT`; switch to `NUMBER`, `DATE`,
`PHONE_NUMBER`, etc. via the `ValueType` StrEnum. `unique=True`
makes the value unique across the instance (National ID,
passport number). `generated=True` + `pattern` lets DHIS2
auto-generate the value when a TEI is registered (common for
case IDs). `option_set_uid` constrains to an option-set
picklist. `confidential=True` tags the attribute as sensitive
for audit + sharing policies.
"""
payload: dict[str, Any] = {
"name": name,
"shortName": short_name,
"valueType": value_type.value if isinstance(value_type, ValueType) else value_type,
"aggregationType": (
aggregation_type.value if isinstance(aggregation_type, AggregationType) else aggregation_type
),
"unique": unique,
"generated": generated,
"confidential": confidential,
"inherit": inherit,
"displayInListNoProgram": display_in_list_no_program,
"orgunitScope": orgunit_scope,
}
if option_set_uid:
payload["optionSet"] = {"id": option_set_uid}
if legend_set_uids:
payload["legendSets"] = [{"id": uid_} for uid_ in legend_set_uids]
if uid:
payload["id"] = uid
if code:
payload["code"] = code
if form_name:
payload["formName"] = form_name
if description:
payload["description"] = description
if pattern:
payload["pattern"] = pattern
if field_mask:
payload["fieldMask"] = field_mask
envelope = await self._client.post("/api/trackedEntityAttributes", payload, model=WebMessageResponse)
created_uid = envelope.created_uid or uid
if not created_uid:
raise RuntimeError("tracked-entity-attribute create did not return a uid")
return await self.get(created_uid)
|
update(attribute)
async
PUT an edited TrackedEntityAttribute back. attribute.id must be set.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_attributes.py
| async def update(self, attribute: TrackedEntityAttribute) -> TrackedEntityAttribute:
"""PUT an edited TrackedEntityAttribute back. `attribute.id` must be set."""
if not attribute.id:
raise ValueError("update requires attribute.id to be set")
body = attribute.model_dump(by_alias=True, exclude_none=True, mode="json")
await self._client.put_raw(f"/api/trackedEntityAttributes/{attribute.id}", body=body)
return await self.get(attribute.id)
|
rename(uid, *, name=None, short_name=None, form_name=None, description=None)
async
Partial-update shortcut — read, mutate the label fields, PUT.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_attributes.py
| async def rename(
self,
uid: str,
*,
name: str | None = None,
short_name: str | None = None,
form_name: str | None = None,
description: str | None = None,
) -> TrackedEntityAttribute:
"""Partial-update shortcut — read, mutate the label fields, PUT."""
if name is None and short_name is None and form_name is None and description is None:
raise ValueError("rename requires at least one of name / short_name / form_name / description")
current = await self.get(uid)
if name is not None:
current.name = name
if short_name is not None:
current.shortName = short_name
if form_name is not None:
current.formName = form_name
if description is not None:
current.description = description
return await self.update(current)
|
delete(uid)
async
Delete a TrackedEntityAttribute — DHIS2 rejects deletes on TEAs wired into a TET or program.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_attributes.py
| async def delete(self, uid: str) -> None:
"""Delete a TrackedEntityAttribute — DHIS2 rejects deletes on TEAs wired into a TET or program."""
if not uid:
raise ValueError("delete requires a non-empty uid")
await self._client.resources.tracked_entity_attributes.delete(uid)
|
tracked_entity_types
TrackedEntityType authoring — Dhis2Client.tracked_entity_types.
A TrackedEntityType is the kind of subject a tracker program enrols
(Person, Case, Animal). Each TET carries its own set of attributes
via trackedEntityTypeAttributes[] — a join table that flags which
TEAs are mandatory, searchable, and visible in the enrollment UI.
create(...) — named kwargs over the minimal required subset
(name, short_name) plus common knobs (description,
allow_audit_log, feature_type,
min_attributes_required_to_search).
add_attribute(tet_uid, tea_uid, *, mandatory=False,
searchable=False, display_in_list=True) — wire a TEA onto the TET
by round-tripping the full TET, mutating
trackedEntityTypeAttributes[], and PUTing back. Mirrors DataSet +
DataSetElement.
remove_attribute(tet_uid, tea_uid) — drops the TEA ref from the TET.
rename(uid, ...) / delete(uid) — standard edit pathways.
No *Spec builder — continues the spec-audit data point.
Classes
TrackedEntityType
Bases: BaseModel
Generated model for DHIS2 TrackedEntityType.
DHIS2 Tracked Entity Type - persisted metadata (generated from /api/schemas at DHIS2 v42).
API endpoint: /api/trackedEntityTypes.
Field Field(description=...) entries flag DHIS2 semantics the bare
type can't capture: which side of a relationship owns the link
(writable) vs the inverse side (ignored by the API), uniqueness
constraints, and length bounds.
Source code in packages/dhis2w-client/src/dhis2w_client/generated/v42/schemas/tracked_entity_type.py
| class TrackedEntityType(BaseModel):
"""Generated model for DHIS2 `TrackedEntityType`.
DHIS2 Tracked Entity Type - persisted metadata (generated from /api/schemas at DHIS2 v42).
API endpoint: /api/trackedEntityTypes.
Field `Field(description=...)` entries flag DHIS2 semantics the bare
type can't capture: which side of a relationship owns the link
(writable) vs the inverse side (ignored by the API), uniqueness
constraints, and length bounds.
"""
model_config = ConfigDict(extra="allow", populate_by_name=True)
access: Any | None = Field(default=None, description="Reference to Access. Read-only (inverse side).")
allowAuditLog: bool | None = None
attributeValues: Any | None = Field(default=None, description="Reference to AttributeValues. Length/value max=255.")
code: str | None = Field(default=None, description="Unique. Length/value max=50.")
created: datetime | None = None
createdBy: Reference | None = Field(default=None, description="Reference to User.")
description: str | None = Field(default=None, description="Length/value min=1, max=2147483647.")
displayDescription: str | None = Field(default=None, description="Read-only.")
displayFormName: str | None = Field(default=None, description="Read-only.")
displayName: str | None = Field(default=None, description="Read-only.")
displayShortName: str | None = Field(default=None, description="Read-only.")
favorite: bool | None = Field(default=None, description="Read-only.")
favorites: list[Any] | None = Field(default=None, description="Collection of String. Read-only (inverse side).")
featureType: FeatureType | None = None
formName: str | None = Field(default=None, description="Length/value max=2147483647.")
href: str | None = None
id: str | None = Field(default=None, description="Unique. Length/value min=11, max=11.")
lastUpdated: datetime | None = None
lastUpdatedBy: Reference | None = Field(default=None, description="Reference to User.")
maxTeiCountToReturn: int | None = Field(default=None, description="Length/value max=2147483647.")
minAttributesRequiredToSearch: int | None = Field(default=None, description="Length/value max=2147483647.")
name: str | None = Field(default=None, description="Unique. Length/value min=1, max=230.")
sharing: Any | None = Field(default=None, description="Reference to Sharing. Length/value max=255.")
shortName: str | None = Field(default=None, description="Unique. Length/value min=1, max=50.")
style: Any | None = Field(default=None, description="Reference to ObjectStyle. Length/value max=255.")
trackedEntityTypeAttributes: list[Any] | None = Field(
default=None, description="Collection of TrackedEntityTypeAttribute."
)
translations: list[Any] | None = Field(default=None, description="Collection of Translation. Length/value max=255.")
user: Reference | None = Field(default=None, description="Reference to User. Read-only (inverse side).")
|
TrackedEntityTypesAccessor
Dhis2Client.tracked_entity_types — CRUD + attribute-linkage helpers.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_types.py
| class TrackedEntityTypesAccessor:
"""`Dhis2Client.tracked_entity_types` — CRUD + attribute-linkage helpers."""
def __init__(self, client: Dhis2Client) -> None:
"""Bind to the sharing client."""
self._client = client
async def list_all(
self,
*,
page: int = 1,
page_size: int = 50,
) -> list[TrackedEntityType]:
"""Page through TrackedEntityTypes."""
raw = await self._client.get_raw(
"/api/trackedEntityTypes",
params={
"fields": _TET_FIELDS,
"page": str(page),
"pageSize": str(page_size),
},
)
rows = raw.get("trackedEntityTypes") or []
return [TrackedEntityType.model_validate(row) for row in rows if isinstance(row, dict)]
async def get(self, uid: str) -> TrackedEntityType:
"""Fetch one TrackedEntityType with its TEA link table resolved inline."""
return await self._client.get(
f"/api/trackedEntityTypes/{uid}", model=TrackedEntityType, params={"fields": _TET_FIELDS}
)
async def create(
self,
*,
name: str,
short_name: str,
description: str | None = None,
code: str | None = None,
form_name: str | None = None,
allow_audit_log: bool | None = None,
feature_type: str | None = None,
min_attributes_required_to_search: int | None = None,
max_tei_count_to_return: int | None = None,
uid: str | None = None,
) -> TrackedEntityType:
"""Create a TrackedEntityType.
`allow_audit_log` enables the per-TEI audit trail (required for
compliance workflows). `feature_type` governs the geometry
attached to each TEI (`NONE` / `POINT` / `POLYGON`).
`min_attributes_required_to_search` sets the enrollment-search
minimum attribute count; higher values reduce accidental
full-table scans.
"""
payload: dict[str, Any] = {"name": name, "shortName": short_name}
if description:
payload["description"] = description
if code:
payload["code"] = code
if form_name:
payload["formName"] = form_name
if allow_audit_log is not None:
payload["allowAuditLog"] = allow_audit_log
if feature_type:
payload["featureType"] = feature_type
if min_attributes_required_to_search is not None:
payload["minAttributesRequiredToSearch"] = min_attributes_required_to_search
if max_tei_count_to_return is not None:
payload["maxTeiCountToReturn"] = max_tei_count_to_return
if uid:
payload["id"] = uid
envelope = await self._client.post("/api/trackedEntityTypes", payload, model=WebMessageResponse)
created_uid = envelope.created_uid or uid
if not created_uid:
raise RuntimeError("tracked-entity-type create did not return a uid")
return await self.get(created_uid)
async def update(self, tet: TrackedEntityType) -> TrackedEntityType:
"""PUT an edited TrackedEntityType back. `tet.id` must be set."""
if not tet.id:
raise ValueError("update requires tet.id to be set")
body = tet.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_teta(body)
await self._client.put_raw(f"/api/trackedEntityTypes/{tet.id}", body=body)
return await self.get(tet.id)
async def rename(
self,
uid: str,
*,
name: str | None = None,
short_name: str | None = None,
form_name: str | None = None,
description: str | None = None,
) -> TrackedEntityType:
"""Partial-update shortcut — read, mutate the label fields, PUT."""
if name is None and short_name is None and form_name is None and description is None:
raise ValueError("rename requires at least one of name / short_name / form_name / description")
current = await self.get(uid)
if name is not None:
current.name = name
if short_name is not None:
current.shortName = short_name
if form_name is not None:
current.formName = form_name
if description is not None:
current.description = description
return await self.update(current)
async def add_attribute(
self,
tet_uid: str,
attribute_uid: str,
*,
mandatory: bool = False,
searchable: bool = False,
display_in_list: bool = True,
) -> TrackedEntityType:
"""Wire a TrackedEntityAttribute onto the TET.
DHIS2 stores the link in `trackedEntityTypeAttributes[]` as a
nested join object — the accessor round-trips the full TET,
appends a new entry, and PUTs it back.
"""
current = await self.get(tet_uid)
raw = current.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_teta(raw)
existing = raw.get("trackedEntityTypeAttributes") or []
if any(
(entry.get("trackedEntityAttribute") or {}).get("id") == attribute_uid
for entry in existing
if isinstance(entry, dict)
):
return current
existing.append(
{
"trackedEntityAttribute": {"id": attribute_uid},
"mandatory": mandatory,
"searchable": searchable,
"displayInList": display_in_list,
},
)
raw["trackedEntityTypeAttributes"] = existing
await self._client.put_raw(f"/api/trackedEntityTypes/{tet_uid}", body=raw)
return await self.get(tet_uid)
async def remove_attribute(self, tet_uid: str, attribute_uid: str) -> TrackedEntityType:
"""Drop a TrackedEntityAttribute from the TET's link table."""
current = await self.get(tet_uid)
raw = current.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_teta(raw)
existing = raw.get("trackedEntityTypeAttributes") or []
filtered = [
entry
for entry in existing
if isinstance(entry, dict) and (entry.get("trackedEntityAttribute") or {}).get("id") != attribute_uid
]
if len(filtered) == len(existing):
return current
raw["trackedEntityTypeAttributes"] = filtered
await self._client.put_raw(f"/api/trackedEntityTypes/{tet_uid}", body=raw)
return await self.get(tet_uid)
async def delete(self, uid: str) -> None:
"""Delete a TrackedEntityType — DHIS2 rejects deletes on TETs in use by enrolled TEIs."""
if not uid:
raise ValueError("delete requires a non-empty uid")
await self._client.resources.tracked_entity_types.delete(uid)
|
Functions
__init__(client)
Bind to the sharing client.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_types.py
| def __init__(self, client: Dhis2Client) -> None:
"""Bind to the sharing client."""
self._client = client
|
list_all(*, page=1, page_size=50)
async
Page through TrackedEntityTypes.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_types.py
| async def list_all(
self,
*,
page: int = 1,
page_size: int = 50,
) -> list[TrackedEntityType]:
"""Page through TrackedEntityTypes."""
raw = await self._client.get_raw(
"/api/trackedEntityTypes",
params={
"fields": _TET_FIELDS,
"page": str(page),
"pageSize": str(page_size),
},
)
rows = raw.get("trackedEntityTypes") or []
return [TrackedEntityType.model_validate(row) for row in rows if isinstance(row, dict)]
|
get(uid)
async
Fetch one TrackedEntityType with its TEA link table resolved inline.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_types.py
| async def get(self, uid: str) -> TrackedEntityType:
"""Fetch one TrackedEntityType with its TEA link table resolved inline."""
return await self._client.get(
f"/api/trackedEntityTypes/{uid}", model=TrackedEntityType, params={"fields": _TET_FIELDS}
)
|
create(*, name, short_name, description=None, code=None, form_name=None, allow_audit_log=None, feature_type=None, min_attributes_required_to_search=None, max_tei_count_to_return=None, uid=None)
async
Create a TrackedEntityType.
allow_audit_log enables the per-TEI audit trail (required for
compliance workflows). feature_type governs the geometry
attached to each TEI (NONE / POINT / POLYGON).
min_attributes_required_to_search sets the enrollment-search
minimum attribute count; higher values reduce accidental
full-table scans.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_types.py
| async def create(
self,
*,
name: str,
short_name: str,
description: str | None = None,
code: str | None = None,
form_name: str | None = None,
allow_audit_log: bool | None = None,
feature_type: str | None = None,
min_attributes_required_to_search: int | None = None,
max_tei_count_to_return: int | None = None,
uid: str | None = None,
) -> TrackedEntityType:
"""Create a TrackedEntityType.
`allow_audit_log` enables the per-TEI audit trail (required for
compliance workflows). `feature_type` governs the geometry
attached to each TEI (`NONE` / `POINT` / `POLYGON`).
`min_attributes_required_to_search` sets the enrollment-search
minimum attribute count; higher values reduce accidental
full-table scans.
"""
payload: dict[str, Any] = {"name": name, "shortName": short_name}
if description:
payload["description"] = description
if code:
payload["code"] = code
if form_name:
payload["formName"] = form_name
if allow_audit_log is not None:
payload["allowAuditLog"] = allow_audit_log
if feature_type:
payload["featureType"] = feature_type
if min_attributes_required_to_search is not None:
payload["minAttributesRequiredToSearch"] = min_attributes_required_to_search
if max_tei_count_to_return is not None:
payload["maxTeiCountToReturn"] = max_tei_count_to_return
if uid:
payload["id"] = uid
envelope = await self._client.post("/api/trackedEntityTypes", payload, model=WebMessageResponse)
created_uid = envelope.created_uid or uid
if not created_uid:
raise RuntimeError("tracked-entity-type create did not return a uid")
return await self.get(created_uid)
|
update(tet)
async
PUT an edited TrackedEntityType back. tet.id must be set.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_types.py
| async def update(self, tet: TrackedEntityType) -> TrackedEntityType:
"""PUT an edited TrackedEntityType back. `tet.id` must be set."""
if not tet.id:
raise ValueError("update requires tet.id to be set")
body = tet.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_teta(body)
await self._client.put_raw(f"/api/trackedEntityTypes/{tet.id}", body=body)
return await self.get(tet.id)
|
rename(uid, *, name=None, short_name=None, form_name=None, description=None)
async
Partial-update shortcut — read, mutate the label fields, PUT.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_types.py
| async def rename(
self,
uid: str,
*,
name: str | None = None,
short_name: str | None = None,
form_name: str | None = None,
description: str | None = None,
) -> TrackedEntityType:
"""Partial-update shortcut — read, mutate the label fields, PUT."""
if name is None and short_name is None and form_name is None and description is None:
raise ValueError("rename requires at least one of name / short_name / form_name / description")
current = await self.get(uid)
if name is not None:
current.name = name
if short_name is not None:
current.shortName = short_name
if form_name is not None:
current.formName = form_name
if description is not None:
current.description = description
return await self.update(current)
|
add_attribute(tet_uid, attribute_uid, *, mandatory=False, searchable=False, display_in_list=True)
async
Wire a TrackedEntityAttribute onto the TET.
DHIS2 stores the link in trackedEntityTypeAttributes[] as a
nested join object — the accessor round-trips the full TET,
appends a new entry, and PUTs it back.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_types.py
| async def add_attribute(
self,
tet_uid: str,
attribute_uid: str,
*,
mandatory: bool = False,
searchable: bool = False,
display_in_list: bool = True,
) -> TrackedEntityType:
"""Wire a TrackedEntityAttribute onto the TET.
DHIS2 stores the link in `trackedEntityTypeAttributes[]` as a
nested join object — the accessor round-trips the full TET,
appends a new entry, and PUTs it back.
"""
current = await self.get(tet_uid)
raw = current.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_teta(raw)
existing = raw.get("trackedEntityTypeAttributes") or []
if any(
(entry.get("trackedEntityAttribute") or {}).get("id") == attribute_uid
for entry in existing
if isinstance(entry, dict)
):
return current
existing.append(
{
"trackedEntityAttribute": {"id": attribute_uid},
"mandatory": mandatory,
"searchable": searchable,
"displayInList": display_in_list,
},
)
raw["trackedEntityTypeAttributes"] = existing
await self._client.put_raw(f"/api/trackedEntityTypes/{tet_uid}", body=raw)
return await self.get(tet_uid)
|
remove_attribute(tet_uid, attribute_uid)
async
Drop a TrackedEntityAttribute from the TET's link table.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_types.py
| async def remove_attribute(self, tet_uid: str, attribute_uid: str) -> TrackedEntityType:
"""Drop a TrackedEntityAttribute from the TET's link table."""
current = await self.get(tet_uid)
raw = current.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_teta(raw)
existing = raw.get("trackedEntityTypeAttributes") or []
filtered = [
entry
for entry in existing
if isinstance(entry, dict) and (entry.get("trackedEntityAttribute") or {}).get("id") != attribute_uid
]
if len(filtered) == len(existing):
return current
raw["trackedEntityTypeAttributes"] = filtered
await self._client.put_raw(f"/api/trackedEntityTypes/{tet_uid}", body=raw)
return await self.get(tet_uid)
|
delete(uid)
async
Delete a TrackedEntityType — DHIS2 rejects deletes on TETs in use by enrolled TEIs.
Source code in packages/dhis2w-client/src/dhis2w_client/tracked_entity_types.py
| async def delete(self, uid: str) -> None:
"""Delete a TrackedEntityType — DHIS2 rejects deletes on TETs in use by enrolled TEIs."""
if not uid:
raise ValueError("delete requires a non-empty uid")
await self._client.resources.tracked_entity_types.delete(uid)
|
programs
Program authoring — Dhis2Client.programs.
A DHIS2 Program is the tracker container: it binds a
TrackedEntityType (for WITH_REGISTRATION programs), a set of
TrackedEntityAttributes shown on the enrollment form, and the
OrganisationUnits that can capture enrollments or events. Programs
come in two flavours:
- WITH_REGISTRATION — tracker programs. Need a
trackedEntityType
and register individual TEIs before capturing events.
- WITHOUT_REGISTRATION — event programs. Capture anonymous events
directly; no TET required.
This module is the authoring flip side of the existing tracker-write
plugin (dhis2 tracker register / enroll / add-event). The leaf half
(TET + TEA) lives in tracked_entity_types.py /
tracked_entity_attributes.py; the inner layer (ProgramStage + PSDE)
ships in a follow-up.
Surface:
- create(...) — named kwargs over the minimal required subset
(name, short_name, program_type) plus the refs + common knobs.
For WITH_REGISTRATION, tracked_entity_type_uid is required.
- add_attribute(program_uid, tea_uid, ...) — wire a TEA into the
enrollment form via the programTrackedEntityAttributes[] join
table.
- add_organisation_unit(program_uid, ou_uid) — scope the program
to an OU. Tracker writes need at least one OU in scope to register.
- rename(uid, ...) / delete(uid) — standard pathways.
No *Spec builder — continues the spec-audit data point.
Classes
Program
Bases: BaseModel
Generated model for DHIS2 Program.
DHIS2 Program - persisted metadata (generated from /api/schemas at DHIS2 v42).
API endpoint: /api/programs.
Field Field(description=...) entries flag DHIS2 semantics the bare
type can't capture: which side of a relationship owns the link
(writable) vs the inverse side (ignored by the API), uniqueness
constraints, and length bounds.
Source code in packages/dhis2w-client/src/dhis2w_client/generated/v42/schemas/program.py
| class Program(BaseModel):
"""Generated model for DHIS2 `Program`.
DHIS2 Program - persisted metadata (generated from /api/schemas at DHIS2 v42).
API endpoint: /api/programs.
Field `Field(description=...)` entries flag DHIS2 semantics the bare
type can't capture: which side of a relationship owns the link
(writable) vs the inverse side (ignored by the API), uniqueness
constraints, and length bounds.
"""
model_config = ConfigDict(extra="allow", populate_by_name=True)
access: Any | None = Field(default=None, description="Reference to Access. Read-only (inverse side).")
accessLevel: AccessLevel | None = None
attributeValues: Any | None = Field(default=None, description="Reference to AttributeValues. Length/value max=255.")
categoryCombo: Reference | None = Field(default=None, description="Reference to CategoryCombo.")
categoryMappings: list[Any] | None = Field(
default=None, description="Collection of ProgramCategoryMapping. Length/value max=255."
)
code: str | None = Field(default=None, description="Unique. Length/value max=50.")
completeEventsExpiryDays: int | None = Field(default=None, description="Length/value max=2147483647.")
created: datetime | None = None
createdBy: Reference | None = Field(default=None, description="Reference to User.")
dataEntryForm: Reference | None = Field(default=None, description="Reference to DataEntryForm.")
description: str | None = Field(default=None, description="Length/value min=1, max=2147483647.")
displayDescription: str | None = Field(default=None, description="Read-only.")
displayEnrollmentDateLabel: str | None = Field(default=None, description="Read-only.")
displayEnrollmentLabel: str | None = Field(default=None, description="Read-only.")
displayEventLabel: str | None = Field(default=None, description="Read-only.")
displayFollowUpLabel: str | None = Field(default=None, description="Read-only.")
displayFormName: str | None = Field(default=None, description="Read-only.")
displayFrontPageList: bool | None = None
displayIncidentDate: bool | None = None
displayIncidentDateLabel: str | None = Field(default=None, description="Read-only.")
displayName: str | None = Field(default=None, description="Read-only.")
displayNoteLabel: str | None = Field(default=None, description="Read-only.")
displayOrgUnitLabel: str | None = Field(default=None, description="Read-only.")
displayProgramStageLabel: str | None = Field(default=None, description="Read-only.")
displayRelationshipLabel: str | None = Field(default=None, description="Read-only.")
displayShortName: str | None = Field(default=None, description="Read-only.")
displayTrackedEntityAttributeLabel: str | None = Field(default=None, description="Read-only.")
enrollmentDateLabel: str | None = Field(default=None, description="Length/value min=2, max=2147483647.")
enrollmentLabel: str | None = Field(default=None, description="Length/value min=2, max=2147483647.")
eventLabel: str | None = Field(default=None, description="Length/value min=2, max=2147483647.")
expiryDays: int | None = Field(default=None, description="Length/value max=2147483647.")
expiryPeriodType: PeriodType | None = Field(
default=None, description="Reference to PeriodType. Length/value max=255."
)
favorite: bool | None = Field(default=None, description="Read-only.")
favorites: list[Any] | None = Field(default=None, description="Collection of String. Read-only (inverse side).")
featureType: FeatureType | None = None
followUpLabel: str | None = Field(default=None, description="Length/value min=2, max=2147483647.")
formName: str | None = Field(default=None, description="Length/value max=2147483647.")
href: str | None = None
id: str | None = Field(default=None, description="Unique. Length/value min=11, max=11.")
ignoreOverdueEvents: bool | None = None
incidentDateLabel: str | None = Field(default=None, description="Length/value min=2, max=2147483647.")
lastUpdated: datetime | None = None
lastUpdatedBy: Reference | None = Field(default=None, description="Reference to User.")
maxTeiCountToReturn: int | None = Field(default=None, description="Length/value max=2147483647.")
minAttributesRequiredToSearch: int | None = Field(default=None, description="Length/value max=2147483647.")
name: str | None = Field(default=None, description="Length/value min=1, max=230.")
noteLabel: str | None = Field(default=None, description="Length/value min=2, max=2147483647.")
notificationTemplates: list[Any] | None = Field(
default=None, description="Collection of ProgramNotificationTemplate."
)
onlyEnrollOnce: bool | None = None
openDaysAfterCoEndDate: int | None = Field(default=None, description="Length/value max=2147483647.")
orgUnitLabel: str | None = Field(default=None, description="Length/value min=2, max=2147483647.")
organisationUnits: list[Any] | None = Field(default=None, description="Collection of OrganisationUnit.")
programIndicators: list[Any] | None = Field(
default=None, description="Collection of ProgramIndicator. Read-only (inverse side)."
)
programRuleVariables: list[Any] | None = Field(
default=None, description="Collection of ProgramRuleVariable. Read-only (inverse side)."
)
programSections: list[Any] | None = Field(default=None, description="Collection of ProgramSection.")
programStageLabel: str | None = Field(default=None, description="Length/value min=2, max=2147483647.")
programStages: list[Any] | None = Field(default=None, description="Collection of ProgramStage.")
programTrackedEntityAttributes: list[Any] | None = Field(
default=None, description="Collection of ProgramTrackedEntityAttribute."
)
programType: ProgramType | None = None
registration: bool | None = Field(default=None, description="Read-only.")
relatedProgram: Reference | None = Field(default=None, description="Reference to Program.")
relationshipLabel: str | None = Field(default=None, description="Length/value min=2, max=2147483647.")
selectEnrollmentDatesInFuture: bool | None = None
selectIncidentDatesInFuture: bool | None = None
sharing: Any | None = Field(default=None, description="Reference to Sharing. Length/value max=255.")
shortName: str | None = Field(default=None, description="Length/value min=1, max=50.")
skipOffline: bool | None = None
style: Any | None = Field(default=None, description="Reference to ObjectStyle. Length/value max=255.")
trackedEntityAttributeLabel: str | None = Field(default=None, description="Length/value min=2, max=2147483647.")
trackedEntityType: Reference | None = Field(default=None, description="Reference to TrackedEntityType.")
translations: list[Any] | None = Field(default=None, description="Collection of Translation. Length/value max=255.")
useFirstStageDuringRegistration: bool | None = None
user: Reference | None = Field(default=None, description="Reference to User. Read-only (inverse side).")
userRoles: list[Any] | None = Field(default=None, description="Collection of UserRole. Read-only (inverse side).")
version: int | None = Field(default=None, description="Length/value max=2147483647.")
withoutRegistration: bool | None = Field(default=None, description="Read-only.")
|
ProgramsAccessor
Dhis2Client.programs — CRUD + attribute + OU linkage over /api/programs.
Source code in packages/dhis2w-client/src/dhis2w_client/programs.py
| class ProgramsAccessor:
"""`Dhis2Client.programs` — CRUD + attribute + OU linkage over `/api/programs`."""
def __init__(self, client: Dhis2Client) -> None:
"""Bind to the sharing client."""
self._client = client
async def list_all(
self,
*,
program_type: ProgramType | str | None = None,
page: int = 1,
page_size: int = 50,
) -> list[Program]:
"""Page through Programs, optionally filtered by programType."""
filters: list[str] | None = None
if program_type is not None:
value = program_type.value if isinstance(program_type, ProgramType) else program_type
filters = [f"programType:eq:{value}"]
return cast(
list[Program],
await self._client.resources.programs.list(
fields=_PROGRAM_FIELDS,
filters=filters,
page=page,
page_size=page_size,
),
)
async def get(self, uid: str) -> Program:
"""Fetch one Program with its PTEAs, OUs, and ProgramStage refs inline."""
return await self._client.get(f"/api/programs/{uid}", model=Program, params={"fields": _PROGRAM_FIELDS})
async def create(
self,
*,
name: str,
short_name: str,
program_type: ProgramType | str = ProgramType.WITH_REGISTRATION,
tracked_entity_type_uid: str | None = None,
category_combo_uid: str | None = None,
description: str | None = None,
code: str | None = None,
form_name: str | None = None,
display_incident_date: bool | None = None,
enrollment_date_label: str | None = None,
incident_date_label: str | None = None,
feature_type: str | None = None,
only_enroll_once: bool | None = None,
select_enrollment_dates_in_future: bool | None = None,
select_incident_dates_in_future: bool | None = None,
expiry_days: int | None = None,
min_attributes_required_to_search: int | None = None,
max_tei_count_to_return: int | None = None,
use_first_stage_during_registration: bool | None = None,
uid: str | None = None,
) -> Program:
"""Create a Program.
`program_type=WITH_REGISTRATION` (default) requires
`tracked_entity_type_uid`; pass `WITHOUT_REGISTRATION` for an
event program that skips TEI registration.
`category_combo_uid` defaults to the instance-wide default
combo (DHIS2 rejects programs without a CC ref).
`display_incident_date` + `incident_date_label` govern whether
the enrollment form captures an incident date distinct from
the enrollment date (required by some case-management flows).
"""
resolved_type = program_type.value if isinstance(program_type, ProgramType) else program_type
if resolved_type == "WITH_REGISTRATION" and not tracked_entity_type_uid:
raise ValueError("WITH_REGISTRATION programs require tracked_entity_type_uid")
default_combo = category_combo_uid or await self._client.system.default_category_combo_uid()
payload: dict[str, Any] = {
"name": name,
"shortName": short_name,
"programType": resolved_type,
"categoryCombo": {"id": default_combo},
}
if tracked_entity_type_uid:
payload["trackedEntityType"] = {"id": tracked_entity_type_uid}
if uid:
payload["id"] = uid
if code:
payload["code"] = code
if form_name:
payload["formName"] = form_name
if description:
payload["description"] = description
if display_incident_date is not None:
payload["displayIncidentDate"] = display_incident_date
if enrollment_date_label:
payload["enrollmentDateLabel"] = enrollment_date_label
if incident_date_label:
payload["incidentDateLabel"] = incident_date_label
if feature_type:
payload["featureType"] = feature_type
if only_enroll_once is not None:
payload["onlyEnrollOnce"] = only_enroll_once
if select_enrollment_dates_in_future is not None:
payload["selectEnrollmentDatesInFuture"] = select_enrollment_dates_in_future
if select_incident_dates_in_future is not None:
payload["selectIncidentDatesInFuture"] = select_incident_dates_in_future
if expiry_days is not None:
payload["expiryDays"] = expiry_days
if min_attributes_required_to_search is not None:
payload["minAttributesRequiredToSearch"] = min_attributes_required_to_search
if max_tei_count_to_return is not None:
payload["maxTeiCountToReturn"] = max_tei_count_to_return
if use_first_stage_during_registration is not None:
payload["useFirstStageDuringRegistration"] = use_first_stage_during_registration
envelope = await self._client.post("/api/programs", payload, model=WebMessageResponse)
created_uid = envelope.created_uid or uid
if not created_uid:
raise RuntimeError("program create did not return a uid")
return await self.get(created_uid)
async def update(self, program: Program) -> Program:
"""PUT an edited Program back. `program.id` must be set.
DHIS2 v42's Program PUT importer treats nested-list updates
additively without `mergeMode=REPLACE` — items omitted from a
list aren't removed. The accessor always passes the flag so
`add_attribute` / `remove_attribute` behave symmetrically.
"""
if not program.id:
raise ValueError("update requires program.id to be set")
body = program.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_ptea(body)
await self._client.put_raw(f"/api/programs/{program.id}", body=body, params={"mergeMode": "REPLACE"})
return await self.get(program.id)
async def rename(
self,
uid: str,
*,
name: str | None = None,
short_name: str | None = None,
form_name: str | None = None,
description: str | None = None,
) -> Program:
"""Partial-update shortcut — read, mutate the label fields, PUT."""
if name is None and short_name is None and form_name is None and description is None:
raise ValueError("rename requires at least one of name / short_name / form_name / description")
current = await self.get(uid)
if name is not None:
current.name = name
if short_name is not None:
current.shortName = short_name
if form_name is not None:
current.formName = form_name
if description is not None:
current.description = description
return await self.update(current)
async def add_attribute(
self,
program_uid: str,
attribute_uid: str,
*,
mandatory: bool = False,
searchable: bool = False,
display_in_list: bool = True,
sort_order: int | None = None,
allow_future_date: bool = False,
render_options_as_radio: bool = False,
) -> Program:
"""Wire a TrackedEntityAttribute into the Program's enrollment form.
The PTEA join table carries the enrollment-form flags
(`mandatory`, `searchable`, `displayInList`, `sortOrder`,
`allowFutureDate`, `renderOptionsAsRadio`). Idempotent when
the TEA is already linked — the existing PTEA is left alone.
"""
current = await self.get(program_uid)
raw = current.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_ptea(raw)
existing = raw.get("programTrackedEntityAttributes") or []
if any(
(entry.get("trackedEntityAttribute") or {}).get("id") == attribute_uid
for entry in existing
if isinstance(entry, dict)
):
return current
new_entry: dict[str, Any] = {
"trackedEntityAttribute": {"id": attribute_uid},
"mandatory": mandatory,
"searchable": searchable,
"displayInList": display_in_list,
"allowFutureDate": allow_future_date,
"renderOptionsAsRadio": render_options_as_radio,
}
if sort_order is not None:
new_entry["sortOrder"] = sort_order
existing.append(new_entry)
raw["programTrackedEntityAttributes"] = existing
await self._client.put_raw(f"/api/programs/{program_uid}", body=raw, params={"mergeMode": "REPLACE"})
return await self.get(program_uid)
async def remove_attribute(self, program_uid: str, attribute_uid: str) -> Program:
"""Drop a TrackedEntityAttribute from the Program's enrollment form."""
current = await self.get(program_uid)
raw = current.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_ptea(raw)
existing = raw.get("programTrackedEntityAttributes") or []
filtered = [
entry
for entry in existing
if isinstance(entry, dict) and (entry.get("trackedEntityAttribute") or {}).get("id") != attribute_uid
]
if len(filtered) == len(existing):
return current
raw["programTrackedEntityAttributes"] = filtered
await self._client.put_raw(f"/api/programs/{program_uid}", body=raw, params={"mergeMode": "REPLACE"})
return await self.get(program_uid)
async def add_organisation_unit(self, program_uid: str, organisation_unit_uid: str) -> Program:
"""Scope the Program to an additional OrganisationUnit via the per-item POST shortcut."""
await self._client.resources.programs.add_collection_item(
program_uid,
"organisationUnits",
organisation_unit_uid,
)
return await self.get(program_uid)
async def remove_organisation_unit(self, program_uid: str, organisation_unit_uid: str) -> Program:
"""Drop an OrganisationUnit from the Program scope via the per-item DELETE shortcut."""
await self._client.resources.programs.remove_collection_item(
program_uid,
"organisationUnits",
organisation_unit_uid,
)
return await self.get(program_uid)
async def delete(self, uid: str) -> None:
"""Delete a Program — DHIS2 rejects deletes on programs with enrolled TEIs or saved events."""
if not uid:
raise ValueError("delete requires a non-empty uid")
await self._client.resources.programs.delete(uid)
|
Functions
__init__(client)
Bind to the sharing client.
Source code in packages/dhis2w-client/src/dhis2w_client/programs.py
| def __init__(self, client: Dhis2Client) -> None:
"""Bind to the sharing client."""
self._client = client
|
list_all(*, program_type=None, page=1, page_size=50)
async
Page through Programs, optionally filtered by programType.
Source code in packages/dhis2w-client/src/dhis2w_client/programs.py
| async def list_all(
self,
*,
program_type: ProgramType | str | None = None,
page: int = 1,
page_size: int = 50,
) -> list[Program]:
"""Page through Programs, optionally filtered by programType."""
filters: list[str] | None = None
if program_type is not None:
value = program_type.value if isinstance(program_type, ProgramType) else program_type
filters = [f"programType:eq:{value}"]
return cast(
list[Program],
await self._client.resources.programs.list(
fields=_PROGRAM_FIELDS,
filters=filters,
page=page,
page_size=page_size,
),
)
|
get(uid)
async
Fetch one Program with its PTEAs, OUs, and ProgramStage refs inline.
Source code in packages/dhis2w-client/src/dhis2w_client/programs.py
| async def get(self, uid: str) -> Program:
"""Fetch one Program with its PTEAs, OUs, and ProgramStage refs inline."""
return await self._client.get(f"/api/programs/{uid}", model=Program, params={"fields": _PROGRAM_FIELDS})
|
create(*, name, short_name, program_type=ProgramType.WITH_REGISTRATION, tracked_entity_type_uid=None, category_combo_uid=None, description=None, code=None, form_name=None, display_incident_date=None, enrollment_date_label=None, incident_date_label=None, feature_type=None, only_enroll_once=None, select_enrollment_dates_in_future=None, select_incident_dates_in_future=None, expiry_days=None, min_attributes_required_to_search=None, max_tei_count_to_return=None, use_first_stage_during_registration=None, uid=None)
async
Create a Program.
program_type=WITH_REGISTRATION (default) requires
tracked_entity_type_uid; pass WITHOUT_REGISTRATION for an
event program that skips TEI registration.
category_combo_uid defaults to the instance-wide default
combo (DHIS2 rejects programs without a CC ref).
display_incident_date + incident_date_label govern whether
the enrollment form captures an incident date distinct from
the enrollment date (required by some case-management flows).
Source code in packages/dhis2w-client/src/dhis2w_client/programs.py
| async def create(
self,
*,
name: str,
short_name: str,
program_type: ProgramType | str = ProgramType.WITH_REGISTRATION,
tracked_entity_type_uid: str | None = None,
category_combo_uid: str | None = None,
description: str | None = None,
code: str | None = None,
form_name: str | None = None,
display_incident_date: bool | None = None,
enrollment_date_label: str | None = None,
incident_date_label: str | None = None,
feature_type: str | None = None,
only_enroll_once: bool | None = None,
select_enrollment_dates_in_future: bool | None = None,
select_incident_dates_in_future: bool | None = None,
expiry_days: int | None = None,
min_attributes_required_to_search: int | None = None,
max_tei_count_to_return: int | None = None,
use_first_stage_during_registration: bool | None = None,
uid: str | None = None,
) -> Program:
"""Create a Program.
`program_type=WITH_REGISTRATION` (default) requires
`tracked_entity_type_uid`; pass `WITHOUT_REGISTRATION` for an
event program that skips TEI registration.
`category_combo_uid` defaults to the instance-wide default
combo (DHIS2 rejects programs without a CC ref).
`display_incident_date` + `incident_date_label` govern whether
the enrollment form captures an incident date distinct from
the enrollment date (required by some case-management flows).
"""
resolved_type = program_type.value if isinstance(program_type, ProgramType) else program_type
if resolved_type == "WITH_REGISTRATION" and not tracked_entity_type_uid:
raise ValueError("WITH_REGISTRATION programs require tracked_entity_type_uid")
default_combo = category_combo_uid or await self._client.system.default_category_combo_uid()
payload: dict[str, Any] = {
"name": name,
"shortName": short_name,
"programType": resolved_type,
"categoryCombo": {"id": default_combo},
}
if tracked_entity_type_uid:
payload["trackedEntityType"] = {"id": tracked_entity_type_uid}
if uid:
payload["id"] = uid
if code:
payload["code"] = code
if form_name:
payload["formName"] = form_name
if description:
payload["description"] = description
if display_incident_date is not None:
payload["displayIncidentDate"] = display_incident_date
if enrollment_date_label:
payload["enrollmentDateLabel"] = enrollment_date_label
if incident_date_label:
payload["incidentDateLabel"] = incident_date_label
if feature_type:
payload["featureType"] = feature_type
if only_enroll_once is not None:
payload["onlyEnrollOnce"] = only_enroll_once
if select_enrollment_dates_in_future is not None:
payload["selectEnrollmentDatesInFuture"] = select_enrollment_dates_in_future
if select_incident_dates_in_future is not None:
payload["selectIncidentDatesInFuture"] = select_incident_dates_in_future
if expiry_days is not None:
payload["expiryDays"] = expiry_days
if min_attributes_required_to_search is not None:
payload["minAttributesRequiredToSearch"] = min_attributes_required_to_search
if max_tei_count_to_return is not None:
payload["maxTeiCountToReturn"] = max_tei_count_to_return
if use_first_stage_during_registration is not None:
payload["useFirstStageDuringRegistration"] = use_first_stage_during_registration
envelope = await self._client.post("/api/programs", payload, model=WebMessageResponse)
created_uid = envelope.created_uid or uid
if not created_uid:
raise RuntimeError("program create did not return a uid")
return await self.get(created_uid)
|
update(program)
async
PUT an edited Program back. program.id must be set.
DHIS2 v42's Program PUT importer treats nested-list updates
additively without mergeMode=REPLACE — items omitted from a
list aren't removed. The accessor always passes the flag so
add_attribute / remove_attribute behave symmetrically.
Source code in packages/dhis2w-client/src/dhis2w_client/programs.py
| async def update(self, program: Program) -> Program:
"""PUT an edited Program back. `program.id` must be set.
DHIS2 v42's Program PUT importer treats nested-list updates
additively without `mergeMode=REPLACE` — items omitted from a
list aren't removed. The accessor always passes the flag so
`add_attribute` / `remove_attribute` behave symmetrically.
"""
if not program.id:
raise ValueError("update requires program.id to be set")
body = program.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_ptea(body)
await self._client.put_raw(f"/api/programs/{program.id}", body=body, params={"mergeMode": "REPLACE"})
return await self.get(program.id)
|
rename(uid, *, name=None, short_name=None, form_name=None, description=None)
async
Partial-update shortcut — read, mutate the label fields, PUT.
Source code in packages/dhis2w-client/src/dhis2w_client/programs.py
| async def rename(
self,
uid: str,
*,
name: str | None = None,
short_name: str | None = None,
form_name: str | None = None,
description: str | None = None,
) -> Program:
"""Partial-update shortcut — read, mutate the label fields, PUT."""
if name is None and short_name is None and form_name is None and description is None:
raise ValueError("rename requires at least one of name / short_name / form_name / description")
current = await self.get(uid)
if name is not None:
current.name = name
if short_name is not None:
current.shortName = short_name
if form_name is not None:
current.formName = form_name
if description is not None:
current.description = description
return await self.update(current)
|
add_attribute(program_uid, attribute_uid, *, mandatory=False, searchable=False, display_in_list=True, sort_order=None, allow_future_date=False, render_options_as_radio=False)
async
Wire a TrackedEntityAttribute into the Program's enrollment form.
The PTEA join table carries the enrollment-form flags
(mandatory, searchable, displayInList, sortOrder,
allowFutureDate, renderOptionsAsRadio). Idempotent when
the TEA is already linked — the existing PTEA is left alone.
Source code in packages/dhis2w-client/src/dhis2w_client/programs.py
| async def add_attribute(
self,
program_uid: str,
attribute_uid: str,
*,
mandatory: bool = False,
searchable: bool = False,
display_in_list: bool = True,
sort_order: int | None = None,
allow_future_date: bool = False,
render_options_as_radio: bool = False,
) -> Program:
"""Wire a TrackedEntityAttribute into the Program's enrollment form.
The PTEA join table carries the enrollment-form flags
(`mandatory`, `searchable`, `displayInList`, `sortOrder`,
`allowFutureDate`, `renderOptionsAsRadio`). Idempotent when
the TEA is already linked — the existing PTEA is left alone.
"""
current = await self.get(program_uid)
raw = current.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_ptea(raw)
existing = raw.get("programTrackedEntityAttributes") or []
if any(
(entry.get("trackedEntityAttribute") or {}).get("id") == attribute_uid
for entry in existing
if isinstance(entry, dict)
):
return current
new_entry: dict[str, Any] = {
"trackedEntityAttribute": {"id": attribute_uid},
"mandatory": mandatory,
"searchable": searchable,
"displayInList": display_in_list,
"allowFutureDate": allow_future_date,
"renderOptionsAsRadio": render_options_as_radio,
}
if sort_order is not None:
new_entry["sortOrder"] = sort_order
existing.append(new_entry)
raw["programTrackedEntityAttributes"] = existing
await self._client.put_raw(f"/api/programs/{program_uid}", body=raw, params={"mergeMode": "REPLACE"})
return await self.get(program_uid)
|
remove_attribute(program_uid, attribute_uid)
async
Drop a TrackedEntityAttribute from the Program's enrollment form.
Source code in packages/dhis2w-client/src/dhis2w_client/programs.py
| async def remove_attribute(self, program_uid: str, attribute_uid: str) -> Program:
"""Drop a TrackedEntityAttribute from the Program's enrollment form."""
current = await self.get(program_uid)
raw = current.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_ptea(raw)
existing = raw.get("programTrackedEntityAttributes") or []
filtered = [
entry
for entry in existing
if isinstance(entry, dict) and (entry.get("trackedEntityAttribute") or {}).get("id") != attribute_uid
]
if len(filtered) == len(existing):
return current
raw["programTrackedEntityAttributes"] = filtered
await self._client.put_raw(f"/api/programs/{program_uid}", body=raw, params={"mergeMode": "REPLACE"})
return await self.get(program_uid)
|
add_organisation_unit(program_uid, organisation_unit_uid)
async
Scope the Program to an additional OrganisationUnit via the per-item POST shortcut.
Source code in packages/dhis2w-client/src/dhis2w_client/programs.py
| async def add_organisation_unit(self, program_uid: str, organisation_unit_uid: str) -> Program:
"""Scope the Program to an additional OrganisationUnit via the per-item POST shortcut."""
await self._client.resources.programs.add_collection_item(
program_uid,
"organisationUnits",
organisation_unit_uid,
)
return await self.get(program_uid)
|
remove_organisation_unit(program_uid, organisation_unit_uid)
async
Drop an OrganisationUnit from the Program scope via the per-item DELETE shortcut.
Source code in packages/dhis2w-client/src/dhis2w_client/programs.py
| async def remove_organisation_unit(self, program_uid: str, organisation_unit_uid: str) -> Program:
"""Drop an OrganisationUnit from the Program scope via the per-item DELETE shortcut."""
await self._client.resources.programs.remove_collection_item(
program_uid,
"organisationUnits",
organisation_unit_uid,
)
return await self.get(program_uid)
|
delete(uid)
async
Delete a Program — DHIS2 rejects deletes on programs with enrolled TEIs or saved events.
Source code in packages/dhis2w-client/src/dhis2w_client/programs.py
| async def delete(self, uid: str) -> None:
"""Delete a Program — DHIS2 rejects deletes on programs with enrolled TEIs or saved events."""
if not uid:
raise ValueError("delete requires a non-empty uid")
await self._client.resources.programs.delete(uid)
|
ProgramStage authoring
Each Program owns a stage sequence (ANC 1st visit, ANC 2nd visit, …). Each stage owns an ordered programStageDataElements[] list — a join table with compulsory, displayInReports, allowFutureDate, allowProvidedElsewhere, renderOptionsAsRadio, sortOrder per entry.
stage = await client.program_stages.create(
name="ANC 1st visit",
program_uid=program.id,
sort_order=1,
repeatable=False,
min_days_from_start=0,
standard_interval=30,
)
await client.program_stages.add_element(
stage.id,
weight_de.id,
compulsory=True,
sort_order=0,
)
await client.program_stages.reorder(stage.id, [second_de.id, weight_de.id])
PSDE ordering helpers
add_element(stage_uid, de_uid, compulsory=..., sort_order=...) — appends a new PSDE entry with typed flags.
remove_element(stage_uid, de_uid) — drops the PSDE entry; other flags on the remaining entries are preserved.
reorder(stage_uid, [de_uids]) — replaces the ordered list; PSDE flags are preserved for DEs that stay in the list and sortOrder is rewritten to match the new position.
mergeMode=REPLACE quirk
DHIS2 v42's PUT /api/programStages/{uid} treats nested-list updates additively by default (same quirk as Programs). The accessor always passes ?mergeMode=REPLACE on PUT so remove_element actually removes the PSDE entry instead of silently retaining it.
PSDE self-ref strip
The generated PSDE entry carries programStage = {id: <parent>} on reads, which DHIS2's importer rejects on PUT (inverse side). Stripped automatically before every update — mirrors DataSet+DSE, TET+TETA, Program+PTEA.
program_stages
ProgramStage authoring — Dhis2Client.program_stages.
The inner layer of tracker schema authoring. A ProgramStage is
a stage/visit inside a Program — ANC 1st visit, ANC 2nd visit,
vaccination schedule entry, etc. Each stage owns an ordered list of
programStageDataElements[] (a join table: which DEs the stage
captures, in what order, with per-DE compulsory / displayInReports
/ allowFutureDate / allowProvidedElsewhere flags).
Ships step 3 of the tracker-schema stretch:
- Step 1 (#188) —
TrackedEntityAttribute + TrackedEntityType.
- Step 2 (#189) —
Program + PTEA + OU scope.
- Step 3 (this) —
ProgramStage + programStageDataElements[].
Surface:
create(...) — kwargs over the minimal required subset (name,
program_uid) plus common knobs (repeatable,
auto_generate_event, min_days_from_start, sort_order,
validation_strategy, feature_type).
add_element(stage_uid, de_uid, *, compulsory, allow_future_date,
display_in_reports, allow_provided_elsewhere, render_options_as_radio)
— wire a DE into the stage's data-entry form via the PSDE join
table. Round-trips the full stage + PUTs with mergeMode=REPLACE
(matches the Program PUT quirk).
remove_element / reorder(stage_uid, [de_uids]).
rename(uid, ...) / delete(uid) — standard edit pathways.
No *Spec builder — continues the spec-audit data point.
Classes
ProgramStage
Bases: BaseModel
Generated model for DHIS2 ProgramStage.
DHIS2 Program Stage - persisted metadata (generated from /api/schemas at DHIS2 v42).
API endpoint: /api/programStages.
Field Field(description=...) entries flag DHIS2 semantics the bare
type can't capture: which side of a relationship owns the link
(writable) vs the inverse side (ignored by the API), uniqueness
constraints, and length bounds.
Source code in packages/dhis2w-client/src/dhis2w_client/generated/v42/schemas/program_stage.py
| class ProgramStage(BaseModel):
"""Generated model for DHIS2 `ProgramStage`.
DHIS2 Program Stage - persisted metadata (generated from /api/schemas at DHIS2 v42).
API endpoint: /api/programStages.
Field `Field(description=...)` entries flag DHIS2 semantics the bare
type can't capture: which side of a relationship owns the link
(writable) vs the inverse side (ignored by the API), uniqueness
constraints, and length bounds.
"""
model_config = ConfigDict(extra="allow", populate_by_name=True)
access: Any | None = Field(default=None, description="Reference to Access. Read-only (inverse side).")
allowGenerateNextVisit: bool | None = None
attributeValues: Any | None = Field(default=None, description="Reference to AttributeValues. Length/value max=255.")
autoGenerateEvent: bool | None = None
blockEntryForm: bool | None = None
code: str | None = Field(default=None, description="Unique. Length/value max=50.")
created: datetime | None = None
createdBy: Reference | None = Field(default=None, description="Reference to User.")
dataEntryForm: Reference | None = Field(default=None, description="Reference to DataEntryForm.")
description: str | None = Field(default=None, description="Length/value min=2, max=2147483647.")
displayDescription: str | None = Field(default=None, description="Read-only.")
displayDueDateLabel: str | None = Field(default=None, description="Read-only.")
displayEventLabel: str | None = Field(default=None, description="Read-only.")
displayExecutionDateLabel: str | None = Field(default=None, description="Read-only.")
displayFormName: str | None = Field(default=None, description="Read-only.")
displayGenerateEventBox: bool | None = None
displayName: str | None = Field(default=None, description="Read-only.")
displayProgramStageLabel: str | None = Field(default=None, description="Read-only.")
displayShortName: str | None = Field(default=None, description="Read-only.")
dueDateLabel: str | None = Field(default=None, description="Length/value min=2, max=255.")
enableUserAssignment: bool | None = None
eventLabel: str | None = Field(default=None, description="Length/value min=2, max=255.")
executionDateLabel: str | None = Field(default=None, description="Length/value min=2, max=255.")
favorite: bool | None = Field(default=None, description="Read-only.")
favorites: list[Any] | None = Field(default=None, description="Collection of String. Read-only (inverse side).")
featureType: FeatureType | None = None
formName: str | None = Field(default=None, description="Length/value max=2147483647.")
formType: FormType | None = Field(default=None, description="Read-only.")
generatedByEnrollmentDate: bool | None = None
hideDueDate: bool | None = None
href: str | None = None
id: str | None = Field(default=None, description="Unique. Length/value min=11, max=11.")
lastUpdated: datetime | None = None
lastUpdatedBy: Reference | None = Field(default=None, description="Reference to User.")
minDaysFromStart: int | None = Field(default=None, description="Length/value max=2147483647.")
name: str | None = Field(default=None, description="Length/value min=1, max=230.")
nextScheduleDate: Reference | None = Field(default=None, description="Reference to DataElement.")
notificationTemplates: list[Any] | None = Field(
default=None, description="Collection of ProgramNotificationTemplate."
)
openAfterEnrollment: bool | None = None
periodType: PeriodType | None = Field(default=None, description="Reference to PeriodType. Length/value max=255.")
preGenerateUID: bool | None = None
program: Reference | None = Field(default=None, description="Reference to Program.")
programStageDataElements: list[Any] | None = Field(
default=None, description="Collection of ProgramStageDataElement."
)
programStageLabel: str | None = Field(default=None, description="Length/value min=2, max=255.")
programStageSections: list[Any] | None = Field(default=None, description="Collection of ProgramStageSection.")
referral: bool | None = None
remindCompleted: bool | None = None
repeatable: bool | None = None
reportDateToUse: str | None = Field(default=None, description="Length/value max=255.")
sharing: Any | None = Field(default=None, description="Reference to Sharing. Length/value max=255.")
shortName: str | None = Field(default=None, description="Length/value min=1, max=50.")
sortOrder: int | None = Field(default=None, description="Length/value max=2147483647.")
standardInterval: int | None = Field(default=None, description="Length/value max=2147483647.")
style: Any | None = Field(default=None, description="Reference to ObjectStyle. Length/value max=255.")
translations: list[Any] | None = Field(default=None, description="Collection of Translation. Length/value max=255.")
user: Reference | None = Field(default=None, description="Reference to User. Read-only (inverse side).")
validationStrategy: ValidationStrategy | None = None
|
ProgramStagesAccessor
Dhis2Client.program_stages — CRUD + PSDE ordering helpers.
Source code in packages/dhis2w-client/src/dhis2w_client/program_stages.py
| class ProgramStagesAccessor:
"""`Dhis2Client.program_stages` — CRUD + PSDE ordering helpers."""
def __init__(self, client: Dhis2Client) -> None:
"""Bind to the sharing client."""
self._client = client
async def list_all(
self,
*,
program_uid: str | None = None,
page: int = 1,
page_size: int = 50,
) -> list[ProgramStage]:
"""Page through ProgramStages, optionally filtered to one parent Program."""
filters: list[str] | None = None
if program_uid:
filters = [f"program.id:eq:{program_uid}"]
return cast(
list[ProgramStage],
await self._client.resources.program_stages.list(
fields=_STAGE_FIELDS,
filters=filters,
page=page,
page_size=page_size,
),
)
async def list_for(self, program_uid: str) -> list[ProgramStage]:
"""Return ProgramStages belonging to one Program, sorted by `sortOrder`."""
return cast(
list[ProgramStage],
await self._client.resources.program_stages.list(
fields=_STAGE_FIELDS,
filters=[f"program.id:eq:{program_uid}"],
order=["sortOrder:asc"],
paging=False,
),
)
async def get(self, uid: str) -> ProgramStage:
"""Fetch one ProgramStage with its PSDE list resolved inline."""
return await self._client.get(
f"/api/programStages/{uid}",
model=ProgramStage,
params={"fields": _STAGE_FIELDS},
)
async def create(
self,
*,
name: str,
program_uid: str,
short_name: str | None = None,
description: str | None = None,
code: str | None = None,
form_name: str | None = None,
sort_order: int | None = None,
repeatable: bool | None = None,
auto_generate_event: bool | None = None,
generated_by_enrollment_date: bool | None = None,
open_after_enrollment: bool | None = None,
block_entry_form: bool | None = None,
feature_type: str | None = None,
period_type: PeriodType | str | None = None,
validation_strategy: str | None = None,
min_days_from_start: int | None = None,
standard_interval: int | None = None,
enable_user_assignment: bool | None = None,
pre_generate_uid: bool | None = None,
due_date_label: str | None = None,
execution_date_label: str | None = None,
event_label: str | None = None,
uid: str | None = None,
) -> ProgramStage:
"""Create a ProgramStage under `program_uid`.
`repeatable=True` makes the stage reusable within one enrollment
(follow-up ANC visits, chronic-care check-ins).
`auto_generate_event=True` + `generated_by_enrollment_date=True`
tells DHIS2 to schedule an event when the enrollment is
created. `min_days_from_start` + `standard_interval` tune the
default due-date math.
"""
payload: dict[str, Any] = {
"name": name,
"program": {"id": program_uid},
}
if short_name:
payload["shortName"] = short_name
if description:
payload["description"] = description
if code:
payload["code"] = code
if form_name:
payload["formName"] = form_name
if sort_order is not None:
payload["sortOrder"] = sort_order
if repeatable is not None:
payload["repeatable"] = repeatable
if auto_generate_event is not None:
payload["autoGenerateEvent"] = auto_generate_event
if generated_by_enrollment_date is not None:
payload["generatedByEnrollmentDate"] = generated_by_enrollment_date
if open_after_enrollment is not None:
payload["openAfterEnrollment"] = open_after_enrollment
if block_entry_form is not None:
payload["blockEntryForm"] = block_entry_form
if feature_type:
payload["featureType"] = feature_type
if period_type is not None:
payload["periodType"] = period_type.value if isinstance(period_type, PeriodType) else period_type
if validation_strategy:
payload["validationStrategy"] = validation_strategy
if min_days_from_start is not None:
payload["minDaysFromStart"] = min_days_from_start
if standard_interval is not None:
payload["standardInterval"] = standard_interval
if enable_user_assignment is not None:
payload["enableUserAssignment"] = enable_user_assignment
if pre_generate_uid is not None:
payload["preGenerateUID"] = pre_generate_uid
if due_date_label:
payload["dueDateLabel"] = due_date_label
if execution_date_label:
payload["executionDateLabel"] = execution_date_label
if event_label:
payload["eventLabel"] = event_label
if uid:
payload["id"] = uid
envelope = await self._client.post("/api/programStages", payload, model=WebMessageResponse)
created_uid = envelope.created_uid or uid
if not created_uid:
raise RuntimeError("program-stage create did not return a uid")
return await self.get(created_uid)
async def update(self, stage: ProgramStage) -> ProgramStage:
"""PUT an edited ProgramStage back. `stage.id` must be set.
Matches the Program PUT quirk: `mergeMode=REPLACE` forces
nested-list replacement so `programStageDataElements[]` items
omitted from the payload actually get removed instead of
silently retained.
"""
if not stage.id:
raise ValueError("update requires stage.id to be set")
body = stage.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_psde(body)
await self._client.put_raw(f"/api/programStages/{stage.id}", body=body, params={"mergeMode": "REPLACE"})
return await self.get(stage.id)
async def rename(
self,
uid: str,
*,
name: str | None = None,
short_name: str | None = None,
form_name: str | None = None,
description: str | None = None,
) -> ProgramStage:
"""Partial-update shortcut — read, mutate label fields, PUT."""
if name is None and short_name is None and form_name is None and description is None:
raise ValueError("rename requires at least one of name / short_name / form_name / description")
current = await self.get(uid)
if name is not None:
current.name = name
if short_name is not None:
current.shortName = short_name
if form_name is not None:
current.formName = form_name
if description is not None:
current.description = description
return await self.update(current)
async def add_element(
self,
stage_uid: str,
data_element_uid: str,
*,
compulsory: bool = False,
allow_future_date: bool = False,
display_in_reports: bool = True,
allow_provided_elsewhere: bool = False,
render_options_as_radio: bool = False,
skip_synchronization: bool = False,
skip_analytics: bool = False,
sort_order: int | None = None,
) -> ProgramStage:
"""Wire a DataElement into the ProgramStage via the PSDE join table.
Round-trips the full stage, appends a new PSDE entry, PUTs with
`mergeMode=REPLACE`. Idempotent — returns early when the DE is
already attached.
"""
current = await self.get(stage_uid)
raw = current.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_psde(raw)
existing = raw.get("programStageDataElements") or []
if any(
(entry.get("dataElement") or {}).get("id") == data_element_uid
for entry in existing
if isinstance(entry, dict)
):
return current
new_entry: dict[str, Any] = {
"dataElement": {"id": data_element_uid},
"compulsory": compulsory,
"allowFutureDate": allow_future_date,
"displayInReports": display_in_reports,
"allowProvidedElsewhere": allow_provided_elsewhere,
"renderOptionsAsRadio": render_options_as_radio,
"skipSynchronization": skip_synchronization,
"skipAnalytics": skip_analytics,
}
if sort_order is not None:
new_entry["sortOrder"] = sort_order
existing.append(new_entry)
raw["programStageDataElements"] = existing
await self._client.put_raw(f"/api/programStages/{stage_uid}", body=raw, params={"mergeMode": "REPLACE"})
return await self.get(stage_uid)
async def remove_element(self, stage_uid: str, data_element_uid: str) -> ProgramStage:
"""Drop a DataElement from the ProgramStage's PSDE list."""
current = await self.get(stage_uid)
raw = current.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_psde(raw)
existing = raw.get("programStageDataElements") or []
filtered = [
entry
for entry in existing
if isinstance(entry, dict) and (entry.get("dataElement") or {}).get("id") != data_element_uid
]
if len(filtered) == len(existing):
return current
raw["programStageDataElements"] = filtered
await self._client.put_raw(f"/api/programStages/{stage_uid}", body=raw, params={"mergeMode": "REPLACE"})
return await self.get(stage_uid)
async def reorder(self, stage_uid: str, data_element_uids: list[str]) -> ProgramStage:
"""Replace the ordered `programStageDataElements` with exactly the given DE UIDs.
Any PSDE flags (`compulsory`, `display_in_reports`, etc.) on
dropped entries are lost. Use `add_element` + `remove_element`
for fine-grained edits; reach for `reorder` when the set of
attached DEs is known.
"""
current = await self.get(stage_uid)
raw = current.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_psde(raw)
# Preserve PSDE flags for DEs that stay in the list.
existing = {
(entry.get("dataElement") or {}).get("id"): entry
for entry in (raw.get("programStageDataElements") or [])
if isinstance(entry, dict)
}
new_entries: list[dict[str, Any]] = []
for index, de_uid in enumerate(data_element_uids):
entry = existing.get(de_uid) or {"dataElement": {"id": de_uid}}
entry["sortOrder"] = index
new_entries.append(entry)
raw["programStageDataElements"] = new_entries
await self._client.put_raw(f"/api/programStages/{stage_uid}", body=raw, params={"mergeMode": "REPLACE"})
return await self.get(stage_uid)
async def delete(self, uid: str) -> None:
"""Delete a ProgramStage — DHIS2 rejects deletes on stages with recorded events."""
if not uid:
raise ValueError("delete requires a non-empty uid")
await self._client.resources.program_stages.delete(uid)
|
Functions
__init__(client)
Bind to the sharing client.
Source code in packages/dhis2w-client/src/dhis2w_client/program_stages.py
| def __init__(self, client: Dhis2Client) -> None:
"""Bind to the sharing client."""
self._client = client
|
list_all(*, program_uid=None, page=1, page_size=50)
async
Page through ProgramStages, optionally filtered to one parent Program.
Source code in packages/dhis2w-client/src/dhis2w_client/program_stages.py
| async def list_all(
self,
*,
program_uid: str | None = None,
page: int = 1,
page_size: int = 50,
) -> list[ProgramStage]:
"""Page through ProgramStages, optionally filtered to one parent Program."""
filters: list[str] | None = None
if program_uid:
filters = [f"program.id:eq:{program_uid}"]
return cast(
list[ProgramStage],
await self._client.resources.program_stages.list(
fields=_STAGE_FIELDS,
filters=filters,
page=page,
page_size=page_size,
),
)
|
list_for(program_uid)
async
Return ProgramStages belonging to one Program, sorted by sortOrder.
Source code in packages/dhis2w-client/src/dhis2w_client/program_stages.py
| async def list_for(self, program_uid: str) -> list[ProgramStage]:
"""Return ProgramStages belonging to one Program, sorted by `sortOrder`."""
return cast(
list[ProgramStage],
await self._client.resources.program_stages.list(
fields=_STAGE_FIELDS,
filters=[f"program.id:eq:{program_uid}"],
order=["sortOrder:asc"],
paging=False,
),
)
|
get(uid)
async
Fetch one ProgramStage with its PSDE list resolved inline.
Source code in packages/dhis2w-client/src/dhis2w_client/program_stages.py
| async def get(self, uid: str) -> ProgramStage:
"""Fetch one ProgramStage with its PSDE list resolved inline."""
return await self._client.get(
f"/api/programStages/{uid}",
model=ProgramStage,
params={"fields": _STAGE_FIELDS},
)
|
create(*, name, program_uid, short_name=None, description=None, code=None, form_name=None, sort_order=None, repeatable=None, auto_generate_event=None, generated_by_enrollment_date=None, open_after_enrollment=None, block_entry_form=None, feature_type=None, period_type=None, validation_strategy=None, min_days_from_start=None, standard_interval=None, enable_user_assignment=None, pre_generate_uid=None, due_date_label=None, execution_date_label=None, event_label=None, uid=None)
async
Create a ProgramStage under program_uid.
repeatable=True makes the stage reusable within one enrollment
(follow-up ANC visits, chronic-care check-ins).
auto_generate_event=True + generated_by_enrollment_date=True
tells DHIS2 to schedule an event when the enrollment is
created. min_days_from_start + standard_interval tune the
default due-date math.
Source code in packages/dhis2w-client/src/dhis2w_client/program_stages.py
| async def create(
self,
*,
name: str,
program_uid: str,
short_name: str | None = None,
description: str | None = None,
code: str | None = None,
form_name: str | None = None,
sort_order: int | None = None,
repeatable: bool | None = None,
auto_generate_event: bool | None = None,
generated_by_enrollment_date: bool | None = None,
open_after_enrollment: bool | None = None,
block_entry_form: bool | None = None,
feature_type: str | None = None,
period_type: PeriodType | str | None = None,
validation_strategy: str | None = None,
min_days_from_start: int | None = None,
standard_interval: int | None = None,
enable_user_assignment: bool | None = None,
pre_generate_uid: bool | None = None,
due_date_label: str | None = None,
execution_date_label: str | None = None,
event_label: str | None = None,
uid: str | None = None,
) -> ProgramStage:
"""Create a ProgramStage under `program_uid`.
`repeatable=True` makes the stage reusable within one enrollment
(follow-up ANC visits, chronic-care check-ins).
`auto_generate_event=True` + `generated_by_enrollment_date=True`
tells DHIS2 to schedule an event when the enrollment is
created. `min_days_from_start` + `standard_interval` tune the
default due-date math.
"""
payload: dict[str, Any] = {
"name": name,
"program": {"id": program_uid},
}
if short_name:
payload["shortName"] = short_name
if description:
payload["description"] = description
if code:
payload["code"] = code
if form_name:
payload["formName"] = form_name
if sort_order is not None:
payload["sortOrder"] = sort_order
if repeatable is not None:
payload["repeatable"] = repeatable
if auto_generate_event is not None:
payload["autoGenerateEvent"] = auto_generate_event
if generated_by_enrollment_date is not None:
payload["generatedByEnrollmentDate"] = generated_by_enrollment_date
if open_after_enrollment is not None:
payload["openAfterEnrollment"] = open_after_enrollment
if block_entry_form is not None:
payload["blockEntryForm"] = block_entry_form
if feature_type:
payload["featureType"] = feature_type
if period_type is not None:
payload["periodType"] = period_type.value if isinstance(period_type, PeriodType) else period_type
if validation_strategy:
payload["validationStrategy"] = validation_strategy
if min_days_from_start is not None:
payload["minDaysFromStart"] = min_days_from_start
if standard_interval is not None:
payload["standardInterval"] = standard_interval
if enable_user_assignment is not None:
payload["enableUserAssignment"] = enable_user_assignment
if pre_generate_uid is not None:
payload["preGenerateUID"] = pre_generate_uid
if due_date_label:
payload["dueDateLabel"] = due_date_label
if execution_date_label:
payload["executionDateLabel"] = execution_date_label
if event_label:
payload["eventLabel"] = event_label
if uid:
payload["id"] = uid
envelope = await self._client.post("/api/programStages", payload, model=WebMessageResponse)
created_uid = envelope.created_uid or uid
if not created_uid:
raise RuntimeError("program-stage create did not return a uid")
return await self.get(created_uid)
|
update(stage)
async
PUT an edited ProgramStage back. stage.id must be set.
Matches the Program PUT quirk: mergeMode=REPLACE forces
nested-list replacement so programStageDataElements[] items
omitted from the payload actually get removed instead of
silently retained.
Source code in packages/dhis2w-client/src/dhis2w_client/program_stages.py
| async def update(self, stage: ProgramStage) -> ProgramStage:
"""PUT an edited ProgramStage back. `stage.id` must be set.
Matches the Program PUT quirk: `mergeMode=REPLACE` forces
nested-list replacement so `programStageDataElements[]` items
omitted from the payload actually get removed instead of
silently retained.
"""
if not stage.id:
raise ValueError("update requires stage.id to be set")
body = stage.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_psde(body)
await self._client.put_raw(f"/api/programStages/{stage.id}", body=body, params={"mergeMode": "REPLACE"})
return await self.get(stage.id)
|
rename(uid, *, name=None, short_name=None, form_name=None, description=None)
async
Partial-update shortcut — read, mutate label fields, PUT.
Source code in packages/dhis2w-client/src/dhis2w_client/program_stages.py
| async def rename(
self,
uid: str,
*,
name: str | None = None,
short_name: str | None = None,
form_name: str | None = None,
description: str | None = None,
) -> ProgramStage:
"""Partial-update shortcut — read, mutate label fields, PUT."""
if name is None and short_name is None and form_name is None and description is None:
raise ValueError("rename requires at least one of name / short_name / form_name / description")
current = await self.get(uid)
if name is not None:
current.name = name
if short_name is not None:
current.shortName = short_name
if form_name is not None:
current.formName = form_name
if description is not None:
current.description = description
return await self.update(current)
|
add_element(stage_uid, data_element_uid, *, compulsory=False, allow_future_date=False, display_in_reports=True, allow_provided_elsewhere=False, render_options_as_radio=False, skip_synchronization=False, skip_analytics=False, sort_order=None)
async
Wire a DataElement into the ProgramStage via the PSDE join table.
Round-trips the full stage, appends a new PSDE entry, PUTs with
mergeMode=REPLACE. Idempotent — returns early when the DE is
already attached.
Source code in packages/dhis2w-client/src/dhis2w_client/program_stages.py
| async def add_element(
self,
stage_uid: str,
data_element_uid: str,
*,
compulsory: bool = False,
allow_future_date: bool = False,
display_in_reports: bool = True,
allow_provided_elsewhere: bool = False,
render_options_as_radio: bool = False,
skip_synchronization: bool = False,
skip_analytics: bool = False,
sort_order: int | None = None,
) -> ProgramStage:
"""Wire a DataElement into the ProgramStage via the PSDE join table.
Round-trips the full stage, appends a new PSDE entry, PUTs with
`mergeMode=REPLACE`. Idempotent — returns early when the DE is
already attached.
"""
current = await self.get(stage_uid)
raw = current.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_psde(raw)
existing = raw.get("programStageDataElements") or []
if any(
(entry.get("dataElement") or {}).get("id") == data_element_uid
for entry in existing
if isinstance(entry, dict)
):
return current
new_entry: dict[str, Any] = {
"dataElement": {"id": data_element_uid},
"compulsory": compulsory,
"allowFutureDate": allow_future_date,
"displayInReports": display_in_reports,
"allowProvidedElsewhere": allow_provided_elsewhere,
"renderOptionsAsRadio": render_options_as_radio,
"skipSynchronization": skip_synchronization,
"skipAnalytics": skip_analytics,
}
if sort_order is not None:
new_entry["sortOrder"] = sort_order
existing.append(new_entry)
raw["programStageDataElements"] = existing
await self._client.put_raw(f"/api/programStages/{stage_uid}", body=raw, params={"mergeMode": "REPLACE"})
return await self.get(stage_uid)
|
remove_element(stage_uid, data_element_uid)
async
Drop a DataElement from the ProgramStage's PSDE list.
Source code in packages/dhis2w-client/src/dhis2w_client/program_stages.py
| async def remove_element(self, stage_uid: str, data_element_uid: str) -> ProgramStage:
"""Drop a DataElement from the ProgramStage's PSDE list."""
current = await self.get(stage_uid)
raw = current.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_psde(raw)
existing = raw.get("programStageDataElements") or []
filtered = [
entry
for entry in existing
if isinstance(entry, dict) and (entry.get("dataElement") or {}).get("id") != data_element_uid
]
if len(filtered) == len(existing):
return current
raw["programStageDataElements"] = filtered
await self._client.put_raw(f"/api/programStages/{stage_uid}", body=raw, params={"mergeMode": "REPLACE"})
return await self.get(stage_uid)
|
reorder(stage_uid, data_element_uids)
async
Replace the ordered programStageDataElements with exactly the given DE UIDs.
Any PSDE flags (compulsory, display_in_reports, etc.) on
dropped entries are lost. Use add_element + remove_element
for fine-grained edits; reach for reorder when the set of
attached DEs is known.
Source code in packages/dhis2w-client/src/dhis2w_client/program_stages.py
| async def reorder(self, stage_uid: str, data_element_uids: list[str]) -> ProgramStage:
"""Replace the ordered `programStageDataElements` with exactly the given DE UIDs.
Any PSDE flags (`compulsory`, `display_in_reports`, etc.) on
dropped entries are lost. Use `add_element` + `remove_element`
for fine-grained edits; reach for `reorder` when the set of
attached DEs is known.
"""
current = await self.get(stage_uid)
raw = current.model_dump(by_alias=True, exclude_none=True, mode="json")
_strip_self_ref_from_psde(raw)
# Preserve PSDE flags for DEs that stay in the list.
existing = {
(entry.get("dataElement") or {}).get("id"): entry
for entry in (raw.get("programStageDataElements") or [])
if isinstance(entry, dict)
}
new_entries: list[dict[str, Any]] = []
for index, de_uid in enumerate(data_element_uids):
entry = existing.get(de_uid) or {"dataElement": {"id": de_uid}}
entry["sortOrder"] = index
new_entries.append(entry)
raw["programStageDataElements"] = new_entries
await self._client.put_raw(f"/api/programStages/{stage_uid}", body=raw, params={"mergeMode": "REPLACE"})
return await self.get(stage_uid)
|
delete(uid)
async
Delete a ProgramStage — DHIS2 rejects deletes on stages with recorded events.
Source code in packages/dhis2w-client/src/dhis2w_client/program_stages.py
| async def delete(self, uid: str) -> None:
"""Delete a ProgramStage — DHIS2 rejects deletes on stages with recorded events."""
if not uid:
raise ValueError("delete requires a non-empty uid")
await self._client.resources.program_stages.delete(uid)
|