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.
- dirigent_integration/__init__.py +29 -0
- dirigent_integration/py.typed +0 -0
- dirigent_integration/shelves/dhis2-analytics-to-csv-report.yaml +174 -0
- dirigent_integration/shelves/dhis2-export-to-s3.yaml +66 -0
- dirigent_integration/shelves/dhis2-metadata-snapshot.yaml +143 -0
- dirigent_integration/shelves/dhis2-tracker-weekly-window.yaml +162 -0
- dirigent_integration/shelves/dhis2-values-per-org-unit-to-parquet.yaml +186 -0
- dirigent_integration/shelves/dhis2-values-to-parquet.yaml +64 -0
- dirigent_integration/shelves/fhir/README.md +79 -0
- dirigent_integration/shelves/fhir/dhis2-to-fhir-observations.yaml +212 -0
- dirigent_integration/shelves/fhir/fhir-capture-bundle-to-data-values.yaml +230 -0
- dirigent_integration/shelves/fhir/fhir-conceptmap-driven-mapping.yaml +222 -0
- dirigent_integration/shelves/fhir/fhir-encounter-to-event.yaml +248 -0
- dirigent_integration/shelves/fhir/fhir-measure-report-to-analytics-check.yaml +205 -0
- dirigent_integration/shelves/fhir/fhir-nightly-window-sync.yaml +182 -0
- dirigent_integration/shelves/fhir/fhir-patient-to-tracked-entity.yaml +225 -0
- dirigent_integration/shelves/fhir/fhir-questionnaire-response-to-data-values.yaml +187 -0
- dirigent_integration/shelves/fhir/fhir-subscription-webhook-to-dhis2.yaml +218 -0
- dirigent_integration/shelves/inbound/README.md +32 -0
- dirigent_integration/shelves/inbound/csv-drop-to-data-values.yaml +176 -0
- dirigent_integration/shelves/inbound/fan-out-per-facility-import.yaml +202 -0
- dirigent_integration/shelves/inbound/fhir-observations-to-data-values.yaml +193 -0
- dirigent_integration/shelves/inbound/http-json-to-data-values.yaml +151 -0
- dirigent_integration/shelves/inbound/outside-to-dhis2-with-checks.yaml +246 -0
- dirigent_integration/shelves/inbound/parquet-lakehouse-to-dhis2.yaml +158 -0
- dirigent_integration/shelves/inbound/webhook-payload-to-tracker-event.yaml +176 -0
- dirigent_integration/shelves/inbound/weekly-window-pull-and-import.yaml +175 -0
- dirigent_integration/shelves/parquet-to-dhis2-import.yaml +195 -0
- dirigent_integration-0.23.2.dist-info/METADATA +231 -0
- dirigent_integration-0.23.2.dist-info/RECORD +33 -0
- dirigent_integration-0.23.2.dist-info/WHEEL +4 -0
- dirigent_integration-0.23.2.dist-info/entry_points.txt +3 -0
- 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}"
|