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,176 @@
1
+ # A csv dropped in a bucket, picked up and imported: the file-drop integration.
2
+ #
3
+ # The commonest inbound shape there is. Somebody's system -- a district DHIS2-less register,
4
+ # a lab machine, an export somebody mails weekly -- writes a csv into object storage, and
5
+ # nothing tells the pipeline it happened. So the pipeline waits.
6
+ #
7
+ # The hops, and the shape at each one:
8
+ #
9
+ # 1. arrived -> a sensor, not a read: it answers only once an object matching the glob is
10
+ # there and at least min_size, and hands on that object's uri
11
+ # 2. decode -> the csv re-encoded as a json object in the run's scratch space: a
12
+ # conversion reads a URI and writes a URI, and carries nothing between them
13
+ # 3. table -> that object read into the run as a value, which is the one door a value
14
+ # comes in by
15
+ # 4. rows -> the table with each row carrying the data element uid the lookup gave it,
16
+ # so the code-to-uid translation is done once and visibly
17
+ # 5. values -> one data value per row, DHIS2's four fields and nothing else
18
+ # 6. kept -> the same list minus the rows that carry no measurement
19
+ # 7. import -> the instance's summary
20
+ #
21
+ # The lookup is the part every deployment rewrites. It sits in its own value.const step
22
+ # because that is where a reader looks for it: the sending system's column codes on the
23
+ # left, this instance's uids on the right, and a row whose code is not in the table falls
24
+ # out at the filter rather than importing under a null id.
25
+ #
26
+ # THE FILTER IS NOT TIDYING. An empty cell means the facility did not report, and a 0 means
27
+ # it reported none. Importing the first as the second invents data, so the empty ones are
28
+ # dropped and the gap stays a gap.
29
+ #
30
+ # What makes it yours: the bucket and prefix, the lookup table, and the period column. The
31
+ # s3:// scheme needs an s3 connection bound to it as an instance setting
32
+ # (storage_connections: {s3: <code>}), exactly as examples/s3 in the dirigent repository
33
+ # sets up; the drop directory can equally be file:// on a worker's disk.
34
+ #
35
+ # dg run --local examples/inbound/csv-drop-to-data-values.yaml -p day=2026-06-01
36
+
37
+ format: dirigent/v1
38
+ kind: pipeline
39
+ code: csv-drop-to-data-values
40
+ name: A csv drop into DHIS2
41
+ description: |
42
+ Wait for a csv to land in object storage, decode it, translate the sender's codes into
43
+ DHIS2 uids, drop the rows that carry no measurement, and import what is left.
44
+
45
+ tags: [inbound, dhis2, storage, transform, cross-boundary]
46
+
47
+ requires:
48
+ blocks:
49
+ - storage.exists
50
+ - convert.std
51
+ - storage.read
52
+ - value.const
53
+ - transform.jq
54
+ - map.jq
55
+ - filter.jq
56
+ - dhis2.data_value_set_import
57
+ storage:
58
+ - s3
59
+
60
+ connections:
61
+ dhis2-demo:
62
+ kind: dhis2
63
+ config:
64
+ base_url: https://play.im.dhis2.org/stable-2-43-1
65
+ basic_username: admin
66
+ basic_password: district
67
+ timeout: 60s
68
+
69
+ params:
70
+ type: object
71
+ required: [day]
72
+ properties:
73
+ day:
74
+ type: string
75
+ format: date
76
+ description: The drop directory the sender writes into, one per day.
77
+ bucket:
78
+ type: string
79
+ description: The bucket the sending system drops into.
80
+ default: dirigent-inbound
81
+ dry_run:
82
+ type: boolean
83
+ description: Whether the instance validates the import and writes nothing.
84
+ default: true
85
+
86
+ steps:
87
+ # A glob in the final segment, because the sender names the file and we do not. min_size
88
+ # is the guard against a half-written object: a multipart upload is visible before it is
89
+ # complete, and a zero-byte placeholder is not a csv.
90
+ arrived:
91
+ block: storage.exists
92
+ poll: 1m
93
+ deadline: 6h
94
+ on_timeout: skip
95
+ config:
96
+ uri: s3://${params.bucket}/drops/${params.day}/*.csv
97
+ min_size: 1b
98
+
99
+ # The drop the sender wrote is left exactly as it is: the conversion reads it and writes
100
+ # the json beside it in the run's own scratch space.
101
+ decode:
102
+ block: convert.std
103
+ depends_on: [arrived]
104
+ config:
105
+ source: "${steps.arrived.output.uri}"
106
+ from: csv
107
+ to: json
108
+ target: "${run.scratch}/drop-${params.day}.json"
109
+
110
+ # The read is where the drop enters the run, and max_size is the ceiling on what one drop
111
+ # may be: an object past it is refused rather than half read.
112
+ table:
113
+ block: storage.read
114
+ depends_on: [decode]
115
+ config:
116
+ source: "${steps.decode.output.target}"
117
+ max_size: 16mb
118
+
119
+ # The sending system's vocabulary, translated. One place, one step, one thing to hand a
120
+ # DHIS2 administrator when a new indicator is added.
121
+ lookup:
122
+ block: value.const
123
+ config:
124
+ value:
125
+ PMTCT_EXPOSED_REGISTERED: x0PshcPLSk1
126
+ PMTCT_NVP_72H: wZqi8EXN5x4
127
+
128
+ # The uid is attached here, while the whole table is in scope; the per-row verbs below
129
+ # cannot see the lookup, because a map program sees one element and nothing else.
130
+ rows:
131
+ block: transform.jq
132
+ depends_on: [table, lookup]
133
+ config:
134
+ input:
135
+ csv: "${steps.table.output.value}"
136
+ lookup: "${steps.lookup.output.value}"
137
+ program: |
138
+ .lookup as $lookup
139
+ | .csv
140
+ | [.[] | . + {dataElement: $lookup[.element_code]}]
141
+
142
+ # Exactly one data value per row, and the list is as long as the one above it -- which is
143
+ # what map.jq guarantees and a transform.jq program would not.
144
+ values:
145
+ block: map.jq
146
+ depends_on: [rows]
147
+ config:
148
+ input: "${steps.rows.output.value}"
149
+ program: |
150
+ {dataElement, period: .period, orgUnit: .org_unit, value: .value}
151
+
152
+ # A subset, nothing edited. Two ways in: an unmapped code left dataElement null, and an
153
+ # empty cell left value "". jq truthiness is not applied here, so both tests are written
154
+ # out rather than leaning on a bare .value.
155
+ kept:
156
+ block: filter.jq
157
+ depends_on: [values]
158
+ config:
159
+ input: "${steps.values.output.value}"
160
+ program: |
161
+ .dataElement != null and .value != "" and .value != null
162
+
163
+ import:
164
+ block: dhis2.data_value_set_import
165
+ depends_on: [kept]
166
+ config:
167
+ connection: dhis2-demo
168
+ # The envelope is assembled here rather than in a step of its own: the list is the
169
+ # only part any earlier step had an opinion about.
170
+ data_values:
171
+ dataValues: "${steps.kept.output.value}"
172
+ dry_run: "${params.dry_run}"
173
+ # NONE takes the rows the instance accepts and reports the rest as conflicts. ALL,
174
+ # the default, refuses the whole file over one bad row -- the right choice when the
175
+ # drop is one facility's month and the wrong one when it is a district's backlog.
176
+ atomic_mode: NONE
@@ -0,0 +1,202 @@
1
+ # One pull per facility, one import for the district, and a report of who did not answer.
2
+ #
3
+ # A district collects from facilities that are up at different times and run different
4
+ # software. The pull therefore fans out -- one call per facility, each its own attempt --
5
+ # and the import does not.
6
+ #
7
+ # WHY THE FAN CONVERGES AT THE IMPORT. Two reasons, and both are worth knowing:
8
+ #
9
+ # for_each is expanded when the run is created, so it may read params, run and item but
10
+ # never another step's output: the cardinality has to exist before anything executes. A
11
+ # step fanned over "the facilities that answered" is therefore not expressible, and
12
+ # should not be -- it would make the shape of the run depend on the weather.
13
+ #
14
+ # A data value set carries orgUnit per value, so one document can hold every facility at
15
+ # once. Fifteen imports of one facility each would be fifteen round trips, fifteen
16
+ # summaries to reconcile, and no gain.
17
+ #
18
+ # The hops, and the shape at each one:
19
+ #
20
+ # 1. pull -> fanned: one http.request per facility. The step's output is the LIST
21
+ # of its items' outputs, in item order, and A FAILED ITEM IS ABSENT
22
+ # FROM IT -- not null, absent. So the list is shorter than the facility
23
+ # list exactly when something went wrong
24
+ # 2. per_facility-> fanned the same way, over the same params: one facility's rows,
25
+ # already carrying its orgUnit
26
+ # 3. batch -> {dataValues: [...]}, every facility's values in one envelope
27
+ # 4. import -> one summary for the district
28
+ # 5. reconcile -> asked for, answered, missing -- computed from the two lists rather
29
+ # than from anything the import said
30
+ # 6. notify -> that reconciliation POSTed where a person will see it
31
+ #
32
+ # items: continue is what makes a facility that is down a line in the report instead of the
33
+ # end of the run. Under the default, fail_fast, one unreachable facility would take the
34
+ # other fourteen with it.
35
+ #
36
+ # The step after a tolerated fan-out still runs, because a fan-out that lost items counts
37
+ # as succeeded and the run ends completed_with_errors. If EVERY facility fails, the step
38
+ # failed, and everything downstream is skipped -- including the reconciliation, which is
39
+ # the one case where the run's own status is the report.
40
+ #
41
+ # What makes it yours: the facilities list, the endpoint each one answers on, and where the
42
+ # reconciliation is posted.
43
+ #
44
+ # dg run --local examples/inbound/fan-out-per-facility-import.yaml
45
+ # dg run --local examples/inbound/fan-out-per-facility-import.yaml \
46
+ # -p facilities='[{"code":"ngelehun-chc","org_unit":"DiszpKrYNg8","reported":42}]'
47
+
48
+ format: dirigent/v1
49
+ kind: pipeline
50
+ code: fan-out-per-facility-import
51
+ name: A facility fan-out into one district import
52
+ description: |
53
+ Pull one facility at a time, tolerate the ones that are down, import what answered as a
54
+ single data value set, and post a reconciliation of asked-for against imported.
55
+
56
+ tags: [inbound, dhis2, http, graph, transform, cross-boundary]
57
+
58
+ requires:
59
+ blocks:
60
+ - http.request
61
+ - transform.jq
62
+ - dhis2.data_value_set_import
63
+ - webhook.post
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
+
74
+ params:
75
+ type: object
76
+ properties:
77
+ facilities:
78
+ type: array
79
+ description: The facilities to collect from; each carries the uid its values land on.
80
+ default:
81
+ - { code: ngelehun-chc, org_unit: DiszpKrYNg8, reported: 42 }
82
+ - { code: gbenikoro-mchp, org_unit: y77LiPqLMoq, reported: 17 }
83
+ - { code: dodo-kortuma-chp, org_unit: rwfuVQHnZJ5, reported: 8 }
84
+ items:
85
+ type: object
86
+ required: [code, org_unit, reported]
87
+ properties:
88
+ code: { type: string }
89
+ org_unit: { type: string }
90
+ # What that facility's register would answer. It rides in the request and comes
91
+ # back in the echo, so the example has a figure to import without pretending a
92
+ # public endpoint holds anyone's health data.
93
+ reported: { type: integer }
94
+ period:
95
+ type: string
96
+ description: The DHIS2 month being collected.
97
+ default: "202606"
98
+ data_element:
99
+ type: string
100
+ description: The uid each facility's figure lands on.
101
+ default: x0PshcPLSk1
102
+ report_url:
103
+ type: string
104
+ description: Where the reconciliation is POSTed.
105
+ default: https://postman-echo.com/post
106
+ dry_run:
107
+ type: boolean
108
+ description: Whether the instance validates the import and writes nothing.
109
+ default: true
110
+
111
+ steps:
112
+ # ${item} is one element of the params list, so ${item.code} reaches into it. The facility
113
+ # endpoint stands in as an echo service that answers whatever it is asked -- the shape
114
+ # this step teaches is one call per facility, not what that call returns.
115
+ pull:
116
+ block: http.request
117
+ for_each: "${params.facilities}"
118
+ items: continue
119
+ config:
120
+ url: https://postman-echo.com/get
121
+ method: GET
122
+ query:
123
+ facility: "${item.code}"
124
+ period: "${params.period}"
125
+ reported: "${item.reported}"
126
+
127
+ # Fanned over the same params list, so item and the pull's list line up index for index
128
+ # -- while every item succeeded. That is exactly why the reconciliation below counts the
129
+ # pull's own output rather than assuming this step saw everything.
130
+ per_facility:
131
+ block: transform.jq
132
+ depends_on: [pull]
133
+ for_each: "${params.facilities}"
134
+ items: continue
135
+ config:
136
+ input:
137
+ facility: "${item}"
138
+ answers: "${steps.pull.output}"
139
+ period: "${params.period}"
140
+ data_element: "${params.data_element}"
141
+ program: |
142
+ . as {$facility, $answers, $period, $data_element}
143
+ | [$answers[] | select(.body.args.facility == $facility.code)] as $mine
144
+ | [$mine[]
145
+ | {dataElement: $data_element,
146
+ period: $period,
147
+ orgUnit: $facility.org_unit,
148
+ value: (.body.args.reported | tostring)}]
149
+
150
+ # The fan-in. A fanned step's output is a list of its items' output objects, so the value
151
+ # each one carries is unwrapped once here before the lists are concatenated.
152
+ batch:
153
+ block: transform.jq
154
+ depends_on: [per_facility]
155
+ config:
156
+ input: "${steps.per_facility.output}"
157
+ program: |
158
+ {dataValues: [.[] | .value[]]}
159
+
160
+ import:
161
+ block: dhis2.data_value_set_import
162
+ depends_on: [batch]
163
+ config:
164
+ connection: dhis2-demo
165
+ data_values: "${steps.batch.output.value}"
166
+ dry_run: "${params.dry_run}"
167
+ # One facility's bad row must not cost the district its import; the conflicts come
168
+ # back in the summary and are reported rather than thrown away.
169
+ atomic_mode: NONE
170
+
171
+ # The honest reconciliation reads BOTH sides: what was asked for is the params list, what
172
+ # answered is the pull's surviving items, and the difference is the facilities nobody has
173
+ # heard from. The import summary cannot supply that -- it only ever saw what arrived.
174
+ reconcile:
175
+ block: transform.jq
176
+ depends_on: [import]
177
+ config:
178
+ input:
179
+ asked: "${params.facilities}"
180
+ answered: "${steps.pull.output}"
181
+ summary: "${steps.import.output}"
182
+ period: "${params.period}"
183
+ program: |
184
+ [.answered[] | .body.args.facility] as $codes
185
+ | {period: .period,
186
+ facilities_asked: (.asked | length),
187
+ facilities_answered: ($codes | length),
188
+ missing: [.asked[] | select(.code as $c | $codes | index($c) | not) | .code],
189
+ imported: .summary.imported,
190
+ updated: .summary.updated,
191
+ ignored: .summary.ignored,
192
+ conflicts: (.summary.conflicts | length)}
193
+
194
+ # A collection run nobody reads is a collection run nobody trusts. The body is the
195
+ # reconciliation exactly as computed; sign_with names a connection holding an hmac_secret
196
+ # when the receiver checks signatures.
197
+ notify:
198
+ block: webhook.post
199
+ depends_on: [reconcile]
200
+ config:
201
+ url: "${params.report_url}"
202
+ body: "${steps.reconcile.output.value}"
@@ -0,0 +1,193 @@
1
+ # A FHIR Observation feed reduced to DHIS2 aggregate values.
2
+ #
3
+ # FHIR keeps one Observation per measurement, coded in LOINC, about one patient. DHIS2's
4
+ # aggregate side keeps one figure per data element, period and org unit. So the integration
5
+ # is three decisions, and every FHIR-to-aggregate integration makes the same three:
6
+ #
7
+ # the LOINC code -> the data element, through a table this document carries. LOINC is
8
+ # the sending vocabulary and a DHIS2 uid is the receiving one; nothing
9
+ # derives one from the other, so the table is the mapping and there is
10
+ # no clever way around writing it.
11
+ # effectiveDateTime-> the period, truncated to the DHIS2 month. FHIR timestamps an
12
+ # instant; DHIS2 stores a reporting month.
13
+ # the org unit -> NOT from the payload. A public FHIR server's Observations are about
14
+ # patients, and a patient is not a facility. Which facility this feed
15
+ # belongs to is knowledge the puller has and the payload does not, so
16
+ # it is a parameter.
17
+ #
18
+ # The hops, and the shape at each one:
19
+ #
20
+ # 1. pull -> a FHIR Bundle: {resourceType: "Bundle", entry: [{resource: {...}}]}
21
+ # 2. flatten -> one row per entry that has both a mapped LOINC code and a numeric
22
+ # value; everything else is dropped, silently and on purpose
23
+ # 3. aggregate -> {dataValues: [...]}, one value per (element, period) pair, summed
24
+ # 4. gate -> refused unless the ids and periods are DHIS2's own
25
+ # 5. import -> the instance's summary
26
+ #
27
+ # HOW A COUNT IS AGGREGATED IS A CLINICAL DECISION, not a technical one. Summing is right
28
+ # for doses given and wrong for a body temperature; an averaged indicator needs a different
29
+ # last line, and the pack cannot know which. This one counts observations, which is the
30
+ # reduction that is always defensible: how many were recorded.
31
+ #
32
+ # The default pull is the public HAPI test server, so the example runs with no setup. It
33
+ # holds whatever the world has posted to it, so the feed is unpredictable in content and
34
+ # perfectly predictable in shape -- which is the half this example teaches. If a run finds
35
+ # nothing mappable, the gate refuses an empty set rather than importing nothing quietly.
36
+ #
37
+ # What makes it yours: fhir_base_url pointed at your server, the lookup table, and the
38
+ # org unit.
39
+ #
40
+ # dg run --local examples/inbound/fhir-observations-to-data-values.yaml
41
+ # dg run --local examples/inbound/fhir-observations-to-data-values.yaml -p count=50
42
+
43
+ format: dirigent/v1
44
+ kind: pipeline
45
+ code: fhir-observations-to-data-values
46
+ name: FHIR Observations into DHIS2
47
+ description: |
48
+ Pull a FHIR Observation bundle, map LOINC codes onto DHIS2 data elements, reduce the
49
+ observations to monthly counts, and import them as a data value set.
50
+
51
+ tags: [inbound, dhis2, http, transform, validate, cross-boundary]
52
+
53
+ requires:
54
+ blocks:
55
+ - http.request
56
+ - transform.jq
57
+ - validate.schema
58
+ - dhis2.data_value_set_import
59
+
60
+ connections:
61
+ dhis2-demo:
62
+ kind: dhis2
63
+ config:
64
+ base_url: https://play.im.dhis2.org/stable-2-43-1
65
+ basic_username: admin
66
+ basic_password: district
67
+ timeout: 60s
68
+ # The outside end is a connection rather than a bare url because a real FHIR server is
69
+ # authenticated and versioned, and both belong to the server rather than to the step.
70
+ fhir-server:
71
+ kind: http
72
+ config:
73
+ base_url: https://hapi.fhir.org/baseR4
74
+ timeout: 60s
75
+
76
+ params:
77
+ type: object
78
+ properties:
79
+ count:
80
+ type: integer
81
+ description: How many Observations one page asks for; kept small so the demo is cheap.
82
+ default: 20
83
+ minimum: 1
84
+ maximum: 200
85
+ org_unit:
86
+ type: string
87
+ description: The facility this feed reports for, which the payload cannot say.
88
+ default: DiszpKrYNg8
89
+ loinc_to_data_element:
90
+ type: object
91
+ description: LOINC code on the left, DHIS2 data element uid on the right.
92
+ default:
93
+ "8302-2": x0PshcPLSk1
94
+ "29463-7": wZqi8EXN5x4
95
+ dry_run:
96
+ type: boolean
97
+ description: Whether the instance validates the import and writes nothing.
98
+ default: true
99
+
100
+ schemas:
101
+ data-value-set:
102
+ type: object
103
+ required: [dataValues]
104
+ properties:
105
+ dataValues:
106
+ type: array
107
+ minItems: 1
108
+ items:
109
+ type: object
110
+ required: [dataElement, period, orgUnit, value]
111
+ properties:
112
+ dataElement: { type: string, format: dhis2-uid }
113
+ period: { type: string, format: dhis2-period }
114
+ orgUnit: { type: string, format: dhis2-uid }
115
+ value: { type: string }
116
+
117
+ steps:
118
+ # _format=json because a FHIR server negotiates content and will answer XML to a client
119
+ # that does not say; _count bounds the page. A real feed pages with _getpages, which is a
120
+ # loop this example deliberately does not have.
121
+ pull:
122
+ block: http.request
123
+ config:
124
+ connection: fhir-server
125
+ path: /Observation
126
+ method: GET
127
+ query:
128
+ _count: "${params.count}"
129
+ _format: json
130
+
131
+ # One program, because the mapping needs the code and the timestamp of the same resource
132
+ # at once. Entries are dropped rather than defaulted at three points: no LOINC coding, no
133
+ # mapped uid, no effective instant. An observation that cannot be placed in a month at a
134
+ # data element is not an observation this instance can store.
135
+ flatten:
136
+ block: transform.jq
137
+ depends_on: [pull]
138
+ config:
139
+ input:
140
+ bundle: "${steps.pull.output.body}"
141
+ lookup: "${params.loinc_to_data_element}"
142
+ program: |
143
+ .lookup as $lookup
144
+ | [ .bundle.entry // []
145
+ | .[]
146
+ | .resource
147
+ | select(.resourceType == "Observation")
148
+ | (.effectiveDateTime // .issued) as $when
149
+ | select($when != null)
150
+ | (.code.coding // [])
151
+ | .[]
152
+ | select(.system == "http://loinc.org")
153
+ | select($lookup[.code] != null)
154
+ | {dataElement: $lookup[.code], period: ($when[0:7] | gsub("-"; ""))}
155
+ ]
156
+
157
+ # Counting, not summing a measured quantity: see the header. group_by leaves one group
158
+ # per distinct pair, and its length is how many observations landed in it.
159
+ aggregate:
160
+ block: transform.jq
161
+ depends_on: [flatten]
162
+ config:
163
+ input:
164
+ rows: "${steps.flatten.output.value}"
165
+ org_unit: "${params.org_unit}"
166
+ program: |
167
+ .org_unit as $ou
168
+ | {dataValues: [
169
+ .rows
170
+ | group_by([.dataElement, .period])[]
171
+ | {dataElement: .[0].dataElement,
172
+ period: .[0].period,
173
+ orgUnit: $ou,
174
+ value: (length | tostring)}
175
+ ]}
176
+
177
+ # minItems: 1 in the schema is the real work here: a feed that happened to carry nothing
178
+ # this instance knows fails the run visibly instead of importing an empty envelope and
179
+ # reporting success.
180
+ gate:
181
+ block: validate.schema
182
+ depends_on: [aggregate]
183
+ config:
184
+ input: "${steps.aggregate.output.value}"
185
+ schema: data-value-set
186
+
187
+ import:
188
+ block: dhis2.data_value_set_import
189
+ depends_on: [gate]
190
+ config:
191
+ connection: dhis2-demo
192
+ data_values: "${steps.gate.output.value}"
193
+ dry_run: "${params.dry_run}"