dirigent-integration 0.23.2__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.
Files changed (33) hide show
  1. dirigent_integration/__init__.py +29 -0
  2. dirigent_integration/py.typed +0 -0
  3. dirigent_integration/shelves/dhis2-analytics-to-csv-report.yaml +174 -0
  4. dirigent_integration/shelves/dhis2-export-to-s3.yaml +66 -0
  5. dirigent_integration/shelves/dhis2-metadata-snapshot.yaml +143 -0
  6. dirigent_integration/shelves/dhis2-tracker-weekly-window.yaml +162 -0
  7. dirigent_integration/shelves/dhis2-values-per-org-unit-to-parquet.yaml +186 -0
  8. dirigent_integration/shelves/dhis2-values-to-parquet.yaml +64 -0
  9. dirigent_integration/shelves/fhir/README.md +79 -0
  10. dirigent_integration/shelves/fhir/dhis2-to-fhir-observations.yaml +212 -0
  11. dirigent_integration/shelves/fhir/fhir-capture-bundle-to-data-values.yaml +230 -0
  12. dirigent_integration/shelves/fhir/fhir-conceptmap-driven-mapping.yaml +222 -0
  13. dirigent_integration/shelves/fhir/fhir-encounter-to-event.yaml +248 -0
  14. dirigent_integration/shelves/fhir/fhir-measure-report-to-analytics-check.yaml +205 -0
  15. dirigent_integration/shelves/fhir/fhir-nightly-window-sync.yaml +182 -0
  16. dirigent_integration/shelves/fhir/fhir-patient-to-tracked-entity.yaml +225 -0
  17. dirigent_integration/shelves/fhir/fhir-questionnaire-response-to-data-values.yaml +187 -0
  18. dirigent_integration/shelves/fhir/fhir-subscription-webhook-to-dhis2.yaml +218 -0
  19. dirigent_integration/shelves/inbound/README.md +32 -0
  20. dirigent_integration/shelves/inbound/csv-drop-to-data-values.yaml +176 -0
  21. dirigent_integration/shelves/inbound/fan-out-per-facility-import.yaml +202 -0
  22. dirigent_integration/shelves/inbound/fhir-observations-to-data-values.yaml +193 -0
  23. dirigent_integration/shelves/inbound/http-json-to-data-values.yaml +151 -0
  24. dirigent_integration/shelves/inbound/outside-to-dhis2-with-checks.yaml +246 -0
  25. dirigent_integration/shelves/inbound/parquet-lakehouse-to-dhis2.yaml +158 -0
  26. dirigent_integration/shelves/inbound/webhook-payload-to-tracker-event.yaml +176 -0
  27. dirigent_integration/shelves/inbound/weekly-window-pull-and-import.yaml +175 -0
  28. dirigent_integration/shelves/parquet-to-dhis2-import.yaml +195 -0
  29. dirigent_integration-0.23.2.dist-info/METADATA +231 -0
  30. dirigent_integration-0.23.2.dist-info/RECORD +33 -0
  31. dirigent_integration-0.23.2.dist-info/WHEEL +4 -0
  32. dirigent_integration-0.23.2.dist-info/entry_points.txt +3 -0
  33. dirigent_integration-0.23.2.dist-info/licenses/LICENSE +15 -0
@@ -0,0 +1,151 @@
1
+ # The shortest honest inbound path: a public JSON API, one reshape, one gated import.
2
+ #
3
+ # The hops, and the shape at each one:
4
+ #
5
+ # 1. pull -> the World Bank indicator API answers [metadata, [observations]]:
6
+ # a two-element array whose second element is the rows, each row
7
+ # {countryiso3code, date: "2023", value: 8791092, ...}
8
+ # 2. reshape -> {dataValues: [{dataElement, period, orgUnit, value}]}, which is the
9
+ # envelope /api/dataValueSets takes. Every value is a STRING there,
10
+ # even a count, which is why the program says tostring
11
+ # 3. gate -> the same envelope, refused unless every id is a real DHIS2 UID and
12
+ # every period a real DHIS2 period
13
+ # 4. import -> the instance's own summary: status and the four counts
14
+ #
15
+ # What makes it yours: point source_url at your API, and set data_element and org_unit to
16
+ # uids from your instance. The mapping in the reshape is the only part that knows both
17
+ # worlds, and it is six lines -- which is the whole argument for keeping the pull and the
18
+ # import as blocks rather than as code.
19
+ #
20
+ # The pairing is a real one, which is why the defaults are these: the World Bank's
21
+ # population figure is yearly, and the demo's Total Population element sits in a yearly
22
+ # data set assigned to Ngelehun CHC. A yearly figure sent at a monthly element is refused
23
+ # by the instance, and no schema catches that -- only the instance holds the period type of
24
+ # a data set, which is exactly what the dry run below is for.
25
+ #
26
+ # dry_run defaults to true, so a first run asks the instance to validate the document and
27
+ # write nothing. The play server is shared, so leave it true there.
28
+ #
29
+ # dg run --local examples/inbound/http-json-to-data-values.yaml
30
+ # dg run --local examples/inbound/http-json-to-data-values.yaml -p country=NGA
31
+
32
+ format: dirigent/v1
33
+ kind: pipeline
34
+ code: http-json-to-data-values
35
+ name: A public JSON API into DHIS2
36
+ description: |
37
+ Pull a public JSON feed, reshape it into a DHIS2 data value set, gate it on DHIS2's own
38
+ id and period formats, and hand it to `dhis2.data_value_set_import` as a dry run.
39
+
40
+ tags: [inbound, dhis2, http, transform, validate, cross-boundary]
41
+
42
+ requires:
43
+ blocks:
44
+ - http.request
45
+ - transform.jq
46
+ - validate.schema
47
+ - dhis2.data_value_set_import
48
+
49
+ connections:
50
+ dhis2-demo:
51
+ kind: dhis2
52
+ config:
53
+ base_url: https://play.im.dhis2.org/stable-2-43-1
54
+ basic_username: admin
55
+ basic_password: district
56
+ timeout: 60s
57
+
58
+ params:
59
+ type: object
60
+ properties:
61
+ source_url:
62
+ type: string
63
+ description: The outside feed. Anything answering JSON works; the reshape is what changes.
64
+ default: https://api.worldbank.org/v2/country/SLE/indicator/SP.POP.TOTL
65
+ country:
66
+ type: string
67
+ description: The ISO3 code spliced into source_url when it is left at its default.
68
+ default: SLE
69
+ data_element:
70
+ type: string
71
+ description: The uid every pulled observation lands on; Total Population, yearly.
72
+ default: WUg3MYWQ7pt
73
+ org_unit:
74
+ type: string
75
+ description: The facility the figure is reported for, and one the data set is assigned to.
76
+ default: DiszpKrYNg8
77
+ dry_run:
78
+ type: boolean
79
+ description: Whether the instance validates the import and writes nothing.
80
+ default: true
81
+
82
+ # Carried with the document, so a --local run needs nothing applied first. dhis2-uid and
83
+ # dhis2-period are format checkers the dhis2 pack contributes: they turn "a string" into
84
+ # "a string DHIS2 will actually accept", which is the difference between failing here and
85
+ # failing halfway through an import.
86
+ schemas:
87
+ data-value-set:
88
+ type: object
89
+ required: [dataValues]
90
+ properties:
91
+ dataValues:
92
+ type: array
93
+ minItems: 1
94
+ items:
95
+ type: object
96
+ required: [dataElement, period, orgUnit, value]
97
+ properties:
98
+ dataElement: { type: string, format: dhis2-uid }
99
+ period: { type: string, format: dhis2-period }
100
+ orgUnit: { type: string, format: dhis2-uid }
101
+ value: { type: string }
102
+
103
+ steps:
104
+ # No connection, because the outside system is not a credential this instance holds: an
105
+ # absolute url is the honest form for a public endpoint. A feed behind a key becomes an
106
+ # http connection instead, and only this step changes.
107
+ pull:
108
+ block: http.request
109
+ config:
110
+ url: "${params.source_url}"
111
+ method: GET
112
+ query:
113
+ format: json
114
+ per_page: 5
115
+
116
+ # Both worlds meet in one program, so the mapping is readable in one place. The select
117
+ # drops years the API has no figure for: an absent observation must not become a zero,
118
+ # because zero is a measurement and absence is not.
119
+ reshape:
120
+ block: transform.jq
121
+ depends_on: [pull]
122
+ config:
123
+ input:
124
+ observations: "${steps.pull.output.body}"
125
+ data_element: "${params.data_element}"
126
+ org_unit: "${params.org_unit}"
127
+ program: |
128
+ .data_element as $de
129
+ | .org_unit as $ou
130
+ | {dataValues: [
131
+ .observations[1][]
132
+ | select(.value != null)
133
+ | {dataElement: $de, period: .date, orgUnit: $ou, value: (.value | tostring)}
134
+ ]}
135
+
136
+ # The gate is also the waypoint: everything downstream reads the gate's output rather
137
+ # than the reshape's, so the shape every later step received is the one that was checked.
138
+ gate:
139
+ block: validate.schema
140
+ depends_on: [reshape]
141
+ config:
142
+ input: "${steps.reshape.output.value}"
143
+ schema: data-value-set
144
+
145
+ import:
146
+ block: dhis2.data_value_set_import
147
+ depends_on: [gate]
148
+ config:
149
+ connection: dhis2-demo
150
+ data_values: "${steps.gate.output.value}"
151
+ dry_run: "${params.dry_run}"
@@ -0,0 +1,246 @@
1
+ # The defensive version: every guard an inbound pipeline earns, and what each one is for.
2
+ #
3
+ # The other examples on this shelf show one mechanism each. This one is what a pipeline
4
+ # looks like once it has been in production for a month and every guard has been paid for
5
+ # by an incident.
6
+ #
7
+ # The guards, in the order the run meets them, and the failure each one buys off:
8
+ #
9
+ # 1. reachable -- an http.ready sensor before the pull. A source that is restarting
10
+ # answers 502 for ninety seconds, and a pull that runs into it is a
11
+ # failed run and a page at 03:00. A sensor waits instead: "not yet" is
12
+ # the expected answer, and each poke is one cheap read. on_timeout: skip
13
+ # ends the run as skipped rather than failed, because a source that was
14
+ # down all night is not this pipeline being broken.
15
+ # 2. raw_gate -- a schema on what arrived, BEFORE anything reads it. This is the guard
16
+ # people leave out, and it is the one that catches the real disaster: a
17
+ # source that has quietly started answering an error page, an empty
18
+ # envelope, or a renamed field. Without it, the reshape below produces a
19
+ # beautifully-formed data value set full of nulls.
20
+ # 3. value_gate -- a schema on what was produced, AFTER the reshape. The raw gate checked
21
+ # the sender; this one checks us. A jq program is code, and code with no
22
+ # test between it and a live instance is code nobody has checked.
23
+ # 4. rehearse -- the import with dry_run: true. DHIS2 validates the whole document,
24
+ # against its own metadata, and reports what it would have refused --
25
+ # which no schema can know, because only the instance holds the list of
26
+ # uids that exist and the periods that are still open.
27
+ # 5. commit -- the same import for real, behind the default all_success edge. It runs
28
+ # only because the rehearsal came back clean; a rehearsal that failed
29
+ # leaves this step skipped, and nothing was written.
30
+ # 6. signed_off -- the completeness sensor, which observes and does not register.
31
+ #
32
+ # THE TWO SCHEMAS ARE NOT THE SAME SHAPE and neither is redundant. One describes the
33
+ # outside system's vocabulary, the other describes DHIS2's, and the reshape between them is
34
+ # the only thing that knows both. When a run fails, which gate it failed at says whose
35
+ # problem it is: the raw gate is the sender's, the value gate is ours, the rehearsal is the
36
+ # instance's metadata.
37
+ #
38
+ # The commit step is a rehearsal too until you say otherwise: commit_dry_run defaults to
39
+ # true so this document is safe against the shared play server, and the second import
40
+ # becomes real by setting it false against an instance of your own.
41
+ #
42
+ # dg run --local examples/inbound/outside-to-dhis2-with-checks.yaml
43
+ # dg run --local examples/inbound/outside-to-dhis2-with-checks.yaml -p commit_dry_run=false
44
+
45
+ format: dirigent/v1
46
+ kind: pipeline
47
+ code: outside-to-dhis2-with-checks
48
+ name: An inbound pipeline with every guard
49
+ description: |
50
+ A readiness sensor, a schema on what arrived, a schema on what was built, a rehearsed
51
+ import, the real import behind it, and the completeness sensor -- each guard buying off a
52
+ different failure.
53
+
54
+ tags: [inbound, dhis2, http, sensor, transform, validate, cross-boundary]
55
+
56
+ requires:
57
+ blocks:
58
+ - http.ready
59
+ - http.request
60
+ - transform.jq
61
+ - validate.schema
62
+ - dhis2.data_value_set_import
63
+ - dhis2.data_set_complete
64
+
65
+ connections:
66
+ dhis2-demo:
67
+ kind: dhis2
68
+ config:
69
+ base_url: https://play.im.dhis2.org/stable-2-43-1
70
+ basic_username: admin
71
+ basic_password: district
72
+ timeout: 60s
73
+ source:
74
+ kind: http
75
+ config:
76
+ base_url: https://api.worldbank.org
77
+ timeout: 30s
78
+
79
+ params:
80
+ type: object
81
+ properties:
82
+ indicator_path:
83
+ type: string
84
+ description: The outside feed's path on the source connection.
85
+ default: /v2/country/SLE/indicator/SP.POP.TOTL
86
+ data_element:
87
+ type: string
88
+ description: The uid every pulled observation lands on; Total Population, yearly.
89
+ default: WUg3MYWQ7pt
90
+ org_unit:
91
+ type: string
92
+ description: The facility the figure is reported for, and one the data set is assigned to.
93
+ default: DiszpKrYNg8
94
+ data_set:
95
+ type: string
96
+ description: The data set whose sign-off closes the reporting year.
97
+ default: aLpVgfXiz0f
98
+ commit_dry_run:
99
+ type: boolean
100
+ description: Whether the second import is still a rehearsal. False makes it real.
101
+ default: true
102
+
103
+ schemas:
104
+ # The sender's shape, in the sender's vocabulary. It asserts the two facts the reshape
105
+ # depends on and nothing more: there is at least one observation, and each one carries a
106
+ # date and either a number or an explicit null. A schema that also pinned the fields the
107
+ # reshape ignores would fail every time the source added a column.
108
+ source-observations:
109
+ type: array
110
+ minItems: 1
111
+ items:
112
+ type: object
113
+ required: [date]
114
+ properties:
115
+ date: { type: string }
116
+ value: { type: [number, "null"] }
117
+
118
+ # DHIS2's shape, in DHIS2's vocabulary, with the pack's own format checkers doing the
119
+ # work: dhis2-uid and dhis2-period turn "a string" into "a string this instance will
120
+ # accept", which is what makes the gate worth having over a required-fields check.
121
+ data-value-set:
122
+ type: object
123
+ required: [dataValues]
124
+ properties:
125
+ dataValues:
126
+ type: array
127
+ minItems: 1
128
+ items:
129
+ type: object
130
+ required: [dataElement, period, orgUnit, value]
131
+ properties:
132
+ dataElement: { type: string, format: dhis2-uid }
133
+ period: { type: string, format: dhis2-period }
134
+ orgUnit: { type: string, format: dhis2-uid }
135
+ value: { type: string }
136
+
137
+ steps:
138
+ # A readiness probe is not the pull: it asks the cheapest question the source answers,
139
+ # and expect_status left empty means any 2xx counts.
140
+ reachable:
141
+ block: http.ready
142
+ poll: 30s
143
+ deadline: 30m
144
+ on_timeout: skip
145
+ config:
146
+ connection: source
147
+ # A sensor has no query block: readiness is one fixed question, so what varies about
148
+ # it belongs in the path.
149
+ path: /v2/country/SLE?format=json
150
+ # A source that answers 200 with a maintenance page is caught here rather than three
151
+ # steps later, because ready means "answering correctly", not "answering".
152
+ contains: Sierra Leone
153
+
154
+ pull:
155
+ block: http.request
156
+ depends_on: [reachable]
157
+ config:
158
+ connection: source
159
+ path: "${params.indicator_path}"
160
+ method: GET
161
+ query:
162
+ format: json
163
+ per_page: 5
164
+ # A source having a bad day can answer megabytes of HTML. The cap fails the step
165
+ # honestly instead of holding it all in the worker.
166
+ max_response: 4mb
167
+
168
+ # The envelope is [metadata, observations], so the gate is pointed at the half that
169
+ # matters: an index into an existing structure is unambiguous, and checking the rows says
170
+ # more than checking that an array has two elements.
171
+ raw_gate:
172
+ block: validate.schema
173
+ depends_on: [pull]
174
+ config:
175
+ input: "${steps.pull.output.body.1}"
176
+ schema: source-observations
177
+
178
+ # Reads the GATE's output, not the pull's. Both hold the same bytes, and only one of them
179
+ # has been checked -- so every step past a gate provably received the shape it asserts.
180
+ reshape:
181
+ block: transform.jq
182
+ depends_on: [raw_gate]
183
+ config:
184
+ input:
185
+ observations: "${steps.raw_gate.output.value}"
186
+ data_element: "${params.data_element}"
187
+ org_unit: "${params.org_unit}"
188
+ program: |
189
+ .data_element as $de
190
+ | .org_unit as $ou
191
+ | {dataValues: [
192
+ .observations[]
193
+ | select(.value != null)
194
+ | {dataElement: $de, period: .date, orgUnit: $ou, value: (.value | tostring)}
195
+ ]}
196
+
197
+ value_gate:
198
+ block: validate.schema
199
+ depends_on: [reshape]
200
+ config:
201
+ input: "${steps.reshape.output.value}"
202
+ schema: data-value-set
203
+
204
+ # The instance's own verdict, at no cost: it parses the document, checks every uid and
205
+ # period against its metadata, and writes nothing. Its summary comes back parsed, so a
206
+ # conflict is a named object and value rather than a wall of JSON.
207
+ rehearse:
208
+ block: dhis2.data_value_set_import
209
+ depends_on: [value_gate]
210
+ config:
211
+ connection: dhis2-demo
212
+ data_values: "${steps.value_gate.output.value}"
213
+ dry_run: true
214
+ # ALL is deliberate here and only here: a rehearsal that quietly tolerates bad rows
215
+ # tells you nothing, so the rehearsal is asked to refuse on any conflict at all.
216
+ atomic_mode: ALL
217
+
218
+ # No rule: is written, because the default all_success is exactly the guard wanted -- a
219
+ # rehearsal that failed leaves this step skipped and the instance untouched. Writing
220
+ # rule: all_done here would undo every guard above it in one line.
221
+ commit:
222
+ block: dhis2.data_value_set_import
223
+ depends_on: [rehearse]
224
+ config:
225
+ connection: dhis2-demo
226
+ # The same document the rehearsal was given, from the same waypoint. Rebuilding it
227
+ # here would mean the thing that was validated and the thing that is written are two
228
+ # different objects that merely ought to agree.
229
+ data_values: "${steps.value_gate.output.value}"
230
+ dry_run: "${params.commit_dry_run}"
231
+ import_strategy: CREATE_AND_UPDATE
232
+
233
+ signed_off:
234
+ block: dhis2.data_set_complete
235
+ depends_on: [commit]
236
+ poll: 30m
237
+ deadline: 72h
238
+ on_timeout: skip
239
+ config:
240
+ connection: dhis2-demo
241
+ data_set: "${params.data_set}"
242
+ # The most recent period the pull covered -- the source lists newest first -- read
243
+ # back off the checked document rather than named a second time: a period written
244
+ # twice is a period that will disagree once.
245
+ period: "${steps.value_gate.output.value.dataValues.0.period}"
246
+ org_unit: "${params.org_unit}"
@@ -0,0 +1,158 @@
1
+ # A lakehouse table aggregated down to what DHIS2 stores, then imported.
2
+ #
3
+ # The lakehouse and DHIS2 disagree about grain. A warehouse keeps one row per encounter,
4
+ # typed and columnar, because analysis wants the detail; DHIS2's aggregate side keeps one
5
+ # figure per data element, period and org unit, because that is what a report is. The whole
6
+ # integration is that reduction, and it is one jq program.
7
+ #
8
+ # The hops, and the shape at each one:
9
+ #
10
+ # 1. decode -> the parquet file re-encoded as a json object in the run's scratch space:
11
+ # rows of {org_unit, period, element, count}, one per encounter, thousands
12
+ # of them
13
+ # 2. rows -> that object read into the run as a value, bounded by max_size
14
+ # 3. aggregate -> {dataValues: [...]}, one value per (element, period, org unit) triple,
15
+ # counts summed -- tens of rows, not thousands
16
+ # 4. import -> the instance's summary
17
+ # 5. signed_off -> a sensor, and the only step here that does not act: it waits for a
18
+ # person to mark the data set complete for that month
19
+ #
20
+ # Parquet is bytes and never travels as a value, so both ends of convert.arrow are URIs and
21
+ # storage.read is what brings the decoded json in. THE REDUCTION HAPPENS AS SOON AS THE ROWS
22
+ # ARE IN THE RUN, which is what keeps the run's memory bounded by the number of distinct
23
+ # triples rather than by the number of encounters. A table too large to hold is aggregated in
24
+ # the warehouse and exported already reduced; this pipeline is unchanged by that.
25
+ #
26
+ # WHY THE LAST STEP IS A SENSOR. `dhis2.data_set_complete` observes a completeness
27
+ # registration; it does not make one. Reading it here closes the loop honestly: the import
28
+ # put the figures in, and the run then holds until somebody with the authority to say so
29
+ # has signed the month off, or skips when nobody does inside the deadline. A pipeline that
30
+ # registered completeness itself would be asserting, on a machine's authority, that a
31
+ # month's reporting is finished.
32
+ #
33
+ # What makes it yours: the warehouse export's uri and its column names, and the data set
34
+ # the month is signed off against. The s3:// scheme needs an s3 connection bound to it as
35
+ # an instance setting (storage_connections: {s3: <code>}).
36
+ #
37
+ # dg run --local examples/inbound/parquet-lakehouse-to-dhis2.yaml -p period=202606
38
+
39
+ format: dirigent/v1
40
+ kind: pipeline
41
+ code: parquet-lakehouse-to-dhis2
42
+ name: A lakehouse table into DHIS2
43
+ description: |
44
+ Read a parquet export of encounter-grain rows, aggregate it to DHIS2's grain, import it,
45
+ and wait for the month to be signed off.
46
+
47
+ tags: [inbound, dhis2, parquet, storage, transform, cross-boundary]
48
+
49
+ requires:
50
+ blocks:
51
+ - convert.arrow
52
+ - storage.read
53
+ - transform.jq
54
+ - dhis2.data_value_set_import
55
+ - dhis2.data_set_complete
56
+ storage:
57
+ - s3
58
+
59
+ connections:
60
+ dhis2-demo:
61
+ kind: dhis2
62
+ config:
63
+ base_url: https://play.im.dhis2.org/stable-2-43-1
64
+ basic_username: admin
65
+ basic_password: district
66
+ timeout: 60s
67
+
68
+ params:
69
+ type: object
70
+ properties:
71
+ bucket:
72
+ type: string
73
+ description: The bucket the warehouse exports into.
74
+ default: dirigent-lakehouse
75
+ period:
76
+ type: string
77
+ description: The DHIS2 month this export covers, and the object it is named as.
78
+ default: "202606"
79
+ data_set:
80
+ type: string
81
+ description: The data set whose sign-off closes the month.
82
+ default: Rl58JxmKJo2
83
+ org_unit:
84
+ type: string
85
+ description: The org unit the sign-off is read for; the values carry their own.
86
+ default: DiszpKrYNg8
87
+ dry_run:
88
+ type: boolean
89
+ description: Whether the instance validates the import and writes nothing.
90
+ default: true
91
+
92
+ steps:
93
+ decode:
94
+ block: convert.arrow
95
+ config:
96
+ source: s3://${params.bucket}/encounters/${params.period}.parquet
97
+ from: parquet
98
+ to: json
99
+ # The run's own scratch space: the warehouse's object is left as it is, and these bytes
100
+ # exist only so the read below has json to decode.
101
+ target: ${run.scratch}/encounters-${params.period}.json
102
+
103
+ rows:
104
+ block: storage.read
105
+ depends_on: [decode]
106
+ config:
107
+ source: ${steps.decode.output.target}
108
+ # Parquet is compact and JSON is not, so the decoded object is several times the file.
109
+ # This is the number to raise when a month stops fitting, and the signal to aggregate
110
+ # upstream instead when raising it stops being reasonable: an object past it is refused
111
+ # rather than truncated into a wrong one.
112
+ max_size: 64mb
113
+
114
+ # The reduction. group_by needs its key sorted together, so the triple is built first and
115
+ # grouped on as a whole; the sum is over what each group holds. A count column that
116
+ # arrived as a string from the warehouse would sum as concatenation, hence tonumber.
117
+ aggregate:
118
+ block: transform.jq
119
+ depends_on: [rows]
120
+ config:
121
+ input: "${steps.rows.output.value}"
122
+ program: |
123
+ [.[] | {key: [.element, .period, .org_unit], count: (.count | tonumber)}]
124
+ | group_by(.key)
125
+ | {dataValues: [
126
+ .[]
127
+ | {dataElement: .[0].key[0],
128
+ period: .[0].key[1],
129
+ orgUnit: .[0].key[2],
130
+ value: ([.[].count] | add | tostring)}
131
+ ]}
132
+
133
+ import:
134
+ block: dhis2.data_value_set_import
135
+ depends_on: [aggregate]
136
+ config:
137
+ connection: dhis2-demo
138
+ data_values: "${steps.aggregate.output.value}"
139
+ dry_run: "${params.dry_run}"
140
+ # The warehouse is the source of truth for these figures, so a re-run of the same
141
+ # month must overwrite rather than refuse: an export corrected upstream is meant to
142
+ # land, and CREATE alone would leave the old figure standing.
143
+ import_strategy: CREATE_AND_UPDATE
144
+
145
+ # Each poke is one short read. on_timeout: skip is what makes the wait a report rather
146
+ # than a failure -- a month nobody has signed off by the deadline is a fact about the
147
+ # month, not a broken pipeline.
148
+ signed_off:
149
+ block: dhis2.data_set_complete
150
+ depends_on: [import]
151
+ poll: 15m
152
+ deadline: 72h
153
+ on_timeout: skip
154
+ config:
155
+ connection: dhis2-demo
156
+ data_set: "${params.data_set}"
157
+ period: "${params.period}"
158
+ org_unit: "${params.org_unit}"