dirigent-dhis2 0.9.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dirigent_dhis2/__init__.py +108 -0
- dirigent_dhis2/analytics.py +199 -0
- dirigent_dhis2/analytics_query.py +144 -0
- dirigent_dhis2/complete.py +80 -0
- dirigent_dhis2/connection.py +102 -0
- dirigent_dhis2/export.py +124 -0
- dirigent_dhis2/formats.py +44 -0
- dirigent_dhis2/imports.py +197 -0
- dirigent_dhis2/metadata.py +117 -0
- dirigent_dhis2/py.typed +0 -0
- dirigent_dhis2/tracker.py +137 -0
- dirigent_dhis2/web.py +82 -0
- dirigent_dhis2-0.9.1.dist-info/METADATA +89 -0
- dirigent_dhis2-0.9.1.dist-info/RECORD +17 -0
- dirigent_dhis2-0.9.1.dist-info/WHEEL +4 -0
- dirigent_dhis2-0.9.1.dist-info/entry_points.txt +3 -0
- dirigent_dhis2-0.9.1.dist-info/licenses/LICENSE +15 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The DHIS2 adapter pack: the ``dhis2`` connection kind and the blocks for one instance."""
|
|
2
|
+
|
|
3
|
+
from dirigent_dhis2.analytics import (
|
|
4
|
+
Dhis2AnalyticsRunConfig,
|
|
5
|
+
Dhis2AnalyticsRunOperator,
|
|
6
|
+
Dhis2AnalyticsRunOutput,
|
|
7
|
+
)
|
|
8
|
+
from dirigent_dhis2.analytics_query import (
|
|
9
|
+
Dhis2AnalyticsQueryConfig,
|
|
10
|
+
Dhis2AnalyticsQueryOperator,
|
|
11
|
+
Dhis2AnalyticsQueryOutput,
|
|
12
|
+
)
|
|
13
|
+
from dirigent_dhis2.complete import (
|
|
14
|
+
Dhis2DataSetCompleteConfig,
|
|
15
|
+
Dhis2DataSetCompleteOutput,
|
|
16
|
+
Dhis2DataSetCompleteSensor,
|
|
17
|
+
)
|
|
18
|
+
from dirigent_dhis2.connection import (
|
|
19
|
+
Dhis2ConnectionConfig,
|
|
20
|
+
Dhis2ConnectionKind,
|
|
21
|
+
build_client,
|
|
22
|
+
client_for,
|
|
23
|
+
)
|
|
24
|
+
from dirigent_dhis2.export import (
|
|
25
|
+
Dhis2DataValueSetExportConfig,
|
|
26
|
+
Dhis2DataValueSetExportOperator,
|
|
27
|
+
Dhis2DataValueSetExportOutput,
|
|
28
|
+
)
|
|
29
|
+
from dirigent_dhis2.formats import DHIS2_FORMATS, is_period, is_uid
|
|
30
|
+
from dirigent_dhis2.imports import (
|
|
31
|
+
Dhis2DataValueSetImportConfig,
|
|
32
|
+
Dhis2DataValueSetImportOperator,
|
|
33
|
+
Dhis2DataValueSetImportOutput,
|
|
34
|
+
Dhis2ImportConflict,
|
|
35
|
+
)
|
|
36
|
+
from dirigent_dhis2.metadata import (
|
|
37
|
+
Dhis2MetadataConfig,
|
|
38
|
+
Dhis2MetadataOperator,
|
|
39
|
+
Dhis2MetadataOutput,
|
|
40
|
+
)
|
|
41
|
+
from dirigent_dhis2.tracker import (
|
|
42
|
+
Dhis2TrackerConfig,
|
|
43
|
+
Dhis2TrackerOperator,
|
|
44
|
+
Dhis2TrackerOutput,
|
|
45
|
+
)
|
|
46
|
+
from dirigent_dhis2.web import Dhis2Operator, Dhis2Sensor, classify
|
|
47
|
+
from dirigent_plugin import Contribution, extension
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Dhis2Plugin:
|
|
51
|
+
"""The plugin object the host discovers under the dirigent.plugins.v1 entry-point group."""
|
|
52
|
+
|
|
53
|
+
@extension
|
|
54
|
+
def contribute(self) -> Contribution:
|
|
55
|
+
"""Contribute the DHIS2 blocks and the ``dhis2`` connection kind they are configured from."""
|
|
56
|
+
return Contribution(
|
|
57
|
+
operators=[
|
|
58
|
+
Dhis2AnalyticsRunOperator(),
|
|
59
|
+
Dhis2AnalyticsQueryOperator(),
|
|
60
|
+
Dhis2DataValueSetExportOperator(),
|
|
61
|
+
Dhis2DataValueSetImportOperator(),
|
|
62
|
+
Dhis2MetadataOperator(),
|
|
63
|
+
Dhis2TrackerOperator(),
|
|
64
|
+
],
|
|
65
|
+
sensors=[Dhis2DataSetCompleteSensor()],
|
|
66
|
+
connection_kinds=[Dhis2ConnectionKind()],
|
|
67
|
+
formats=DHIS2_FORMATS,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
plugin = Dhis2Plugin()
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"DHIS2_FORMATS",
|
|
75
|
+
"Dhis2AnalyticsQueryConfig",
|
|
76
|
+
"Dhis2AnalyticsQueryOperator",
|
|
77
|
+
"Dhis2AnalyticsQueryOutput",
|
|
78
|
+
"Dhis2AnalyticsRunConfig",
|
|
79
|
+
"Dhis2AnalyticsRunOperator",
|
|
80
|
+
"Dhis2AnalyticsRunOutput",
|
|
81
|
+
"Dhis2ConnectionConfig",
|
|
82
|
+
"Dhis2ConnectionKind",
|
|
83
|
+
"Dhis2DataSetCompleteConfig",
|
|
84
|
+
"Dhis2DataSetCompleteOutput",
|
|
85
|
+
"Dhis2DataSetCompleteSensor",
|
|
86
|
+
"Dhis2DataValueSetExportConfig",
|
|
87
|
+
"Dhis2DataValueSetExportOperator",
|
|
88
|
+
"Dhis2DataValueSetExportOutput",
|
|
89
|
+
"Dhis2DataValueSetImportConfig",
|
|
90
|
+
"Dhis2DataValueSetImportOperator",
|
|
91
|
+
"Dhis2DataValueSetImportOutput",
|
|
92
|
+
"Dhis2ImportConflict",
|
|
93
|
+
"Dhis2MetadataConfig",
|
|
94
|
+
"Dhis2MetadataOperator",
|
|
95
|
+
"Dhis2MetadataOutput",
|
|
96
|
+
"Dhis2Operator",
|
|
97
|
+
"Dhis2Plugin",
|
|
98
|
+
"Dhis2Sensor",
|
|
99
|
+
"Dhis2TrackerConfig",
|
|
100
|
+
"Dhis2TrackerOperator",
|
|
101
|
+
"Dhis2TrackerOutput",
|
|
102
|
+
"build_client",
|
|
103
|
+
"classify",
|
|
104
|
+
"client_for",
|
|
105
|
+
"is_period",
|
|
106
|
+
"is_uid",
|
|
107
|
+
"plugin",
|
|
108
|
+
]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""``dhis2.analytics_run``: run the analytics tables job, submitted once and then probed."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
|
+
from typing import Any, ClassVar, Final
|
|
6
|
+
|
|
7
|
+
from dhis2w_client.errors import AuthenticationError, Dhis2ApiError
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from dirigent_common import BlockModel
|
|
11
|
+
from dirigent_dhis2.connection import client_for
|
|
12
|
+
from dirigent_dhis2.web import Dhis2Operator, refuse
|
|
13
|
+
from dirigent_plugin import (
|
|
14
|
+
BlockFailure,
|
|
15
|
+
ErrorClass,
|
|
16
|
+
OperatorSpec,
|
|
17
|
+
ProbeResult,
|
|
18
|
+
ProbeStatus,
|
|
19
|
+
RemoteHandle,
|
|
20
|
+
StepContext,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
#: Where the analytics tables job is submitted.
|
|
24
|
+
ANALYTICS_PATH: Final = "/api/resourceTables/analytics"
|
|
25
|
+
|
|
26
|
+
#: The handle key holding the notifier endpoint the submission answered with.
|
|
27
|
+
NOTIFIER = "notifier"
|
|
28
|
+
|
|
29
|
+
#: The handle key holding the job type the task poll is keyed by, beside the handle's task uid.
|
|
30
|
+
JOB_TYPE = "job_type"
|
|
31
|
+
|
|
32
|
+
#: The handle key holding the poll cursor: the notification identifiers already streamed in.
|
|
33
|
+
CURSOR = "cursor"
|
|
34
|
+
|
|
35
|
+
#: How many notification messages the output keeps, from the end of the task's story.
|
|
36
|
+
MESSAGE_TAIL = 10
|
|
37
|
+
|
|
38
|
+
#: How long a task may stay silent from the attempt's start before it is taken to be lost.
|
|
39
|
+
#:
|
|
40
|
+
#: DHIS2 answers a task it has never heard of exactly the way it answers one that has not
|
|
41
|
+
#: written its first notification yet: 200 and an empty feed. The two are told apart by time.
|
|
42
|
+
#: A submitted job writes its first line within seconds of starting, so a feed still empty
|
|
43
|
+
#: this long after the submission is a task the instance lost, restarted away, or never had.
|
|
44
|
+
GONE_AFTER: Final = timedelta(minutes=15)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Dhis2AnalyticsRunConfig(BlockModel):
|
|
48
|
+
"""What one analytics tables run asks of the instance."""
|
|
49
|
+
|
|
50
|
+
connection: str
|
|
51
|
+
"""The code of the dhis2 connection naming the instance."""
|
|
52
|
+
|
|
53
|
+
last_years: int | None = None
|
|
54
|
+
"""Limit the tables to this many years back, or leave unset to build them all."""
|
|
55
|
+
|
|
56
|
+
skip_resource_tables: bool = False
|
|
57
|
+
"""Whether the resource tables are left as they are."""
|
|
58
|
+
|
|
59
|
+
skip_aggregate: bool = False
|
|
60
|
+
"""Whether aggregate data analytics tables are left as they are."""
|
|
61
|
+
|
|
62
|
+
skip_events: bool = False
|
|
63
|
+
"""Whether event analytics tables are left as they are."""
|
|
64
|
+
|
|
65
|
+
skip_enrollment: bool = False
|
|
66
|
+
"""Whether enrollment analytics tables are left as they are."""
|
|
67
|
+
|
|
68
|
+
skip_org_unit_ownership: bool = False
|
|
69
|
+
"""Whether the org unit ownership table is left as it is."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Dhis2AnalyticsRunOutput(BlockModel):
|
|
73
|
+
"""What the finished analytics job reported."""
|
|
74
|
+
|
|
75
|
+
task_id: str
|
|
76
|
+
"""The id the instance gave the job."""
|
|
77
|
+
|
|
78
|
+
completed_at: str | None = None
|
|
79
|
+
"""When the task said it was done, in the instance's own timestamp."""
|
|
80
|
+
|
|
81
|
+
messages: list[str]
|
|
82
|
+
"""The last few notification messages, oldest first."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _level(entry: Any) -> str:
|
|
86
|
+
"""Read a notification's level, defaulting the one a version left unset."""
|
|
87
|
+
return (entry.level or "INFO").upper()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _completed_at(entry: Any) -> str | None:
|
|
91
|
+
"""Read the completing notification's timestamp as the instance's own ISO instant."""
|
|
92
|
+
return entry.time.isoformat() if entry is not None and entry.time is not None else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _silent_for(ctx: StepContext) -> timedelta:
|
|
96
|
+
"""How long this attempt has been waiting on the task, measured from its first start."""
|
|
97
|
+
started = ctx.started_at if ctx.started_at.tzinfo is not None else ctx.started_at.replace(tzinfo=UTC)
|
|
98
|
+
return datetime.now(UTC) - started
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Dhis2AnalyticsRunOperator(Dhis2Operator[Dhis2AnalyticsRunConfig, Dhis2AnalyticsRunOutput]):
|
|
102
|
+
"""Submits the analytics tables job and follows its notifications until it settles."""
|
|
103
|
+
|
|
104
|
+
spec = OperatorSpec(
|
|
105
|
+
id="dhis2.analytics_run",
|
|
106
|
+
summary="Run the DHIS2 analytics tables job.",
|
|
107
|
+
default_poll=timedelta(minutes=1),
|
|
108
|
+
)
|
|
109
|
+
config_model: ClassVar[type[BaseModel]] = Dhis2AnalyticsRunConfig
|
|
110
|
+
output_model: ClassVar[type[BaseModel]] = Dhis2AnalyticsRunOutput
|
|
111
|
+
|
|
112
|
+
async def execute(self, config: Dhis2AnalyticsRunConfig, ctx: StepContext) -> RemoteHandle:
|
|
113
|
+
"""Submit the job and hand back the task reference the engine probes."""
|
|
114
|
+
async with client_for(ctx, config.connection) as client:
|
|
115
|
+
try:
|
|
116
|
+
envelope = await client.maintenance.run_analytics_tables(
|
|
117
|
+
last_years=config.last_years,
|
|
118
|
+
skip_resource_tables=config.skip_resource_tables,
|
|
119
|
+
skip_aggregate=config.skip_aggregate,
|
|
120
|
+
skip_events=config.skip_events,
|
|
121
|
+
skip_enrollment=config.skip_enrollment,
|
|
122
|
+
skip_org_unit_ownership=config.skip_org_unit_ownership,
|
|
123
|
+
)
|
|
124
|
+
except (Dhis2ApiError, AuthenticationError) as error:
|
|
125
|
+
raise refuse(error, f"POST {ANALYTICS_PATH}") from error
|
|
126
|
+
task_ref = envelope.task_ref()
|
|
127
|
+
endpoint = envelope.notifier_endpoint()
|
|
128
|
+
if task_ref is None or endpoint is None:
|
|
129
|
+
raise BlockFailure(
|
|
130
|
+
"the analytics job submission answered without a task reference to follow",
|
|
131
|
+
error_class=ErrorClass.REJECTED,
|
|
132
|
+
)
|
|
133
|
+
job_type, task_uid = task_ref
|
|
134
|
+
ctx.log.info("analytics job submitted", notifier=endpoint)
|
|
135
|
+
return RemoteHandle(block_id=self.spec.id, ref=task_uid, meta={NOTIFIER: endpoint, JOB_TYPE: job_type})
|
|
136
|
+
|
|
137
|
+
async def probe(self, handle: RemoteHandle, config: Dhis2AnalyticsRunConfig, ctx: StepContext) -> ProbeResult:
|
|
138
|
+
"""Poll the task once, stream the notifications new since the cursor, and map its state."""
|
|
139
|
+
task_ref = (handle.meta[JOB_TYPE], handle.ref)
|
|
140
|
+
cursor = json.loads(handle.meta.get(CURSOR, "[]"))
|
|
141
|
+
async with client_for(ctx, config.connection) as client:
|
|
142
|
+
try:
|
|
143
|
+
poll = await client.tasks.poll_once(task_ref, cursor=cursor)
|
|
144
|
+
except Dhis2ApiError as error:
|
|
145
|
+
if error.status_code == 404:
|
|
146
|
+
return ProbeResult(
|
|
147
|
+
status=ProbeStatus.GONE, message=f"the instance no longer knows task {handle.ref}"
|
|
148
|
+
)
|
|
149
|
+
raise refuse(error, f"GET {handle.meta[NOTIFIER]}") from error
|
|
150
|
+
except AuthenticationError as error:
|
|
151
|
+
raise refuse(error, f"GET {handle.meta[NOTIFIER]}") from error
|
|
152
|
+
for entry in poll.new:
|
|
153
|
+
log = ctx.log.warning if _level(entry) == "ERROR" else ctx.log.info
|
|
154
|
+
log(entry.message or "", level=_level(entry))
|
|
155
|
+
advanced = {**handle.meta, CURSOR: json.dumps(sorted(poll.cursor))}
|
|
156
|
+
if not poll.completed:
|
|
157
|
+
if not poll.cursor and _silent_for(ctx) >= GONE_AFTER:
|
|
158
|
+
return ProbeResult(
|
|
159
|
+
status=ProbeStatus.GONE,
|
|
160
|
+
message=f"the instance has reported nothing for task {handle.ref} since it was submitted",
|
|
161
|
+
)
|
|
162
|
+
return ProbeResult(status=ProbeStatus.RUNNING, message="the task is still running", meta=advanced)
|
|
163
|
+
terminal = poll.new[-1] if poll.new else None
|
|
164
|
+
if terminal is not None and _level(terminal) == "ERROR":
|
|
165
|
+
return ProbeResult(status=ProbeStatus.FAILED, message=f"the task failed: {terminal.message or ''}")
|
|
166
|
+
return ProbeResult(
|
|
167
|
+
status=ProbeStatus.SUCCEEDED,
|
|
168
|
+
message=terminal.message if terminal is not None else None,
|
|
169
|
+
meta=advanced,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def fetch(
|
|
173
|
+
self, handle: RemoteHandle, config: Dhis2AnalyticsRunConfig, ctx: StepContext
|
|
174
|
+
) -> Dhis2AnalyticsRunOutput:
|
|
175
|
+
"""Collect the finished task's story; safe to call again."""
|
|
176
|
+
task_ref = (handle.meta[JOB_TYPE], handle.ref)
|
|
177
|
+
async with client_for(ctx, config.connection) as client:
|
|
178
|
+
try:
|
|
179
|
+
poll = await client.tasks.poll_once(task_ref)
|
|
180
|
+
except Dhis2ApiError as error:
|
|
181
|
+
if error.status_code == 404:
|
|
182
|
+
raise BlockFailure(
|
|
183
|
+
f"task {handle.ref} disappeared before its result could be collected",
|
|
184
|
+
error_class=ErrorClass.TRANSIENT,
|
|
185
|
+
) from error
|
|
186
|
+
raise refuse(error, f"GET {handle.meta[NOTIFIER]}") from error
|
|
187
|
+
except AuthenticationError as error:
|
|
188
|
+
raise refuse(error, f"GET {handle.meta[NOTIFIER]}") from error
|
|
189
|
+
story = poll.new
|
|
190
|
+
terminal = story[-1] if story and story[-1].completed else None
|
|
191
|
+
return Dhis2AnalyticsRunOutput(
|
|
192
|
+
task_id=handle.ref,
|
|
193
|
+
completed_at=_completed_at(terminal),
|
|
194
|
+
messages=[entry.message or "" for entry in story][-MESSAGE_TAIL:],
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
async def cancel(self, handle: RemoteHandle, config: Dhis2AnalyticsRunConfig, ctx: StepContext) -> bool:
|
|
198
|
+
"""Report that the job could not be told: DHIS2 offers no way to stop a running analytics job."""
|
|
199
|
+
return False
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""``dhis2.analytics_query``: read one analytics query, aggregate or event/enrollment."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, ClassVar, Final, Literal
|
|
5
|
+
|
|
6
|
+
from dhis2w_client import AnalyticsAccessor
|
|
7
|
+
from dhis2w_client.errors import AuthenticationError, Dhis2ApiError
|
|
8
|
+
from pydantic import BaseModel, JsonValue, model_validator
|
|
9
|
+
|
|
10
|
+
from dirigent_common import BlockModel
|
|
11
|
+
from dirigent_dhis2.connection import client_for
|
|
12
|
+
from dirigent_dhis2.web import Dhis2Operator, refuse
|
|
13
|
+
from dirigent_plugin import BlockFailure, ErrorClass, OperatorSpec, StepContext
|
|
14
|
+
|
|
15
|
+
#: The aggregate analytics endpoint the client's analytics accessor reads.
|
|
16
|
+
AGGREGATE_ENDPOINT: Final = "/api/analytics.json"
|
|
17
|
+
|
|
18
|
+
#: The query modes this block runs, keyed by the ``mode`` a document names.
|
|
19
|
+
AnalyticsMode = Literal["aggregate", "event", "enrollment"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Dhis2AnalyticsQueryConfig(BlockModel):
|
|
23
|
+
"""What one analytics query asks of the instance."""
|
|
24
|
+
|
|
25
|
+
connection: str
|
|
26
|
+
"""The code of the dhis2 connection naming the instance."""
|
|
27
|
+
|
|
28
|
+
mode: AnalyticsMode
|
|
29
|
+
"""Which analytics query to run: ``aggregate`` over ``/api/analytics``, or an ``event`` or
|
|
30
|
+
``enrollment`` query over ``/api/analytics/{events,enrollments}/query``."""
|
|
31
|
+
|
|
32
|
+
dimension: list[str] = []
|
|
33
|
+
"""The DHIS2 ``dimension=`` axes, such as ``dx:fbfJHSPpUQD`` or ``pe:LAST_12_MONTHS``."""
|
|
34
|
+
|
|
35
|
+
filter: list[str] = []
|
|
36
|
+
"""The DHIS2 ``filter=`` axes, fixing a dimension the result is not broken down by."""
|
|
37
|
+
|
|
38
|
+
program: str | None = None
|
|
39
|
+
"""The uid of the program an ``event`` or ``enrollment`` query reads; unused for aggregate."""
|
|
40
|
+
|
|
41
|
+
start_date: str | None = None
|
|
42
|
+
"""The ISO start of the query window, for the query kinds that take one."""
|
|
43
|
+
|
|
44
|
+
end_date: str | None = None
|
|
45
|
+
"""The ISO end of the query window, for the query kinds that take one."""
|
|
46
|
+
|
|
47
|
+
output_id_scheme: str | None = None
|
|
48
|
+
"""The DHIS2 ``outputIdScheme``, such as ``UID`` or ``NAME``, applied to the answer."""
|
|
49
|
+
|
|
50
|
+
page: int | None = None
|
|
51
|
+
"""The 1-based page to read, for the event and enrollment queries that page."""
|
|
52
|
+
|
|
53
|
+
page_size: int | None = None
|
|
54
|
+
"""How many rows a page holds, for the event and enrollment queries that page."""
|
|
55
|
+
|
|
56
|
+
@model_validator(mode="after")
|
|
57
|
+
def _program_scopes_the_query_kinds_that_need_it(self) -> "Dhis2AnalyticsQueryConfig":
|
|
58
|
+
"""An event or enrollment query reads under one program, so it must be named."""
|
|
59
|
+
if self.mode in ("event", "enrollment") and not self.program:
|
|
60
|
+
raise ValueError(f"a {self.mode} analytics query needs a program to read under")
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Dhis2AnalyticsQueryOutput(BlockModel):
|
|
65
|
+
"""The query the analytics endpoint answered, for a downstream step to reference by field."""
|
|
66
|
+
|
|
67
|
+
json_body: JsonValue | None = None
|
|
68
|
+
"""The parsed response: the analytics grid, its headers, metaData, and rows."""
|
|
69
|
+
|
|
70
|
+
duration_ms: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _params(config: Dhis2AnalyticsQueryConfig) -> dict[str, Any]:
|
|
74
|
+
"""Build the analytics query from the axes and window the step set, in DHIS2's own names."""
|
|
75
|
+
params: dict[str, Any] = {}
|
|
76
|
+
if config.dimension:
|
|
77
|
+
params["dimension"] = config.dimension
|
|
78
|
+
if config.filter:
|
|
79
|
+
params["filter"] = config.filter
|
|
80
|
+
if config.start_date is not None:
|
|
81
|
+
params["startDate"] = config.start_date
|
|
82
|
+
if config.end_date is not None:
|
|
83
|
+
params["endDate"] = config.end_date
|
|
84
|
+
if config.output_id_scheme is not None:
|
|
85
|
+
params["outputIdScheme"] = config.output_id_scheme
|
|
86
|
+
if config.page is not None:
|
|
87
|
+
params["page"] = config.page
|
|
88
|
+
if config.page_size is not None:
|
|
89
|
+
params["pageSize"] = config.page_size
|
|
90
|
+
return params
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Dhis2AnalyticsQueryOperator(Dhis2Operator[Dhis2AnalyticsQueryConfig, Dhis2AnalyticsQueryOutput]):
|
|
94
|
+
"""Runs one analytics query -- aggregate, event, or enrollment -- and hands the grid on."""
|
|
95
|
+
|
|
96
|
+
spec = OperatorSpec(
|
|
97
|
+
id="dhis2.analytics_query",
|
|
98
|
+
summary="Run a DHIS2 analytics query.",
|
|
99
|
+
idempotent=True,
|
|
100
|
+
)
|
|
101
|
+
config_model: ClassVar[type[BaseModel]] = Dhis2AnalyticsQueryConfig
|
|
102
|
+
output_model: ClassVar[type[BaseModel]] = Dhis2AnalyticsQueryOutput
|
|
103
|
+
|
|
104
|
+
async def execute(self, config: Dhis2AnalyticsQueryConfig, ctx: StepContext) -> Dhis2AnalyticsQueryOutput:
|
|
105
|
+
"""Read the query once through the accessor for aggregate, the request path otherwise."""
|
|
106
|
+
started = time.monotonic()
|
|
107
|
+
async with client_for(ctx, config.connection) as client:
|
|
108
|
+
if config.mode == "aggregate":
|
|
109
|
+
body = await self._aggregate(client.analytics, _params(config))
|
|
110
|
+
else:
|
|
111
|
+
body = await self._events(client.analytics, config)
|
|
112
|
+
duration = round((time.monotonic() - started) * 1000)
|
|
113
|
+
ctx.log.info("analytics query read", mode=config.mode, duration_ms=duration)
|
|
114
|
+
return Dhis2AnalyticsQueryOutput(json_body=body, duration_ms=duration)
|
|
115
|
+
|
|
116
|
+
async def _aggregate(self, analytics: AnalyticsAccessor, params: dict[str, Any]) -> JsonValue:
|
|
117
|
+
"""Read the aggregate grid through the client's analytics accessor."""
|
|
118
|
+
try:
|
|
119
|
+
grid = await analytics.aggregate(endpoint=AGGREGATE_ENDPOINT, extra_params=params)
|
|
120
|
+
except (Dhis2ApiError, AuthenticationError) as error:
|
|
121
|
+
raise refuse(error, f"GET {AGGREGATE_ENDPOINT}") from error
|
|
122
|
+
return grid.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
123
|
+
|
|
124
|
+
async def _events(self, analytics: AnalyticsAccessor, config: Dhis2AnalyticsQueryConfig) -> JsonValue:
|
|
125
|
+
"""Read an event or enrollment query grid through the analytics accessor."""
|
|
126
|
+
collection = "events" if config.mode == "event" else "enrollments"
|
|
127
|
+
if config.program is None:
|
|
128
|
+
raise BlockFailure(f"a {config.mode} analytics query needs a program", error_class=ErrorClass.REJECTED)
|
|
129
|
+
extra = {"outputIdScheme": config.output_id_scheme} if config.output_id_scheme is not None else None
|
|
130
|
+
query = analytics.event_query if config.mode == "event" else analytics.enrollment_query
|
|
131
|
+
try:
|
|
132
|
+
grid = await query(
|
|
133
|
+
config.program,
|
|
134
|
+
dimension=config.dimension or None,
|
|
135
|
+
filter=config.filter or None,
|
|
136
|
+
start_date=config.start_date,
|
|
137
|
+
end_date=config.end_date,
|
|
138
|
+
page=config.page,
|
|
139
|
+
page_size=config.page_size,
|
|
140
|
+
extra_params=extra,
|
|
141
|
+
)
|
|
142
|
+
except (Dhis2ApiError, AuthenticationError) as error:
|
|
143
|
+
raise refuse(error, f"GET /api/analytics/{collection}/query/{config.program}") from error
|
|
144
|
+
return grid.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""``dhis2.data_set_complete``: wait for a data set registration to be marked complete."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar, Final
|
|
4
|
+
|
|
5
|
+
from dhis2w_client import CompleteDataSetRegistration, CompleteDataSetRegistrations
|
|
6
|
+
from dhis2w_client.errors import AuthenticationError, Dhis2ApiError
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from dirigent_common import BlockModel
|
|
10
|
+
from dirigent_dhis2.connection import client_for
|
|
11
|
+
from dirigent_dhis2.web import Dhis2Sensor, refuse
|
|
12
|
+
from dirigent_plugin import NotYet, SensorSpec, StepContext
|
|
13
|
+
|
|
14
|
+
#: Where completion registrations are read.
|
|
15
|
+
REGISTRATIONS_PATH: Final = "/api/completeDataSetRegistrations"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Dhis2DataSetCompleteConfig(BlockModel):
|
|
19
|
+
"""Which data set window the run is waiting on."""
|
|
20
|
+
|
|
21
|
+
connection: str
|
|
22
|
+
"""The code of the dhis2 connection naming the instance."""
|
|
23
|
+
|
|
24
|
+
data_set: str
|
|
25
|
+
"""The uid of the data set."""
|
|
26
|
+
|
|
27
|
+
period: str
|
|
28
|
+
"""An ISO period identifier, such as 2026Q1."""
|
|
29
|
+
|
|
30
|
+
org_unit: str
|
|
31
|
+
"""The uid of the organisation unit."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Dhis2DataSetCompleteOutput(BlockModel):
|
|
35
|
+
"""The registration that ended the wait."""
|
|
36
|
+
|
|
37
|
+
completed_at: str | None = None
|
|
38
|
+
"""When the registration was made, in the instance's own timestamp."""
|
|
39
|
+
|
|
40
|
+
stored_by: str | None = None
|
|
41
|
+
"""Who marked the data set complete."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Dhis2DataSetCompleteSensor(Dhis2Sensor[Dhis2DataSetCompleteConfig, Dhis2DataSetCompleteOutput]):
|
|
45
|
+
"""Waits for the registration; each poke is one short, read-only GET."""
|
|
46
|
+
|
|
47
|
+
spec = SensorSpec(id="dhis2.data_set_complete", summary="Wait for a DHIS2 data set to be marked complete.")
|
|
48
|
+
config_model: ClassVar[type[BaseModel]] = Dhis2DataSetCompleteConfig
|
|
49
|
+
output_model: ClassVar[type[BaseModel]] = Dhis2DataSetCompleteOutput
|
|
50
|
+
|
|
51
|
+
async def poke(self, config: Dhis2DataSetCompleteConfig, ctx: StepContext) -> Dhis2DataSetCompleteOutput | NotYet:
|
|
52
|
+
"""Observe once. A window nobody has closed yet is the condition, not an error."""
|
|
53
|
+
async with client_for(ctx, config.connection) as client:
|
|
54
|
+
try:
|
|
55
|
+
registrations = await client.complete_data_set_registrations.export(
|
|
56
|
+
data_set=config.data_set,
|
|
57
|
+
period=config.period,
|
|
58
|
+
org_unit=config.org_unit,
|
|
59
|
+
)
|
|
60
|
+
except (Dhis2ApiError, AuthenticationError) as error:
|
|
61
|
+
raise refuse(error, f"GET {REGISTRATIONS_PATH}") from error
|
|
62
|
+
registration = _completed(registrations)
|
|
63
|
+
if registration is None:
|
|
64
|
+
return NotYet(
|
|
65
|
+
message=f"data set {config.data_set} for {config.period} at {config.org_unit} is not complete"
|
|
66
|
+
)
|
|
67
|
+
return Dhis2DataSetCompleteOutput(completed_at=registration.date, stored_by=registration.storedBy)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _completed(envelope: CompleteDataSetRegistrations) -> CompleteDataSetRegistration | None:
|
|
71
|
+
"""Find a completed registration in the endpoint's answer, or nothing while there is none.
|
|
72
|
+
|
|
73
|
+
A registration can be unmade, so one carrying ``completed: false`` is a window that was
|
|
74
|
+
reopened and the wait goes on. Older instances answer without the flag at all, and there
|
|
75
|
+
a registration's existence is the completion.
|
|
76
|
+
"""
|
|
77
|
+
for registration in envelope.completeDataSetRegistrations:
|
|
78
|
+
if registration.completed is True or registration.completed is None:
|
|
79
|
+
return registration
|
|
80
|
+
return None
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The ``dhis2`` connection kind: one DHIS2 instance, its credential, and the client that talks to it."""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from dhis2w_client import Dhis2Client, Profile, build_auth_provider
|
|
7
|
+
from pydantic import BaseModel, Field, SecretStr, model_validator
|
|
8
|
+
|
|
9
|
+
from dirigent_common import BlockModel, Duration, HealthReport
|
|
10
|
+
from dirigent_plugin import ConnectionKind, StepContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Dhis2ConnectionConfig(BlockModel):
|
|
14
|
+
"""Everything needed to talk to one DHIS2 instance, credentials included."""
|
|
15
|
+
|
|
16
|
+
base_url: str = Field(min_length=1)
|
|
17
|
+
"""The instance root every request path is resolved against, version pin included.
|
|
18
|
+
|
|
19
|
+
Name the host the instance actually answers on: a redirect to another origin arrives
|
|
20
|
+
without the credential, because the client drops the Authorization header when the
|
|
21
|
+
origin changes.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
api_token: SecretStr | None = None
|
|
25
|
+
"""A personal access token, sent as ``Authorization: ApiToken``."""
|
|
26
|
+
|
|
27
|
+
basic_username: str | None = None
|
|
28
|
+
"""The user half of HTTP basic authentication."""
|
|
29
|
+
|
|
30
|
+
basic_password: SecretStr | None = None
|
|
31
|
+
"""The secret half of HTTP basic authentication."""
|
|
32
|
+
|
|
33
|
+
verify_tls: bool = True
|
|
34
|
+
"""Whether certificates are verified; turning this off is a per-connection decision."""
|
|
35
|
+
|
|
36
|
+
timeout: Duration = Field(default=timedelta(seconds=30), gt=timedelta(0))
|
|
37
|
+
"""The timeout applied to every request through this connection."""
|
|
38
|
+
|
|
39
|
+
@model_validator(mode="after")
|
|
40
|
+
def _require_one_credential(self) -> "Dhis2ConnectionConfig":
|
|
41
|
+
"""Reject a config carrying both credential kinds, neither, or half of the basic pair."""
|
|
42
|
+
if self.api_token is not None and self.basic_username:
|
|
43
|
+
raise ValueError("a dhis2 connection takes an api_token or basic credentials, not both")
|
|
44
|
+
if self.api_token is None and not self.basic_username:
|
|
45
|
+
raise ValueError("a dhis2 connection needs an api_token or basic credentials")
|
|
46
|
+
if self.basic_username and self.basic_password is None:
|
|
47
|
+
raise ValueError("basic credentials need a basic_password beside the basic_username")
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _profile(config: Dhis2ConnectionConfig) -> Profile:
|
|
52
|
+
"""Map the connection's credential onto the profile dhis2w-client authenticates from."""
|
|
53
|
+
if config.api_token is not None:
|
|
54
|
+
return Profile(base_url=config.base_url, auth="pat", token=config.api_token.get_secret_value())
|
|
55
|
+
password = config.basic_password.get_secret_value() if config.basic_password else ""
|
|
56
|
+
return Profile(base_url=config.base_url, auth="basic", username=config.basic_username, password=password)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_client(config: Dhis2ConnectionConfig) -> Dhis2Client:
|
|
60
|
+
"""Build a dhis2w-client bound to the connection's URL, credential, TLS setting, and timeout.
|
|
61
|
+
|
|
62
|
+
The client is returned unconnected: entering it as an async context manager opens the pool
|
|
63
|
+
and detects the instance's version from ``/api/system/info`` the dhis2w way.
|
|
64
|
+
"""
|
|
65
|
+
return Dhis2Client(
|
|
66
|
+
config.base_url,
|
|
67
|
+
auth=build_auth_provider(_profile(config)),
|
|
68
|
+
timeout=config.timeout.total_seconds(),
|
|
69
|
+
verify=config.verify_tls,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def client_for(ctx: StepContext, ref: str) -> Dhis2Client:
|
|
74
|
+
"""Build the dhis2w-client one block call uses, from the named connection's own config."""
|
|
75
|
+
return build_client(ctx.connection(ref, Dhis2ConnectionConfig))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def settings_of(config: BaseModel) -> Dhis2ConnectionConfig:
|
|
79
|
+
"""Read a connection's config as this kind's own model, whichever model the caller held."""
|
|
80
|
+
if isinstance(config, Dhis2ConnectionConfig):
|
|
81
|
+
return config
|
|
82
|
+
return Dhis2ConnectionConfig.model_validate(config.model_dump())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Dhis2ConnectionKind(ConnectionKind):
|
|
86
|
+
"""The connection kind every block in this pack resolves its instance and credential through."""
|
|
87
|
+
|
|
88
|
+
id: ClassVar[str] = "dhis2"
|
|
89
|
+
config_model: ClassVar[type[BaseModel]] = Dhis2ConnectionConfig
|
|
90
|
+
|
|
91
|
+
async def check(self, config: BaseModel) -> HealthReport:
|
|
92
|
+
"""Ask the instance what it is, proving the credential works, never raising.
|
|
93
|
+
|
|
94
|
+
Connecting reads ``/api/system/info`` with the credential attached, so a healthy report
|
|
95
|
+
means the credential is good and the version is the instance's own word for itself.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
async with build_client(settings_of(config)) as client:
|
|
99
|
+
version = client.raw_version
|
|
100
|
+
except Exception as error: # noqa: BLE001 - a health check reports its verdict, it never raises
|
|
101
|
+
return HealthReport(healthy=False, detail=f"{type(error).__name__}: {error}")
|
|
102
|
+
return HealthReport(healthy=True, version=version or None)
|