Coverage for cc_modules/cc_fhir.py: 43%

176 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3# noinspection HttpUrlsUsage 

4""" 

5camcops_server/cc_modules/cc_fhir.py 

6 

7=============================================================================== 

8 

9 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

10 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

11 

12 This file is part of CamCOPS. 

13 

14 CamCOPS is free software: you can redistribute it and/or modify 

15 it under the terms of the GNU General Public License as published by 

16 the Free Software Foundation, either version 3 of the License, or 

17 (at your option) any later version. 

18 

19 CamCOPS is distributed in the hope that it will be useful, 

20 but WITHOUT ANY WARRANTY; without even the implied warranty of 

21 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

22 GNU General Public License for more details. 

23 

24 You should have received a copy of the GNU General Public License 

25 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

26 

27=============================================================================== 

28 

29**Implements communication with a FHIR server.** 

30 

31Fast Healthcare Interoperability Resources 

32 

33https://www.hl7.org/fhir/ 

34 

35Our implementation exports: 

36 

37- patients as FHIR Patient resources; 

38- task concepts as FHIR Questionnaire resources; 

39- task instances as FHIR QuestionnaireResponse resources. 

40 

41Currently PHQ9 and APEQPT (anonymous) are supported. Each task and patient (if 

42appropriate is sent to the FHIR server in a single "transaction" Bundle). 

43The resources are given a unique identifier based on the URL of the CamCOPS 

44server. 

45 

46We use the Python client https://github.com/smart-on-fhir/client-py/. 

47This only supports one version of the FHIR specification (currently 4.0.1). 

48 

49 

50*Testing: HAPI FHIR server locally* 

51 

52To test with a HAPI FHIR server locally, which was installed from instructions 

53at https://github.com/hapifhir/hapi-fhir-jpaserver-starter (Docker). Most 

54simply: 

55 

56.. code-block:: bash 

57 

58 docker run -p 8080:8080 hapiproject/hapi:latest 

59 

60with the following entry in the CamCOPS export recipient configuration: 

61 

62.. code-block:: ini 

63 

64 FHIR_API_URL = http://localhost:8080/fhir 

65 

66To inspect it while it's running (apart from via its log): 

67 

68- Browse to (by default) http://localhost:8080/ 

69 

70 - then e.g. Patient --> Search, which is a pretty version of 

71 http://localhost:8080/fhir/Patient?_pretty=true; 

72 

73 - Questionnaire --> Search, which is a pretty version of 

74 http://localhost:8080/fhir/Questionnaire?_pretty=true. 

75 

76- Can also browse to (by default) http://localhost:8080/fhir/metadata 

77 

78 

79*Testing: Other* 

80 

81There are also public sandboxes at: 

82 

83- http://hapi.fhir.org/baseR4 

84- https://r4.smarthealthit.org (errors when exporting questionnaire responses) 

85 

86 

87*Intermittent problem with If-None-Exist* 

88 

89This problem occurs intermittently: 

90 

91- "Failed to CREATE resource with match URL ... because this search matched 2 

92 resources" -- an OperationOutcome error. 

93 

94 At https://groups.google.com/g/hapi-fhir/c/8OolMOpf8SU, it says (for an error 

95 with 40 resources) "You can only do a conditional create if there are 0..1 

96 existing resources on the server that match the criteria, and in this case 

97 there are 40." But I think that is an error in the explanation. 

98 

99 Proper documentation for ``ifNoneExist`` (Python client) or ``If-None-Exist`` 

100 (FHIR itself) is at https://www.hl7.org/fhir/http.html#ccreate. 

101 

102 I suspect that "0..1" comment relates to "cardinality" 

103 (https://www.hl7.org/fhir/bundle.html#resource), which is how many times the 

104 attribute can appear in a resource type 

105 (https://www.hl7.org/fhir/conformance-rules.html#cardinality); that is, this 

106 statement is optional. It would clearly be silly if it meant "create if no 

107 more than 1 exist"! 

108 

109 However, the "Failed to CREATE" problem seemed to go away. It does work fine, 

110 and you get status messages of "200 OK" rather than "201 Created" if you try 

111 to insert the same information again (``SELECT * FROM 

112 _exported_task_fhir_entry;``). 

113 

114- This is a concurrency problem (they dispute "bug") in the HAPI FHIR 

115 implementation. See our bug report at 

116 https://github.com/hapifhir/hapi-fhir/issues/3141. 

117 

118- The suggested fix is a "unique combo search index parameter", as per 

119 https://smilecdr.com/docs/fhir_repository/custom_search_parameters.html#uniqueness. 

120 

121- However, that seems implementation-specific (e.g. HAPI FHIR, SmileCDR). A 

122 specific value of ``http://hapifhir.io/fhir/StructureDefinition/sp-unique`` 

123 must be used. Specimen code is 

124 https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/src-html/ca/uhn/fhir/util/HapiExtensions.html. 

125 

126- Instead, we could force a FHIR export for a given recipient to occur in 

127 serial (particularly as other FHIR implementations may have this bug). 

128 

129 Celery doesn't allow you to send multiple jobs and enforce that they all 

130 happen via the same worker 

131 (https://docs.celeryproject.org/en/stable/userguide/calling.html). However, 

132 our exports already start (mostly!) as "one recipient, one job", via 

133 :func:`camcops_server.cc_modules.celery.export_to_recipient_backend` (see 

134 :func:`camcops_server.cc_modules.celery.get_celery_settings_dict`). 

135 

136 The tricky bit is that push exports require back-end single-task jobs, so 

137 they are hard to de-parallelize. 

138 

139 So we use a carefully sequenced file lock; see 

140 :func:`camcops_server.cc_modules.cc_export.export_task`. 

141 

142""" # noqa 

143 

144 

145# ============================================================================= 

146# Imports 

147# ============================================================================= 

148 

149from enum import Enum 

150import json 

151import logging 

152from typing import Any, Dict, List, TYPE_CHECKING 

153 

154from cardinal_pythonlib.datetimefunc import format_datetime 

155from cardinal_pythonlib.httpconst import HttpMethod 

156from fhirclient.client import FHIRClient 

157from fhirclient.models.bundle import Bundle, BundleEntry, BundleEntryRequest 

158from fhirclient.models.codeableconcept import CodeableConcept 

159from fhirclient.models.coding import Coding 

160from fhirclient.models.fhirdate import FHIRDate 

161from fhirclient.models.identifier import Identifier 

162from fhirclient.models.observation import ObservationComponent 

163from fhirclient.models.questionnaire import ( 

164 QuestionnaireItem, 

165 QuestionnaireItemAnswerOption, 

166) 

167from fhirclient.models.quantity import Quantity 

168from fhirclient.models.questionnaireresponse import ( 

169 QuestionnaireResponseItem, 

170 QuestionnaireResponseItemAnswer, 

171) 

172from requests.exceptions import HTTPError 

173 

174from camcops_server.cc_modules.cc_constants import ( 

175 DateFormat, 

176 FHIRConst as Fc, 

177 JSON_INDENT, 

178) 

179from camcops_server.cc_modules.cc_exception import FhirExportException 

180from camcops_server.cc_modules.cc_pyramid import Routes 

181from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup 

182 

183if TYPE_CHECKING: 

184 from camcops_server.cc_modules.cc_exportmodels import ExportedTaskFhir 

185 from camcops_server.cc_modules.cc_request import CamcopsRequest 

186 

187log = logging.getLogger(__name__) 

188 

189 

190# ============================================================================= 

191# Debugging options 

192# ============================================================================= 

193 

194DEBUG_FHIR_TX = False # needs workers to be launched with "--verbose" option 

195 

196if any([DEBUG_FHIR_TX]): 

197 log.warning("Debugging options enabled!") 

198 

199 

200# ============================================================================= 

201# Development thoughts 

202# ============================================================================= 

203 

204_ = """ 

205 

206Dive into the internals of the HAPI FHIR server 

207=============================================== 

208 

209.. code-block:: bash 

210 

211 docker container ls | grep hapi # find its container ID 

212 docker exec -it <CONTAINER_NAME_OR_ID> bash 

213 

214 # Find files modified in the last 10 minutes: 

215 find / -mmin -10 -type f -not -path "/proc/*" -not -path "/sys/*" -exec ls -l {} \; 

216 # ... which reveals /usr/local/tomcat/target/database/h2.mv.db 

217 # and /usr/local/tomcat/logs/localhost_access_log* 

218 

219 # Now, from https://h2database.com/html/tutorial.html#command_line_tools, 

220 find / -name "h2*.jar" 

221 # ... /usr/local/tomcat/webapps/ROOT/WEB-INF/lib/h2-1.4.200.jar 

222 

223 java -cp /usr/local/tomcat/webapps/ROOT/WEB-INF/lib/h2*.jar org.h2.tools.Shell 

224 # - URL = jdbc:h2:/usr/local/tomcat/target/database/h2 

225 # ... it will append ".mv.db" 

226 # - Accept other defaults. 

227 # - Then from the "sql>" prompt, try e.g. SHOW TABLES; 

228 

229However, it won't connect with the server open. (And you can't stop the Docker 

230FHIR server and repeat the connection using ``docker run -it <IMAGE_ID> bash`` 

231rather than ``docker exec``, because then the data will disappear as Docker 

232returns to its starting image.) But you can copy the database and open the 

233copy, e.g. with 

234 

235.. code-block:: bash 

236 

237 cd /usr/local/tomcat/target/database 

238 cp h2.mv.db h2_copy.mv.db 

239 java -cp /usr/local/tomcat/webapps/ROOT/WEB-INF/lib/h2*.jar org.h2.tools.Shell 

240 # URL = jdbc:h2:/usr/local/tomcat/target/database/h2_copy 

241 

242but then that needs a username/password. Better is to create 

243``application.yaml`` in a host machine directory, like this: 

244 

245.. code-block:: bash 

246 

247 # From MySQL: 

248 # CREATE DATABASE hapi_test_db; 

249 # CREATE USER 'hapi_test_user'@'localhost' IDENTIFIED BY 'hapi_test_password'; 

250 # GRANT ALL PRIVILEGES ON hapi_test_db.* TO 'hapi_test_user'@'localhost'; 

251 

252 mkdir ~/hapi_test 

253 cd ~/hapi_test 

254 git clone https://github.com/hapifhir/hapi-fhir-jpaserver-starter 

255 cd hapi-fhir-jpaserver-starter 

256 nano src/main/resources/application.yaml 

257 

258... no, better is to use the web interface! 

259 

260 

261Wipe FHIR exports 

262================= 

263 

264.. code-block:: sql 

265 

266 -- Delete all records of tasks exported via FHIR: 

267 DELETE FROM _exported_task_fhir_entry; 

268 DELETE FROM _exported_task_fhir; 

269 DELETE FROM _exported_tasks WHERE recipient_id IN ( 

270 SELECT id FROM _export_recipients WHERE transmission_method = 'fhir' 

271 ); 

272 

273 -- Delete ALL export information 

274 DELETE FROM _exported_task_fhir_entry; 

275 DELETE FROM _exported_task_fhir; 

276 DELETE FROM _exported_task_email; 

277 DELETE FROM _exported_task_filegroup; 

278 DELETE FROM _exported_task_hl7msg; 

279 DELETE FROM _exported_task_redcap; 

280 DELETE FROM _exported_tasks; 

281 DELETE FROM _export_recipients; 

282 

283What's been sent? 

284 

285.. code-block:: sql 

286 

287 -- Tasks exported via FHIR: 

288 SELECT * FROM _exported_tasks WHERE recipient_id IN ( 

289 SELECT id FROM _export_recipients WHERE transmission_method = 'fhir' 

290 ); 

291 

292 -- Entries for all BMI tasks: 

293 SELECT * FROM _exported_task_fhir_entry WHERE exported_task_fhir_id IN ( 

294 SELECT _exported_task_fhir.id FROM _exported_task_fhir 

295 INNER JOIN _exported_tasks 

296 ON _exported_task_fhir.exported_task_id = _exported_tasks.id 

297 INNER JOIN _export_recipients 

298 ON _exported_tasks.recipient_id = _export_recipients.id 

299 WHERE _export_recipients.transmission_method = 'fhir' 

300 AND _exported_tasks.basetable = 'bmi' 

301 ); 

302 

303 

304Inspecting fhirclient 

305===================== 

306 

307Each class has entries like this: 

308 

309.. code-block:: python 

310 

311 def elementProperties(self): 

312 js = super(DocumentReference, self).elementProperties() 

313 js.extend([ 

314 ("authenticator", "authenticator", fhirreference.FHIRReference, False, None, False), 

315 ("author", "author", fhirreference.FHIRReference, True, None, False), 

316 # ... 

317 ]) 

318 return js 

319 

320The fields are: ``name, jsname, typ, is_list, of_many, not_optional``. 

321They are validated in FHIRAbstractBase.update_with_json(). 

322 

323""" # noqa 

324 

325 

326# ============================================================================= 

327# Export tasks via FHIR 

328# ============================================================================= 

329 

330 

331class FhirTaskExporter(object): 

332 """ 

333 Class that knows how to export a single task to FHIR. 

334 """ 

335 

336 def __init__( 

337 self, request: "CamcopsRequest", exported_task_fhir: "ExportedTaskFhir" 

338 ) -> None: 

339 self.request = request 

340 self.exported_task = exported_task_fhir.exported_task 

341 self.exported_task_fhir = exported_task_fhir 

342 

343 self.recipient = self.exported_task.recipient 

344 self.task = self.exported_task.task 

345 

346 # TODO: In theory these settings should handle authentication 

347 # for any server that is SMART-compliant but we've not tested this. 

348 # https://sep.com/blog/smart-on-fhir-what-is-smart-what-is-fhir/ 

349 settings = { 

350 Fc.API_BASE: self.recipient.fhir_api_url, 

351 Fc.APP_ID: self.recipient.fhir_app_id, 

352 Fc.APP_SECRET: self.recipient.fhir_app_secret, 

353 Fc.LAUNCH_TOKEN: self.recipient.fhir_launch_token, 

354 } 

355 

356 try: 

357 self.client = FHIRClient(settings=settings) 

358 except Exception as e: 

359 raise FhirExportException(f"Error creating FHIRClient: {e}") 

360 

361 def export_task(self) -> None: 

362 """ 

363 Export a single task to the server, with associated patient information 

364 if the task has an associated patient. 

365 """ 

366 

367 # TODO: Check FHIR server's capability statement 

368 # https://www.hl7.org/fhir/capabilitystatement.html 

369 # 

370 # statement = self.client.server.capabilityStatement 

371 # The client doesn't support looking for a particular capability 

372 # We could check for: 

373 # fhirVersion (the client does not support multiple versions) 

374 # conditional create 

375 # supported resource types (statement.rest[0].resource[]) 

376 

377 bundle = self.task.get_fhir_bundle( 

378 self.request, self.exported_task.recipient 

379 ) # may raise FhirExportException 

380 

381 try: 

382 # Attempt to create the receiver on the server, via POST: 

383 if DEBUG_FHIR_TX: 

384 bundle_str = json.dumps(bundle.as_json(), indent=JSON_INDENT) 

385 log.debug(f"FHIR bundle outbound to server:\n{bundle_str}") 

386 response = bundle.create(self.client.server) 

387 if response is None: 

388 # Not sure this will ever happen. 

389 # fhirabstractresource.py create() says it returns 

390 # "None or the response JSON on success" but an exception will 

391 # already have been raised if there was a failure 

392 raise FhirExportException( 

393 "The FHIR server unexpectedly returned an OK, empty " 

394 "response" 

395 ) 

396 

397 self.parse_response(response) 

398 

399 except HTTPError as e: 

400 raise FhirExportException( 

401 f"The FHIR server returned an error: {e.response.text}" 

402 ) 

403 

404 except Exception as e: 

405 # Unfortunate that fhirclient doesn't give us anything more 

406 # specific 

407 raise FhirExportException(f"Error from fhirclient: {e}") 

408 

409 def parse_response(self, response: Dict) -> None: 

410 """ 

411 Parse the response from the FHIR server to which we have sent our 

412 task. The response looks something like this: 

413 

414 .. code-block:: json 

415 

416 { 

417 "resourceType": "Bundle", 

418 "id": "cae48957-e7e6-4649-97f8-0a882076ad0a", 

419 "type": "transaction-response", 

420 "link": [ 

421 { 

422 "relation": "self", 

423 "url": "http://localhost:8080/fhir" 

424 } 

425 ], 

426 "entry": [ 

427 { 

428 "response": { 

429 "status": "200 OK", 

430 "location": "Patient/1/_history/1", 

431 "etag": "1" 

432 } 

433 }, 

434 { 

435 "response": { 

436 "status": "200 OK", 

437 "location": "Questionnaire/26/_history/1", 

438 "etag": "1" 

439 } 

440 }, 

441 { 

442 "response": { 

443 "status": "201 Created", 

444 "location": "QuestionnaireResponse/42/_history/1", 

445 "etag": "1", 

446 "lastModified": "2021-05-24T09:30:11.098+00:00" 

447 } 

448 } 

449 ] 

450 } 

451 

452 The server's reply contains a Bundle 

453 (https://www.hl7.org/fhir/bundle.html), which is a container for 

454 resources. Here, the bundle contains entry objects 

455 (https://www.hl7.org/fhir/bundle-definitions.html#Bundle.entry). 

456 

457 """ 

458 bundle = Bundle(jsondict=response) 

459 

460 if bundle.entry is not None: 

461 self._save_exported_entries(bundle) 

462 

463 def _save_exported_entries(self, bundle: Bundle) -> None: 

464 """ 

465 Record the server's reply components in strucured format. 

466 """ 

467 from camcops_server.cc_modules.cc_exportmodels import ( 

468 ExportedTaskFhirEntry, 

469 ) # delayed import 

470 

471 for entry in bundle.entry: 

472 saved_entry = ExportedTaskFhirEntry() 

473 saved_entry.exported_task_fhir_id = self.exported_task_fhir.id 

474 saved_entry.status = entry.response.status 

475 saved_entry.location = entry.response.location 

476 saved_entry.etag = entry.response.etag 

477 if entry.response.lastModified is not None: 

478 # ... of type :class:`fhirclient.models.fhirdate.FHIRDate` 

479 saved_entry.last_modified = entry.response.lastModified.date 

480 

481 self.request.dbsession.add(saved_entry) 

482 

483 

484# ============================================================================= 

485# Helper functions for building FHIR component objects 

486# ============================================================================= 

487 

488 

489def fhir_pk_identifier( 

490 req: "CamcopsRequest", tablename: str, pk: int, value_within_task: str 

491) -> Identifier: 

492 """ 

493 Creates a "fallback" identifier -- this is poor, but allows unique 

494 identification of anything (such as a patient with no proper ID numbers) 

495 based on its CamCOPS table name and server PK. 

496 """ 

497 return Identifier( 

498 jsondict={ 

499 Fc.SYSTEM: req.route_url( 

500 Routes.FHIR_TABLENAME_PK_ID, table_name=tablename, server_pk=pk 

501 ), 

502 Fc.VALUE: value_within_task, 

503 } 

504 ) 

505 

506 

507def fhir_system_value(system: str, value: str) -> str: 

508 """ 

509 How FHIR expresses system/value pairs. 

510 """ 

511 return f"{system}|{value}" 

512 

513 

514def fhir_sysval_from_id(identifier: Identifier) -> str: 

515 """ 

516 How FHIR expresses system/value pairs. 

517 """ 

518 return f"{identifier.system}|{identifier.value}" 

519 

520 

521def fhir_reference_from_identifier(identifier: Identifier) -> str: 

522 """ 

523 Returns a reference to a specific FHIR identifier. 

524 """ 

525 return f"{Fc.IDENTIFIER}={fhir_sysval_from_id(identifier)}" 

526 

527 

528def fhir_observation_component_from_snomed( 

529 req: "CamcopsRequest", expr: SnomedExpression 

530) -> Dict: 

531 """ 

532 Returns a FHIR ObservationComponent (as a dict in JSON format) for a SNOMED 

533 CT expression. 

534 """ 

535 observable_entity = req.snomed(SnomedLookup.OBSERVABLE_ENTITY) 

536 expr_longform = expr.as_string(longform=True) 

537 # For SNOMED, we are providing an observation where the "value" is a code 

538 # -- thus, we use "valueCodeableConcept" as the specific value (the generic 

539 # being "value<something>" or what FHIR calls "value[x]"). But there also 

540 # needs to be a coding system, specified via "code". 

541 return ObservationComponent( 

542 jsondict={ 

543 # code = "the type of thing reported here" 

544 # Per https://www.hl7.org/fhir/observation.html#code-interop, we 

545 # use SNOMED 363787002 = Observable entity. 

546 Fc.CODE: CodeableConcept( 

547 jsondict={ 

548 Fc.CODING: [ 

549 Coding( 

550 jsondict={ 

551 Fc.SYSTEM: Fc.CODE_SYSTEM_SNOMED_CT, 

552 Fc.CODE: str(observable_entity.identifier), 

553 Fc.DISPLAY: observable_entity.as_string( 

554 longform=True 

555 ), 

556 Fc.USER_SELECTED: False, 

557 } 

558 ).as_json() 

559 ], 

560 Fc.TEXT: observable_entity.term, 

561 } 

562 ).as_json(), 

563 # value = "the value of the thing"; the actual SNOMED code of 

564 # interest: 

565 Fc.VALUE_CODEABLE_CONCEPT: CodeableConcept( 

566 jsondict={ 

567 Fc.CODING: [ 

568 Coding( 

569 jsondict={ 

570 # http://www.hl7.org/fhir/snomedct.html 

571 Fc.SYSTEM: Fc.CODE_SYSTEM_SNOMED_CT, 

572 Fc.CODE: expr.as_string(longform=False), 

573 Fc.DISPLAY: expr_longform, 

574 Fc.USER_SELECTED: False, 

575 # ... means "did the user choose it themselves?" # noqa: E501 

576 # version: not used 

577 } 

578 ).as_json() 

579 ], 

580 Fc.TEXT: expr_longform, 

581 } 

582 ).as_json(), 

583 } 

584 ).as_json() 

585 

586 

587def make_fhir_bundle_entry( 

588 resource_type_url: str, 

589 identifier: Identifier, 

590 resource: Dict, 

591 identifier_is_list: bool = True, 

592) -> Dict: 

593 """ 

594 Builds a FHIR BundleEntry, as a JSON dict. 

595 

596 This also takes care of the identifier, by ensuring (a) that the resource 

597 is labelled with the identifier, and (b) that the BundleEntryRequest has 

598 an ifNoneExist condition referring to that identifier. 

599 """ 

600 if Fc.IDENTIFIER in resource: 

601 log.warning( 

602 f"Duplication: {Fc.IDENTIFIER!r} specified in resource " 

603 f"but would be auto-added by make_fhir_bundle_entry()" 

604 ) 

605 if identifier_is_list: 

606 # Some, like Observation, Patient, and Questionnaire, need lists here. 

607 resource[Fc.IDENTIFIER] = [identifier.as_json()] 

608 else: 

609 # Others, like QuestionnaireResponse, don't. 

610 resource[Fc.IDENTIFIER] = identifier.as_json() 

611 bundle_request = BundleEntryRequest( 

612 jsondict={ 

613 Fc.METHOD: HttpMethod.POST, 

614 Fc.URL: resource_type_url, 

615 Fc.IF_NONE_EXIST: fhir_reference_from_identifier(identifier), 

616 # "If this resource doesn't exist, as determined by this 

617 # identifier, then create it:" 

618 # https://www.hl7.org/fhir/http.html#ccreate 

619 } 

620 ) 

621 return BundleEntry( 

622 jsondict={Fc.REQUEST: bundle_request.as_json(), Fc.RESOURCE: resource} 

623 ).as_json() 

624 

625 

626# ============================================================================= 

627# Helper classes for building FHIR component objects 

628# ============================================================================= 

629 

630 

631class FHIRQuestionType(Enum): 

632 """ 

633 An enum for value type keys of QuestionnaireResponseItemAnswer. 

634 """ 

635 

636 ATTACHMENT = Fc.QITEM_TYPE_ATTACHMENT 

637 BOOLEAN = Fc.QITEM_TYPE_BOOLEAN 

638 CHOICE = Fc.QITEM_TYPE_CHOICE 

639 DATE = Fc.QITEM_TYPE_DATE 

640 DATETIME = Fc.QITEM_TYPE_DATETIME 

641 DECIMAL = Fc.QITEM_TYPE_DECIMAL 

642 DISPLAY = Fc.QITEM_TYPE_DISPLAY 

643 GROUP = Fc.QITEM_TYPE_GROUP 

644 INTEGER = Fc.QITEM_TYPE_INTEGER 

645 OPEN_CHOICE = Fc.QITEM_TYPE_OPEN_CHOICE 

646 QUANTITY = Fc.QITEM_TYPE_QUANTITY 

647 QUESTION = Fc.QITEM_TYPE_QUESTION 

648 REFERENCE = Fc.QITEM_TYPE_REFERENCE 

649 STRING = Fc.QITEM_TYPE_STRING 

650 TIME = Fc.QITEM_TYPE_TIME 

651 URL = Fc.QITEM_TYPE_URL 

652 

653 

654class FHIRAnswerType(Enum): 

655 """ 

656 An enum for value type keys of QuestionnaireResponseItemAnswer. 

657 """ 

658 

659 ATTACHMENT = Fc.VALUE_ATTACHMENT 

660 BOOLEAN = Fc.VALUE_BOOLEAN 

661 CODING = Fc.VALUE_CODING 

662 DATE = Fc.VALUE_DATE 

663 DATETIME = Fc.VALUE_DATETIME 

664 DECIMAL = Fc.VALUE_DECIMAL 

665 INTEGER = Fc.VALUE_INTEGER 

666 QUANTITY = Fc.VALUE_QUANTITY # e.g. real number 

667 REFERENCE = Fc.VALUE_REFERENCE 

668 STRING = Fc.VALUE_STRING 

669 TIME = Fc.VALUE_TIME 

670 URI = Fc.VALUE_URI 

671 

672 

673class FHIRAnsweredQuestion: 

674 """ 

675 Represents a question in a questionnaire-based task. That includes both the 

676 abstract aspects: 

677 

678 - What kind of question is it (e.g. multiple-choice, real-value answer, 

679 text)? That can go into some detail, e.g. possible responses for a 

680 multiple-choice question. (Thus, the FHIR Questionnaire.) 

681 

682 and the concrete aspects: 

683 

684 - what is the response/answer for a specific task instance? 

685 (Thus, the FHIR QuestionnaireResponse.) 

686 

687 Used for autodiscovery. 

688 """ 

689 

690 def __init__( 

691 self, 

692 qname: str, 

693 qtext: str, 

694 qtype: FHIRQuestionType, 

695 answer_type: FHIRAnswerType, 

696 answer: Any, 

697 answer_options: Dict[Any, str] = None, 

698 ) -> None: 

699 """ 

700 Args: 

701 qname: 

702 Name (task attribute name) of the question, e.g. "q1". 

703 qtext: 

704 Question text (e.g. "How was your day?"). 

705 qtype: 

706 Question type, e.g. multiple-choice. 

707 answer_type: 

708 Answer type, e.g. integer. 

709 answer: 

710 Actual answer. 

711 answer_options: 

712 For multiple-choice questions (MCQs), a dictionary mapping 

713 answer codes to human-legible display text. 

714 """ 

715 self.qname = qname 

716 self.qtext = qtext 

717 self.qtype = qtype 

718 self.answer = answer 

719 self.answer_type = answer_type 

720 self.answer_options = answer_options or {} # type: Dict[Any, str] 

721 

722 # Checks 

723 if self.is_mcq: 

724 assert self.answer_options, ( 

725 f"Multiple choice item {self.qname!r} needs mcq_qa parameter, " 

726 f"currently {answer_options!r}" 

727 ) 

728 

729 def __str__(self) -> str: 

730 if self.is_mcq: 

731 options = " / ".join( 

732 f"{code} = {display}" 

733 for code, display in self.answer_options.items() 

734 ) 

735 else: 

736 options = "N/A" 

737 return ( 

738 f"{self.qname} " 

739 f"// QUESTION: {self.qtext} " 

740 f"// OPTIONS: {options} " 

741 f"// ANSWER: {self.answer!r}, of type {self.answer_type.value}" 

742 ) 

743 

744 @property 

745 def is_mcq(self) -> bool: 

746 """ 

747 Is this a multiple-choice question? 

748 """ 

749 return self.qtype in ( 

750 FHIRQuestionType.CHOICE, 

751 FHIRQuestionType.OPEN_CHOICE, 

752 ) 

753 

754 # ------------------------------------------------------------------------- 

755 # Abstract (class) 

756 # ------------------------------------------------------------------------- 

757 

758 def questionnaire_item(self) -> Dict: 

759 """ 

760 Returns a JSON/dict representation of a FHIR QuestionnaireItem. 

761 """ 

762 qtype = self.qtype 

763 # Basics 

764 qitem_dict = { 

765 Fc.LINK_ID: self.qname, 

766 Fc.TEXT: self.qtext, 

767 Fc.TYPE: qtype.value, 

768 } 

769 

770 # Extras for multiple-choice questions: what are the possible answers? 

771 if self.is_mcq: 

772 # Add permitted answers. 

773 options = [] # type: List[Dict] 

774 # We asserted mcq_qa earlier. 

775 for code, display in self.answer_options.items(): 

776 options.append( 

777 QuestionnaireItemAnswerOption( 

778 jsondict={ 

779 Fc.VALUE_CODING: { 

780 Fc.CODE: str(code), 

781 Fc.DISPLAY: display, 

782 } 

783 } 

784 ).as_json() 

785 ) 

786 qitem_dict[Fc.ANSWER_OPTION] = options 

787 

788 return QuestionnaireItem(jsondict=qitem_dict).as_json() 

789 

790 # ------------------------------------------------------------------------- 

791 # Concrete (instance) 

792 # ------------------------------------------------------------------------- 

793 

794 def _qr_item_answer(self) -> QuestionnaireResponseItemAnswer: 

795 """ 

796 Returns a QuestionnaireResponseItemAnswer. 

797 """ 

798 # Look things up 

799 raw_answer = self.answer 

800 answer_type = self.answer_type 

801 

802 # Convert the value 

803 if raw_answer is None: 

804 # Deal with null values first, otherwise we will get 

805 # mis-conversion, e.g. str(None) == "None", bool(None) == False. 

806 fhir_answer = None 

807 elif answer_type == FHIRAnswerType.BOOLEAN: 

808 fhir_answer = bool(raw_answer) 

809 elif answer_type == FHIRAnswerType.DATE: 

810 fhir_answer = FHIRDate( 

811 format_datetime(raw_answer, DateFormat.FHIR_DATE) 

812 ).as_json() 

813 elif answer_type == FHIRAnswerType.DATETIME: 

814 fhir_answer = FHIRDate(raw_answer.isoformat()).as_json() 

815 elif answer_type == FHIRAnswerType.DECIMAL: 

816 fhir_answer = float(raw_answer) 

817 elif answer_type == FHIRAnswerType.INTEGER: 

818 fhir_answer = int(raw_answer) 

819 elif answer_type == FHIRAnswerType.QUANTITY: 

820 fhir_answer = Quantity( 

821 jsondict={ 

822 Fc.VALUE: float(raw_answer) 

823 # More sophistication is possible -- units, for example. 

824 } 

825 ).as_json() 

826 elif answer_type == FHIRAnswerType.STRING: 

827 fhir_answer = str(raw_answer) 

828 elif answer_type == FHIRAnswerType.TIME: 

829 fhir_answer = FHIRDate( 

830 format_datetime(raw_answer, DateFormat.FHIR_TIME) 

831 ).as_json() 

832 elif answer_type == FHIRAnswerType.URI: 

833 fhir_answer = str(raw_answer) 

834 else: 

835 raise NotImplementedError( 

836 f"Don't know how to handle FHIR answer type {answer_type}" 

837 ) 

838 

839 # Build the FHIR object 

840 return QuestionnaireResponseItemAnswer( 

841 jsondict={answer_type.value: fhir_answer} 

842 ) 

843 

844 def questionnaire_response_item(self) -> Dict: 

845 """ 

846 Returns a JSON/dict representation of a FHIR QuestionnaireResponseItem. 

847 """ 

848 answer = self._qr_item_answer() 

849 return QuestionnaireResponseItem( 

850 jsondict={ 

851 Fc.LINK_ID: self.qname, 

852 Fc.TEXT: self.qtext, # question text 

853 Fc.ANSWER: [answer.as_json()], 

854 # Not supported yet: nesting, via "item". 

855 } 

856 ).as_json()