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
|
+
# 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}"
|