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,176 @@
|
|
|
1
|
+
# An event posted by an outside system, landing in DHIS2 the moment it is captured.
|
|
2
|
+
#
|
|
3
|
+
# The other examples on this shelf poll or wait. This one is pushed: a mobile capture app,
|
|
4
|
+
# an EMR, a lab middleware POSTs one encounter to this pipeline's webhook and the run starts
|
|
5
|
+
# with that encounter already in its parameters. Nothing is scheduled and nothing is
|
|
6
|
+
# scanned.
|
|
7
|
+
#
|
|
8
|
+
# WHERE THIS LANDS, AND WHY IT IS NOT A TRACKER EVENT. An event-shaped capture is what
|
|
9
|
+
# arrives, but `dhis2.tracker` in this pack READS `/api/tracker` -- tracked entities,
|
|
10
|
+
# enrollments, events -- and has no write side, so there is nothing to hand an event to
|
|
11
|
+
# today. The encounter is therefore summed into the aggregate figures it contributes to and
|
|
12
|
+
# imported as a data value set, which is what an aggregate DHIS2 wants from an event stream
|
|
13
|
+
# anyway. When a tracker write block lands, only the reshape and the import change; the
|
|
14
|
+
# intake, the mapping table and the guard do not.
|
|
15
|
+
#
|
|
16
|
+
# The hops, and the shape at each one:
|
|
17
|
+
#
|
|
18
|
+
# 1. the POST -> {encounter: {facility, occurred_at, items: [{code, quantity}]}}
|
|
19
|
+
# 2. the mapping -> three params, each one path into that body and nothing else
|
|
20
|
+
# 3. reshape -> {dataValues: [{dataElement, period, orgUnit, value}]}, one value per
|
|
21
|
+
# distinct item code, quantities summed
|
|
22
|
+
# 4. gate -> the same, refused unless the ids and the period are DHIS2's own
|
|
23
|
+
# 5. import -> the instance's summary
|
|
24
|
+
#
|
|
25
|
+
# THE MAPPING IS THE WHOLE SECURITY BOUNDARY. A caller reaches exactly the three parameters
|
|
26
|
+
# named under params_from_payload, and each mapped value is validated against the params
|
|
27
|
+
# schema like any other run's. A payload carrying a connection code, a dry_run: false, or a
|
|
28
|
+
# different org unit reaches none of them, because none of them is mapped.
|
|
29
|
+
#
|
|
30
|
+
# The period is derived rather than accepted. A capture knows when it happened; it does not
|
|
31
|
+
# get to say which DHIS2 month it belongs to, because that is the receiving instance's
|
|
32
|
+
# vocabulary and a caller that gets it wrong would silently write into the wrong month.
|
|
33
|
+
#
|
|
34
|
+
# Locally, the payload is supplied as parameters, which is exactly what a delivery does:
|
|
35
|
+
#
|
|
36
|
+
# dg run --local examples/inbound/webhook-payload-to-tracker-event.yaml \
|
|
37
|
+
# -p facility=DiszpKrYNg8 -p occurred_at=2026-06-14T09:20:00Z
|
|
38
|
+
#
|
|
39
|
+
# Against an instance, where the endpoint exists:
|
|
40
|
+
#
|
|
41
|
+
# dg apply examples/inbound/webhook-payload-to-tracker-event.yaml
|
|
42
|
+
# dg webhook rotate-token webhook-payload-to-tracker-event capture-posted
|
|
43
|
+
# curl -X POST "$DG_URL/hooks/$TOKEN" -d '{"encounter": {
|
|
44
|
+
# "facility": "DiszpKrYNg8", "occurred_at": "2026-06-14T09:20:00Z",
|
|
45
|
+
# "items": [{"code": "EXPOSED_REGISTERED", "quantity": 2}]}}'
|
|
46
|
+
|
|
47
|
+
format: dirigent/v1
|
|
48
|
+
kind: pipeline
|
|
49
|
+
code: webhook-payload-to-tracker-event
|
|
50
|
+
name: A pushed encounter into DHIS2
|
|
51
|
+
description: |
|
|
52
|
+
An outside system POSTs one captured encounter; the run reshapes it into a DHIS2 data
|
|
53
|
+
value set and imports it.
|
|
54
|
+
|
|
55
|
+
The pack's `dhis2.tracker` block reads `/api/tracker` and does not write it, so an
|
|
56
|
+
event-shaped capture lands as the aggregate figures it contributes to.
|
|
57
|
+
|
|
58
|
+
tags: [inbound, dhis2, webhook, transform, validate, cross-boundary]
|
|
59
|
+
|
|
60
|
+
requires:
|
|
61
|
+
blocks:
|
|
62
|
+
- transform.jq
|
|
63
|
+
- validate.schema
|
|
64
|
+
- dhis2.data_value_set_import
|
|
65
|
+
|
|
66
|
+
connections:
|
|
67
|
+
dhis2-demo:
|
|
68
|
+
kind: dhis2
|
|
69
|
+
config:
|
|
70
|
+
base_url: https://play.im.dhis2.org/stable-2-43-1
|
|
71
|
+
basic_username: admin
|
|
72
|
+
basic_password: district
|
|
73
|
+
timeout: 60s
|
|
74
|
+
|
|
75
|
+
params:
|
|
76
|
+
type: object
|
|
77
|
+
properties:
|
|
78
|
+
facility:
|
|
79
|
+
type: string
|
|
80
|
+
description: The uid of the facility the encounter was captured at.
|
|
81
|
+
default: DiszpKrYNg8
|
|
82
|
+
occurred_at:
|
|
83
|
+
type: string
|
|
84
|
+
format: date-time
|
|
85
|
+
description: When the encounter happened, which is what the DHIS2 period is derived from.
|
|
86
|
+
default: "2026-06-14T09:20:00Z"
|
|
87
|
+
items:
|
|
88
|
+
type: array
|
|
89
|
+
description: What was given or done, in the sending system's own item codes.
|
|
90
|
+
default:
|
|
91
|
+
- { code: EXPOSED_REGISTERED, quantity: 2 }
|
|
92
|
+
- { code: NVP_WITHIN_72H, quantity: 1 }
|
|
93
|
+
- { code: EXPOSED_REGISTERED, quantity: 1 }
|
|
94
|
+
items:
|
|
95
|
+
type: object
|
|
96
|
+
required: [code, quantity]
|
|
97
|
+
properties:
|
|
98
|
+
code: { type: string }
|
|
99
|
+
quantity: { type: integer }
|
|
100
|
+
dry_run:
|
|
101
|
+
type: boolean
|
|
102
|
+
description: Whether the instance validates the import and writes nothing.
|
|
103
|
+
default: true
|
|
104
|
+
|
|
105
|
+
schemas:
|
|
106
|
+
data-value-set:
|
|
107
|
+
type: object
|
|
108
|
+
required: [dataValues]
|
|
109
|
+
properties:
|
|
110
|
+
dataValues:
|
|
111
|
+
type: array
|
|
112
|
+
minItems: 1
|
|
113
|
+
items:
|
|
114
|
+
type: object
|
|
115
|
+
required: [dataElement, period, orgUnit, value]
|
|
116
|
+
properties:
|
|
117
|
+
dataElement: { type: string, format: dhis2-uid }
|
|
118
|
+
period: { type: string, format: dhis2-period }
|
|
119
|
+
orgUnit: { type: string, format: dhis2-uid }
|
|
120
|
+
value: { type: string }
|
|
121
|
+
|
|
122
|
+
steps:
|
|
123
|
+
# One encounter can carry the same item twice -- two children registered in one visit --
|
|
124
|
+
# so the items are grouped and summed before they become data values. Importing them as
|
|
125
|
+
# two values would have the second silently replace the first rather than add to it.
|
|
126
|
+
reshape:
|
|
127
|
+
block: transform.jq
|
|
128
|
+
config:
|
|
129
|
+
input:
|
|
130
|
+
facility: "${params.facility}"
|
|
131
|
+
occurred_at: "${params.occurred_at}"
|
|
132
|
+
items: "${params.items}"
|
|
133
|
+
lookup:
|
|
134
|
+
EXPOSED_REGISTERED: x0PshcPLSk1
|
|
135
|
+
NVP_WITHIN_72H: wZqi8EXN5x4
|
|
136
|
+
program: |
|
|
137
|
+
.lookup as $lookup
|
|
138
|
+
| .facility as $ou
|
|
139
|
+
# A monthly DHIS2 period is YYYYMM, which is the first seven characters of an ISO
|
|
140
|
+
# instant with the dash taken out. The capture's own clock decides the month.
|
|
141
|
+
| (.occurred_at[0:7] | gsub("-"; "")) as $period
|
|
142
|
+
| {dataValues: [
|
|
143
|
+
.items
|
|
144
|
+
| group_by(.code)[]
|
|
145
|
+
| {dataElement: $lookup[.[0].code],
|
|
146
|
+
period: $period,
|
|
147
|
+
orgUnit: $ou,
|
|
148
|
+
value: ([.[].quantity] | add | tostring)}
|
|
149
|
+
]}
|
|
150
|
+
|
|
151
|
+
# An item code the table does not know left dataElement null, and null is not a uid: the
|
|
152
|
+
# gate is what turns a sender's typo into a failed run instead of a refused import.
|
|
153
|
+
gate:
|
|
154
|
+
block: validate.schema
|
|
155
|
+
depends_on: [reshape]
|
|
156
|
+
config:
|
|
157
|
+
input: "${steps.reshape.output.value}"
|
|
158
|
+
schema: data-value-set
|
|
159
|
+
|
|
160
|
+
import:
|
|
161
|
+
block: dhis2.data_value_set_import
|
|
162
|
+
depends_on: [gate]
|
|
163
|
+
config:
|
|
164
|
+
connection: dhis2-demo
|
|
165
|
+
data_values: "${steps.gate.output.value}"
|
|
166
|
+
dry_run: "${params.dry_run}"
|
|
167
|
+
|
|
168
|
+
triggers:
|
|
169
|
+
webhooks:
|
|
170
|
+
- code: capture-posted
|
|
171
|
+
name: One encounter, pushed
|
|
172
|
+
description: The capture app POSTs an encounter as it is recorded.
|
|
173
|
+
params_from_payload:
|
|
174
|
+
facility: "$.encounter.facility"
|
|
175
|
+
occurred_at: "$.encounter.occurred_at"
|
|
176
|
+
items: "$.encounter.items"
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# A weekly pull bounded by the run's own window, not by "now".
|
|
2
|
+
#
|
|
3
|
+
# THE WINDOW IS THE POINT. A schedule says when the run starts; the window says what the run
|
|
4
|
+
# covers. Monday's run is meant to read the week that just closed, and it must read exactly
|
|
5
|
+
# that week whether it fires on time, fires an hour late after a worker restart, or is
|
|
6
|
+
# backfilled next year. ${run.window.start} and ${run.window.end} are that interval, and
|
|
7
|
+
# they are half-open -- start included, end excluded -- so consecutive firings tile the
|
|
8
|
+
# timeline with no overlap and no gap. A pull bounded by "now minus seven days" has neither
|
|
9
|
+
# property.
|
|
10
|
+
#
|
|
11
|
+
# Nothing in this document declares the window: the cadence at the bottom computes it. So an
|
|
12
|
+
# ad hoc run has to be given one, and a run carrying none refuses the reference rather than
|
|
13
|
+
# resolving it to nothing and asking the outside system for its entire history.
|
|
14
|
+
#
|
|
15
|
+
# The hops, and the shape at each one:
|
|
16
|
+
#
|
|
17
|
+
# 1. pull -> the USGS earthquake catalogue, a GeoJSON FeatureCollection:
|
|
18
|
+
# {type, metadata, features: [...]} -- one feature per event in the week
|
|
19
|
+
# 2. reshape -> {dataSet, dataValues: [{dataElement, period, orgUnit, value}]}: one
|
|
20
|
+
# value, the week's event count, filed against the ISO week the window
|
|
21
|
+
# opened in
|
|
22
|
+
# 3. import -> the instance's summary
|
|
23
|
+
# 4. signed_off -> the sensor that waits for a person to sign the week off
|
|
24
|
+
#
|
|
25
|
+
# A public catalogue that takes an explicit time range is what makes this runnable with no
|
|
26
|
+
# setup; the mechanism is the same for a district register or a lab system that takes
|
|
27
|
+
# ?from=&to=. The catalogue's event counts stand in for a surveillance system's weekly case
|
|
28
|
+
# counts -- that pairing is a stand-in and nothing else here is.
|
|
29
|
+
#
|
|
30
|
+
# A count of zero is a measurement and is imported as one -- the week genuinely had no
|
|
31
|
+
# qualifying event. That is the opposite of the empty cell in csv-drop-to-data-values.yaml,
|
|
32
|
+
# where nothing was reported at all.
|
|
33
|
+
#
|
|
34
|
+
# dg run --local examples/inbound/weekly-window-pull-and-import.yaml \
|
|
35
|
+
# --window 2026-08-03..2026-08-10
|
|
36
|
+
# dg apply examples/inbound/weekly-window-pull-and-import.yaml
|
|
37
|
+
# dg backfill weekly-window-pull-and-import --schedule monday-morning \
|
|
38
|
+
# --from 2026-06-01T06:00:00Z --to 2026-08-31T06:00:00Z --dry-run
|
|
39
|
+
|
|
40
|
+
format: dirigent/v1
|
|
41
|
+
kind: pipeline
|
|
42
|
+
code: weekly-window-pull-and-import
|
|
43
|
+
name: A weekly windowed pull into DHIS2
|
|
44
|
+
description: |
|
|
45
|
+
A Monday schedule whose runs pull the week that just closed -- `${run.window.start}` up
|
|
46
|
+
to but not including `${run.window.end}` -- reshape it into a data value set, import it,
|
|
47
|
+
and wait for the week to be signed off.
|
|
48
|
+
|
|
49
|
+
tags: [inbound, dhis2, http, schedule, transform, cross-boundary]
|
|
50
|
+
|
|
51
|
+
requires:
|
|
52
|
+
blocks:
|
|
53
|
+
- http.request
|
|
54
|
+
- transform.jq
|
|
55
|
+
- dhis2.data_value_set_import
|
|
56
|
+
- dhis2.data_set_complete
|
|
57
|
+
|
|
58
|
+
connections:
|
|
59
|
+
dhis2-demo:
|
|
60
|
+
kind: dhis2
|
|
61
|
+
config:
|
|
62
|
+
base_url: https://play.im.dhis2.org/stable-2-43-1
|
|
63
|
+
basic_username: admin
|
|
64
|
+
basic_password: district
|
|
65
|
+
timeout: 60s
|
|
66
|
+
catalogue:
|
|
67
|
+
kind: http
|
|
68
|
+
config:
|
|
69
|
+
base_url: https://earthquake.usgs.gov
|
|
70
|
+
timeout: 60s
|
|
71
|
+
|
|
72
|
+
params:
|
|
73
|
+
type: object
|
|
74
|
+
properties:
|
|
75
|
+
min_magnitude:
|
|
76
|
+
type: number
|
|
77
|
+
description: What counts as an event worth reporting; the schedule pins it per firing.
|
|
78
|
+
default: 5
|
|
79
|
+
data_element:
|
|
80
|
+
type: string
|
|
81
|
+
description: The uid the weekly count lands on; a weekly surveillance element.
|
|
82
|
+
default: UsSUX0cpKsH
|
|
83
|
+
org_unit:
|
|
84
|
+
type: string
|
|
85
|
+
description: The facility the weekly count is reported for.
|
|
86
|
+
default: DiszpKrYNg8
|
|
87
|
+
data_set:
|
|
88
|
+
type: string
|
|
89
|
+
description: The weekly data set the count belongs to, and whose sign-off closes it.
|
|
90
|
+
default: Nyh6laLdBEJ
|
|
91
|
+
dry_run:
|
|
92
|
+
type: boolean
|
|
93
|
+
description: Whether the instance validates the import and writes nothing.
|
|
94
|
+
default: true
|
|
95
|
+
|
|
96
|
+
steps:
|
|
97
|
+
# Both ends of the interval go on the wire, because the outside system is the one that
|
|
98
|
+
# must not double-count: >= start and < end is the same half-open rule the window is.
|
|
99
|
+
pull:
|
|
100
|
+
block: http.request
|
|
101
|
+
config:
|
|
102
|
+
connection: catalogue
|
|
103
|
+
path: /fdsnws/event/1/query
|
|
104
|
+
method: GET
|
|
105
|
+
query:
|
|
106
|
+
format: geojson
|
|
107
|
+
starttime: "${run.window.start}"
|
|
108
|
+
endtime: "${run.window.end}"
|
|
109
|
+
minmagnitude: "${params.min_magnitude}"
|
|
110
|
+
|
|
111
|
+
# A DHIS2 weekly period is YYYYWnn, and getting there from an instant has two traps.
|
|
112
|
+
# strptime alone leaves the weekday and year-day fields empty, so the week number comes
|
|
113
|
+
# out wrong unless the parse is pushed through mktime|gmtime first; and DHIS2 writes W6,
|
|
114
|
+
# not W06, so the zero-padded %V is put through tonumber. The date is truncated to ten
|
|
115
|
+
# characters so the same program reads both an instant and a plain date.
|
|
116
|
+
reshape:
|
|
117
|
+
block: transform.jq
|
|
118
|
+
depends_on: [pull]
|
|
119
|
+
config:
|
|
120
|
+
input:
|
|
121
|
+
events: "${steps.pull.output.body}"
|
|
122
|
+
window_start: "${run.window.start}"
|
|
123
|
+
data_element: "${params.data_element}"
|
|
124
|
+
org_unit: "${params.org_unit}"
|
|
125
|
+
data_set: "${params.data_set}"
|
|
126
|
+
program: |
|
|
127
|
+
(.window_start[0:10] | strptime("%Y-%m-%d") | mktime | gmtime) as $opened
|
|
128
|
+
# The envelope names its data set because this element belongs to two of them, and
|
|
129
|
+
# an import that cannot tell which set a value is for refuses the whole document.
|
|
130
|
+
| {dataSet: .data_set,
|
|
131
|
+
dataValues: [{
|
|
132
|
+
dataElement: .data_element,
|
|
133
|
+
period: "\($opened | strftime("%G"))W\($opened | strftime("%V") | tonumber)",
|
|
134
|
+
orgUnit: .org_unit,
|
|
135
|
+
value: (.events.features | length | tostring)
|
|
136
|
+
}]}
|
|
137
|
+
|
|
138
|
+
import:
|
|
139
|
+
block: dhis2.data_value_set_import
|
|
140
|
+
depends_on: [reshape]
|
|
141
|
+
config:
|
|
142
|
+
connection: dhis2-demo
|
|
143
|
+
data_values: "${steps.reshape.output.value}"
|
|
144
|
+
dry_run: "${params.dry_run}"
|
|
145
|
+
# A backfilled week must land on top of whatever a previous run put there, because
|
|
146
|
+
# the catalogue revises events after the fact and the newer read is the truer one.
|
|
147
|
+
import_strategy: CREATE_AND_UPDATE
|
|
148
|
+
|
|
149
|
+
# Observes, never registers -- the same sensor, and the same reasoning, as
|
|
150
|
+
# parquet-lakehouse-to-dhis2.yaml. The period is read back off the document that was
|
|
151
|
+
# imported rather than derived a second time: one derivation, one truth.
|
|
152
|
+
signed_off:
|
|
153
|
+
block: dhis2.data_set_complete
|
|
154
|
+
depends_on: [import]
|
|
155
|
+
poll: 30m
|
|
156
|
+
deadline: 72h
|
|
157
|
+
on_timeout: skip
|
|
158
|
+
config:
|
|
159
|
+
connection: dhis2-demo
|
|
160
|
+
data_set: "${params.data_set}"
|
|
161
|
+
period: "${steps.reshape.output.value.dataValues.0.period}"
|
|
162
|
+
org_unit: "${params.org_unit}"
|
|
163
|
+
|
|
164
|
+
triggers:
|
|
165
|
+
schedules:
|
|
166
|
+
- code: monday-morning
|
|
167
|
+
name: Monday, Freetown time
|
|
168
|
+
description: Reads the week that closed at six on Monday morning.
|
|
169
|
+
cron: "0 6 * * 1"
|
|
170
|
+
# The zone belongs to the schedule, so the window it derives is a week of the
|
|
171
|
+
# reporting country's clock rather than a fixed 168 hours from a server's idea of
|
|
172
|
+
# midnight.
|
|
173
|
+
timezone: Africa/Freetown
|
|
174
|
+
params:
|
|
175
|
+
min_magnitude: 5
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# The other direction: a parquet file somebody drops in a bucket, ending up inside DHIS2.
|
|
2
|
+
#
|
|
3
|
+
# dhis2-values-to-parquet.yaml reads DHIS2 and writes parquet. This is the return leg, and it
|
|
4
|
+
# is the one that needs a gate: bytes arriving from outside are not a data value set until
|
|
5
|
+
# something has checked that they are. Three packs meet here -- storage-s3 for the drop,
|
|
6
|
+
# parquet for the decode, builtin for the reshape and the schema gate, dhis2 for the write --
|
|
7
|
+
# which is why it lives in the integration and in none of them.
|
|
8
|
+
#
|
|
9
|
+
# What happens, hop by hop:
|
|
10
|
+
#
|
|
11
|
+
# dropped storage.exists is a sensor: each poke is one cheap head request, "not yet" is
|
|
12
|
+
# the expected answer, and the run parks rather than holding a worker. min_size
|
|
13
|
+
# is what stops it opening a half-written object a producer is still uploading.
|
|
14
|
+
# decode convert.arrow parquet -> json, into the run's scratch space. Parquet is bytes and
|
|
15
|
+
# never travels as a value, so both ends of a conversion are URIs: source and
|
|
16
|
+
# target. The json it writes is an array of row objects, one per data value,
|
|
17
|
+
# because that is what a parquet table is -- rows and columns, with no envelope.
|
|
18
|
+
# load storage.read, that json object as a value. storage.read is the one door a value
|
|
19
|
+
# comes in by, and max_size is how much of it this step will hold: an object past
|
|
20
|
+
# it is refused rather than read half way.
|
|
21
|
+
# shape the list is wrapped: DHIS2 wants {"dataValues": [...]}, the file carries the bare
|
|
22
|
+
# list. This is the whole impedance mismatch between a columnar file and a DHIS2
|
|
23
|
+
# payload, and it is one line of jq.
|
|
24
|
+
# gate validate.schema against the carried schema below. Every dataElement and orgUnit
|
|
25
|
+
# must be a real DHIS2 uid and every period a real DHIS2 period, checked by the
|
|
26
|
+
# dhis2-uid and dhis2-period formats the dhis2 pack contributes -- so a typo is a
|
|
27
|
+
# refused run and not a partially-written instance. The gate has no `rule`, so the
|
|
28
|
+
# default all_success applies: the import below simply never starts if this fails.
|
|
29
|
+
# import dhis2.data_value_set_import of the value the gate handed on. atomic_mode ALL
|
|
30
|
+
# means one conflicted value refuses the whole set, which is the right default for
|
|
31
|
+
# a file: half an import is worse than none.
|
|
32
|
+
# signed_off dhis2.data_set_complete is a sensor, not a writer -- it waits for a human in
|
|
33
|
+
# DHIS2 to mark the set complete for that period and org unit. That is the honest
|
|
34
|
+
# end of a load: the data is in, and the run stays open until somebody stands
|
|
35
|
+
# behind it. on_timeout: skip ends the run cleanly when nobody does.
|
|
36
|
+
#
|
|
37
|
+
# To make it yours: point drop_uri at the object your producer writes, name your own data set,
|
|
38
|
+
# period and org unit, and turn dry_run off once you are importing into an instance you own.
|
|
39
|
+
# It is on by default because the play server is shared: the import is fully exercised and
|
|
40
|
+
# nothing is written. Which connection backs s3:// is an instance setting
|
|
41
|
+
# (storage_connections: {s3: <code>}).
|
|
42
|
+
#
|
|
43
|
+
# dg run --local examples/parquet-to-dhis2-import.yaml
|
|
44
|
+
# dg run --local examples/parquet-to-dhis2-import.yaml -p dry_run=false
|
|
45
|
+
|
|
46
|
+
format: dirigent/v1
|
|
47
|
+
kind: pipeline
|
|
48
|
+
code: parquet-to-dhis2-import
|
|
49
|
+
name: A parquet drop imported into DHIS2
|
|
50
|
+
description: Wait for a parquet object, check it, then import it as a DHIS2 data value set.
|
|
51
|
+
|
|
52
|
+
tags: [dhis2, parquet, s3, validate, cross-boundary]
|
|
53
|
+
|
|
54
|
+
requires:
|
|
55
|
+
blocks:
|
|
56
|
+
- storage.exists
|
|
57
|
+
- convert.arrow
|
|
58
|
+
- storage.read
|
|
59
|
+
- transform.jq
|
|
60
|
+
- validate.schema
|
|
61
|
+
- dhis2.data_value_set_import
|
|
62
|
+
- dhis2.data_set_complete
|
|
63
|
+
|
|
64
|
+
# The public DHIS2 play server, carried inline so this document runs standalone. Against an
|
|
65
|
+
# instance you own, name a connection that instance holds instead of carrying one.
|
|
66
|
+
connections:
|
|
67
|
+
dhis2-demo:
|
|
68
|
+
kind: dhis2
|
|
69
|
+
config:
|
|
70
|
+
base_url: https://play.im.dhis2.org/stable-2-43-1
|
|
71
|
+
basic_username: admin
|
|
72
|
+
basic_password: district
|
|
73
|
+
timeout: 60s
|
|
74
|
+
|
|
75
|
+
# Carried in the document, so the gate needs nothing handed to it and a --local run checks the
|
|
76
|
+
# same shape a server would. dhis2-uid and dhis2-period are formats the dhis2 pack contributes
|
|
77
|
+
# to the catalog; an instance without that pack cannot run this document at all, which is the
|
|
78
|
+
# point of naming them in requires.
|
|
79
|
+
schemas:
|
|
80
|
+
dhis2-data-value-set:
|
|
81
|
+
type: object
|
|
82
|
+
required: [dataValues]
|
|
83
|
+
additionalProperties: false
|
|
84
|
+
properties:
|
|
85
|
+
dataValues:
|
|
86
|
+
type: array
|
|
87
|
+
minItems: 1
|
|
88
|
+
items:
|
|
89
|
+
type: object
|
|
90
|
+
required: [dataElement, period, orgUnit, value]
|
|
91
|
+
properties:
|
|
92
|
+
dataElement: { type: string, format: dhis2-uid }
|
|
93
|
+
period: { type: string, format: dhis2-period }
|
|
94
|
+
orgUnit: { type: string, format: dhis2-uid }
|
|
95
|
+
categoryOptionCombo: { type: string, format: dhis2-uid }
|
|
96
|
+
attributeOptionCombo: { type: string, format: dhis2-uid }
|
|
97
|
+
value: { type: string }
|
|
98
|
+
comment: { type: string }
|
|
99
|
+
|
|
100
|
+
params:
|
|
101
|
+
type: object
|
|
102
|
+
properties:
|
|
103
|
+
drop_uri:
|
|
104
|
+
type: string
|
|
105
|
+
default: s3://dirigent-incoming/dhis2/data-values.parquet
|
|
106
|
+
description: The object the upstream producer writes; this run starts when it appears.
|
|
107
|
+
data_set:
|
|
108
|
+
type: string
|
|
109
|
+
default: BfMAe6Itzgt
|
|
110
|
+
period:
|
|
111
|
+
type: string
|
|
112
|
+
default: "202507"
|
|
113
|
+
org_unit:
|
|
114
|
+
type: string
|
|
115
|
+
default: vSbt6cezomG
|
|
116
|
+
dry_run:
|
|
117
|
+
type: boolean
|
|
118
|
+
default: true
|
|
119
|
+
description: On by default because the play server is shared; off on an instance you own.
|
|
120
|
+
|
|
121
|
+
steps:
|
|
122
|
+
dropped:
|
|
123
|
+
block: storage.exists
|
|
124
|
+
poll: 1m
|
|
125
|
+
deadline: 12h
|
|
126
|
+
# Nobody dropped a file inside the window: that is a quiet day, not a broken pipeline.
|
|
127
|
+
on_timeout: skip
|
|
128
|
+
config:
|
|
129
|
+
uri: ${params.drop_uri}
|
|
130
|
+
# A producer uploading a large object makes the key visible before the bytes are all
|
|
131
|
+
# there. A floor stops the decode reading a truncated file.
|
|
132
|
+
min_size: 1kb
|
|
133
|
+
|
|
134
|
+
decode:
|
|
135
|
+
block: convert.arrow
|
|
136
|
+
depends_on: [dropped]
|
|
137
|
+
config:
|
|
138
|
+
source: ${params.drop_uri}
|
|
139
|
+
from: parquet
|
|
140
|
+
to: json
|
|
141
|
+
# The run's own scratch space: these bytes exist only so the read below has something to
|
|
142
|
+
# decode, and the object the producer dropped is left exactly as it was.
|
|
143
|
+
target: ${run.scratch}/data-values.json
|
|
144
|
+
|
|
145
|
+
load:
|
|
146
|
+
block: storage.read
|
|
147
|
+
depends_on: [decode]
|
|
148
|
+
config:
|
|
149
|
+
# Where the conversion said it wrote, not the same path typed twice.
|
|
150
|
+
source: ${steps.decode.output.target}
|
|
151
|
+
# A data value set that fits a single import call also fits a step output; a file bigger
|
|
152
|
+
# than this is refused here rather than truncated into a wrong one.
|
|
153
|
+
max_size: 64mb
|
|
154
|
+
|
|
155
|
+
shape:
|
|
156
|
+
block: transform.jq
|
|
157
|
+
depends_on: [load]
|
|
158
|
+
config:
|
|
159
|
+
input: ${steps.load.output.value}
|
|
160
|
+
# The object literal is the envelope DHIS2 requires and a parquet table has no room for.
|
|
161
|
+
program: |
|
|
162
|
+
{dataValues: .}
|
|
163
|
+
|
|
164
|
+
gate:
|
|
165
|
+
block: validate.schema
|
|
166
|
+
depends_on: [shape]
|
|
167
|
+
config:
|
|
168
|
+
input: ${steps.shape.output.value}
|
|
169
|
+
schema: dhis2-data-value-set
|
|
170
|
+
|
|
171
|
+
import:
|
|
172
|
+
block: dhis2.data_value_set_import
|
|
173
|
+
depends_on: [gate]
|
|
174
|
+
config:
|
|
175
|
+
connection: dhis2-demo
|
|
176
|
+
# Read from the gate rather than from shape: the reference is what proves, in the
|
|
177
|
+
# document itself, that nothing reaches DHIS2 without passing the schema.
|
|
178
|
+
data_values: ${steps.gate.output.value}
|
|
179
|
+
dry_run: ${params.dry_run}
|
|
180
|
+
import_strategy: CREATE_AND_UPDATE
|
|
181
|
+
# One refused value refuses the whole file. A partially imported month is harder to
|
|
182
|
+
# reason about than a failed run, and the file can simply be dropped again.
|
|
183
|
+
atomic_mode: ALL
|
|
184
|
+
|
|
185
|
+
signed_off:
|
|
186
|
+
block: dhis2.data_set_complete
|
|
187
|
+
depends_on: [import]
|
|
188
|
+
poll: 30m
|
|
189
|
+
deadline: 72h
|
|
190
|
+
on_timeout: skip
|
|
191
|
+
config:
|
|
192
|
+
connection: dhis2-demo
|
|
193
|
+
data_set: ${params.data_set}
|
|
194
|
+
period: ${params.period}
|
|
195
|
+
org_unit: ${params.org_unit}
|