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,225 @@
|
|
|
1
|
+
# A FHIR Patient search turned into a DHIS2 tracked entity, posted to /api/tracker by hand.
|
|
2
|
+
#
|
|
3
|
+
# THE FLOW, END TO END. Search the facade's Patient register by identifier, read one Patient
|
|
4
|
+
# out of the searchset Bundle, build the `/api/tracker` payload DHIS2 wants for a tracked
|
|
5
|
+
# entity and its enrollment, and post it with importMode=VALIDATE.
|
|
6
|
+
#
|
|
7
|
+
# WHY http.request AND NOT dhis2.tracker. `dhis2.tracker` READS: it is idempotent, it takes a
|
|
8
|
+
# `kind` of trackedEntities, enrollments or events, and it has no payload field at all. There
|
|
9
|
+
# is no tracker write block in the pack today, so a pipeline that writes tracker objects posts
|
|
10
|
+
# them itself. That works without a second credential, because `http.request` resolves a
|
|
11
|
+
# connection's base URL, basic credentials, TLS setting and timeout STRUCTURALLY, by field
|
|
12
|
+
# name, whatever kind the connection is -- so the `dhis2` connection this pack contributes
|
|
13
|
+
# drives an ordinary HTTP call. One caveat, and it is the reason this comment exists: only
|
|
14
|
+
# `basic_username`/`basic_password` are read that way. A dhis2 connection authenticating with
|
|
15
|
+
# `api_token` reaches `http.request` UNAUTHENTICATED, so a token-based instance needs an
|
|
16
|
+
# `http` connection carrying the token as a header instead.
|
|
17
|
+
#
|
|
18
|
+
# WHAT ARRIVES. The register projects each DHIS2 tracked entity as a Patient:
|
|
19
|
+
#
|
|
20
|
+
# {"resourceType": "Patient", "id": "coxwIkMqNBB",
|
|
21
|
+
# "meta": {"tag": [{"system": "http://dhis2.org/fhir/id/tracked-entity-type",
|
|
22
|
+
# "code": "nEenWmSyUEp"}]},
|
|
23
|
+
# "identifier": [
|
|
24
|
+
# {"system": "http://dhis2.org/fhir/id/tracked-entity", "value": "coxwIkMqNBB"},
|
|
25
|
+
# {"system": "http://dhis2.org/fhir/tracked-entity-attribute/AuPLng5hLbE",
|
|
26
|
+
# "value": "299554435"}],
|
|
27
|
+
# "extension": [{"url": ".../d2-tracked-entity-attribute-value",
|
|
28
|
+
# "extension": [{"url": "attributeId", "valueString": "w75KJ2mc4zz"},
|
|
29
|
+
# {"url": "value", "valueString": "Eden"}]}]}
|
|
30
|
+
#
|
|
31
|
+
# The split is deliberate and it is the whole mapping: a tracked entity attribute DHIS2
|
|
32
|
+
# declares `unique` is projected as an `identifier` under its own per-attribute system, and
|
|
33
|
+
# every other attribute rides as a `D2TrackedEntityAttributeValue` extension. Both are
|
|
34
|
+
# attributes on the way back in.
|
|
35
|
+
#
|
|
36
|
+
# WHAT LEAVES. One `/api/tracker` document:
|
|
37
|
+
#
|
|
38
|
+
# {"trackedEntities": [
|
|
39
|
+
# {"trackedEntity": "coxwIkMqNBB", "trackedEntityType": "nEenWmSyUEp",
|
|
40
|
+
# "orgUnit": "ImspTQPwCqd",
|
|
41
|
+
# "attributes": [{"attribute": "w75KJ2mc4zz", "value": "Eden"},
|
|
42
|
+
# {"attribute": "AuPLng5hLbE", "value": "299554435"}],
|
|
43
|
+
# "enrollments": [{"enrollment": "EnAaBbCcDd1", "program": "IpHINAT79UW",
|
|
44
|
+
# "orgUnit": "ImspTQPwCqd", "enrolledAt": "2026-09-01",
|
|
45
|
+
# "status": "ACTIVE"}]}]}
|
|
46
|
+
#
|
|
47
|
+
# WHICH SYSTEM BECOMES WHAT.
|
|
48
|
+
#
|
|
49
|
+
# meta.tag under .../id/tracked-entity-type -> trackedEntityType
|
|
50
|
+
# identifier under .../id/tracked-entity -> trackedEntity (the uid)
|
|
51
|
+
# identifier under .../tracked-entity-attribute/<uid> -> one attribute, uid from the system
|
|
52
|
+
# D2TrackedEntityAttributeValue extension -> one attribute, uid from attributeId
|
|
53
|
+
#
|
|
54
|
+
# WHERE A READER CHANGES IT. `program` is the tracker program to enrol into and `org_unit` the
|
|
55
|
+
# unit that owns the registration; both are DHIS2 uids and neither is anywhere in the Patient,
|
|
56
|
+
# because the register publishes people rather than enrollments. `search_identifier` is what
|
|
57
|
+
# picks the person: a bare value tries every key, `system|value` pins one.
|
|
58
|
+
#
|
|
59
|
+
# WHAT THIS DOES NOT DO. It does not mint an enrollment uid. A registration needs one, DHIS2
|
|
60
|
+
# does not generate it for this shape, and inventing an 11-character uid in jq would teach the
|
|
61
|
+
# wrong lesson -- so `enrollment_uid` is a parameter, which is exactly where a real forwarder
|
|
62
|
+
# derives one deterministically so a rehearsal and an import name the same object.
|
|
63
|
+
#
|
|
64
|
+
# dg run --local examples/fhir/fhir-patient-to-tracked-entity.yaml
|
|
65
|
+
# dg run --local examples/fhir/fhir-patient-to-tracked-entity.yaml -p search_identifier=299554435
|
|
66
|
+
|
|
67
|
+
format: dirigent/v1
|
|
68
|
+
kind: pipeline
|
|
69
|
+
code: fhir-patient-to-tracked-entity
|
|
70
|
+
name: FHIR Patient to a DHIS2 tracked entity
|
|
71
|
+
description: |
|
|
72
|
+
Search the facade's `Patient` register and build the `/api/tracker` payload for one tracked
|
|
73
|
+
entity and its enrollment, posted with `importMode=VALIDATE`.
|
|
74
|
+
|
|
75
|
+
`dhis2.tracker` reads and does not write, so the write is an ordinary `http.request` on the
|
|
76
|
+
`dhis2` connection -- which works because the HTTP blocks read `base_url` and the basic
|
|
77
|
+
credentials structurally, by field name, from a connection of any kind.
|
|
78
|
+
|
|
79
|
+
tags: [fhir, dhis2, cross-boundary]
|
|
80
|
+
|
|
81
|
+
requires:
|
|
82
|
+
blocks:
|
|
83
|
+
- http.request
|
|
84
|
+
- transform.jq
|
|
85
|
+
|
|
86
|
+
connections:
|
|
87
|
+
fhir-facade:
|
|
88
|
+
kind: http
|
|
89
|
+
config:
|
|
90
|
+
base_url: http://localhost:8095
|
|
91
|
+
timeout: 30s
|
|
92
|
+
# Driven by two different blocks below: `dhis2.*` blocks would resolve it through
|
|
93
|
+
# dhis2w-client, and `http.request` resolves the same record structurally. The password is a
|
|
94
|
+
# SecretStr either way -- sealed on the way in, redacted in every read back.
|
|
95
|
+
dhis2-demo:
|
|
96
|
+
kind: dhis2
|
|
97
|
+
config:
|
|
98
|
+
base_url: https://play.im.dhis2.org/stable-2-43-1
|
|
99
|
+
basic_username: admin
|
|
100
|
+
basic_password: district
|
|
101
|
+
timeout: 60s
|
|
102
|
+
|
|
103
|
+
params:
|
|
104
|
+
type: object
|
|
105
|
+
properties:
|
|
106
|
+
search_identifier:
|
|
107
|
+
type: string
|
|
108
|
+
description: The identifier token to find the person by; bare value, or system|value.
|
|
109
|
+
default: "299554435"
|
|
110
|
+
tracked_entity_type:
|
|
111
|
+
type: string
|
|
112
|
+
description: Which register to search, over meta.tag.
|
|
113
|
+
default: nEenWmSyUEp
|
|
114
|
+
program:
|
|
115
|
+
type: string
|
|
116
|
+
description: The tracker program to enrol into. Not in the Patient, because the register
|
|
117
|
+
publishes people and not enrollments.
|
|
118
|
+
default: IpHINAT79UW
|
|
119
|
+
org_unit:
|
|
120
|
+
type: string
|
|
121
|
+
description: The org unit owning the registration.
|
|
122
|
+
default: ImspTQPwCqd
|
|
123
|
+
enrollment_uid:
|
|
124
|
+
type: string
|
|
125
|
+
description: The enrollment's own uid, which the client mints. A real forwarder derives
|
|
126
|
+
it from the receipt so a rehearsal and an import name the same object.
|
|
127
|
+
default: EnAaBbCcDd1
|
|
128
|
+
enrolled_at:
|
|
129
|
+
type: string
|
|
130
|
+
description: The enrolment date, a zone-less wall clock in DHIS2's vocabulary.
|
|
131
|
+
default: "2026-09-01"
|
|
132
|
+
|
|
133
|
+
steps:
|
|
134
|
+
# The register answers exactly three search parameters -- identifier, _tag and d2-attribute
|
|
135
|
+
# -- and REFUSES anything else with a 400 naming the three, rather than ignoring it. That is
|
|
136
|
+
# the opposite of the definitional types, which ignore what they do not know: an unapplied
|
|
137
|
+
# filter on a register would otherwise answer with every person in the country.
|
|
138
|
+
find_patient:
|
|
139
|
+
block: http.request
|
|
140
|
+
config:
|
|
141
|
+
connection: fhir-facade
|
|
142
|
+
path: /Patient
|
|
143
|
+
method: GET
|
|
144
|
+
query:
|
|
145
|
+
identifier: "${params.search_identifier}"
|
|
146
|
+
_tag: "${params.tracked_entity_type}"
|
|
147
|
+
_count: 1
|
|
148
|
+
headers:
|
|
149
|
+
Accept: application/fhir+json
|
|
150
|
+
|
|
151
|
+
# Both attribute carriers are read here, and they union into one `attributes` array. The
|
|
152
|
+
# per-attribute identifier system ends in the attribute's own uid, so the uid is the last
|
|
153
|
+
# path segment of the system rather than a field of its own.
|
|
154
|
+
to_tracker_payload:
|
|
155
|
+
block: transform.jq
|
|
156
|
+
depends_on: [find_patient]
|
|
157
|
+
config:
|
|
158
|
+
input:
|
|
159
|
+
bundle: "${steps.find_patient.output.body}"
|
|
160
|
+
program: "${params.program}"
|
|
161
|
+
org_unit: "${params.org_unit}"
|
|
162
|
+
enrollment: "${params.enrollment_uid}"
|
|
163
|
+
enrolled_at: "${params.enrolled_at}"
|
|
164
|
+
program: |
|
|
165
|
+
. as {$bundle, $program, $org_unit, $enrollment, $enrolled_at}
|
|
166
|
+
| ($bundle.entry // [])[0].resource as $p
|
|
167
|
+
| if $p == null then error("the register answered no Patient for that identifier") else . end
|
|
168
|
+
| {trackedEntities: [{
|
|
169
|
+
trackedEntity: (
|
|
170
|
+
[$p.identifier[] | select(.system | endswith("/id/tracked-entity"))][0].value),
|
|
171
|
+
trackedEntityType: (
|
|
172
|
+
[$p.meta.tag[] | select(.system | endswith("/id/tracked-entity-type"))][0].code),
|
|
173
|
+
orgUnit: $org_unit,
|
|
174
|
+
attributes: (
|
|
175
|
+
[$p.identifier[]
|
|
176
|
+
| select(.system | contains("/tracked-entity-attribute/"))
|
|
177
|
+
| {attribute: (.system | split("/") | last), value: .value}]
|
|
178
|
+
+ [($p.extension // [])[]
|
|
179
|
+
| select(.url | endswith("/d2-tracked-entity-attribute-value"))
|
|
180
|
+
| {attribute: ([.extension[] | select(.url == "attributeId").valueString][0]),
|
|
181
|
+
value: ([.extension[] | select(.url == "value").valueString][0])}]),
|
|
182
|
+
enrollments: [{
|
|
183
|
+
enrollment: $enrollment,
|
|
184
|
+
program: $program,
|
|
185
|
+
orgUnit: $org_unit,
|
|
186
|
+
enrolledAt: $enrolled_at,
|
|
187
|
+
status: "ACTIVE"}]}]}
|
|
188
|
+
|
|
189
|
+
# A dry run on /api/tracker is importMode=VALIDATE. It is NOT dryRun=true -- that is
|
|
190
|
+
# /api/dataValueSets -- and reaching for the wrong spelling on either endpoint does not
|
|
191
|
+
# fail, it imports.
|
|
192
|
+
#
|
|
193
|
+
# A payload carrying `trackedEntities` implies CREATE_AND_UPDATE. Enrolling a person who
|
|
194
|
+
# already exists is a different document -- a top-level `enrollments` array with no
|
|
195
|
+
# trackedEntities wrapper, under plain CREATE -- because the wrapper would rewrite the
|
|
196
|
+
# person's owning org unit on the way past.
|
|
197
|
+
rehearse_import:
|
|
198
|
+
block: http.request
|
|
199
|
+
depends_on: [to_tracker_payload]
|
|
200
|
+
config:
|
|
201
|
+
connection: dhis2-demo
|
|
202
|
+
path: /api/tracker
|
|
203
|
+
method: POST
|
|
204
|
+
query:
|
|
205
|
+
importMode: VALIDATE
|
|
206
|
+
headers:
|
|
207
|
+
Content-Type: application/json
|
|
208
|
+
body: "${steps.to_tracker_payload.output.value}"
|
|
209
|
+
# /api/tracker answers an async job with 200 and a completed one with 200 or 409; the
|
|
210
|
+
# report is in the body either way, so a 409 is a verdict to read rather than a
|
|
211
|
+
# transport failure to fail the step on.
|
|
212
|
+
success_status: [200, 201, 409]
|
|
213
|
+
|
|
214
|
+
# The tracker import report, not the HTTP status. `status` is the instance's own word, and
|
|
215
|
+
# validationReport.errorReports names every refused object with its DHIS2 error code.
|
|
216
|
+
read_report:
|
|
217
|
+
block: transform.jq
|
|
218
|
+
depends_on: [rehearse_import]
|
|
219
|
+
config:
|
|
220
|
+
input: "${steps.rehearse_import.output.body}"
|
|
221
|
+
program: |
|
|
222
|
+
{status: .status,
|
|
223
|
+
stats: (.stats // .response.stats),
|
|
224
|
+
errors: [((.validationReport // .response.validationReport).errorReports // [])[]
|
|
225
|
+
| {code: .errorCode, uid: .uid, message: .message}]}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# One QuestionnaireResponse mapped onto data elements by a linkId lookup the document carries.
|
|
2
|
+
#
|
|
3
|
+
# THE FLOW, END TO END. Read one capture off the facade by its receipt id, translate its
|
|
4
|
+
# answers into data values through an explicit linkId-to-data-element table held in params,
|
|
5
|
+
# and import the result -- dry run first.
|
|
6
|
+
#
|
|
7
|
+
# WHY A LOOKUP AT ALL. On a `d2w fhir serve` facade the linkIds already ARE DHIS2 uids, so
|
|
8
|
+
# capture-bundle-to-data-values.yaml needs no table: it splits the dotted linkId and is done.
|
|
9
|
+
# Every other FHIR server in the world uses linkIds a form designer chose -- "anc.visits.total",
|
|
10
|
+
# "q3b", "weight_kg" -- and then the table IS the integration. This document is the general
|
|
11
|
+
# case, and the facade is only where the example gets a real response to run against.
|
|
12
|
+
#
|
|
13
|
+
# WHAT ARRIVES. One QuestionnaireResponse, answers keyed by whatever the form calls them:
|
|
14
|
+
#
|
|
15
|
+
# {"resourceType": "QuestionnaireResponse", "status": "completed",
|
|
16
|
+
# "subject": {"reference": "Location/dczh6Jfd4no"},
|
|
17
|
+
# "item": [{"linkId": "anc.visits.first", "answer": [{"valueInteger": 42}]},
|
|
18
|
+
# {"linkId": "anc.visits.followup", "answer": [{"valueInteger": 17}]},
|
|
19
|
+
# {"linkId": "anc.notes", "answer": [{"valueString": "clinic closed on the 4th"}]}]}
|
|
20
|
+
#
|
|
21
|
+
# WHAT LEAVES. The same `/api/dataValueSets` envelope every aggregate import wants:
|
|
22
|
+
#
|
|
23
|
+
# {"dataSet": "lyLU2wR22tC", "period": "202608", "orgUnit": "dczh6Jfd4no",
|
|
24
|
+
# "dataValues": [{"dataElement": "GMd99K8gVut", "categoryOptionCombo": "qNCMOhkoQju",
|
|
25
|
+
# "value": "42"}]}
|
|
26
|
+
#
|
|
27
|
+
# WHERE A READER CHANGES IT. `link_ids` below, and nowhere else. Each entry maps one linkId to
|
|
28
|
+
# the DHIS2 cell it means: `data_element` alone for an undisaggregated data element, plus
|
|
29
|
+
# `category_option_combo` for one column of a category combination. A linkId absent from the
|
|
30
|
+
# table is dropped rather than guessed at, and the drop is counted and reported, because an
|
|
31
|
+
# answer that silently vanishes is worse than an import that refuses.
|
|
32
|
+
#
|
|
33
|
+
# dg run --local examples/fhir/fhir-questionnaire-response-to-data-values.yaml
|
|
34
|
+
# dg run --local examples/fhir/fhir-questionnaire-response-to-data-values.yaml -p period=202607
|
|
35
|
+
|
|
36
|
+
format: dirigent/v1
|
|
37
|
+
kind: pipeline
|
|
38
|
+
code: fhir-questionnaire-response-to-data-values
|
|
39
|
+
name: QuestionnaireResponse through a linkId table
|
|
40
|
+
description: |
|
|
41
|
+
Map one `QuestionnaireResponse` onto DHIS2 data values through an explicit linkId lookup,
|
|
42
|
+
for the general case where a form's `linkId` is a designer's name rather than a DHIS2 uid.
|
|
43
|
+
|
|
44
|
+
A linkId the table does not name is dropped and counted, never guessed at.
|
|
45
|
+
|
|
46
|
+
tags: [fhir, dhis2, cross-boundary]
|
|
47
|
+
|
|
48
|
+
requires:
|
|
49
|
+
blocks:
|
|
50
|
+
- value.const
|
|
51
|
+
- transform.jq
|
|
52
|
+
- dhis2.data_value_set_import
|
|
53
|
+
|
|
54
|
+
connections:
|
|
55
|
+
dhis2-demo:
|
|
56
|
+
kind: dhis2
|
|
57
|
+
config:
|
|
58
|
+
base_url: https://play.im.dhis2.org/stable-2-43-1
|
|
59
|
+
basic_username: admin
|
|
60
|
+
basic_password: district
|
|
61
|
+
timeout: 60s
|
|
62
|
+
|
|
63
|
+
params:
|
|
64
|
+
type: object
|
|
65
|
+
properties:
|
|
66
|
+
data_set:
|
|
67
|
+
type: string
|
|
68
|
+
description: The DHIS2 data set the answers belong to.
|
|
69
|
+
default: lyLU2wR22tC
|
|
70
|
+
org_unit:
|
|
71
|
+
type: string
|
|
72
|
+
description: The org unit reporting, which a real capture reads off subject.reference.
|
|
73
|
+
default: dczh6Jfd4no
|
|
74
|
+
period:
|
|
75
|
+
type: string
|
|
76
|
+
description: The DHIS2 ISO period, which is a period identifier and not a date range.
|
|
77
|
+
default: "202608"
|
|
78
|
+
link_ids:
|
|
79
|
+
type: object
|
|
80
|
+
description: |
|
|
81
|
+
The mapping. One entry per linkId the form can answer: data_element on its own is an
|
|
82
|
+
undisaggregated data element, and category_option_combo names one column of a
|
|
83
|
+
category combination.
|
|
84
|
+
default:
|
|
85
|
+
anc.visits.first:
|
|
86
|
+
data_element: GMd99K8gVut
|
|
87
|
+
category_option_combo: qNCMOhkoQju
|
|
88
|
+
anc.visits.followup:
|
|
89
|
+
data_element: GMd99K8gVut
|
|
90
|
+
category_option_combo: LbeIlyHEhKr
|
|
91
|
+
anc.staff.count:
|
|
92
|
+
data_element: wfKKFhBn0Q0
|
|
93
|
+
|
|
94
|
+
steps:
|
|
95
|
+
# The capture, written inline so the document runs with nothing set up. In service it is the
|
|
96
|
+
# body of a webhook delivery (fhir-subscription-webhook-to-dhis2.yaml) or one entry of a
|
|
97
|
+
# searchset Bundle (capture-bundle-to-data-values.yaml); either way this step's output is
|
|
98
|
+
# exactly one QuestionnaireResponse, and nothing downstream knows where it came from.
|
|
99
|
+
receive:
|
|
100
|
+
block: value.const
|
|
101
|
+
config:
|
|
102
|
+
value:
|
|
103
|
+
resourceType: QuestionnaireResponse
|
|
104
|
+
status: completed
|
|
105
|
+
authored: "2026-08-31T09:00:00Z"
|
|
106
|
+
questionnaire: http://example.org/fhir/Questionnaire/anc-monthly
|
|
107
|
+
subject:
|
|
108
|
+
reference: Location/dczh6Jfd4no
|
|
109
|
+
item:
|
|
110
|
+
- linkId: anc.visits.first
|
|
111
|
+
text: First antenatal visits
|
|
112
|
+
answer:
|
|
113
|
+
- valueInteger: 42
|
|
114
|
+
- linkId: anc.visits.followup
|
|
115
|
+
text: Follow-up antenatal visits
|
|
116
|
+
answer:
|
|
117
|
+
- valueInteger: 17
|
|
118
|
+
- linkId: anc.staff.count
|
|
119
|
+
text: Midwives on duty
|
|
120
|
+
answer:
|
|
121
|
+
- valueInteger: 3
|
|
122
|
+
# Answered, and mapped to nothing. It must not reach DHIS2, and it must not vanish
|
|
123
|
+
# in silence either: the translation counts it.
|
|
124
|
+
- linkId: anc.notes
|
|
125
|
+
text: Notes
|
|
126
|
+
answer:
|
|
127
|
+
- valueString: clinic closed on the 4th
|
|
128
|
+
# Asked and not answered. An absent answer and a zero are different facts.
|
|
129
|
+
- linkId: anc.visits.referred
|
|
130
|
+
text: Referrals
|
|
131
|
+
|
|
132
|
+
# The translation. It hands on the import document AND the drops beside it, so the receipt
|
|
133
|
+
# step below can report what did not map without re-deriving anything.
|
|
134
|
+
translate:
|
|
135
|
+
block: transform.jq
|
|
136
|
+
depends_on: [receive]
|
|
137
|
+
config:
|
|
138
|
+
input:
|
|
139
|
+
response: "${steps.receive.output.value}"
|
|
140
|
+
link_ids: "${params.link_ids}"
|
|
141
|
+
data_set: "${params.data_set}"
|
|
142
|
+
org_unit: "${params.org_unit}"
|
|
143
|
+
period: "${params.period}"
|
|
144
|
+
program: |
|
|
145
|
+
. as {$response, $link_ids, $data_set, $org_unit, $period}
|
|
146
|
+
| [$response.item[] | recurse(.item[]?) | select((.answer // []) | length > 0)] as $answered
|
|
147
|
+
| {document: {
|
|
148
|
+
dataSet: $data_set,
|
|
149
|
+
period: $period,
|
|
150
|
+
orgUnit: $org_unit,
|
|
151
|
+
dataValues: [
|
|
152
|
+
$answered[]
|
|
153
|
+
| select($link_ids[.linkId] != null)
|
|
154
|
+
| $link_ids[.linkId] as $cell
|
|
155
|
+
| (.answer[0] | (.valueDecimal // .valueInteger // .valueString // .valueBoolean)) as $v
|
|
156
|
+
| {dataElement: $cell.data_element, value: ($v | tostring)}
|
|
157
|
+
+ (if $cell.category_option_combo
|
|
158
|
+
then {categoryOptionCombo: $cell.category_option_combo} else {} end)]},
|
|
159
|
+
unmapped: [$answered[] | select($link_ids[.linkId] == null) | .linkId]}
|
|
160
|
+
|
|
161
|
+
rehearse:
|
|
162
|
+
block: dhis2.data_value_set_import
|
|
163
|
+
depends_on: [translate]
|
|
164
|
+
config:
|
|
165
|
+
connection: dhis2-demo
|
|
166
|
+
data_values: "${steps.translate.output.value.document}"
|
|
167
|
+
# dryRun on /api/dataValueSets. The instance validates every value against the data set,
|
|
168
|
+
# the period and the org unit and reports conflicts, and writes nothing.
|
|
169
|
+
dry_run: true
|
|
170
|
+
atomic_mode: ALL
|
|
171
|
+
|
|
172
|
+
# What the run is for: the import summary's counts beside the linkIds nothing claimed. A
|
|
173
|
+
# growing `unmapped` list is the table falling behind the form, which is the failure mode
|
|
174
|
+
# this whole shape exists to make visible.
|
|
175
|
+
receipt:
|
|
176
|
+
block: transform.jq
|
|
177
|
+
depends_on: [rehearse]
|
|
178
|
+
config:
|
|
179
|
+
input:
|
|
180
|
+
summary: "${steps.rehearse.output}"
|
|
181
|
+
unmapped: "${steps.translate.output.value.unmapped}"
|
|
182
|
+
program: |
|
|
183
|
+
{rehearsed: .summary.status,
|
|
184
|
+
would_import: .summary.imported,
|
|
185
|
+
would_update: .summary.updated,
|
|
186
|
+
conflicts: (.summary.conflicts | length),
|
|
187
|
+
unmapped_link_ids: .unmapped}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# A FHIR Subscription notification delivered to a webhook, translated, and imported.
|
|
2
|
+
#
|
|
3
|
+
# THE FLOW, END TO END. A FHIR server holds a Subscription that watches for new captures. When
|
|
4
|
+
# one arrives it POSTs a notification to this pipeline's webhook endpoint; the webhook maps the
|
|
5
|
+
# notified resource onto a parameter, the document translates it into a data value set, and the
|
|
6
|
+
# import runs as a dry run.
|
|
7
|
+
#
|
|
8
|
+
# THE SUBSCRIPTION CONTRACT, WHICH IS THE POINT OF THIS FILE. A FHIR Subscription is a standing
|
|
9
|
+
# request: "when a resource matching this search appears, tell me here". Three fields carry it.
|
|
10
|
+
#
|
|
11
|
+
# criteria a search string, exactly as it would be typed against the server, e.g.
|
|
12
|
+
# "QuestionnaireResponse?questionnaire=http://example.org/fhir/Questionnaire/lyLU2wR22tC"
|
|
13
|
+
# channel.type how to be told. `rest-hook` is an HTTP POST, which is what a dirigent
|
|
14
|
+
# webhook is; the others are websocket, email, and message.
|
|
15
|
+
# channel.endpoint where to POST it -- the hooks URL the instance minted for this pipeline.
|
|
16
|
+
#
|
|
17
|
+
# What arrives in that POST depends on `channel.payload`, and the three choices are a real
|
|
18
|
+
# decision rather than a formality:
|
|
19
|
+
#
|
|
20
|
+
# absent a PING. An empty body: "something matched, come and look". The pipeline
|
|
21
|
+
# then has to search the server itself, which is more work and is also the
|
|
22
|
+
# only choice that never puts patient data in a webhook body.
|
|
23
|
+
# id-only the notification names the resource; the pipeline reads it back.
|
|
24
|
+
# full-resource the resource rides in the notification. One request, no read-back, and
|
|
25
|
+
# the delivery is now carrying clinical data -- so it is signed, and the
|
|
26
|
+
# endpoint is not on the public internet.
|
|
27
|
+
#
|
|
28
|
+
# This document takes full-resource, because it is the shape that shows the whole translation
|
|
29
|
+
# in one file. In R4 that body is the bare resource; R5 and the R4 backport wrap it in a
|
|
30
|
+
# history Bundle whose first entry is a SubscriptionStatus and whose second is the resource,
|
|
31
|
+
# which is the shape mapped below and the one most servers now send.
|
|
32
|
+
#
|
|
33
|
+
# `d2w fhir serve` HAS NO SUBSCRIPTION. Its capture surface is `POST /QuestionnaireResponse`
|
|
34
|
+
# and its notification surface is the receipt spool -- `GET /facade/spool`, whose lifecycle
|
|
35
|
+
# moves received -> forwarded or rejected. A pipeline pulls that spool on a schedule instead
|
|
36
|
+
# (fhir-nightly-window-sync.yaml is that shape). This document is for the FHIR server that DOES
|
|
37
|
+
# support Subscription, which is most of them, and it is written so the same translation serves
|
|
38
|
+
# either way.
|
|
39
|
+
#
|
|
40
|
+
# WHAT ARRIVES. The notification body:
|
|
41
|
+
#
|
|
42
|
+
# {"resourceType": "Bundle", "type": "history",
|
|
43
|
+
# "entry": [
|
|
44
|
+
# {"resource": {"resourceType": "SubscriptionStatus", "type": "event-notification",
|
|
45
|
+
# "eventsSinceSubscriptionStart": "17"}},
|
|
46
|
+
# {"resource": {"resourceType": "QuestionnaireResponse", "status": "completed",
|
|
47
|
+
# "questionnaire": ".../Questionnaire/lyLU2wR22tC",
|
|
48
|
+
# "subject": {"reference": "Location/dczh6Jfd4no"},
|
|
49
|
+
# "extension": [{"url": ".../d2-period",
|
|
50
|
+
# "extension": [{"url": "iso", "valueString": "202608"}]}],
|
|
51
|
+
# "item": [...]}}]}
|
|
52
|
+
#
|
|
53
|
+
# WHAT LEAVES. The `/api/dataValueSets` envelope, same as every inbound document here:
|
|
54
|
+
#
|
|
55
|
+
# {"dataSet": "lyLU2wR22tC", "period": "202608", "orgUnit": "dczh6Jfd4no",
|
|
56
|
+
# "dataValues": [{"dataElement": "GMd99K8gVut",
|
|
57
|
+
# "categoryOptionCombo": "qNCMOhkoQju", "value": "426.3"}]}
|
|
58
|
+
#
|
|
59
|
+
# WHERE A READER CHANGES IT. The webhook's `params_from_payload` at the foot of this file, and
|
|
60
|
+
# nothing else. The mapping is STRICT: each parameter names one dotted path into the body, a
|
|
61
|
+
# path the body does not have refuses the delivery, and a parameter the mapping does not
|
|
62
|
+
# mention cannot be reached by any caller whatever they POST. A server sending the bare R4
|
|
63
|
+
# resource rather than a Bundle changes `capture` to `$` -- which this syntax spells as the
|
|
64
|
+
# resource's own top-level keys, so the mapping names them.
|
|
65
|
+
#
|
|
66
|
+
# WHAT LIVES ON THE INSTANCE AND NOT IN GIT. The endpoint's token, whether it is active, and
|
|
67
|
+
# every delivery it has taken. `dg webhook rotate-token` shows a token exactly once.
|
|
68
|
+
#
|
|
69
|
+
# dg run --local examples/fhir/fhir-subscription-webhook-to-dhis2.yaml
|
|
70
|
+
#
|
|
71
|
+
# Against an instance, where the endpoint exists:
|
|
72
|
+
#
|
|
73
|
+
# dg apply examples/fhir/fhir-subscription-webhook-to-dhis2.yaml
|
|
74
|
+
# dg webhook rotate-token fhir-subscription-webhook-to-dhis2 fhir-notification
|
|
75
|
+
# curl -X POST "$DG_URL/hooks/$TOKEN" -H 'Content-Type: application/json' --data @notification.json
|
|
76
|
+
|
|
77
|
+
format: dirigent/v1
|
|
78
|
+
kind: pipeline
|
|
79
|
+
code: fhir-subscription-webhook-to-dhis2
|
|
80
|
+
name: A FHIR Subscription notification imported
|
|
81
|
+
description: |
|
|
82
|
+
Take a FHIR `Subscription` rest-hook notification on a webhook, translate the notified
|
|
83
|
+
`QuestionnaireResponse` into a DHIS2 data value set, and rehearse the import.
|
|
84
|
+
|
|
85
|
+
The subscription contract is three fields: `criteria` (a search string), `channel.type`
|
|
86
|
+
(`rest-hook` is an HTTP POST), and `channel.endpoint` (the hooks URL). `channel.payload`
|
|
87
|
+
decides whether the delivery is a ping, an id, or the resource itself -- this takes the
|
|
88
|
+
resource, wrapped in the history `Bundle` R5 and the R4 backport send.
|
|
89
|
+
|
|
90
|
+
tags: [fhir, dhis2, cross-boundary]
|
|
91
|
+
|
|
92
|
+
requires:
|
|
93
|
+
blocks:
|
|
94
|
+
- transform.jq
|
|
95
|
+
- dhis2.data_value_set_import
|
|
96
|
+
|
|
97
|
+
connections:
|
|
98
|
+
dhis2-demo:
|
|
99
|
+
kind: dhis2
|
|
100
|
+
config:
|
|
101
|
+
base_url: https://play.im.dhis2.org/stable-2-43-1
|
|
102
|
+
basic_username: admin
|
|
103
|
+
basic_password: district
|
|
104
|
+
timeout: 60s
|
|
105
|
+
|
|
106
|
+
params:
|
|
107
|
+
type: object
|
|
108
|
+
properties:
|
|
109
|
+
capture:
|
|
110
|
+
type: object
|
|
111
|
+
description: The notified QuestionnaireResponse. The default is what a delivery would
|
|
112
|
+
have carried, so the document runs without one.
|
|
113
|
+
default:
|
|
114
|
+
resourceType: QuestionnaireResponse
|
|
115
|
+
status: completed
|
|
116
|
+
authored: "2026-08-31T09:00:00Z"
|
|
117
|
+
questionnaire: http://example.org/fhir/Questionnaire/lyLU2wR22tC
|
|
118
|
+
subject:
|
|
119
|
+
reference: Location/dczh6Jfd4no
|
|
120
|
+
extension:
|
|
121
|
+
- url: http://example.org/fhir/StructureDefinition/d2-period
|
|
122
|
+
extension:
|
|
123
|
+
- url: iso
|
|
124
|
+
valueString: "202608"
|
|
125
|
+
- url: type
|
|
126
|
+
valueCode: Monthly
|
|
127
|
+
- url: http://example.org/fhir/StructureDefinition/d2-attribute-option-combo
|
|
128
|
+
valueCoding:
|
|
129
|
+
code: N7QFN41eTN8
|
|
130
|
+
item:
|
|
131
|
+
- linkId: GMd99K8gVut
|
|
132
|
+
item:
|
|
133
|
+
- linkId: GMd99K8gVut.qNCMOhkoQju
|
|
134
|
+
answer:
|
|
135
|
+
- valueDecimal: 426.3
|
|
136
|
+
- linkId: GMd99K8gVut.LbeIlyHEhKr
|
|
137
|
+
answer:
|
|
138
|
+
- valueDecimal: 628.2
|
|
139
|
+
- linkId: wfKKFhBn0Q0
|
|
140
|
+
answer:
|
|
141
|
+
- valueDecimal: 142.1
|
|
142
|
+
events_since_start:
|
|
143
|
+
type: string
|
|
144
|
+
description: The SubscriptionStatus counter. A gap between two deliveries means a
|
|
145
|
+
notification was lost, which is the only way this channel says so.
|
|
146
|
+
default: "0"
|
|
147
|
+
|
|
148
|
+
steps:
|
|
149
|
+
# Only a completed response is translated. An in-progress one is a form still being filled,
|
|
150
|
+
# and a notification is allowed to fire on it -- `criteria` matches a resource, not a state.
|
|
151
|
+
translate:
|
|
152
|
+
block: transform.jq
|
|
153
|
+
config:
|
|
154
|
+
input: "${params.capture}"
|
|
155
|
+
program: |
|
|
156
|
+
if .status != "completed" then
|
|
157
|
+
error("the notified response is \(.status), not completed; nothing to import")
|
|
158
|
+
else . end
|
|
159
|
+
| . as $r
|
|
160
|
+
| {dataSet: ($r.questionnaire | split("/") | last),
|
|
161
|
+
period: ([$r.extension[] | select(.url | endswith("/d2-period")).extension[]
|
|
162
|
+
| select(.url == "iso").valueString][0]),
|
|
163
|
+
orgUnit: ($r.subject.reference | ltrimstr("Location/")),
|
|
164
|
+
attributeOptionCombo: (
|
|
165
|
+
[$r.extension[] | select(.url | endswith("/d2-attribute-option-combo"))][0]
|
|
166
|
+
| .valueCoding.code),
|
|
167
|
+
dataValues: [
|
|
168
|
+
$r.item[] | recurse(.item[]?)
|
|
169
|
+
| select((.answer // []) | length > 0)
|
|
170
|
+
| (.linkId | split(".")) as $key
|
|
171
|
+
| (.answer[0] | (.valueDecimal // .valueInteger // .valueString // .valueBoolean)) as $v
|
|
172
|
+
| {dataElement: $key[0], value: ($v | tostring)}
|
|
173
|
+
+ (if ($key | length) > 1 then {categoryOptionCombo: $key[1]} else {} end)]}
|
|
174
|
+
| del(.attributeOptionCombo | select(. == null))
|
|
175
|
+
|
|
176
|
+
# A notification arrives once and is not replayed, so the import's retry budget is what
|
|
177
|
+
# stands between a momentary DHIS2 and a lost capture. dry_run makes this a rehearsal;
|
|
178
|
+
# dropping it is the whole difference between this document and a live forwarder.
|
|
179
|
+
rehearse:
|
|
180
|
+
block: dhis2.data_value_set_import
|
|
181
|
+
depends_on: [translate]
|
|
182
|
+
retry:
|
|
183
|
+
# The budget counts the first try, so this is one attempt and two retries.
|
|
184
|
+
max_attempts: 3
|
|
185
|
+
backoff: 5s
|
|
186
|
+
config:
|
|
187
|
+
connection: dhis2-demo
|
|
188
|
+
data_values: "${steps.translate.output.value}"
|
|
189
|
+
dry_run: true
|
|
190
|
+
atomic_mode: ALL
|
|
191
|
+
|
|
192
|
+
receipt:
|
|
193
|
+
block: transform.jq
|
|
194
|
+
depends_on: [rehearse]
|
|
195
|
+
config:
|
|
196
|
+
input:
|
|
197
|
+
summary: "${steps.rehearse.output}"
|
|
198
|
+
events_since_start: "${params.events_since_start}"
|
|
199
|
+
document: "${steps.translate.output.value}"
|
|
200
|
+
program: |
|
|
201
|
+
{notification: .events_since_start,
|
|
202
|
+
data_set: .document.dataSet,
|
|
203
|
+
period: .document.period,
|
|
204
|
+
org_unit: .document.orgUnit,
|
|
205
|
+
status: .summary.status,
|
|
206
|
+
would_import: .summary.imported,
|
|
207
|
+
conflicts: (.summary.conflicts | length)}
|
|
208
|
+
|
|
209
|
+
triggers:
|
|
210
|
+
webhooks:
|
|
211
|
+
# The whole declaration. `capture` takes the notified resource out of the history Bundle's
|
|
212
|
+
# second entry -- the first is the SubscriptionStatus -- and the counter comes off that
|
|
213
|
+
# status. Both are validated against params above before a run is created, so a delivery
|
|
214
|
+
# that does not carry them is refused rather than half-run.
|
|
215
|
+
- code: fhir-notification
|
|
216
|
+
params_from_payload:
|
|
217
|
+
capture: "$.entry.1.resource"
|
|
218
|
+
events_since_start: "$.entry.0.resource.eventsSinceSubscriptionStart"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Inbound examples
|
|
2
|
+
|
|
3
|
+
Eight pipelines going the same direction: an outside system, a transform, DHIS2. They are
|
|
4
|
+
authored here rather than in a pack because each one spans several -- the http, storage,
|
|
5
|
+
transform and parquet blocks the runtime contributes, feeding the blocks `dirigent-dhis2`
|
|
6
|
+
contributes -- and no single pack can hold, or test, a pipeline that needs them all.
|
|
7
|
+
|
|
8
|
+
Every one of them:
|
|
9
|
+
|
|
10
|
+
- names every block it uses under `requires: blocks:`, so an environment missing one refuses
|
|
11
|
+
the document rather than failing halfway through a run;
|
|
12
|
+
- carries its DHIS2 connection in the document, pointed at the public play server, so
|
|
13
|
+
`dg run --local <file>` works with nothing applied and nothing configured;
|
|
14
|
+
- imports with `dry_run` defaulting to true. The play server is shared: leave it that way
|
|
15
|
+
there, and turn it off against an instance of your own;
|
|
16
|
+
- uses ids that exist on the demo. `WUg3MYWQ7pt` (Total Population, yearly) and
|
|
17
|
+
`x0PshcPLSk1` / `wZqi8EXN5x4` (PMTCT monthly) at Ngelehun CHC, `DiszpKrYNg8`, are the
|
|
18
|
+
defaults, because those are elements the demo will actually accept values for.
|
|
19
|
+
|
|
20
|
+
The two that read object storage need an `s3` connection bound to the scheme as an instance
|
|
21
|
+
setting (`storage_connections: {s3: <code>}`); the rest need only a network.
|
|
22
|
+
|
|
23
|
+
| Example | What it teaches |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| [`http-json-to-data-values.yaml`](http-json-to-data-values.yaml) | The shortest inbound path: pull a public JSON API, reshape it, gate it on DHIS2's own id and period formats, import it as a dry run. |
|
|
26
|
+
| [`csv-drop-to-data-values.yaml`](csv-drop-to-data-values.yaml) | The file drop: wait for a csv to land in a bucket, decode it, translate the sender's codes to uids through a lookup, drop the rows that carry no measurement. |
|
|
27
|
+
| [`webhook-payload-to-tracker-event.yaml`](webhook-payload-to-tracker-event.yaml) | Push instead of poll: a webhook maps one posted encounter onto parameters, and that mapping is the whole security boundary. |
|
|
28
|
+
| [`parquet-lakehouse-to-dhis2.yaml`](parquet-lakehouse-to-dhis2.yaml) | Grain: a parquet table of encounter-level rows reduced to the figures DHIS2 stores, then a completeness sensor that observes rather than registers. |
|
|
29
|
+
| [`fhir-observations-to-data-values.yaml`](fhir-observations-to-data-values.yaml) | Two standards meeting: LOINC codes mapped onto data elements, instants truncated to periods, and the org unit the payload cannot supply. |
|
|
30
|
+
| [`weekly-window-pull-and-import.yaml`](weekly-window-pull-and-import.yaml) | The window: a schedule whose runs pull the week that closed, `${run.window.start}` to `${run.window.end}`, so a late run and a backfill read the same week. |
|
|
31
|
+
| [`fan-out-per-facility-import.yaml`](fan-out-per-facility-import.yaml) | One call per facility with `items: continue`, why the fan converges before the import, and a reconciliation of asked-for against answered. |
|
|
32
|
+
| [`outside-to-dhis2-with-checks.yaml`](outside-to-dhis2-with-checks.yaml) | Every guard at once: a readiness sensor, a schema on what arrived, a schema on what was built, a rehearsed import, and the real one behind it. |
|