fhir-sdc-testkit 3.0.0a1__tar.gz
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.
- fhir_sdc_testkit-3.0.0a1/.gitignore +5 -0
- fhir_sdc_testkit-3.0.0a1/LICENSE +21 -0
- fhir_sdc_testkit-3.0.0a1/PKG-INFO +141 -0
- fhir_sdc_testkit-3.0.0a1/README.md +121 -0
- fhir_sdc_testkit-3.0.0a1/fhir_sdc_testkit/__init__.py +14 -0
- fhir_sdc_testkit-3.0.0a1/fhir_sdc_testkit/_version.py +24 -0
- fhir_sdc_testkit-3.0.0a1/fhir_sdc_testkit/extraction.py +48 -0
- fhir_sdc_testkit-3.0.0a1/fhir_sdc_testkit/forms.py +202 -0
- fhir_sdc_testkit-3.0.0a1/fhir_sdc_testkit/sugar.py +43 -0
- fhir_sdc_testkit-3.0.0a1/pyproject.toml +73 -0
- fhir_sdc_testkit-3.0.0a1/tests/test_extraction.py +60 -0
- fhir_sdc_testkit-3.0.0a1/tests/test_forms.py +246 -0
- fhir_sdc_testkit-3.0.0a1/tests/test_sugar.py +67 -0
- fhir_sdc_testkit-3.0.0a1/uv.lock +1466 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 beda.software
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fhir-sdc-testkit
|
|
3
|
+
Version: 3.0.0a1
|
|
4
|
+
Summary: Drive an SDC Questionnaire through $populate, answers and $extract in tests
|
|
5
|
+
Project-URL: Homepage, https://github.com/beda-software/fhir-sdc
|
|
6
|
+
Project-URL: Source, https://github.com/beda-software/fhir-sdc/tree/master/fhir-sdc-testkit
|
|
7
|
+
Author-email: "beda.software" <hi@beda.software>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: fhir,pytest,questionnaire,sdc,testing
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: Pytest
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: fhirpy-types-r4b>=0.1.1
|
|
18
|
+
Requires-Dist: fhirpy>=2.2.0
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# fhir-sdc-testkit
|
|
22
|
+
|
|
23
|
+
Test an SDC Questionnaire and its extraction the way a form is really filled: `$populate` runs, answers are placed at
|
|
24
|
+
each `linkId`'s nesting, then `QuestionnaireResponse/$extract` runs. With `submit=True` the QuestionnaireResponse is
|
|
25
|
+
saved before extraction and the bundle is submitted after it. Only what a user types (or the frontend calculates) is
|
|
26
|
+
faked; everything else runs on
|
|
27
|
+
a real server implementing the [SDC](https://build.fhir.org/ig/HL7/sdc/) operations, such as
|
|
28
|
+
[fhir-sdc](../README.md).
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install fhir-sdc-testkit
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
It is released from the fhir-sdc repository, with the same version as the fhir-sdc release it is tested against.
|
|
35
|
+
|
|
36
|
+
## The client
|
|
37
|
+
|
|
38
|
+
Everything takes an `AsyncFHIRClient` rooted at the server's `/fhir` base, built with `dump_resource` — fhirpy cannot
|
|
39
|
+
serialize pydantic resources without it:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
fhir_client = AsyncFHIRClient(
|
|
43
|
+
f"{base_url}/fhir",
|
|
44
|
+
authorization=authorization,
|
|
45
|
+
dump_resource=lambda resource: resource.model_dump(),
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import fhirpy_types_r4b as r4b
|
|
53
|
+
from fhir_sdc_testkit import answer, launch_context, populate_and_extract
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def test_intake_records_the_address(fhir_client):
|
|
57
|
+
patient = await fhir_client.create(r4b.Patient(name=[r4b.HumanName(family="Test")]))
|
|
58
|
+
|
|
59
|
+
await populate_and_extract(
|
|
60
|
+
fhir_client,
|
|
61
|
+
"patient-intake",
|
|
62
|
+
parameters=launch_context(
|
|
63
|
+
{"Patient": patient}, subject=r4b.Reference(reference=f"Patient/{patient.id}")
|
|
64
|
+
),
|
|
65
|
+
answers={"postal-code": [answer(valueString="75001")]},
|
|
66
|
+
submit=True,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
updated = await fhir_client.get(r4b.Patient, patient.id)
|
|
70
|
+
assert updated.address[0].postalCode == "75001"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`populate_and_extract` returns the QuestionnaireResponse and the bundle `$extract` returned (`None` when there is
|
|
74
|
+
nothing to extract). Without `submit` neither is saved, so the bundle can be asserted on directly. With it, the
|
|
75
|
+
response is the saved one — saved first, since a Mapping may reference it by id — and the bundle has been submitted.
|
|
76
|
+
|
|
77
|
+
`populate` and `extract` run the two operations on their own and save nothing.
|
|
78
|
+
|
|
79
|
+
### Answers
|
|
80
|
+
|
|
81
|
+
Keys are `linkId`s, addressed flatly — groups in between are created for you:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
answers={
|
|
85
|
+
"country": [answer(valueCoding=r4b.Coding(code="FR"))],
|
|
86
|
+
"postal-code": [answer(valueString="75001")],
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A repeating group takes one answer set per occurrence, filled positionally over the occurrences `$populate` produced,
|
|
91
|
+
appending any that are missing and leaving extra populated ones alone:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
answers={
|
|
95
|
+
"contacts": [
|
|
96
|
+
{"phone": [answer(valueString="+1 555 0100")], "label": [answer(valueString="home")]},
|
|
97
|
+
{"phone": [answer(valueString="+1 555 0111")], "label": [answer(valueString="work")]},
|
|
98
|
+
],
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Inside a set, `linkId`s are flat again. A `linkId` that does not belong to the group raises, naming it.
|
|
103
|
+
|
|
104
|
+
### Parameters
|
|
105
|
+
|
|
106
|
+
`parameters` is required: it is passed to `$populate` and merged into the `$extract` call, so anything the server
|
|
107
|
+
accepts can go in it. `launch_context()` builds the SDC `$populate` input — a resource or a `Reference` under each
|
|
108
|
+
context name, and the optional `subject`. Pass at least one of them: a `$populate` reply that is not `Parameters`
|
|
109
|
+
raises `ValueError`.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
parameters=launch_context({"Patient": patient, "Author": r4b.Reference(reference="Practitioner/1")})
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
A list under a name builds one `context` with a `content` part per value, as SDC defines it. fhir-sdc uses only the
|
|
116
|
+
first `content` for now.
|
|
117
|
+
|
|
118
|
+
## Failures
|
|
119
|
+
|
|
120
|
+
An error response from the server raises fhirpy's exception for its status — `OperationOutcome` for most 4xx and 5xx
|
|
121
|
+
responses. A violated `itemConstraint` the server checks during `$extract` and a rejected transaction both arrive this
|
|
122
|
+
way; they differ in `issue[].code`. fhir-sdc reports a constraint's `key`, and a server's own validation code, such as
|
|
123
|
+
`invalid`, for a transaction. An `error` or `fatal` issue in the `$extract` result and, with `submit`, a batch entry
|
|
124
|
+
the server rejects raise `ExtractionError`.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
with pytest.raises(OperationOutcome) as refused:
|
|
128
|
+
await populate_and_extract(fhir_client, "patient-intake", parameters=parameters, answers=answers)
|
|
129
|
+
assert refused.value.resource["issue"][0]["code"] == "postal-code-required"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
uv sync
|
|
136
|
+
uv run pytest
|
|
137
|
+
uv run ruff format . && uv run ruff check . && uv run mypy
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
That suite runs without a server. `$populate`, constraints and extraction are covered against a running Aidbox and
|
|
141
|
+
fhir-sdc by `tests/testkit/` in the repository root — `./run_test.sh tests/testkit`.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# fhir-sdc-testkit
|
|
2
|
+
|
|
3
|
+
Test an SDC Questionnaire and its extraction the way a form is really filled: `$populate` runs, answers are placed at
|
|
4
|
+
each `linkId`'s nesting, then `QuestionnaireResponse/$extract` runs. With `submit=True` the QuestionnaireResponse is
|
|
5
|
+
saved before extraction and the bundle is submitted after it. Only what a user types (or the frontend calculates) is
|
|
6
|
+
faked; everything else runs on
|
|
7
|
+
a real server implementing the [SDC](https://build.fhir.org/ig/HL7/sdc/) operations, such as
|
|
8
|
+
[fhir-sdc](../README.md).
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pip install fhir-sdc-testkit
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
It is released from the fhir-sdc repository, with the same version as the fhir-sdc release it is tested against.
|
|
15
|
+
|
|
16
|
+
## The client
|
|
17
|
+
|
|
18
|
+
Everything takes an `AsyncFHIRClient` rooted at the server's `/fhir` base, built with `dump_resource` — fhirpy cannot
|
|
19
|
+
serialize pydantic resources without it:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
fhir_client = AsyncFHIRClient(
|
|
23
|
+
f"{base_url}/fhir",
|
|
24
|
+
authorization=authorization,
|
|
25
|
+
dump_resource=lambda resource: resource.model_dump(),
|
|
26
|
+
)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import fhirpy_types_r4b as r4b
|
|
33
|
+
from fhir_sdc_testkit import answer, launch_context, populate_and_extract
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def test_intake_records_the_address(fhir_client):
|
|
37
|
+
patient = await fhir_client.create(r4b.Patient(name=[r4b.HumanName(family="Test")]))
|
|
38
|
+
|
|
39
|
+
await populate_and_extract(
|
|
40
|
+
fhir_client,
|
|
41
|
+
"patient-intake",
|
|
42
|
+
parameters=launch_context(
|
|
43
|
+
{"Patient": patient}, subject=r4b.Reference(reference=f"Patient/{patient.id}")
|
|
44
|
+
),
|
|
45
|
+
answers={"postal-code": [answer(valueString="75001")]},
|
|
46
|
+
submit=True,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
updated = await fhir_client.get(r4b.Patient, patient.id)
|
|
50
|
+
assert updated.address[0].postalCode == "75001"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`populate_and_extract` returns the QuestionnaireResponse and the bundle `$extract` returned (`None` when there is
|
|
54
|
+
nothing to extract). Without `submit` neither is saved, so the bundle can be asserted on directly. With it, the
|
|
55
|
+
response is the saved one — saved first, since a Mapping may reference it by id — and the bundle has been submitted.
|
|
56
|
+
|
|
57
|
+
`populate` and `extract` run the two operations on their own and save nothing.
|
|
58
|
+
|
|
59
|
+
### Answers
|
|
60
|
+
|
|
61
|
+
Keys are `linkId`s, addressed flatly — groups in between are created for you:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
answers={
|
|
65
|
+
"country": [answer(valueCoding=r4b.Coding(code="FR"))],
|
|
66
|
+
"postal-code": [answer(valueString="75001")],
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A repeating group takes one answer set per occurrence, filled positionally over the occurrences `$populate` produced,
|
|
71
|
+
appending any that are missing and leaving extra populated ones alone:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
answers={
|
|
75
|
+
"contacts": [
|
|
76
|
+
{"phone": [answer(valueString="+1 555 0100")], "label": [answer(valueString="home")]},
|
|
77
|
+
{"phone": [answer(valueString="+1 555 0111")], "label": [answer(valueString="work")]},
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Inside a set, `linkId`s are flat again. A `linkId` that does not belong to the group raises, naming it.
|
|
83
|
+
|
|
84
|
+
### Parameters
|
|
85
|
+
|
|
86
|
+
`parameters` is required: it is passed to `$populate` and merged into the `$extract` call, so anything the server
|
|
87
|
+
accepts can go in it. `launch_context()` builds the SDC `$populate` input — a resource or a `Reference` under each
|
|
88
|
+
context name, and the optional `subject`. Pass at least one of them: a `$populate` reply that is not `Parameters`
|
|
89
|
+
raises `ValueError`.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
parameters=launch_context({"Patient": patient, "Author": r4b.Reference(reference="Practitioner/1")})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A list under a name builds one `context` with a `content` part per value, as SDC defines it. fhir-sdc uses only the
|
|
96
|
+
first `content` for now.
|
|
97
|
+
|
|
98
|
+
## Failures
|
|
99
|
+
|
|
100
|
+
An error response from the server raises fhirpy's exception for its status — `OperationOutcome` for most 4xx and 5xx
|
|
101
|
+
responses. A violated `itemConstraint` the server checks during `$extract` and a rejected transaction both arrive this
|
|
102
|
+
way; they differ in `issue[].code`. fhir-sdc reports a constraint's `key`, and a server's own validation code, such as
|
|
103
|
+
`invalid`, for a transaction. An `error` or `fatal` issue in the `$extract` result and, with `submit`, a batch entry
|
|
104
|
+
the server rejects raise `ExtractionError`.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
with pytest.raises(OperationOutcome) as refused:
|
|
108
|
+
await populate_and_extract(fhir_client, "patient-intake", parameters=parameters, answers=answers)
|
|
109
|
+
assert refused.value.resource["issue"][0]["code"] == "postal-code-required"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
uv sync
|
|
116
|
+
uv run pytest
|
|
117
|
+
uv run ruff format . && uv run ruff check . && uv run mypy
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
That suite runs without a server. `$populate`, constraints and extraction are covered against a running Aidbox and
|
|
121
|
+
fhir-sdc by `tests/testkit/` in the repository root — `./run_test.sh tests/testkit`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from fhir_sdc_testkit.extraction import ExtractionError
|
|
2
|
+
from fhir_sdc_testkit.forms import extract, populate, populate_and_extract
|
|
3
|
+
from fhir_sdc_testkit.sugar import Answers, LaunchContext, answer, launch_context
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Answers",
|
|
7
|
+
"ExtractionError",
|
|
8
|
+
"LaunchContext",
|
|
9
|
+
"answer",
|
|
10
|
+
"extract",
|
|
11
|
+
"launch_context",
|
|
12
|
+
"populate",
|
|
13
|
+
"populate_and_extract",
|
|
14
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '3.0.0a1'
|
|
22
|
+
__version_tuple__ = version_tuple = (3, 0, 0, 'a1')
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Raising for a failed SDC `QuestionnaireResponse/$extract` and for a rejected submission."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import fhirpy_types_r4b as r4b
|
|
7
|
+
|
|
8
|
+
# $extract returns what the mappers created, which may be resources R4B does not model.
|
|
9
|
+
Json = Mapping[str, Any]
|
|
10
|
+
FAILED_SEVERITIES = ("error", "fatal")
|
|
11
|
+
SUCCESSFUL_CODES = range(200, 300)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ExtractionError(Exception):
|
|
15
|
+
"""A failure the server reported in a successful response."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def raise_for_failed_issues(extracted: Json) -> None:
|
|
19
|
+
"""SDC puts hints and warnings in `issues` on success, so only error and fatal are failures."""
|
|
20
|
+
for parameter in extracted.get("parameter") or []:
|
|
21
|
+
if parameter["name"] == "issues":
|
|
22
|
+
issues = r4b.OperationOutcome.model_validate(parameter["resource"]).issue
|
|
23
|
+
failures = [issue for issue in issues if issue.severity in FAILED_SEVERITIES]
|
|
24
|
+
if failures:
|
|
25
|
+
raise ExtractionError(str(failures))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_bundle_to_submit(extracted: Json) -> dict[str, Any] | None:
|
|
29
|
+
return next(
|
|
30
|
+
(
|
|
31
|
+
parameter["resource"]
|
|
32
|
+
for parameter in extracted.get("parameter") or []
|
|
33
|
+
if parameter["name"] == "return"
|
|
34
|
+
),
|
|
35
|
+
None,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def raise_for_rejected_entries(bundle: Json) -> None:
|
|
40
|
+
"""A batch reports a rejected entry in the entry's response, under HTTP 200 for the bundle."""
|
|
41
|
+
for entry in bundle.get("entry") or []:
|
|
42
|
+
if not is_successful_status(entry["response"]["status"]):
|
|
43
|
+
raise ExtractionError(f"{entry.get('request', {})} -> {entry['response']}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_successful_status(status: str) -> bool:
|
|
47
|
+
"""An entry status leads with the HTTP code, as in `201 Created`."""
|
|
48
|
+
return int(status.split(maxsplit=1)[0]) in SUCCESSFUL_CODES
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Only user-entered answers are faked; $populate, constraints and the Mapping run for real."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Any, NamedTuple, cast
|
|
5
|
+
|
|
6
|
+
import fhirpy_types_r4b as r4b
|
|
7
|
+
from fhirpy import AsyncFHIRClient
|
|
8
|
+
|
|
9
|
+
from fhir_sdc_testkit.extraction import (
|
|
10
|
+
find_bundle_to_submit,
|
|
11
|
+
raise_for_failed_issues,
|
|
12
|
+
raise_for_rejected_entries,
|
|
13
|
+
)
|
|
14
|
+
from fhir_sdc_testkit.sugar import Answers
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ItemPath(NamedTuple):
|
|
18
|
+
path: tuple[str, ...]
|
|
19
|
+
item: r4b.QuestionnaireItem
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def populate_and_extract(
|
|
23
|
+
fhir_client: AsyncFHIRClient,
|
|
24
|
+
questionnaire_id: str,
|
|
25
|
+
*,
|
|
26
|
+
parameters: r4b.Parameters,
|
|
27
|
+
answers: Answers,
|
|
28
|
+
submit: bool = False,
|
|
29
|
+
) -> tuple[r4b.QuestionnaireResponse, dict[str, Any] | None]:
|
|
30
|
+
"""The filled QuestionnaireResponse and its extracted bundle; `submit` saves both, as a form does."""
|
|
31
|
+
questionnaire = await fhir_client.get(r4b.Questionnaire, questionnaire_id)
|
|
32
|
+
populated = await populate(fhir_client, questionnaire_id, parameters=parameters)
|
|
33
|
+
response = fill_questionnaire_response(questionnaire, populated, answers)
|
|
34
|
+
if submit:
|
|
35
|
+
# A Mapping may reference the response by id, so it is saved before extraction.
|
|
36
|
+
response = await fhir_client.create(response)
|
|
37
|
+
bundle = await extract(fhir_client, questionnaire, response, parameters=parameters)
|
|
38
|
+
if submit and bundle is not None:
|
|
39
|
+
raise_for_rejected_entries(await fhir_client.execute("/", data=bundle))
|
|
40
|
+
return response, bundle
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def populate(
|
|
44
|
+
fhir_client: AsyncFHIRClient, questionnaire_id: str, *, parameters: r4b.Parameters
|
|
45
|
+
) -> r4b.QuestionnaireResponse:
|
|
46
|
+
"""SDC `Questionnaire/{id}/$populate`: the populated response, not saved."""
|
|
47
|
+
populated = await fhir_client.execute(
|
|
48
|
+
f"Questionnaire/{questionnaire_id}/$populate", data=parameters.model_dump()
|
|
49
|
+
)
|
|
50
|
+
return get_populated_response(populated)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_populated_response(populated: Mapping[str, Any]) -> r4b.QuestionnaireResponse:
|
|
54
|
+
if populated.get("resourceType") != "Parameters":
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"$populate returned {populated.get('resourceType')}, not SDC Parameters: "
|
|
57
|
+
"pass a subject or a launch context"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
response = next(p for p in populated["parameter"] if p["name"] == "response")
|
|
61
|
+
return r4b.QuestionnaireResponse.model_validate(response["resource"])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def fill_questionnaire_response(
|
|
65
|
+
questionnaire: r4b.Questionnaire, populated: r4b.QuestionnaireResponse, answers: Answers
|
|
66
|
+
) -> r4b.QuestionnaireResponse:
|
|
67
|
+
"""Set each answer at its linkId's nesting in the Questionnaire, replacing a populated one."""
|
|
68
|
+
response = populated.model_copy(deep=True, update={"status": "completed"})
|
|
69
|
+
children = get_or_create_children(response)
|
|
70
|
+
fill_items(
|
|
71
|
+
questionnaire.item or [], children, answers, scope=f"Questionnaire/{questionnaire.id}"
|
|
72
|
+
)
|
|
73
|
+
sort_items(questionnaire.item or [], children)
|
|
74
|
+
return response
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def fill_items(
|
|
78
|
+
items: list[r4b.QuestionnaireItem],
|
|
79
|
+
children: list[r4b.QuestionnaireResponseItem],
|
|
80
|
+
answers: Answers,
|
|
81
|
+
*,
|
|
82
|
+
scope: str,
|
|
83
|
+
) -> None:
|
|
84
|
+
paths = build_link_id_paths(items)
|
|
85
|
+
for link_id, value in answers.items():
|
|
86
|
+
if link_id not in paths:
|
|
87
|
+
raise KeyError(f"{link_id!r} is not an item of {scope} outside a repeating group")
|
|
88
|
+
|
|
89
|
+
path, item = paths[link_id]
|
|
90
|
+
siblings = find_or_create_siblings(children, path)
|
|
91
|
+
if is_repeating_group(item):
|
|
92
|
+
fill_occurrences(item, siblings, cast(Sequence[Answers], value))
|
|
93
|
+
else:
|
|
94
|
+
find_or_append_item(siblings, link_id).answer = list(
|
|
95
|
+
cast(Sequence[r4b.QuestionnaireResponseItemAnswer], value)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_link_id_paths(
|
|
100
|
+
items: list[r4b.QuestionnaireItem], ancestors: tuple[str, ...] = ()
|
|
101
|
+
) -> dict[str, ItemPath]:
|
|
102
|
+
"""linkId → the linkIds from the top-level item down to it. Repeating items are not descended."""
|
|
103
|
+
paths: dict[str, ItemPath] = {}
|
|
104
|
+
for item in items:
|
|
105
|
+
path = (*ancestors, item.linkId)
|
|
106
|
+
paths[item.linkId] = ItemPath(path, item)
|
|
107
|
+
if not item.repeats:
|
|
108
|
+
paths.update(build_link_id_paths(item.item or [], path))
|
|
109
|
+
return paths
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def find_or_create_siblings(
|
|
113
|
+
children: list[r4b.QuestionnaireResponseItem], path: tuple[str, ...]
|
|
114
|
+
) -> list[r4b.QuestionnaireResponseItem]:
|
|
115
|
+
"""The item list holding the path's last linkId, creating the groups above it."""
|
|
116
|
+
for link_id in path[:-1]:
|
|
117
|
+
children = get_or_create_children(find_or_append_item(children, link_id))
|
|
118
|
+
return children
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def is_repeating_group(item: r4b.QuestionnaireItem) -> bool:
|
|
122
|
+
return item.type == "group" and bool(item.repeats)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def fill_occurrences(
|
|
126
|
+
group: r4b.QuestionnaireItem,
|
|
127
|
+
siblings: list[r4b.QuestionnaireResponseItem],
|
|
128
|
+
answer_sets: Sequence[Answers],
|
|
129
|
+
) -> None:
|
|
130
|
+
"""One answer set per occurrence, positionally over the populated ones. Extra ones are kept."""
|
|
131
|
+
occurrences = find_or_append_occurrences(siblings, group.linkId, len(answer_sets))
|
|
132
|
+
for occurrence, answers in zip(occurrences, answer_sets, strict=True):
|
|
133
|
+
fill_items(
|
|
134
|
+
group.item or [],
|
|
135
|
+
get_or_create_children(occurrence),
|
|
136
|
+
answers,
|
|
137
|
+
scope=f"repeating group {group.linkId!r}",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def find_or_append_occurrences(
|
|
142
|
+
children: list[r4b.QuestionnaireResponseItem], link_id: str, count: int
|
|
143
|
+
) -> list[r4b.QuestionnaireResponseItem]:
|
|
144
|
+
occurrences = [child for child in children if child.linkId == link_id]
|
|
145
|
+
while len(occurrences) < count:
|
|
146
|
+
item = r4b.QuestionnaireResponseItem(linkId=link_id)
|
|
147
|
+
children.append(item)
|
|
148
|
+
occurrences.append(item)
|
|
149
|
+
return occurrences[:count]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def find_or_append_item(
|
|
153
|
+
children: list[r4b.QuestionnaireResponseItem], link_id: str
|
|
154
|
+
) -> r4b.QuestionnaireResponseItem:
|
|
155
|
+
for child in children:
|
|
156
|
+
if child.linkId == link_id:
|
|
157
|
+
return child
|
|
158
|
+
|
|
159
|
+
item = r4b.QuestionnaireResponseItem(linkId=link_id)
|
|
160
|
+
children.append(item)
|
|
161
|
+
return item
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_or_create_children(
|
|
165
|
+
parent: r4b.QuestionnaireResponse | r4b.QuestionnaireResponseItem,
|
|
166
|
+
) -> list[r4b.QuestionnaireResponseItem]:
|
|
167
|
+
if parent.item is None:
|
|
168
|
+
parent.item = []
|
|
169
|
+
return parent.item
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def sort_items(
|
|
173
|
+
items: list[r4b.QuestionnaireItem], children: list[r4b.QuestionnaireResponseItem]
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Answered items are appended as they come; a response follows the Questionnaire's order."""
|
|
176
|
+
order = {item.linkId: index for index, item in enumerate(items)}
|
|
177
|
+
children.sort(key=lambda child: order[child.linkId])
|
|
178
|
+
definitions = {item.linkId: item for item in items}
|
|
179
|
+
for child in children:
|
|
180
|
+
if child.item:
|
|
181
|
+
sort_items(definitions[child.linkId].item or [], child.item)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def extract(
|
|
185
|
+
fhir_client: AsyncFHIRClient,
|
|
186
|
+
questionnaire: r4b.Questionnaire,
|
|
187
|
+
questionnaire_response: r4b.QuestionnaireResponse,
|
|
188
|
+
*,
|
|
189
|
+
parameters: r4b.Parameters,
|
|
190
|
+
) -> dict[str, Any] | None:
|
|
191
|
+
"""SDC `QuestionnaireResponse/$extract`: the bundle to submit, not submitted."""
|
|
192
|
+
extract_parameters = [
|
|
193
|
+
r4b.ParametersParameter(name="questionnaire", resource=questionnaire),
|
|
194
|
+
r4b.ParametersParameter(name="questionnaire-response", resource=questionnaire_response),
|
|
195
|
+
*(parameters.parameter or []),
|
|
196
|
+
]
|
|
197
|
+
extracted = await fhir_client.execute(
|
|
198
|
+
"QuestionnaireResponse/$extract",
|
|
199
|
+
data=r4b.Parameters(parameter=extract_parameters).model_dump(),
|
|
200
|
+
)
|
|
201
|
+
raise_for_failed_issues(extracted)
|
|
202
|
+
return find_bundle_to_submit(extracted)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""What a test writes: an answer, a set of answers, and the launch context to populate with."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
|
|
5
|
+
import fhirpy_types_r4b as r4b
|
|
6
|
+
|
|
7
|
+
answer = r4b.QuestionnaireResponseItemAnswer
|
|
8
|
+
Answers = Mapping[str, "Sequence[r4b.QuestionnaireResponseItemAnswer] | Sequence[Answers]"]
|
|
9
|
+
LaunchContextValue = r4b.AnyResource | r4b.Reference
|
|
10
|
+
LaunchContext = dict[str, "LaunchContextValue | list[LaunchContextValue]"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def launch_context(
|
|
14
|
+
context: LaunchContext, *, subject: r4b.Reference | None = None
|
|
15
|
+
) -> r4b.Parameters:
|
|
16
|
+
"""SDC `$populate` input: one `context` parameter per name, with a `content` part per value."""
|
|
17
|
+
parameters = [
|
|
18
|
+
build_context_parameter(name, values if isinstance(values, list) else [values])
|
|
19
|
+
for name, values in context.items()
|
|
20
|
+
]
|
|
21
|
+
if subject is not None:
|
|
22
|
+
parameters.append(r4b.ParametersParameter(name="subject", valueReference=subject))
|
|
23
|
+
return r4b.Parameters(parameter=parameters)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_context_parameter(name: str, values: list[LaunchContextValue]) -> r4b.ParametersParameter:
|
|
27
|
+
if not values:
|
|
28
|
+
raise ValueError(f"Launch context {name!r} needs at least one value")
|
|
29
|
+
|
|
30
|
+
return r4b.ParametersParameter(
|
|
31
|
+
name="context",
|
|
32
|
+
part=[
|
|
33
|
+
r4b.ParametersParameter(name="name", valueString=name),
|
|
34
|
+
*(build_content_part(value) for value in values),
|
|
35
|
+
],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_content_part(value: LaunchContextValue) -> r4b.ParametersParameter:
|
|
40
|
+
if isinstance(value, r4b.Reference):
|
|
41
|
+
return r4b.ParametersParameter(name="content", valueReference=value)
|
|
42
|
+
|
|
43
|
+
return r4b.ParametersParameter(name="content", resource=value)
|