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
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3# noinspection HttpUrlsUsage
4"""
5camcops_server/cc_modules/cc_fhir.py
7===============================================================================
9 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
10 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
12 This file is part of CamCOPS.
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.
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.
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/>.
27===============================================================================
29**Implements communication with a FHIR server.**
31Fast Healthcare Interoperability Resources
33https://www.hl7.org/fhir/
35Our implementation exports:
37- patients as FHIR Patient resources;
38- task concepts as FHIR Questionnaire resources;
39- task instances as FHIR QuestionnaireResponse resources.
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.
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).
50*Testing: HAPI FHIR server locally*
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:
56.. code-block:: bash
58 docker run -p 8080:8080 hapiproject/hapi:latest
60with the following entry in the CamCOPS export recipient configuration:
62.. code-block:: ini
64 FHIR_API_URL = http://localhost:8080/fhir
66To inspect it while it's running (apart from via its log):
68- Browse to (by default) http://localhost:8080/
70 - then e.g. Patient --> Search, which is a pretty version of
71 http://localhost:8080/fhir/Patient?_pretty=true;
73 - Questionnaire --> Search, which is a pretty version of
74 http://localhost:8080/fhir/Questionnaire?_pretty=true.
76- Can also browse to (by default) http://localhost:8080/fhir/metadata
79*Testing: Other*
81There are also public sandboxes at:
83- http://hapi.fhir.org/baseR4
84- https://r4.smarthealthit.org (errors when exporting questionnaire responses)
87*Intermittent problem with If-None-Exist*
89This problem occurs intermittently:
91- "Failed to CREATE resource with match URL ... because this search matched 2
92 resources" -- an OperationOutcome error.
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.
99 Proper documentation for ``ifNoneExist`` (Python client) or ``If-None-Exist``
100 (FHIR itself) is at https://www.hl7.org/fhir/http.html#ccreate.
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"!
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;``).
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.
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.
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.
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).
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`).
136 The tricky bit is that push exports require back-end single-task jobs, so
137 they are hard to de-parallelize.
139 So we use a carefully sequenced file lock; see
140 :func:`camcops_server.cc_modules.cc_export.export_task`.
142""" # noqa
145# =============================================================================
146# Imports
147# =============================================================================
149from enum import Enum
150import json
151import logging
152from typing import Any, Dict, List, TYPE_CHECKING
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
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
183if TYPE_CHECKING:
184 from camcops_server.cc_modules.cc_exportmodels import ExportedTaskFhir
185 from camcops_server.cc_modules.cc_request import CamcopsRequest
187log = logging.getLogger(__name__)
190# =============================================================================
191# Debugging options
192# =============================================================================
194DEBUG_FHIR_TX = False # needs workers to be launched with "--verbose" option
196if any([DEBUG_FHIR_TX]):
197 log.warning("Debugging options enabled!")
200# =============================================================================
201# Development thoughts
202# =============================================================================
204_ = """
206Dive into the internals of the HAPI FHIR server
207===============================================
209.. code-block:: bash
211 docker container ls | grep hapi # find its container ID
212 docker exec -it <CONTAINER_NAME_OR_ID> bash
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*
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
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;
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
235.. code-block:: bash
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
242but then that needs a username/password. Better is to create
243``application.yaml`` in a host machine directory, like this:
245.. code-block:: bash
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';
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
258... no, better is to use the web interface!
261Wipe FHIR exports
262=================
264.. code-block:: sql
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 );
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;
283What's been sent?
285.. code-block:: sql
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 );
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 );
304Inspecting fhirclient
305=====================
307Each class has entries like this:
309.. code-block:: python
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
320The fields are: ``name, jsname, typ, is_list, of_many, not_optional``.
321They are validated in FHIRAbstractBase.update_with_json().
323""" # noqa
326# =============================================================================
327# Export tasks via FHIR
328# =============================================================================
331class FhirTaskExporter(object):
332 """
333 Class that knows how to export a single task to FHIR.
334 """
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
343 self.recipient = self.exported_task.recipient
344 self.task = self.exported_task.task
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 }
356 try:
357 self.client = FHIRClient(settings=settings)
358 except Exception as e:
359 raise FhirExportException(f"Error creating FHIRClient: {e}")
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 """
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[])
377 bundle = self.task.get_fhir_bundle(
378 self.request, self.exported_task.recipient
379 ) # may raise FhirExportException
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 )
397 self.parse_response(response)
399 except HTTPError as e:
400 raise FhirExportException(
401 f"The FHIR server returned an error: {e.response.text}"
402 )
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}")
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:
414 .. code-block:: json
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 }
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).
457 """
458 bundle = Bundle(jsondict=response)
460 if bundle.entry is not None:
461 self._save_exported_entries(bundle)
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
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
481 self.request.dbsession.add(saved_entry)
484# =============================================================================
485# Helper functions for building FHIR component objects
486# =============================================================================
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 )
507def fhir_system_value(system: str, value: str) -> str:
508 """
509 How FHIR expresses system/value pairs.
510 """
511 return f"{system}|{value}"
514def fhir_sysval_from_id(identifier: Identifier) -> str:
515 """
516 How FHIR expresses system/value pairs.
517 """
518 return f"{identifier.system}|{identifier.value}"
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)}"
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()
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.
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()
626# =============================================================================
627# Helper classes for building FHIR component objects
628# =============================================================================
631class FHIRQuestionType(Enum):
632 """
633 An enum for value type keys of QuestionnaireResponseItemAnswer.
634 """
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
654class FHIRAnswerType(Enum):
655 """
656 An enum for value type keys of QuestionnaireResponseItemAnswer.
657 """
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
673class FHIRAnsweredQuestion:
674 """
675 Represents a question in a questionnaire-based task. That includes both the
676 abstract aspects:
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.)
682 and the concrete aspects:
684 - what is the response/answer for a specific task instance?
685 (Thus, the FHIR QuestionnaireResponse.)
687 Used for autodiscovery.
688 """
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]
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 )
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 )
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 )
754 # -------------------------------------------------------------------------
755 # Abstract (class)
756 # -------------------------------------------------------------------------
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 }
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
788 return QuestionnaireItem(jsondict=qitem_dict).as_json()
790 # -------------------------------------------------------------------------
791 # Concrete (instance)
792 # -------------------------------------------------------------------------
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
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 )
839 # Build the FHIR object
840 return QuestionnaireResponseItemAnswer(
841 jsondict={answer_type.value: fhir_answer}
842 )
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()