Coverage for cc_modules/cc_task.py: 36%
920 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"""
4camcops_server/cc_modules/cc_task.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
13 CamCOPS is free software: you can redistribute it and/or modify
14 it under the terms of the GNU General Public License as published by
15 the Free Software Foundation, either version 3 of the License, or
16 (at your option) any later version.
18 CamCOPS is distributed in the hope that it will be useful,
19 but WITHOUT ANY WARRANTY; without even the implied warranty of
20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 GNU General Public License for more details.
23 You should have received a copy of the GNU General Public License
24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
26===============================================================================
28**Represents CamCOPS tasks.**
30Core task export methods:
32======= =======================================================================
33Format Comment
34======= =======================================================================
35HTML The task in a user-friendly format.
36PDF Essentially the HTML output, but with page headers and (for clinician
37 tasks) a signature block, and without additional HTML administrative
38 hyperlinks.
39XML Centres on the task with its subdata integrated.
40TSV Tab-separated value format.
41SQL As part of an SQL or SQLite download.
42======= =======================================================================
44"""
46from base64 import b64encode
47from collections import Counter, OrderedDict
48import datetime
49import logging
50import statistics
51from typing import (
52 Any,
53 Dict,
54 Iterable,
55 Generator,
56 List,
57 Optional,
58 Set,
59 Tuple,
60 Type,
61 TYPE_CHECKING,
62 Union,
63)
65from cardinal_pythonlib.classes import classproperty
66from cardinal_pythonlib.datetimefunc import (
67 convert_datetime_to_utc,
68 format_datetime,
69 pendulum_to_utc_datetime_without_tz,
70)
71from cardinal_pythonlib.httpconst import MimeType
72from cardinal_pythonlib.logs import BraceStyleAdapter
73from cardinal_pythonlib.sqlalchemy.dialect import SqlaDialectName
74from cardinal_pythonlib.sqlalchemy.orm_inspect import (
75 gen_columns,
76 gen_orm_classes_from_base,
77)
78from cardinal_pythonlib.sqlalchemy.schema import (
79 is_sqlatype_binary,
80 is_sqlatype_string,
81)
82from cardinal_pythonlib.stringfunc import mangle_unicode_to_ascii
83from fhirclient.models.attachment import Attachment
84from fhirclient.models.bundle import Bundle
85from fhirclient.models.codeableconcept import CodeableConcept
86from fhirclient.models.coding import Coding
87from fhirclient.models.contactpoint import ContactPoint
88from fhirclient.models.documentreference import (
89 DocumentReference,
90 DocumentReferenceContent,
91)
92from fhirclient.models.fhirreference import FHIRReference
93from fhirclient.models.humanname import HumanName
94from fhirclient.models.identifier import Identifier
95from fhirclient.models.observation import Observation
96from fhirclient.models.practitioner import Practitioner
97from fhirclient.models.questionnaire import Questionnaire
98from fhirclient.models.questionnaireresponse import QuestionnaireResponse
99import hl7
100from pendulum import Date as PendulumDate, DateTime as Pendulum
101from pyramid.renderers import render
102from semantic_version import Version
103from sqlalchemy.ext.declarative import declared_attr
104from sqlalchemy.orm import relationship
105from sqlalchemy.orm.relationships import RelationshipProperty
106from sqlalchemy.sql.expression import not_, update
107from sqlalchemy.sql.schema import Column, Table
108from sqlalchemy.sql.sqltypes import (
109 Boolean,
110 Date as DateColType,
111 DateTime,
112 Float,
113 Integer,
114 Numeric,
115 String,
116 Text,
117 Time,
118)
120from camcops_server.cc_modules.cc_audit import audit
121from camcops_server.cc_modules.cc_baseconstants import DOCUMENTATION_URL
122from camcops_server.cc_modules.cc_blob import Blob, get_blob_img_html
123from camcops_server.cc_modules.cc_cache import cache_region_static, fkg
124from camcops_server.cc_modules.cc_constants import (
125 ASCII,
126 CssClass,
127 CSS_PAGED_MEDIA,
128 DateFormat,
129 FHIRConst as Fc,
130 FileType,
131 ERA_NOW,
132 INVALID_VALUE,
133 UTF8,
134)
135from camcops_server.cc_modules.cc_dataclasses import SummarySchemaInfo
136from camcops_server.cc_modules.cc_db import (
137 GenericTabletRecordMixin,
138 SFN_CAMCOPS_SERVER_VERSION,
139 SFN_IS_COMPLETE,
140 SFN_SECONDS_CREATION_TO_FIRST_FINISH,
141 TASK_FREQUENT_FIELDS,
142 TFN_CLINICIAN_CONTACT_DETAILS,
143 TFN_CLINICIAN_NAME,
144 TFN_CLINICIAN_POST,
145 TFN_CLINICIAN_PROFESSIONAL_REGISTRATION,
146 TFN_CLINICIAN_SERVICE,
147 TFN_CLINICIAN_SPECIALTY,
148 TFN_EDITING_TIME_S,
149 TFN_FIRSTEXIT_IS_ABORT,
150 TFN_FIRSTEXIT_IS_FINISH,
151 TFN_PATIENT_ID,
152 TFN_RESPONDENT_NAME,
153 TFN_RESPONDENT_RELATIONSHIP,
154 TFN_WHEN_CREATED,
155 TFN_WHEN_FIRSTEXIT,
156)
157from camcops_server.cc_modules.cc_exception import FhirExportException
158from camcops_server.cc_modules.cc_fhir import (
159 fhir_observation_component_from_snomed,
160 fhir_system_value,
161 fhir_sysval_from_id,
162 FHIRAnsweredQuestion,
163 FHIRAnswerType,
164 FHIRQuestionType,
165 make_fhir_bundle_entry,
166)
167from camcops_server.cc_modules.cc_filename import get_export_filename
168from camcops_server.cc_modules.cc_hl7 import make_obr_segment, make_obx_segment
169from camcops_server.cc_modules.cc_html import (
170 get_present_absent_none,
171 get_true_false_none,
172 get_yes_no,
173 get_yes_no_none,
174 tr,
175 tr_qa,
176)
177from camcops_server.cc_modules.cc_pdf import pdf_from_html
178from camcops_server.cc_modules.cc_pyramid import Routes, ViewArg
179from camcops_server.cc_modules.cc_simpleobjects import TaskExportOptions
180from camcops_server.cc_modules.cc_snomed import SnomedLookup
181from camcops_server.cc_modules.cc_specialnote import SpecialNote
182from camcops_server.cc_modules.cc_sqla_coltypes import (
183 BoolColumn,
184 CamcopsColumn,
185 COLATTR_PERMITTED_VALUE_CHECKER,
186 gen_ancillary_relationships,
187 get_camcops_blob_column_attr_names,
188 get_column_attr_names,
189 PendulumDateTimeAsIsoTextColType,
190 permitted_value_failure_msgs,
191 permitted_values_ok,
192 PermittedValueChecker,
193 SemanticVersionColType,
194 TableNameColType,
195)
196from camcops_server.cc_modules.cc_sqlalchemy import Base, get_table_ddl
197from camcops_server.cc_modules.cc_summaryelement import (
198 ExtraSummaryTable,
199 SummaryElement,
200)
201from camcops_server.cc_modules.cc_version import (
202 CAMCOPS_SERVER_VERSION,
203 CAMCOPS_SERVER_VERSION_STRING,
204 MINIMUM_TABLET_VERSION,
205)
206from camcops_server.cc_modules.cc_xml import (
207 get_xml_document,
208 XML_COMMENT_ANCILLARY,
209 XML_COMMENT_ANONYMOUS,
210 XML_COMMENT_BLOBS,
211 XML_COMMENT_CALCULATED,
212 XML_COMMENT_PATIENT,
213 XML_COMMENT_SNOMED_CT,
214 XML_COMMENT_SPECIAL_NOTES,
215 XML_NAME_SNOMED_CODES,
216 XmlElement,
217 XmlLiteral,
218)
220if TYPE_CHECKING:
221 from camcops_server.cc_modules.cc_ctvinfo import CtvInfo # noqa: F401
222 from camcops_server.cc_modules.cc_exportrecipient import ( # noqa: F401
223 ExportRecipient,
224 )
225 from camcops_server.cc_modules.cc_patient import Patient # noqa: F401
226 from camcops_server.cc_modules.cc_patientidnum import ( # noqa: F401
227 PatientIdNum,
228 )
229 from camcops_server.cc_modules.cc_request import ( # noqa: F401
230 CamcopsRequest,
231 )
232 from camcops_server.cc_modules.cc_snomed import ( # noqa: F401
233 SnomedExpression,
234 )
235 from camcops_server.cc_modules.cc_trackerhelpers import ( # noqa: F401
236 TrackerInfo,
237 )
238 from camcops_server.cc_modules.cc_spreadsheet import ( # noqa: F401
239 SpreadsheetPage,
240 )
242log = BraceStyleAdapter(logging.getLogger(__name__))
245# =============================================================================
246# Debugging options
247# =============================================================================
249DEBUG_SKIP_FHIR_DOCS = False
250DEBUG_SHOW_FHIR_QUESTIONNAIRE = False
252if any([DEBUG_SKIP_FHIR_DOCS, DEBUG_SHOW_FHIR_QUESTIONNAIRE]):
253 log.warning("Debugging options enabled!")
256# =============================================================================
257# Constants
258# =============================================================================
260ANCILLARY_FWD_REF = "Ancillary"
261TASK_FWD_REF = "Task"
263FHIR_UNKNOWN_TEXT = "[?]"
265SNOMED_TABLENAME = "_snomed_ct"
266SNOMED_COLNAME_TASKTABLE = "task_tablename"
267SNOMED_COLNAME_TASKPK = "task_pk"
268SNOMED_COLNAME_WHENCREATED_UTC = "when_created"
269SNOMED_COLNAME_EXPRESSION = "snomed_expression"
270UNUSED_SNOMED_XML_NAME = "snomed_ct_expressions"
273# =============================================================================
274# Patient mixin
275# =============================================================================
278class TaskHasPatientMixin(object):
279 """
280 Mixin for tasks that have a patient (aren't anonymous).
281 """
283 # https://docs.sqlalchemy.org/en/latest/orm/extensions/declarative/mixins.html#using-advanced-relationship-arguments-e-g-primaryjoin-etc # noqa
285 # noinspection PyMethodParameters
286 @declared_attr
287 def patient_id(cls) -> Column:
288 """
289 SQLAlchemy :class:`Column` that is a foreign key to the patient table.
290 """
291 return Column(
292 TFN_PATIENT_ID,
293 Integer,
294 nullable=False,
295 index=True,
296 comment="(TASK) Foreign key to patient.id (for this device/era)",
297 )
299 # noinspection PyMethodParameters
300 @declared_attr
301 def patient(cls) -> RelationshipProperty:
302 """
303 SQLAlchemy relationship: "the patient for this task".
305 Note that this refers to the CURRENT version of the patient. If there
306 is an editing chain, older patient versions are not retrieved.
308 Compare :func:`camcops_server.cc_modules.cc_blob.blob_relationship`,
309 which uses the same strategy, as do several other similar functions.
311 """
312 return relationship(
313 "Patient",
314 primaryjoin=(
315 "and_("
316 " remote(Patient.id) == foreign({task}.patient_id), "
317 " remote(Patient._device_id) == foreign({task}._device_id), "
318 " remote(Patient._era) == foreign({task}._era), "
319 " remote(Patient._current) == True "
320 ")".format(task=cls.__name__)
321 ),
322 uselist=False,
323 viewonly=True,
324 # Profiling results 2019-10-14 exporting 4185 phq9 records with
325 # unique patients to xlsx
326 # lazy="select" : 59.7s
327 # lazy="joined" : 44.3s
328 # lazy="subquery": 36.9s
329 # lazy="selectin": 35.3s
330 # See also idnums relationship on Patient class (cc_patient.py)
331 lazy="selectin",
332 )
333 # NOTE: this retrieves the most recent (i.e. the current) information
334 # on that patient. Consequently, task version history doesn't show the
335 # history of patient edits. This is consistent with our relationship
336 # strategy throughout for the web front-end viewer.
338 # noinspection PyMethodParameters
339 @classproperty
340 def has_patient(cls) -> bool:
341 """
342 Does this task have a patient? (Yes.)
343 """
344 return True
347# =============================================================================
348# Clinician mixin
349# =============================================================================
352class TaskHasClinicianMixin(object):
353 """
354 Mixin to add clinician columns and override clinician-related methods.
356 Must be to the LEFT of ``Task`` in the class's base class list, i.e.
357 must have higher precedence than ``Task`` in the method resolution order.
358 """
360 # noinspection PyMethodParameters
361 @declared_attr
362 def clinician_specialty(cls) -> Column:
363 return CamcopsColumn(
364 TFN_CLINICIAN_SPECIALTY,
365 Text,
366 exempt_from_anonymisation=True,
367 comment="(CLINICIAN) Clinician's specialty "
368 "(e.g. Liaison Psychiatry)",
369 )
371 # noinspection PyMethodParameters
372 @declared_attr
373 def clinician_name(cls) -> Column:
374 return CamcopsColumn(
375 TFN_CLINICIAN_NAME,
376 Text,
377 exempt_from_anonymisation=True,
378 comment="(CLINICIAN) Clinician's name (e.g. Dr X)",
379 )
381 # noinspection PyMethodParameters
382 @declared_attr
383 def clinician_professional_registration(cls) -> Column:
384 return CamcopsColumn(
385 TFN_CLINICIAN_PROFESSIONAL_REGISTRATION,
386 Text,
387 exempt_from_anonymisation=True,
388 comment="(CLINICIAN) Clinician's professional registration (e.g. "
389 "GMC# 12345)",
390 )
392 # noinspection PyMethodParameters
393 @declared_attr
394 def clinician_post(cls) -> Column:
395 return CamcopsColumn(
396 TFN_CLINICIAN_POST,
397 Text,
398 exempt_from_anonymisation=True,
399 comment="(CLINICIAN) Clinician's post (e.g. Consultant)",
400 )
402 # noinspection PyMethodParameters
403 @declared_attr
404 def clinician_service(cls) -> Column:
405 return CamcopsColumn(
406 TFN_CLINICIAN_SERVICE,
407 Text,
408 exempt_from_anonymisation=True,
409 comment="(CLINICIAN) Clinician's service (e.g. Liaison Psychiatry "
410 "Service)",
411 )
413 # noinspection PyMethodParameters
414 @declared_attr
415 def clinician_contact_details(cls) -> Column:
416 return CamcopsColumn(
417 TFN_CLINICIAN_CONTACT_DETAILS,
418 Text,
419 exempt_from_anonymisation=True,
420 comment="(CLINICIAN) Clinician's contact details (e.g. bleep, "
421 "extension)",
422 )
424 # For field order, see also:
425 # https://stackoverflow.com/questions/3923910/sqlalchemy-move-mixin-columns-to-end # noqa
427 # noinspection PyMethodParameters
428 @classproperty
429 def has_clinician(cls) -> bool:
430 """
431 Does the task have a clinician? (Yes.)
432 """
433 return True
435 def get_clinician_name(self) -> str:
436 """
437 Returns the clinician's name.
438 """
439 return self.clinician_name or ""
441 def get_clinician_fhir_telecom_other(self, req: "CamcopsRequest") -> str:
442 """
443 Return a mishmash of information that doesn't fit neatly into a FHIR
444 Practitioner object, but people might actually want to know.
445 """
446 _ = req.gettext
447 components = [] # type: List[str]
448 # In sequence, e.g.:
449 # - Consultant
450 if self.clinician_post:
451 components.append(f'{_("Post:")} {self.clinician_post}')
452 # - Liaison Psychiatry
453 if self.clinician_specialty:
454 components.append(f'{_("Specialty:")} {self.clinician_specialty}')
455 # - GMC# 12345
456 if self.clinician_professional_registration:
457 components.append(
458 f'{_("Professional registration:")} '
459 f"{self.clinician_professional_registration}"
460 )
461 # - Liaison Psychiatry Service
462 if self.clinician_service:
463 components.append(f'{_("Service:")} {self.clinician_service}')
464 # - tel. x12345
465 if self.clinician_contact_details:
466 components.append(
467 f'{_("Contact details:")} ' f"{self.clinician_contact_details}"
468 )
469 return " | ".join(components)
472# =============================================================================
473# Respondent mixin
474# =============================================================================
477class TaskHasRespondentMixin(object):
478 """
479 Mixin to add respondent columns and override respondent-related methods.
481 A respondent is someone who isn't the patient and isn't a clinician, such
482 as a family member or carer.
484 Must be to the LEFT of ``Task`` in the class's base class list, i.e.
485 must have higher precedence than ``Task`` in the method resolution order.
487 Notes:
489 - If you don't use ``@declared_attr``, the ``comment`` property on columns
490 doesn't work.
491 """
493 # noinspection PyMethodParameters
494 @declared_attr
495 def respondent_name(cls) -> Column:
496 return CamcopsColumn(
497 TFN_RESPONDENT_NAME,
498 Text,
499 identifies_patient=True,
500 comment="(RESPONDENT) Respondent's name",
501 )
503 # noinspection PyMethodParameters
504 @declared_attr
505 def respondent_relationship(cls) -> Column:
506 return Column(
507 TFN_RESPONDENT_RELATIONSHIP,
508 Text,
509 comment="(RESPONDENT) Respondent's relationship to patient",
510 )
512 # noinspection PyMethodParameters
513 @classproperty
514 def has_respondent(cls) -> bool:
515 """
516 Does the class have a respondent? (Yes.)
517 """
518 return True
520 def is_respondent_complete(self) -> bool:
521 """
522 Do we have sufficient information about the respondent?
523 (That means: name, relationship to the patient.)
524 """
525 return all([self.respondent_name, self.respondent_relationship])
528# =============================================================================
529# Task base class
530# =============================================================================
533class Task(GenericTabletRecordMixin, Base):
534 """
535 Abstract base class for all tasks.
537 Note:
539 - For column definitions: use
540 :class:`camcops_server.cc_modules.cc_sqla_coltypes.CamcopsColumn`, not
541 :class:`Column`, if you have fields that need to define permitted values,
542 mark them as BLOB-referencing fields, or do other CamCOPS-specific
543 things.
545 """
547 __abstract__ = True
549 # noinspection PyMethodParameters
550 @declared_attr
551 def __mapper_args__(cls):
552 return {"polymorphic_identity": cls.__name__, "concrete": True}
554 # =========================================================================
555 # PART 0: COLUMNS COMMON TO ALL TASKS
556 # =========================================================================
558 # Columns
560 # noinspection PyMethodParameters
561 @declared_attr
562 def when_created(cls) -> Column:
563 """
564 Column representing the task's creation time.
565 """
566 return Column(
567 TFN_WHEN_CREATED,
568 PendulumDateTimeAsIsoTextColType,
569 nullable=False,
570 comment="(TASK) Date/time this task instance was created "
571 "(ISO 8601)",
572 )
574 # noinspection PyMethodParameters
575 @declared_attr
576 def when_firstexit(cls) -> Column:
577 """
578 Column representing when the user first exited the task's editor
579 (i.e. first "finish" or first "abort").
580 """
581 return Column(
582 TFN_WHEN_FIRSTEXIT,
583 PendulumDateTimeAsIsoTextColType,
584 comment="(TASK) Date/time of the first exit from this task "
585 "(ISO 8601)",
586 )
588 # noinspection PyMethodParameters
589 @declared_attr
590 def firstexit_is_finish(cls) -> Column:
591 """
592 Was the first exit from the task's editor a successful "finish"?
593 """
594 return Column(
595 TFN_FIRSTEXIT_IS_FINISH,
596 Boolean,
597 comment="(TASK) Was the first exit from the task because it was "
598 "finished (1)?",
599 )
601 # noinspection PyMethodParameters
602 @declared_attr
603 def firstexit_is_abort(cls) -> Column:
604 """
605 Was the first exit from the task's editor an "abort"?
606 """
607 return Column(
608 TFN_FIRSTEXIT_IS_ABORT,
609 Boolean,
610 comment="(TASK) Was the first exit from this task because it was "
611 "aborted (1)?",
612 )
614 # noinspection PyMethodParameters
615 @declared_attr
616 def editing_time_s(cls) -> Column:
617 """
618 How long has the user spent editing the task?
619 (Calculated by the CamCOPS client.)
620 """
621 return Column(
622 TFN_EDITING_TIME_S, Float, comment="(TASK) Time spent editing (s)"
623 )
625 # Relationships
627 # noinspection PyMethodParameters
628 @declared_attr
629 def special_notes(cls) -> RelationshipProperty:
630 """
631 List-style SQLAlchemy relationship to any :class:`SpecialNote` objects
632 attached to this class. Skips hidden (quasi-deleted) notes.
633 """
634 return relationship(
635 SpecialNote,
636 primaryjoin=(
637 "and_("
638 " remote(SpecialNote.basetable) == literal({repr_task_tablename}), " # noqa
639 " remote(SpecialNote.task_id) == foreign({task}.id), "
640 " remote(SpecialNote.device_id) == foreign({task}._device_id), " # noqa
641 " remote(SpecialNote.era) == foreign({task}._era), "
642 " not_(SpecialNote.hidden)"
643 ")".format(
644 task=cls.__name__,
645 repr_task_tablename=repr(cls.__tablename__),
646 )
647 ),
648 uselist=True,
649 order_by="SpecialNote.note_at",
650 viewonly=True, # for now!
651 )
653 # =========================================================================
654 # PART 1: THINGS THAT DERIVED CLASSES MAY CARE ABOUT
655 # =========================================================================
656 #
657 # Notes:
658 #
659 # - for summaries, see GenericTabletRecordMixin.get_summaries
661 # -------------------------------------------------------------------------
662 # Attributes that must be provided
663 # -------------------------------------------------------------------------
664 __tablename__ = None # type: str # also the SQLAlchemy table name
665 shortname = None # type: str
667 # -------------------------------------------------------------------------
668 # Attributes that can be overridden
669 # -------------------------------------------------------------------------
670 extrastring_taskname = (
671 None
672 ) # type: str # if None, tablename is used instead # noqa
673 info_filename_stem = (
674 None
675 ) # type: str # if None, tablename is used instead # noqa
676 provides_trackers = False
677 use_landscape_for_pdf = False
678 dependent_classes = []
680 prohibits_clinical = False
681 prohibits_commercial = False
682 prohibits_educational = False
683 prohibits_research = False
685 @classmethod
686 def prohibits_anything(cls) -> bool:
687 return any(
688 [
689 cls.prohibits_clinical,
690 cls.prohibits_commercial,
691 cls.prohibits_educational,
692 cls.prohibits_research,
693 ]
694 )
696 # -------------------------------------------------------------------------
697 # Methods always overridden by the actual task
698 # -------------------------------------------------------------------------
700 @staticmethod
701 def longname(req: "CamcopsRequest") -> str:
702 """
703 Long name (in the relevant language).
704 """
705 raise NotImplementedError("Task.longname must be overridden")
707 def is_complete(self) -> bool:
708 """
709 Is the task instance complete?
711 Must be overridden.
712 """
713 raise NotImplementedError("Task.is_complete must be overridden")
715 def get_task_html(self, req: "CamcopsRequest") -> str:
716 """
717 HTML for the main task content.
719 Must be overridden by derived classes.
720 """
721 raise NotImplementedError(
722 "No get_task_html() HTML generator for this task class!"
723 )
725 # -------------------------------------------------------------------------
726 # Implement if you provide trackers
727 # -------------------------------------------------------------------------
729 def get_trackers(self, req: "CamcopsRequest") -> List["TrackerInfo"]:
730 """
731 Tasks that provide quantitative information for tracking over time
732 should override this and return a list of
733 :class:`camcops_server.cc_modules.cc_trackerhelpers.TrackerInfo`
734 objects, one per tracker.
736 The information is read by
737 :meth:`camcops_server.cc_modules.cc_tracker.Tracker.get_all_plots_for_one_task_html`.
739 Time information will be retrieved using :func:`get_creation_datetime`.
740 """ # noqa
741 return []
743 # -------------------------------------------------------------------------
744 # Override to provide clinical text
745 # -------------------------------------------------------------------------
747 # noinspection PyMethodMayBeStatic
748 def get_clinical_text(
749 self, req: "CamcopsRequest"
750 ) -> Optional[List["CtvInfo"]]:
751 """
752 Tasks that provide clinical text information should override this
753 to provide a list of
754 :class:`camcops_server.cc_modules.cc_ctvinfo.CtvInfo` objects.
756 Return ``None`` (default) for a task that doesn't provide clinical
757 text, or ``[]`` for one that does in general but has no information for
758 this particular instance, or a list of
759 :class:`camcops_server.cc_modules.cc_ctvinfo.CtvInfo` objects.
760 """
761 return None
763 # -------------------------------------------------------------------------
764 # Override some of these if you provide summaries
765 # -------------------------------------------------------------------------
767 # noinspection PyMethodMayBeStatic,PyUnusedLocal
768 def get_extra_summary_tables(
769 self, req: "CamcopsRequest"
770 ) -> List[ExtraSummaryTable]:
771 """
772 Override if you wish to create extra summary tables, not just add
773 summary columns to task/ancillary tables.
775 Return a list of
776 :class:`camcops_server.cc_modules.cc_summaryelement.ExtraSummaryTable`
777 objects.
778 """
779 return []
781 # -------------------------------------------------------------------------
782 # Implement if you provide SNOMED-CT codes
783 # -------------------------------------------------------------------------
785 # noinspection PyMethodMayBeStatic,PyUnusedLocal
786 def get_snomed_codes(
787 self, req: "CamcopsRequest"
788 ) -> List["SnomedExpression"]:
789 """
790 Returns all SNOMED-CT codes for this task.
792 Args:
793 req: the
794 :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
796 Returns:
797 a list of
798 :class:`camcops_server.cc_modules.cc_snomed.SnomedExpression`
799 objects
801 """
802 return []
804 # =========================================================================
805 # PART 2: INTERNALS
806 # =========================================================================
808 # -------------------------------------------------------------------------
809 # Representations
810 # -------------------------------------------------------------------------
812 def __str__(self) -> str:
813 if self.is_anonymous:
814 patient_str = ""
815 else:
816 patient_str = f", patient={self.patient}"
817 return "{t} (_pk={pk}, when_created={wc}{patient})".format(
818 t=self.tablename,
819 pk=self.pk,
820 wc=(
821 format_datetime(self.when_created, DateFormat.ERA)
822 if self.when_created
823 else "None"
824 ),
825 patient=patient_str,
826 )
828 def __repr__(self) -> str:
829 return "<{classname}(_pk={pk}, when_created={wc})>".format(
830 classname=self.__class__.__qualname__,
831 pk=self.pk,
832 wc=(
833 format_datetime(self.when_created, DateFormat.ERA)
834 if self.when_created
835 else "None"
836 ),
837 )
839 # -------------------------------------------------------------------------
840 # Way to fetch all task types
841 # -------------------------------------------------------------------------
843 @classmethod
844 def gen_all_subclasses(cls) -> Generator[Type[TASK_FWD_REF], None, None]:
845 """
846 Generate all non-abstract SQLAlchemy ORM subclasses of :class:`Task` --
847 that is, all task classes.
849 We require that actual tasks are subclasses of both :class:`Task` and
850 :class:`camcops_server.cc_modules.cc_sqlalchemy.Base`.
852 OLD WAY (ignore): this means we can (a) inherit from Task to make an
853 abstract base class for actual tasks, as with PCL, HADS, HoNOS, etc.;
854 and (b) not have those intermediate classes appear in the task list.
855 Since all actual classes must be SQLAlchemy ORM objects inheriting from
856 Base, that common inheritance is an excellent way to define them.
858 NEW WAY: things now inherit from Base/Task without necessarily
859 being actual tasks; we discriminate using ``__abstract__`` and/or
860 ``__tablename__``. See
861 https://docs.sqlalchemy.org/en/latest/orm/inheritance.html#abstract-concrete-classes
862 """ # noqa
863 # noinspection PyTypeChecker
864 return gen_orm_classes_from_base(cls)
866 @classmethod
867 @cache_region_static.cache_on_arguments(function_key_generator=fkg)
868 def all_subclasses_by_tablename(cls) -> List[Type[TASK_FWD_REF]]:
869 """
870 Return all task classes, ordered by table name.
871 """
872 classes = list(cls.gen_all_subclasses())
873 classes.sort(key=lambda c: c.tablename)
874 return classes
876 @classmethod
877 @cache_region_static.cache_on_arguments(function_key_generator=fkg)
878 def all_subclasses_by_shortname(cls) -> List[Type[TASK_FWD_REF]]:
879 """
880 Return all task classes, ordered by short name.
881 """
882 classes = list(cls.gen_all_subclasses())
883 classes.sort(key=lambda c: c.shortname)
884 return classes
886 @classmethod
887 def all_subclasses_by_longname(
888 cls, req: "CamcopsRequest"
889 ) -> List[Type[TASK_FWD_REF]]:
890 """
891 Return all task classes, ordered by long name.
892 """
893 classes = cls.all_subclasses_by_shortname()
894 classes.sort(key=lambda c: c.longname(req))
895 return classes
897 # -------------------------------------------------------------------------
898 # Methods that may be overridden by mixins
899 # -------------------------------------------------------------------------
901 # noinspection PyMethodParameters
902 @classproperty
903 def has_patient(cls) -> bool:
904 """
905 Does the task have a patient? (No.)
907 May be overridden by :class:`TaskHasPatientMixin`.
908 """
909 return False
911 # noinspection PyMethodParameters
912 @classproperty
913 def is_anonymous(cls) -> bool:
914 """
915 Antonym for :attr:`has_patient`.
916 """
917 return not cls.has_patient
919 # noinspection PyMethodParameters
920 @classproperty
921 def has_clinician(cls) -> bool:
922 """
923 Does the task have a clinician? (No.)
925 May be overridden by :class:`TaskHasClinicianMixin`.
926 """
927 return False
929 # noinspection PyMethodParameters
930 @classproperty
931 def has_respondent(cls) -> bool:
932 """
933 Does the task have a respondent? (No.)
935 May be overridden by :class:`TaskHasRespondentMixin`.
936 """
937 return False
939 # -------------------------------------------------------------------------
940 # Other classmethods
941 # -------------------------------------------------------------------------
943 # noinspection PyMethodParameters
944 @classproperty
945 def tablename(cls) -> str:
946 """
947 Returns the database table name for the task's primary table.
948 """
949 return cls.__tablename__
951 # noinspection PyMethodParameters
952 @classproperty
953 def minimum_client_version(cls) -> Version:
954 """
955 Returns the minimum client version that provides this task.
957 Override this as you add tasks.
959 Used by
960 :func:`camcops_server.cc_modules.client_api.ensure_valid_table_name`.
962 (There are some pre-C++ client versions for which the default is not
963 exactly accurate, and the tasks do not override, but this is of no
964 consequence and the version numbering system also changed, from
965 something legible as a float -- e.g. ``1.2 > 1.14`` -- to something
966 interpreted as a semantic version -- e.g. ``1.2 < 1.14``. So we ignore
967 that.)
968 """
969 return MINIMUM_TABLET_VERSION
971 # noinspection PyMethodParameters
972 @classmethod
973 def all_tables_with_min_client_version(cls) -> Dict[str, Version]:
974 """
975 Returns a dictionary mapping all this task's table names (primary and
976 ancillary) to the corresponding minimum client version.
977 """
978 v = cls.minimum_client_version
979 d = {cls.__tablename__: v} # type: Dict[str, Version]
980 for _, _, rel_cls in gen_ancillary_relationships(cls):
981 d[rel_cls.__tablename__] = v
982 return d
984 @classmethod
985 def all_tables(cls) -> List[Table]:
986 """
987 Returns all table classes (primary table plus any ancillary tables).
988 """
989 # noinspection PyUnresolvedReferences
990 return [cls.__table__] + [
991 rel_cls.__table__
992 for _, _, rel_cls in gen_ancillary_relationships(cls)
993 ]
995 @classmethod
996 def get_ddl(cls, dialect_name: str = SqlaDialectName.MYSQL) -> str:
997 """
998 Returns DDL for the primary and any ancillary tables.
999 """
1000 return "\n\n".join(
1001 get_table_ddl(t, dialect_name).strip() for t in cls.all_tables()
1002 )
1004 @classmethod
1005 def help_url(cls) -> str:
1006 """
1007 Returns the URL for task-specific online help.
1009 By default, this is based on the tablename -- e.g. ``phq9``, giving
1010 ``phq9.html`` in the documentation (from ``phq9.rst`` in the source).
1011 However, some tasks override this -- which they may do by writing
1013 .. code-block:: python
1015 info_filename_stem = "XXX"
1017 In the C++ code, compare infoFilenameStem() for individual tasks and
1018 urlconst::taskDocUrl() overall.
1020 The online help is presently only in English.
1021 """
1022 basename = cls.info_filename_stem or cls.tablename
1023 language = "en"
1024 # DOCUMENTATION_URL has a trailing slash already
1025 return f"{DOCUMENTATION_URL}{language}/latest/tasks/{basename}.html"
1027 # -------------------------------------------------------------------------
1028 # More on fields
1029 # -------------------------------------------------------------------------
1031 @classmethod
1032 def get_fieldnames(cls) -> List[str]:
1033 """
1034 Returns all field (column) names for this task's primary table.
1035 """
1036 return get_column_attr_names(cls)
1038 def field_contents_valid(self) -> bool:
1039 """
1040 Checks field contents validity.
1042 This is a high-speed function that doesn't bother with explanations,
1043 since we use it for lots of task :func:`is_complete` calculations.
1044 """
1045 return permitted_values_ok(self)
1047 def field_contents_invalid_because(self) -> List[str]:
1048 """
1049 Explains why contents are invalid.
1050 """
1051 return permitted_value_failure_msgs(self)
1053 def get_blob_fields(self) -> List[str]:
1054 """
1055 Returns field (column) names for all BLOB fields in this class.
1056 """
1057 return get_camcops_blob_column_attr_names(self)
1059 # -------------------------------------------------------------------------
1060 # Server field calculations
1061 # -------------------------------------------------------------------------
1063 def is_preserved(self) -> bool:
1064 """
1065 Is the task preserved and erased from the tablet?
1066 """
1067 return self._pk is not None and self._era != ERA_NOW
1069 def was_forcibly_preserved(self) -> bool:
1070 """
1071 Was this task forcibly preserved?
1072 """
1073 return self._forcibly_preserved and self.is_preserved()
1075 def get_creation_datetime(self) -> Optional[Pendulum]:
1076 """
1077 Creation datetime, or None.
1078 """
1079 return self.when_created
1081 def get_creation_datetime_utc(self) -> Optional[Pendulum]:
1082 """
1083 Creation datetime in UTC, or None.
1084 """
1085 localtime = self.get_creation_datetime()
1086 if localtime is None:
1087 return None
1088 return convert_datetime_to_utc(localtime)
1090 def get_creation_datetime_utc_tz_unaware(
1091 self,
1092 ) -> Optional[datetime.datetime]:
1093 """
1094 Creation time as a :class:`datetime.datetime` object on UTC with no
1095 timezone (i.e. an "offset-naive" datetime), or None.
1096 """
1097 localtime = self.get_creation_datetime()
1098 if localtime is None:
1099 return None
1100 return pendulum_to_utc_datetime_without_tz(localtime)
1102 def get_seconds_from_creation_to_first_finish(self) -> Optional[float]:
1103 """
1104 Time in seconds from creation time to first finish (i.e. first exit
1105 if the first exit was a finish rather than an abort), or None.
1106 """
1107 if not self.firstexit_is_finish:
1108 return None
1109 start = self.get_creation_datetime()
1110 end = self.when_firstexit
1111 if not start or not end:
1112 return None
1113 diff = end - start
1114 return diff.total_seconds()
1116 def get_adding_user_id(self) -> Optional[int]:
1117 """
1118 Returns the user ID of the user who uploaded this task.
1119 """
1120 # noinspection PyTypeChecker
1121 return self._adding_user_id
1123 def get_adding_user_username(self) -> str:
1124 """
1125 Returns the username of the user who uploaded this task.
1126 """
1127 return self._adding_user.username if self._adding_user else ""
1129 def get_removing_user_username(self) -> str:
1130 """
1131 Returns the username of the user who deleted this task (by removing it
1132 on the client and re-uploading).
1133 """
1134 return self._removing_user.username if self._removing_user else ""
1136 def get_preserving_user_username(self) -> str:
1137 """
1138 Returns the username of the user who "preserved" this task (marking it
1139 to be saved on the server and then deleting it from the client).
1140 """
1141 return self._preserving_user.username if self._preserving_user else ""
1143 def get_manually_erasing_user_username(self) -> str:
1144 """
1145 Returns the username of the user who erased this task manually on the
1146 server.
1147 """
1148 return (
1149 self._manually_erasing_user.username
1150 if self._manually_erasing_user
1151 else ""
1152 )
1154 # -------------------------------------------------------------------------
1155 # Summary tables
1156 # -------------------------------------------------------------------------
1158 def standard_task_summary_fields(self) -> List[SummaryElement]:
1159 """
1160 Returns summary fields/values provided by all tasks.
1161 """
1162 return [
1163 SummaryElement(
1164 name=SFN_IS_COMPLETE,
1165 coltype=Boolean(),
1166 value=self.is_complete(),
1167 comment="(GENERIC) Task complete?",
1168 ),
1169 SummaryElement(
1170 name=SFN_SECONDS_CREATION_TO_FIRST_FINISH,
1171 coltype=Float(),
1172 value=self.get_seconds_from_creation_to_first_finish(),
1173 comment="(GENERIC) Time (in seconds) from record creation to "
1174 "first exit, if that was a finish not an abort",
1175 ),
1176 SummaryElement(
1177 name=SFN_CAMCOPS_SERVER_VERSION,
1178 coltype=SemanticVersionColType(),
1179 value=CAMCOPS_SERVER_VERSION,
1180 comment="(GENERIC) CamCOPS server version that created the "
1181 "summary information",
1182 ),
1183 ]
1185 def get_all_summary_tables(
1186 self, req: "CamcopsRequest"
1187 ) -> List[ExtraSummaryTable]:
1188 """
1189 Returns all
1190 :class:`camcops_server.cc_modules.cc_summaryelement.ExtraSummaryTable`
1191 objects for this class, including any provided by subclasses, plus
1192 SNOMED CT codes if enabled.
1193 """
1194 tables = self.get_extra_summary_tables(req)
1195 if req.snomed_supported:
1196 tables.append(self._get_snomed_extra_summary_table(req))
1197 return tables
1199 def _get_snomed_extra_summary_table(
1200 self, req: "CamcopsRequest"
1201 ) -> ExtraSummaryTable:
1202 """
1203 Returns a
1204 :class:`camcops_server.cc_modules.cc_summaryelement.ExtraSummaryTable`
1205 for this task's SNOMED CT codes.
1206 """
1207 codes = self.get_snomed_codes(req)
1208 columns = [
1209 Column(
1210 SNOMED_COLNAME_TASKTABLE,
1211 TableNameColType,
1212 comment="Task's base table name",
1213 ),
1214 Column(
1215 SNOMED_COLNAME_TASKPK,
1216 Integer,
1217 comment="Task's server primary key",
1218 ),
1219 Column(
1220 SNOMED_COLNAME_WHENCREATED_UTC,
1221 DateTime,
1222 comment="Task's creation date/time (UTC)",
1223 ),
1224 CamcopsColumn(
1225 SNOMED_COLNAME_EXPRESSION,
1226 Text,
1227 exempt_from_anonymisation=True,
1228 comment="SNOMED CT expression",
1229 ),
1230 ]
1231 rows = [] # type: List[Dict[str, Any]]
1232 for code in codes:
1233 d = OrderedDict(
1234 [
1235 (SNOMED_COLNAME_TASKTABLE, self.tablename),
1236 (SNOMED_COLNAME_TASKPK, self.pk),
1237 (
1238 SNOMED_COLNAME_WHENCREATED_UTC,
1239 self.get_creation_datetime_utc_tz_unaware(),
1240 ),
1241 (SNOMED_COLNAME_EXPRESSION, code.as_string()),
1242 ]
1243 )
1244 rows.append(d)
1245 return ExtraSummaryTable(
1246 tablename=SNOMED_TABLENAME,
1247 xmlname=UNUSED_SNOMED_XML_NAME, # though actual XML doesn't use this route # noqa
1248 columns=columns,
1249 rows=rows,
1250 task=self,
1251 )
1253 # -------------------------------------------------------------------------
1254 # Testing
1255 # -------------------------------------------------------------------------
1257 def dump(self) -> None:
1258 """
1259 Dump a description of the task instance to the Python log, for
1260 debugging.
1261 """
1262 line_equals = "=" * 79
1263 lines = ["", line_equals]
1264 for f in self.get_fieldnames():
1265 lines.append(f"{f}: {getattr(self, f)!r}")
1266 lines.append(line_equals)
1267 log.info("\n".join(lines))
1269 # -------------------------------------------------------------------------
1270 # Special notes
1271 # -------------------------------------------------------------------------
1273 def apply_special_note(
1274 self, req: "CamcopsRequest", note: str, from_console: bool = False
1275 ) -> None:
1276 """
1277 Manually applies a special note to a task.
1279 Applies it to all predecessor/successor versions as well.
1280 WRITES TO THE DATABASE.
1281 """
1282 sn = SpecialNote()
1283 sn.basetable = self.tablename
1284 sn.task_id = self.id
1285 sn.device_id = self._device_id
1286 sn.era = self._era
1287 sn.note_at = req.now
1288 sn.user_id = req.user_id
1289 sn.note = note
1290 dbsession = req.dbsession
1291 dbsession.add(sn)
1292 self.audit(req, "Special note applied manually", from_console)
1293 self.cancel_from_export_log(req, from_console)
1295 # -------------------------------------------------------------------------
1296 # Clinician
1297 # -------------------------------------------------------------------------
1299 # noinspection PyMethodMayBeStatic
1300 def get_clinician_name(self) -> str:
1301 """
1302 May be overridden by :class:`TaskHasClinicianMixin`; q.v.
1303 """
1304 return ""
1306 # noinspection PyMethodMayBeStatic,PyUnusedLocal
1307 def get_clinician_fhir_telecom_other(self, req: "CamcopsRequest") -> str:
1308 """
1309 May be overridden by :class:`TaskHasClinicianMixin`; q.v.
1310 """
1311 return ""
1313 # -------------------------------------------------------------------------
1314 # Respondent
1315 # -------------------------------------------------------------------------
1317 # noinspection PyMethodMayBeStatic
1318 def is_respondent_complete(self) -> bool:
1319 """
1320 Is the respondent information complete?
1322 May be overridden by :class:`TaskHasRespondentMixin`.
1323 """
1324 return False
1326 # -------------------------------------------------------------------------
1327 # About the associated patient
1328 # -------------------------------------------------------------------------
1330 @property
1331 def patient(self) -> Optional["Patient"]:
1332 """
1333 Returns the :class:`camcops_server.cc_modules.cc_patient.Patient` for
1334 this task.
1336 Overridden by :class:`TaskHasPatientMixin`.
1337 """
1338 return None
1340 def is_female(self) -> bool:
1341 """
1342 Is the patient female?
1343 """
1344 return self.patient.is_female() if self.patient else False
1346 def is_male(self) -> bool:
1347 """
1348 Is the patient male?
1349 """
1350 return self.patient.is_male() if self.patient else False
1352 def get_patient_server_pk(self) -> Optional[int]:
1353 """
1354 Get the server PK of the patient, or None.
1355 """
1356 return self.patient.pk if self.patient else None
1358 def get_patient_forename(self) -> str:
1359 """
1360 Get the patient's forename, in upper case, or "".
1361 """
1362 return self.patient.get_forename() if self.patient else ""
1364 def get_patient_surname(self) -> str:
1365 """
1366 Get the patient's surname, in upper case, or "".
1367 """
1368 return self.patient.get_surname() if self.patient else ""
1370 def get_patient_dob(self) -> Optional[PendulumDate]:
1371 """
1372 Get the patient's DOB, or None.
1373 """
1374 return self.patient.get_dob() if self.patient else None
1376 def get_patient_dob_first11chars(self) -> Optional[str]:
1377 """
1378 Gets the patient's date of birth in an 11-character human-readable
1379 short format. For example: ``29 Dec 1999``.
1380 """
1381 if not self.patient:
1382 return None
1383 dob_str = self.patient.get_dob_str()
1384 if not dob_str:
1385 return None
1386 return dob_str[:11]
1388 def get_patient_sex(self) -> str:
1389 """
1390 Get the patient's sex, or "".
1391 """
1392 return self.patient.get_sex() if self.patient else ""
1394 def get_patient_address(self) -> str:
1395 """
1396 Get the patient's address, or "".
1397 """
1398 return self.patient.get_address() if self.patient else ""
1400 def get_patient_idnum_objects(self) -> List["PatientIdNum"]:
1401 """
1402 Gets all
1403 :class:`camcops_server.cc_modules.cc_patientidnum.PatientIdNum` objects
1404 for the patient.
1405 """
1406 return self.patient.get_idnum_objects() if self.patient else []
1408 def get_patient_idnum_object(
1409 self, which_idnum: int
1410 ) -> Optional["PatientIdNum"]:
1411 """
1412 Get the patient's
1413 :class:`camcops_server.cc_modules.cc_patientidnum.PatientIdNum` for the
1414 specified ID number type (``which_idnum``), or None.
1415 """
1416 return (
1417 self.patient.get_idnum_object(which_idnum)
1418 if self.patient
1419 else None
1420 )
1422 def any_patient_idnums_invalid(self, req: "CamcopsRequest") -> bool:
1423 """
1424 Do we have a patient who has any invalid ID numbers?
1426 Args:
1427 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1428 """
1429 idnums = self.get_patient_idnum_objects()
1430 for idnum in idnums:
1431 if not idnum.is_fully_valid(req):
1432 return True
1433 return False
1435 def get_patient_idnum_value(self, which_idnum: int) -> Optional[int]:
1436 """
1437 Get the patient's ID number value for the specified ID number
1438 type (``which_idnum``), or None.
1439 """
1440 idobj = self.get_patient_idnum_object(which_idnum=which_idnum)
1441 return idobj.idnum_value if idobj else None
1443 def get_patient_hl7_pid_segment(
1444 self, req: "CamcopsRequest", recipient_def: "ExportRecipient"
1445 ) -> Union[hl7.Segment, str]:
1446 """
1447 Get an HL7 PID segment for the patient, or "".
1448 """
1449 return (
1450 self.patient.get_hl7_pid_segment(req, recipient_def)
1451 if self.patient
1452 else ""
1453 )
1455 # -------------------------------------------------------------------------
1456 # HL7 v2
1457 # -------------------------------------------------------------------------
1459 def get_hl7_data_segments(
1460 self, req: "CamcopsRequest", recipient_def: "ExportRecipient"
1461 ) -> List[hl7.Segment]:
1462 """
1463 Returns a list of HL7 data segments.
1465 These will be:
1467 - observation request (OBR) segment
1468 - observation result (OBX) segment
1469 - any extra ones offered by the task
1470 """
1471 obr_segment = make_obr_segment(self)
1472 export_options = recipient_def.get_task_export_options()
1473 obx_segment = make_obx_segment(
1474 req,
1475 self,
1476 task_format=recipient_def.task_format,
1477 observation_identifier=self.tablename + "_" + str(self._pk),
1478 observation_datetime=self.get_creation_datetime(),
1479 responsible_observer=self.get_clinician_name(),
1480 export_options=export_options,
1481 )
1482 return [obr_segment, obx_segment] + self.get_hl7_extra_data_segments(
1483 recipient_def
1484 )
1486 # noinspection PyMethodMayBeStatic,PyUnusedLocal
1487 def get_hl7_extra_data_segments(
1488 self, recipient_def: "ExportRecipient"
1489 ) -> List[hl7.Segment]:
1490 """
1491 Return a list of any extra HL7 data segments. (See
1492 :func:`get_hl7_data_segments`, which calls this function.)
1494 May be overridden.
1495 """
1496 return []
1498 # -------------------------------------------------------------------------
1499 # FHIR: framework
1500 # -------------------------------------------------------------------------
1502 def get_fhir_bundle(
1503 self,
1504 req: "CamcopsRequest",
1505 recipient: "ExportRecipient",
1506 skip_docs_if_other_content: bool = DEBUG_SKIP_FHIR_DOCS,
1507 ) -> Bundle:
1508 """
1509 Get a single FHIR Bundle with all entries. See
1510 :meth:`get_fhir_bundle_entries`.
1511 """
1512 # Get the content:
1513 bundle_entries = self.get_fhir_bundle_entries(
1514 req,
1515 recipient,
1516 skip_docs_if_other_content=skip_docs_if_other_content,
1517 )
1518 # ... may raise FhirExportException
1520 # Sanity checks:
1521 id_counter = Counter()
1522 for entry in bundle_entries:
1523 assert (
1524 Fc.RESOURCE in entry
1525 ), f"Bundle entry has no resource: {entry}" # just wrong
1526 resource = entry[Fc.RESOURCE]
1527 assert Fc.IDENTIFIER in resource, (
1528 f"Bundle entry has no identifier for its resource: "
1529 f"{resource}"
1530 ) # might succeed, but would insert an unidentified resource
1531 identifier = resource[Fc.IDENTIFIER]
1532 if not isinstance(identifier, list):
1533 identifier = [identifier]
1534 for id_ in identifier:
1535 system = id_[Fc.SYSTEM]
1536 value = id_[Fc.VALUE]
1537 id_counter.update([fhir_system_value(system, value)])
1538 most_common = id_counter.most_common(1)[0]
1539 assert (
1540 most_common[1] == 1
1541 ), f"Resources have duplicate IDs: {most_common[0]}"
1543 # Bundle up the content into a transaction bundle:
1544 return Bundle(
1545 jsondict={Fc.TYPE: Fc.TRANSACTION, Fc.ENTRY: bundle_entries}
1546 )
1547 # This is one of the few FHIR objects that we don't return with
1548 # ".as_json()", because Bundle objects have useful methods for talking
1549 # to the FHIR server.
1551 def get_fhir_bundle_entries(
1552 self,
1553 req: "CamcopsRequest",
1554 recipient: "ExportRecipient",
1555 skip_docs_if_other_content: bool = DEBUG_SKIP_FHIR_DOCS,
1556 ) -> List[Dict]:
1557 """
1558 Get all FHIR bundle entries. This is the "top-level" function to
1559 provide all FHIR information for the task. That information includes:
1561 - the Patient, if applicable;
1562 - the Questionnaire (task) itself;
1563 - multiple QuestionnaireResponse entries for the specific answers from
1564 this task instance.
1566 If the task refuses to support FHIR, raises :exc:`FhirExportException`.
1568 Args:
1569 req:
1570 a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1571 recipient:
1572 an
1573 :class:`camcops_server.cc_modules.cc_exportrecipient.ExportRecipient`
1574 skip_docs_if_other_content:
1575 A debugging option: skip the document (e.g. PDF, HTML, XML),
1576 making the FHIR output smaller and more legible for debugging.
1577 However, if the task offers no other content, this will raise
1578 :exc:`FhirExportException`.
1579 """ # noqa
1580 bundle_entries = [] # type: List[Dict]
1582 # Patient (0 or 1)
1583 if self.has_patient:
1584 bundle_entries.append(
1585 self.patient.get_fhir_bundle_entry(req, recipient)
1586 )
1588 # Clinician (0 or 1)
1589 if self.has_clinician:
1590 bundle_entries.append(self._get_fhir_clinician_bundle_entry(req))
1592 # Questionnaire, QuestionnaireResponse
1593 q_bundle_entry, qr_bundle_entry = self._get_fhir_q_qr_bundle_entries(
1594 req, recipient
1595 )
1596 if q_bundle_entry and qr_bundle_entry:
1597 bundle_entries += [
1598 # Questionnaire
1599 q_bundle_entry,
1600 # Collection of QuestionnaireResponse entries
1601 qr_bundle_entry,
1602 ]
1604 # Observation (0 or more) -- includes Coding
1605 bundle_entries += self._get_fhir_detail_bundle_entries(req, recipient)
1607 # DocumentReference (0-1; always 1 in normal use )
1608 if skip_docs_if_other_content:
1609 if not bundle_entries:
1610 # We can't have nothing!
1611 raise FhirExportException(
1612 "Skipping task because DEBUG_SKIP_FHIR_DOCS set and no "
1613 "other content"
1614 )
1615 else:
1616 bundle_entries.append(
1617 self._get_fhir_docref_bundle_entry(req, recipient)
1618 )
1620 return bundle_entries
1622 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1623 # Generic
1624 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1626 @property
1627 def fhir_when_task_created(self) -> str:
1628 """
1629 Time of task creation, in a FHIR-compatible format.
1630 """
1631 return self.when_created.isoformat()
1633 def _get_fhir_detail_bundle_entries(
1634 self, req: "CamcopsRequest", recipient: "ExportRecipient"
1635 ) -> List[Dict]:
1636 """
1637 Returns a list of bundle entries (0-1 of them) for Observation objects,
1638 which may each contain several ObservationComponent objects. This
1639 includes any SNOMED codes offered, and any extras.
1641 See:
1643 - https://www.hl7.org/fhir/terminologies-systems.html
1644 - https://www.hl7.org/fhir/observation.html#code-interop
1645 - https://www.hl7.org/fhir/observation.html#gr-comp
1647 In particular, whether information should be grouped into one
1648 Observation (via ObservationComponent objects) or as separate
1649 observations depends on whether it is conceptually independent. For
1650 example, for BMI, height and weight should be separate.
1651 """
1652 bundle_entries = [] # type: List[Dict]
1654 # SNOMED, as one observation with several components:
1655 if req.snomed_supported:
1656 snomed_components = [] # type: List[Dict]
1657 for expr in self.get_snomed_codes(req):
1658 snomed_components.append(
1659 fhir_observation_component_from_snomed(req, expr)
1660 )
1661 if snomed_components:
1662 observable_entity = req.snomed(SnomedLookup.OBSERVABLE_ENTITY)
1663 snomed_observation = self._get_fhir_observation(
1664 req,
1665 recipient,
1666 obs_dict={
1667 # "code" is mandatory even if there are components.
1668 Fc.CODE: CodeableConcept(
1669 jsondict={
1670 Fc.CODING: [
1671 Coding(
1672 jsondict={
1673 Fc.SYSTEM: Fc.CODE_SYSTEM_SNOMED_CT, # noqa
1674 Fc.CODE: str(
1675 observable_entity.identifier
1676 ),
1677 Fc.DISPLAY: observable_entity.as_string( # noqa
1678 longform=True
1679 ),
1680 Fc.USER_SELECTED: False,
1681 }
1682 ).as_json()
1683 ],
1684 Fc.TEXT: observable_entity.term,
1685 }
1686 ).as_json(),
1687 Fc.COMPONENT: snomed_components,
1688 },
1689 )
1690 bundle_entries.append(
1691 make_fhir_bundle_entry(
1692 resource_type_url=Fc.RESOURCE_TYPE_OBSERVATION,
1693 identifier=self._get_fhir_observation_id(
1694 req, name="snomed"
1695 ),
1696 resource=snomed_observation,
1697 )
1698 )
1700 # Extra -- these can be very varied:
1701 bundle_entries += self.get_fhir_extra_bundle_entries(req, recipient)
1703 # Done
1704 return bundle_entries
1706 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1707 # Identifiers
1708 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1710 # Generic:
1712 def _get_fhir_id_this_task_class(
1713 self,
1714 req: "CamcopsRequest",
1715 route_name: str,
1716 value_within_task_class: Union[int, str],
1717 ) -> Identifier:
1718 """
1719 For when we want to refer to something within a specific task class, in
1720 the abstract. The URL refers to the task class, not the task instance.
1721 """
1722 return Identifier(
1723 jsondict={
1724 Fc.SYSTEM: req.route_url(
1725 route_name,
1726 table_name=self.tablename, # to match ViewParam.TABLE_NAME
1727 ),
1728 Fc.VALUE: str(value_within_task_class),
1729 }
1730 )
1732 def _get_fhir_id_this_task_instance(
1733 self,
1734 req: "CamcopsRequest",
1735 route_name: str,
1736 value_within_task_instance: Union[int, str],
1737 ) -> Identifier:
1738 """
1739 A number of FHIR identifiers refer to "this task" and nothing very much
1740 more specific (because they represent a type of thing of which there
1741 can only be one per task), but do so through a range of different route
1742 names that make the FHIR URLs look sensible. This is a convenience
1743 function for them. The intention is to route to the specific task
1744 instance concerned.
1745 """
1746 return Identifier(
1747 jsondict={
1748 Fc.SYSTEM: req.route_url(
1749 route_name,
1750 table_name=self.tablename, # to match ViewParam.TABLE_NAME
1751 server_pk=str(self._pk), # to match ViewParam.SERVER_PK
1752 ),
1753 Fc.VALUE: str(value_within_task_instance),
1754 }
1755 )
1757 # Specific:
1759 def _get_fhir_condition_id(
1760 self, req: "CamcopsRequest", name: Union[int, str]
1761 ) -> Identifier:
1762 """
1763 Returns a FHIR Identifier for an Observation, representing this task
1764 instance and a named observation within it.
1765 """
1766 return self._get_fhir_id_this_task_instance(
1767 req, Routes.FHIR_CONDITION, name
1768 )
1770 def _get_fhir_docref_id(
1771 self, req: "CamcopsRequest", task_format: str
1772 ) -> Identifier:
1773 """
1774 Returns a FHIR Identifier (e.g. for a DocumentReference collection)
1775 representing the view of this task.
1776 """
1777 return self._get_fhir_id_this_task_instance(
1778 req, Routes.FHIR_DOCUMENT_REFERENCE, task_format
1779 )
1781 def _get_fhir_observation_id(
1782 self, req: "CamcopsRequest", name: str
1783 ) -> Identifier:
1784 """
1785 Returns a FHIR Identifier for an Observation, representing this task
1786 instance and a named observation within it.
1787 """
1788 return self._get_fhir_id_this_task_instance(
1789 req, Routes.FHIR_OBSERVATION, name
1790 )
1792 def _get_fhir_practitioner_id(self, req: "CamcopsRequest") -> Identifier:
1793 """
1794 Returns a FHIR Identifier for the clinician. (Clinicians are not
1795 sensibly made unique across tasks, but are task-specific.)
1796 """
1797 return self._get_fhir_id_this_task_instance(
1798 req,
1799 Routes.FHIR_PRACTITIONER,
1800 Fc.CAMCOPS_VALUE_CLINICIAN_WITHIN_TASK,
1801 )
1803 def _get_fhir_questionnaire_id(self, req: "CamcopsRequest") -> Identifier:
1804 """
1805 Returns a FHIR Identifier (e.g. for a Questionnaire) representing this
1806 task, in the abstract.
1808 Incorporates the CamCOPS version, so that if aspects (even the
1809 formatting of question text) changes, a new version will be stored
1810 despite the "ifNoneExist" clause.
1811 """
1812 return Identifier(
1813 jsondict={
1814 Fc.SYSTEM: req.route_url(Routes.FHIR_QUESTIONNAIRE_SYSTEM),
1815 Fc.VALUE: f"{self.tablename}/{CAMCOPS_SERVER_VERSION_STRING}",
1816 }
1817 )
1819 def _get_fhir_questionnaire_response_id(
1820 self, req: "CamcopsRequest"
1821 ) -> Identifier:
1822 """
1823 Returns a FHIR Identifier (e.g. for a QuestionnaireResponse collection)
1824 representing this task instance. QuestionnaireResponse items are
1825 specific answers, not abstract descriptions.
1826 """
1827 return self._get_fhir_id_this_task_instance(
1828 req,
1829 Routes.FHIR_QUESTIONNAIRE_RESPONSE,
1830 Fc.CAMCOPS_VALUE_QUESTIONNAIRE_RESPONSE_WITHIN_TASK,
1831 )
1833 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1834 # References to identifiers
1835 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1837 def _get_fhir_subject_ref(
1838 self, req: "CamcopsRequest", recipient: "ExportRecipient"
1839 ) -> Dict:
1840 """
1841 Returns a reference to the patient, for "subject" fields.
1842 """
1843 assert (
1844 self.has_patient
1845 ), "Don't call Task._get_fhir_subject_ref() for anonymous tasks"
1846 return self.patient.get_fhir_subject_ref(req, recipient)
1848 def _get_fhir_practitioner_ref(self, req: "CamcopsRequest") -> Dict:
1849 """
1850 Returns a reference to the clinician, for "practitioner" fields.
1851 """
1852 assert self.has_clinician, (
1853 "Don't call Task._get_fhir_clinician_ref() "
1854 "for tasks without a clinician"
1855 )
1856 return FHIRReference(
1857 jsondict={
1858 Fc.TYPE: Fc.RESOURCE_TYPE_PRACTITIONER,
1859 Fc.IDENTIFIER: self._get_fhir_practitioner_id(req).as_json(),
1860 }
1861 ).as_json()
1863 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1864 # DocumentReference
1865 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1867 def _get_fhir_docref_bundle_entry(
1868 self,
1869 req: "CamcopsRequest",
1870 recipient: "ExportRecipient",
1871 text_encoding: str = UTF8,
1872 ) -> Dict:
1873 """
1874 Returns bundle entries for an attached document, which is a full
1875 representation of the task according to the selected task format (e.g.
1876 PDF).
1878 This requires a DocumentReference, which can (in theory) either embed
1879 the data, or refer via a URL to an associated Binary object. We do it
1880 directly.
1882 See:
1884 - https://fhirblog.com/2013/11/06/fhir-and-xds-submitting-a-document-from-a-document-source/
1885 - https://fhirblog.com/2013/11/12/the-fhir-documentreference-resource/
1886 - https://build.fhir.org/ig/HL7/US-Core/StructureDefinition-us-core-documentreference.html
1887 - https://build.fhir.org/ig/HL7/US-Core/clinical-notes-guidance.html
1888 """ # noqa
1890 # Establish content_type and binary_data
1891 task_format = recipient.task_format
1892 if task_format == FileType.PDF:
1893 binary_data = self.get_pdf(req)
1894 content_type = MimeType.PDF
1895 else:
1896 if task_format == FileType.XML:
1897 txt = self.get_xml(
1898 req,
1899 options=TaskExportOptions(
1900 include_blobs=False,
1901 xml_include_ancillary=True,
1902 xml_include_calculated=True,
1903 xml_include_comments=True,
1904 xml_include_patient=True,
1905 xml_include_plain_columns=True,
1906 xml_include_snomed=True,
1907 xml_with_header_comments=True,
1908 ),
1909 )
1910 content_type = MimeType.XML
1911 elif task_format == FileType.HTML:
1912 txt = self.get_html(req)
1913 content_type = MimeType.HTML
1914 else:
1915 raise ValueError(f"Unknown task format: {task_format!r}")
1916 binary_data = txt.encode(text_encoding)
1917 b64_encoded_bytes = b64encode(binary_data) # type: bytes
1918 b64_encoded_str = b64_encoded_bytes.decode(ASCII)
1920 # Build the DocumentReference
1921 docref_id = self._get_fhir_docref_id(req, task_format)
1922 dr_dict = {
1923 # Metadata:
1924 Fc.DATE: self.fhir_when_task_created,
1925 Fc.DESCRIPTION: self.longname(req),
1926 Fc.DOCSTATUS: (
1927 Fc.DOCSTATUS_FINAL
1928 if self.is_finalized()
1929 else Fc.DOCSTATUS_PRELIMINARY
1930 ),
1931 Fc.MASTER_IDENTIFIER: docref_id.as_json(),
1932 Fc.STATUS: Fc.DOCSTATUS_CURRENT,
1933 # And the content:
1934 Fc.CONTENT: [
1935 DocumentReferenceContent(
1936 jsondict={
1937 Fc.ATTACHMENT: Attachment(
1938 jsondict={
1939 Fc.CONTENT_TYPE: content_type,
1940 Fc.DATA: b64_encoded_str,
1941 }
1942 ).as_json()
1943 }
1944 ).as_json()
1945 ],
1946 }
1947 # Optional metadata:
1948 if self.has_clinician:
1949 dr_dict[Fc.AUTHOR] = [self._get_fhir_practitioner_ref(req)]
1950 if self.has_patient:
1951 dr_dict[Fc.SUBJECT] = self._get_fhir_subject_ref(req, recipient)
1953 # DocumentReference
1954 return make_fhir_bundle_entry(
1955 resource_type_url=Fc.RESOURCE_TYPE_DOCUMENT_REFERENCE,
1956 identifier=docref_id,
1957 resource=DocumentReference(jsondict=dr_dict).as_json(),
1958 )
1960 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1961 # Observation
1962 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1964 def _get_fhir_observation(
1965 self,
1966 req: "CamcopsRequest",
1967 recipient: "ExportRecipient",
1968 obs_dict: Dict,
1969 ) -> Dict:
1970 """
1971 Given a starting dictionary for an Observation, complete it for this
1972 task (by adding "when", "who", and status information) and return the
1973 Observation (as a dict in JSON format).
1974 """
1975 obs_dict.update(
1976 {
1977 Fc.EFFECTIVE_DATE_TIME: self.fhir_when_task_created,
1978 Fc.STATUS: (
1979 Fc.OBSSTATUS_FINAL
1980 if self.is_finalized()
1981 else Fc.OBSSTATUS_PRELIMINARY
1982 ),
1983 }
1984 )
1985 if self.has_patient:
1986 obs_dict[Fc.SUBJECT] = self._get_fhir_subject_ref(req, recipient)
1987 return Observation(jsondict=obs_dict).as_json()
1989 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1990 # Practitioner (clinician)
1991 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1993 def _get_fhir_clinician_bundle_entry(self, req: "CamcopsRequest") -> Dict:
1994 """
1995 Supplies information on the clinician associated with this task, as a
1996 FHIR Practitioner object (within a bundle).
1997 """
1998 assert self.has_clinician, (
1999 "Don't call Task._get_fhir_practitioner_bundle_entry() "
2000 "for tasks without a clinician"
2001 )
2002 practitioner = Practitioner(
2003 jsondict={
2004 Fc.NAME: [
2005 HumanName(
2006 jsondict={Fc.TEXT: self.get_clinician_name()}
2007 ).as_json()
2008 ],
2009 # "qualification" is too structured.
2010 # There isn't anywhere to represent our other information, so
2011 # we jam it in to "telecom"/"other".
2012 Fc.TELECOM: [
2013 ContactPoint(
2014 jsondict={
2015 Fc.SYSTEM: Fc.TELECOM_SYSTEM_OTHER,
2016 Fc.VALUE: self.get_clinician_fhir_telecom_other(
2017 req
2018 ),
2019 }
2020 ).as_json()
2021 ],
2022 }
2023 ).as_json()
2024 return make_fhir_bundle_entry(
2025 resource_type_url=Fc.RESOURCE_TYPE_PRACTITIONER,
2026 identifier=self._get_fhir_practitioner_id(req),
2027 resource=practitioner,
2028 )
2030 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2031 # Questionnaire, QuestionnaireResponse
2032 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2034 def _get_fhir_q_qr_bundle_entries(
2035 self, req: "CamcopsRequest", recipient: "ExportRecipient"
2036 ) -> Tuple[Optional[Dict], Optional[Dict]]:
2037 """
2038 Get a tuple of FHIR bundles: ``questionnaire_bundle_entry,
2039 questionnaire_response_bundle_entry``.
2041 A Questionnaire object represents the task in the abstract;
2042 QuestionnaireReponse items represent each answered question for a
2043 specific task instance.
2044 """
2045 # Ask the task for its details (which it may provide directly, by
2046 # overriding, or rely on autodiscovery for the default).
2047 aq_items = self.get_fhir_questionnaire(req)
2048 if DEBUG_SHOW_FHIR_QUESTIONNAIRE:
2049 if aq_items:
2050 qa_str = "\n".join(f"- {str(x)}" for x in aq_items)
2051 log.debug(f"FHIR questions/answers:\n{qa_str}")
2052 else:
2053 log.debug("No FHIR questionnaire data")
2055 # Do we have data?
2056 if not aq_items:
2057 return None, None
2059 # Now finish off:
2060 q_items = [aq.questionnaire_item() for aq in aq_items]
2061 qr_items = [aq.questionnaire_response_item() for aq in aq_items]
2062 q_bundle_entry = self._make_fhir_questionnaire_bundle_entry(
2063 req, q_items
2064 )
2065 qr_bundle_entry = self._make_fhir_questionnaire_response_bundle_entry(
2066 req, recipient, qr_items
2067 )
2068 return q_bundle_entry, qr_bundle_entry
2070 def _make_fhir_questionnaire_bundle_entry(
2071 self, req: "CamcopsRequest", q_items: List[Dict]
2072 ) -> Optional[Dict]:
2073 """
2074 Make a FHIR bundle entry describing this task, as a FHIR Questionnaire,
2075 from supplied Questionnaire items. Note: here we mean "abstract task",
2076 not "task instance".
2077 """
2078 # FHIR supports versioning of questionnaires. Might be useful if the
2079 # wording of questions change. Could either use FHIR's version
2080 # field or include the version in the identifier below. Either way
2081 # we'd need the version in the 'ifNoneExist' part of the request.
2082 q_identifier = self._get_fhir_questionnaire_id(req)
2084 # Other things we could add:
2085 # https://www.hl7.org/fhir/questionnaire.html
2086 #
2087 # date: Date last changed
2088 # useContext: https://www.hl7.org/fhir/metadatatypes.html#UsageContext
2089 help_url = self.help_url()
2090 questionnaire = Questionnaire(
2091 jsondict={
2092 Fc.NAME: self.shortname, # Computer-friendly name
2093 Fc.TITLE: self.longname(req), # Human name
2094 Fc.DESCRIPTION: help_url, # Natural language description of the questionnaire # noqa
2095 Fc.COPYRIGHT: help_url, # Use and/or publishing restrictions
2096 Fc.VERSION: CAMCOPS_SERVER_VERSION_STRING,
2097 Fc.STATUS: Fc.QSTATUS_ACTIVE, # Could also be: draft, retired, unknown # noqa
2098 Fc.ITEM: q_items,
2099 }
2100 )
2101 return make_fhir_bundle_entry(
2102 resource_type_url=Fc.RESOURCE_TYPE_QUESTIONNAIRE,
2103 identifier=q_identifier,
2104 resource=questionnaire.as_json(),
2105 )
2107 def _make_fhir_questionnaire_response_bundle_entry(
2108 self,
2109 req: "CamcopsRequest",
2110 recipient: "ExportRecipient",
2111 qr_items: List[Dict],
2112 ) -> Dict:
2113 """
2114 Make a bundle entry from FHIR QuestionnaireResponse items (e.g. one for
2115 the response to each question in a quesionnaire-style task).
2116 """
2117 q_identifier = self._get_fhir_questionnaire_id(req)
2118 qr_identifier = self._get_fhir_questionnaire_response_id(req)
2120 # Status:
2121 # https://www.hl7.org/fhir/valueset-questionnaire-answers-status.html
2122 # It is probably undesirable to export tasks that are incomplete in the
2123 # sense of "not finalized". The user can control this (via the
2124 # FINALIZED_ONLY config option for exports). However, we also need to
2125 # handle finalized but incomplete data.
2126 if self.is_complete():
2127 status = Fc.QSTATUS_COMPLETED
2128 elif self.is_live_on_tablet():
2129 status = Fc.QSTATUS_IN_PROGRESS
2130 else:
2131 # Incomplete, but finalized.
2132 status = Fc.QSTATUS_STOPPED
2134 qr_jsondict = {
2135 # https://r4.smarthealthit.org does not like "questionnaire" in
2136 # this form:
2137 # FHIR Server; FHIR 4.0.0/R4; HAPI FHIR 4.0.0-SNAPSHOT)
2138 # error is:
2139 # Invalid resource reference found at
2140 # path[QuestionnaireResponse.questionnaire]- Resource type is
2141 # unknown or not supported on this server
2142 # - http://127.0.0.1:8000/fhir_questionnaire|phq9
2143 # http://hapi.fhir.org/baseR4/ (4.0.1 (R4)) is OK
2144 Fc.QUESTIONNAIRE: fhir_sysval_from_id(q_identifier),
2145 Fc.AUTHORED: self.fhir_when_task_created,
2146 Fc.STATUS: status,
2147 # TODO: Could also add:
2148 # https://www.hl7.org/fhir/questionnaireresponse.html
2149 # author: Person who received and recorded the answers
2150 # source: The person who answered the questions
2151 Fc.ITEM: qr_items,
2152 }
2154 if self.has_patient:
2155 qr_jsondict[Fc.SUBJECT] = self._get_fhir_subject_ref(
2156 req, recipient
2157 )
2159 return make_fhir_bundle_entry(
2160 resource_type_url=Fc.RESOURCE_TYPE_QUESTIONNAIRE_RESPONSE,
2161 identifier=qr_identifier,
2162 resource=QuestionnaireResponse(qr_jsondict).as_json(),
2163 identifier_is_list=False,
2164 )
2166 # -------------------------------------------------------------------------
2167 # FHIR: functions to override if desired
2168 # -------------------------------------------------------------------------
2170 def get_fhir_questionnaire(
2171 self, req: "CamcopsRequest"
2172 ) -> List[FHIRAnsweredQuestion]:
2173 """
2174 Return FHIR information about a questionnaire: both about the task in
2175 the abstract (the questions) and the answers for this specific
2176 instance.
2178 May be overridden.
2179 """
2180 return self._fhir_autodiscover(req)
2182 def get_fhir_extra_bundle_entries(
2183 self, req: "CamcopsRequest", recipient: "ExportRecipient"
2184 ) -> List[Dict]:
2185 """
2186 Return a list of extra FHIR bundle entries, if relevant. (SNOMED-CT
2187 codes are done automatically; don't repeat those.)
2188 """
2189 return []
2191 def get_qtext(self, req: "CamcopsRequest", attrname: str) -> Optional[str]:
2192 """
2193 Returns the text associated with a particular question.
2194 The default implementation is a guess.
2196 Args:
2197 req:
2198 A :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`.
2199 attrname:
2200 Name of the attribute (field) on this task that represents the
2201 question.
2202 """
2203 return self.xstring(req, attrname, provide_default_if_none=False)
2205 def get_atext(
2206 self, req: "CamcopsRequest", attrname: str, answer_value: int
2207 ) -> Optional[str]:
2208 """
2209 Returns the text associated with a particular answer to a question.
2210 The default implementation is a guess.
2212 Args:
2213 req:
2214 A :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`.
2215 attrname:
2216 Name of the attribute (field) on this task that represents the
2217 question.
2218 answer_value:
2219 Answer value.
2220 """
2221 stringname = f"{attrname}_a{answer_value}"
2222 return self.xstring(req, stringname, provide_default_if_none=False)
2224 # -------------------------------------------------------------------------
2225 # FHIR automatic interrogation
2226 # -------------------------------------------------------------------------
2228 def _fhir_autodiscover(
2229 self, req: "CamcopsRequest"
2230 ) -> List[FHIRAnsweredQuestion]:
2231 """
2232 Inspect this task instance and create information about both the task
2233 in the abstract and the answers for this specific instance.
2234 """
2235 qa_items = [] # type: List[FHIRAnsweredQuestion]
2237 skip_fields = TASK_FREQUENT_FIELDS
2238 for attrname, column in gen_columns(self):
2239 if attrname in skip_fields:
2240 continue
2241 comment = column.comment
2242 coltype = column.type
2244 # Question text:
2245 retrieved_qtext = self.get_qtext(req, attrname)
2246 qtext_components = []
2247 if retrieved_qtext:
2248 qtext_components.append(retrieved_qtext)
2249 if comment:
2250 qtext_components.append(f"[{comment}]")
2251 if not qtext_components:
2252 qtext_components = (attrname,)
2253 if not qtext_components:
2254 qtext_components = (FHIR_UNKNOWN_TEXT,)
2255 qtext = " ".join(qtext_components)
2256 # Note that it's good to get the column comment in somewhere; these
2257 # often explain the meaning of the field quite well. It may or may
2258 # not be possible to get it into the option values -- many answer
2259 # types don't permit those. QuestionnaireItem records don't have a
2260 # comment field (see
2261 # https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item), # noqa
2262 # so the best we can do is probably to stuff it into the question
2263 # text, even if that causes some visual duplication.
2265 # Thinking about types:
2266 int_type = isinstance(coltype, Integer)
2267 bool_type = (
2268 is_sqlatype_binary(coltype)
2269 or isinstance(coltype, BoolColumn)
2270 or isinstance(coltype, Boolean)
2271 # For booleans represented as integers: it is better to be as
2272 # constraining as possible and say that only 0/1 options are
2273 # present by marking these as Boolean, which is less
2274 # complicated for the recipient than "integer but with possible
2275 # options 0 or 1". We will *also* show the possible options,
2276 # just to be clear.
2277 )
2278 if int_type:
2279 qtype = FHIRQuestionType.INTEGER
2280 atype = FHIRAnswerType.INTEGER
2281 elif isinstance(coltype, String): # includes its subclass, Text
2282 qtype = FHIRQuestionType.STRING
2283 atype = FHIRAnswerType.STRING
2284 elif isinstance(coltype, Numeric): # includes Float, Decimal
2285 qtype = FHIRQuestionType.QUANTITY
2286 atype = FHIRAnswerType.QUANTITY
2287 elif isinstance(
2288 coltype, (DateTime, PendulumDateTimeAsIsoTextColType)
2289 ):
2290 qtype = FHIRQuestionType.DATETIME
2291 atype = FHIRAnswerType.DATETIME
2292 elif isinstance(coltype, DateColType):
2293 qtype = FHIRQuestionType.DATE
2294 atype = FHIRAnswerType.DATE
2295 elif isinstance(coltype, Time):
2296 qtype = FHIRQuestionType.TIME
2297 atype = FHIRAnswerType.TIME
2298 elif bool_type:
2299 qtype = FHIRQuestionType.BOOLEAN
2300 atype = FHIRAnswerType.BOOLEAN
2301 else:
2302 raise NotImplementedError(f"Unknown column type: {coltype!r}")
2304 # Thinking about MCQ options:
2305 answer_options = None # type: Optional[Dict[Any, str]]
2306 if (int_type or bool_type) and hasattr(
2307 column, COLATTR_PERMITTED_VALUE_CHECKER
2308 ):
2309 pvc = getattr(
2310 column, COLATTR_PERMITTED_VALUE_CHECKER
2311 ) # type: PermittedValueChecker # noqa
2312 if pvc is not None:
2313 pv = pvc.permitted_values_inc_minmax()
2314 if pv:
2315 qtype = FHIRQuestionType.CHOICE
2316 # ... has to be of type "choice" to transmit the
2317 # possible values.
2318 answer_options = {}
2319 for v in pv:
2320 answer_options[v] = (
2321 self.get_atext(req, attrname, v)
2322 or comment
2323 or FHIR_UNKNOWN_TEXT
2324 )
2326 # Assemble:
2327 qa_items.append(
2328 FHIRAnsweredQuestion(
2329 qname=attrname,
2330 qtext=qtext,
2331 qtype=qtype,
2332 answer_type=atype,
2333 answer=getattr(self, attrname),
2334 answer_options=answer_options,
2335 )
2336 )
2338 # We don't currently put any summary information into FHIR exports. I
2339 # think that isn't within the spirit of the system, but am not sure.
2340 # todo: Check if summary information should go into FHIR exports.
2342 return qa_items
2344 # -------------------------------------------------------------------------
2345 # Export (generically)
2346 # -------------------------------------------------------------------------
2348 def cancel_from_export_log(
2349 self, req: "CamcopsRequest", from_console: bool = False
2350 ) -> None:
2351 """
2352 Marks all instances of this task as "cancelled" in the export log, so
2353 it will be resent.
2354 """
2355 if self._pk is None:
2356 return
2357 from camcops_server.cc_modules.cc_exportmodels import (
2358 ExportedTask,
2359 ) # delayed import
2361 # noinspection PyUnresolvedReferences
2362 statement = (
2363 update(ExportedTask.__table__)
2364 .where(ExportedTask.basetable == self.tablename)
2365 .where(ExportedTask.task_server_pk == self._pk)
2366 .where(
2367 not_(ExportedTask.cancelled) | ExportedTask.cancelled.is_(None)
2368 )
2369 .values(cancelled=1, cancelled_at_utc=req.now_utc)
2370 )
2371 # ... this bit: ... AND (NOT cancelled OR cancelled IS NULL) ...:
2372 # https://stackoverflow.com/questions/37445041/sqlalchemy-how-to-filter-column-which-contains-both-null-and-integer-values # noqa
2373 req.dbsession.execute(statement)
2374 self.audit(
2375 req,
2376 "Task cancelled in export log (may trigger resending)",
2377 from_console,
2378 )
2380 # -------------------------------------------------------------------------
2381 # Audit
2382 # -------------------------------------------------------------------------
2384 def audit(
2385 self, req: "CamcopsRequest", details: str, from_console: bool = False
2386 ) -> None:
2387 """
2388 Audits actions to this task.
2389 """
2390 audit(
2391 req,
2392 details,
2393 patient_server_pk=self.get_patient_server_pk(),
2394 table=self.tablename,
2395 server_pk=self._pk,
2396 from_console=from_console,
2397 )
2399 # -------------------------------------------------------------------------
2400 # Erasure (wiping, leaving record as placeholder)
2401 # -------------------------------------------------------------------------
2403 def manually_erase(self, req: "CamcopsRequest") -> None:
2404 """
2405 Manually erases a task (including sub-tables).
2406 Also erases linked non-current records.
2407 This WIPES THE CONTENTS but LEAVES THE RECORD AS A PLACEHOLDER.
2409 Audits the erasure. Propagates erase through to the HL7 log, so those
2410 records will be re-sent. WRITES TO DATABASE.
2411 """
2412 # Erase ourself and any other in our "family"
2413 for task in self.get_lineage():
2414 task.manually_erase_with_dependants(req)
2415 # Audit and clear HL7 message log
2416 self.audit(req, "Task details erased manually")
2417 self.cancel_from_export_log(req)
2419 def is_erased(self) -> bool:
2420 """
2421 Has the task been manually erased? See :func:`manually_erase`.
2422 """
2423 return self._manually_erased
2425 # -------------------------------------------------------------------------
2426 # Complete deletion
2427 # -------------------------------------------------------------------------
2429 def delete_entirely(self, req: "CamcopsRequest") -> None:
2430 """
2431 Completely delete this task, its lineage, and its dependants.
2432 """
2433 for task in self.get_lineage():
2434 task.delete_with_dependants(req)
2435 self.audit(req, "Task deleted")
2437 # -------------------------------------------------------------------------
2438 # Filtering tasks for the task list
2439 # -------------------------------------------------------------------------
2441 @classmethod
2442 def gen_text_filter_columns(
2443 cls,
2444 ) -> Generator[Tuple[str, Column], None, None]:
2445 """
2446 Yields tuples of ``attrname, column``, for columns that are suitable
2447 for text filtering.
2448 """
2449 for attrname, column in gen_columns(cls):
2450 if attrname.startswith("_"): # system field
2451 continue
2452 if not is_sqlatype_string(column.type):
2453 continue
2454 yield attrname, column
2456 @classmethod
2457 @cache_region_static.cache_on_arguments(function_key_generator=fkg)
2458 def get_text_filter_columns(cls) -> List[Column]:
2459 """
2460 Cached function to return a list of SQLAlchemy Column objects suitable
2461 for text filtering.
2462 """
2463 return [col for _, col in cls.gen_text_filter_columns()]
2465 def contains_text(self, text: str) -> bool:
2466 """
2467 Does this task contain the specified text?
2469 Args:
2470 text:
2471 string that must be present in at least one of our text
2472 columns
2474 Returns:
2475 is the strings present?
2476 """
2477 text = text.lower()
2478 for attrname, _ in self.gen_text_filter_columns():
2479 value = getattr(self, attrname)
2480 if value is None:
2481 continue
2482 assert isinstance(value, str), "Internal bug in contains_text"
2483 if text in value.lower():
2484 return True
2485 return False
2487 def contains_all_strings(self, strings: List[str]) -> bool:
2488 """
2489 Does this task contain all the specified strings?
2491 Args:
2492 strings:
2493 list of strings; each string must be present in at least
2494 one of our text columns
2496 Returns:
2497 are all strings present?
2498 """
2499 return all(self.contains_text(text) for text in strings)
2501 # -------------------------------------------------------------------------
2502 # Spreadsheet export for basic research dump
2503 # -------------------------------------------------------------------------
2505 def get_spreadsheet_pages(
2506 self, req: "CamcopsRequest"
2507 ) -> List["SpreadsheetPage"]:
2508 """
2509 Returns information used for the basic research dump in (e.g.) TSV
2510 format.
2511 """
2512 # 1. Our core fields, plus summary information
2513 main_page = self._get_core_spreadsheet_page(req)
2515 # 2. Patient details.
2516 if self.patient:
2517 main_page.add_or_set_columns_from_page(
2518 self.patient.get_spreadsheet_page(req)
2519 )
2520 pages = [main_page]
2522 # 3. +/- Ancillary objects
2523 for (
2524 ancillary
2525 ) in (
2526 self.gen_ancillary_instances()
2527 ): # type: GenericTabletRecordMixin # noqa
2528 page = ancillary._get_core_spreadsheet_page(req)
2529 pages.append(page)
2531 # 4. +/- Extra summary tables (inc. SNOMED)
2532 for est in self.get_all_summary_tables(req):
2533 pages.append(est.get_spreadsheet_page())
2535 # Done
2536 return pages
2538 def get_spreadsheet_schema_elements(
2539 self, req: "CamcopsRequest"
2540 ) -> Set[SummarySchemaInfo]:
2541 """
2542 Returns schema information used for spreadsheets -- more than just
2543 the database columns, and in the same format as the spreadsheets.
2544 """
2545 table_name = self.__tablename__
2547 # 1(a). Database columns: main table
2548 items = self._get_core_spreadsheet_schema()
2549 # 1(b). Summary information.
2550 for summary in self.get_summaries(req):
2551 items.add(
2552 SummarySchemaInfo.from_summary_element(table_name, summary)
2553 )
2555 # 2. Patient details
2556 if self.patient:
2557 items.update(
2558 self.patient.get_spreadsheet_schema_elements(req, table_name)
2559 )
2561 # 3. Ancillary objects
2562 for (
2563 ancillary
2564 ) in (
2565 self.gen_ancillary_instances()
2566 ): # type: GenericTabletRecordMixin # noqa
2567 items.update(ancillary._get_core_spreadsheet_schema())
2569 # 4. Extra summary tables
2570 for est in self.get_all_summary_tables(req):
2571 items.update(est.get_spreadsheet_schema_elements())
2573 return items
2575 # -------------------------------------------------------------------------
2576 # XML view
2577 # -------------------------------------------------------------------------
2579 def get_xml(
2580 self,
2581 req: "CamcopsRequest",
2582 options: TaskExportOptions = None,
2583 indent_spaces: int = 4,
2584 eol: str = "\n",
2585 ) -> str:
2586 """
2587 Returns XML describing the task.
2589 Args:
2590 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2591 options: a :class:`camcops_server.cc_modules.cc_simpleobjects.TaskExportOptions`
2593 indent_spaces: number of spaces to indent formatted XML
2594 eol: end-of-line string
2596 Returns:
2597 an XML UTF-8 document representing the task.
2599 """ # noqa
2600 options = options or TaskExportOptions()
2601 tree = self.get_xml_root(req=req, options=options)
2602 return get_xml_document(
2603 tree,
2604 indent_spaces=indent_spaces,
2605 eol=eol,
2606 include_comments=options.xml_include_comments,
2607 )
2609 def get_xml_root(
2610 self, req: "CamcopsRequest", options: TaskExportOptions
2611 ) -> XmlElement:
2612 """
2613 Returns an XML tree. The return value is the root
2614 :class:`camcops_server.cc_modules.cc_xml.XmlElement`.
2616 Override to include other tables, or to deal with BLOBs, if the default
2617 methods are insufficient.
2619 Args:
2620 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2621 options: a :class:`camcops_server.cc_modules.cc_simpleobjects.TaskExportOptions`
2622 """ # noqa
2623 # Core (inc. core BLOBs)
2624 branches = self._get_xml_core_branches(req=req, options=options)
2625 tree = XmlElement(name=self.tablename, value=branches)
2626 return tree
2628 def _get_xml_core_branches(
2629 self, req: "CamcopsRequest", options: TaskExportOptions = None
2630 ) -> List[XmlElement]:
2631 """
2632 Returns a list of :class:`camcops_server.cc_modules.cc_xml.XmlElement`
2633 elements representing stored, calculated, patient, and/or BLOB fields,
2634 depending on the options.
2636 Args:
2637 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2638 options: a :class:`camcops_server.cc_modules.cc_simpleobjects.TaskExportOptions`
2639 """ # noqa
2640 options = options or TaskExportOptions(
2641 xml_include_plain_columns=True,
2642 xml_include_ancillary=True,
2643 include_blobs=False,
2644 xml_include_calculated=True,
2645 xml_include_patient=True,
2646 xml_include_snomed=True,
2647 )
2649 def add_comment(comment: XmlLiteral) -> None:
2650 if options.xml_with_header_comments:
2651 branches.append(comment)
2653 # Stored values +/- calculated values
2654 core_options = options.clone()
2655 core_options.include_blobs = False
2656 branches = self._get_xml_branches(req=req, options=core_options)
2658 # SNOMED-CT codes
2659 if options.xml_include_snomed and req.snomed_supported:
2660 add_comment(XML_COMMENT_SNOMED_CT)
2661 snomed_codes = self.get_snomed_codes(req)
2662 snomed_branches = [] # type: List[XmlElement]
2663 for code in snomed_codes:
2664 snomed_branches.append(code.xml_element())
2665 branches.append(
2666 XmlElement(name=XML_NAME_SNOMED_CODES, value=snomed_branches)
2667 )
2669 # Special notes
2670 add_comment(XML_COMMENT_SPECIAL_NOTES)
2671 for sn in self.special_notes:
2672 branches.append(sn.get_xml_root())
2674 # Patient details
2675 if self.is_anonymous:
2676 add_comment(XML_COMMENT_ANONYMOUS)
2677 elif options.xml_include_patient:
2678 add_comment(XML_COMMENT_PATIENT)
2679 patient_options = TaskExportOptions(
2680 xml_include_plain_columns=True,
2681 xml_with_header_comments=options.xml_with_header_comments,
2682 )
2683 if self.patient:
2684 branches.append(
2685 self.patient.get_xml_root(req, patient_options)
2686 )
2688 # BLOBs
2689 if options.include_blobs:
2690 add_comment(XML_COMMENT_BLOBS)
2691 blob_options = TaskExportOptions(
2692 include_blobs=True,
2693 xml_skip_fields=options.xml_skip_fields,
2694 xml_sort_by_name=True,
2695 xml_with_header_comments=False,
2696 )
2697 branches += self._get_xml_branches(req=req, options=blob_options)
2699 # Ancillary objects
2700 if options.xml_include_ancillary:
2701 ancillary_options = TaskExportOptions(
2702 xml_include_plain_columns=True,
2703 xml_include_ancillary=True,
2704 include_blobs=options.include_blobs,
2705 xml_include_calculated=options.xml_include_calculated,
2706 xml_sort_by_name=True,
2707 xml_with_header_comments=options.xml_with_header_comments,
2708 )
2709 item_collections = [] # type: List[XmlElement]
2710 found_ancillary = False
2711 # We use a slightly more manual iteration process here so that
2712 # we iterate through individual ancillaries but clustered by their
2713 # name (e.g. if we have 50 trials and 5 groups, we do them in
2714 # collections).
2715 for attrname, rel_prop, rel_cls in gen_ancillary_relationships(
2716 self
2717 ):
2718 if not found_ancillary:
2719 add_comment(XML_COMMENT_ANCILLARY)
2720 found_ancillary = True
2721 itembranches = [] # type: List[XmlElement]
2722 if rel_prop.uselist:
2723 ancillaries = getattr(
2724 self, attrname
2725 ) # type: List[GenericTabletRecordMixin] # noqa
2726 else:
2727 ancillaries = [
2728 getattr(self, attrname)
2729 ] # type: List[GenericTabletRecordMixin] # noqa
2730 for ancillary in ancillaries:
2731 itembranches.append(
2732 ancillary._get_xml_root(
2733 req=req, options=ancillary_options
2734 )
2735 )
2736 itemcollection = XmlElement(name=attrname, value=itembranches)
2737 item_collections.append(itemcollection)
2738 item_collections.sort(key=lambda el: el.name)
2739 branches += item_collections
2741 # Completely separate additional summary tables
2742 if options.xml_include_calculated:
2743 item_collections = [] # type: List[XmlElement]
2744 found_est = False
2745 for est in self.get_extra_summary_tables(req):
2746 # ... not get_all_summary_tables(); we handled SNOMED
2747 # differently, above
2748 if not found_est and est.rows:
2749 add_comment(XML_COMMENT_CALCULATED)
2750 found_est = True
2751 item_collections.append(est.get_xml_element())
2752 item_collections.sort(key=lambda el: el.name)
2753 branches += item_collections
2755 return branches
2757 # -------------------------------------------------------------------------
2758 # HTML view
2759 # -------------------------------------------------------------------------
2761 def get_html(self, req: "CamcopsRequest", anonymise: bool = False) -> str:
2762 """
2763 Returns HTML representing the task, for our HTML view.
2765 Args:
2766 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2767 anonymise: hide patient identifying details?
2768 """
2769 req.prepare_for_html_figures()
2770 return render(
2771 "task.mako",
2772 dict(
2773 task=self,
2774 anonymise=anonymise,
2775 signature=False,
2776 viewtype=ViewArg.HTML,
2777 ),
2778 request=req,
2779 )
2781 def title_for_html(
2782 self, req: "CamcopsRequest", anonymise: bool = False
2783 ) -> str:
2784 """
2785 Returns the plain text used for the HTML ``<title>`` block (by
2786 ``task.mako``), and also for the PDF title for PDF exports.
2788 Should be plain text only.
2790 Args:
2791 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2792 anonymise: hide patient identifying details?
2793 """
2794 if anonymise:
2795 patient = "?"
2796 elif self.patient:
2797 patient = self.patient.prettystr(req)
2798 else:
2799 _ = req.gettext
2800 patient = _("Anonymous")
2801 tasktype = self.tablename
2802 when = format_datetime(
2803 self.get_creation_datetime(),
2804 DateFormat.ISO8601_HUMANIZED_TO_MINUTES,
2805 "",
2806 )
2807 return f"CamCOPS: {patient}; {tasktype}; {when}"
2809 # -------------------------------------------------------------------------
2810 # PDF view
2811 # -------------------------------------------------------------------------
2813 def get_pdf(self, req: "CamcopsRequest", anonymise: bool = False) -> bytes:
2814 """
2815 Returns a PDF representing the task.
2817 Args:
2818 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2819 anonymise: hide patient identifying details?
2820 """
2821 html = self.get_pdf_html(req, anonymise=anonymise) # main content
2822 if CSS_PAGED_MEDIA:
2823 return pdf_from_html(req, html=html)
2824 else:
2825 return pdf_from_html(
2826 req,
2827 html=html,
2828 header_html=render(
2829 "wkhtmltopdf_header.mako",
2830 dict(
2831 inner_text=render(
2832 "task_page_header.mako",
2833 dict(task=self, anonymise=anonymise),
2834 request=req,
2835 )
2836 ),
2837 request=req,
2838 ),
2839 footer_html=render(
2840 "wkhtmltopdf_footer.mako",
2841 dict(
2842 inner_text=render(
2843 "task_page_footer.mako",
2844 dict(task=self),
2845 request=req,
2846 )
2847 ),
2848 request=req,
2849 ),
2850 extra_wkhtmltopdf_options={
2851 "orientation": (
2852 "Landscape"
2853 if self.use_landscape_for_pdf
2854 else "Portrait"
2855 )
2856 },
2857 )
2859 def get_pdf_html(
2860 self, req: "CamcopsRequest", anonymise: bool = False
2861 ) -> str:
2862 """
2863 Gets the HTML used to make the PDF (slightly different from the HTML
2864 used for the HTML view).
2865 """
2866 req.prepare_for_pdf_figures()
2867 return render(
2868 "task.mako",
2869 dict(
2870 task=self,
2871 anonymise=anonymise,
2872 pdf_landscape=self.use_landscape_for_pdf,
2873 signature=self.has_clinician,
2874 viewtype=ViewArg.PDF,
2875 ),
2876 request=req,
2877 )
2879 def suggested_pdf_filename(
2880 self, req: "CamcopsRequest", anonymise: bool = False
2881 ) -> str:
2882 """
2883 Suggested filename for the PDF copy (for downloads).
2885 Args:
2886 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2887 anonymise: hide patient identifying details?
2888 """
2889 cfg = req.config
2890 if anonymise:
2891 is_anonymous = True
2892 else:
2893 is_anonymous = self.is_anonymous
2894 patient = self.patient
2895 return get_export_filename(
2896 req=req,
2897 patient_spec_if_anonymous=cfg.patient_spec_if_anonymous,
2898 patient_spec=cfg.patient_spec,
2899 filename_spec=cfg.task_filename_spec,
2900 filetype=ViewArg.PDF,
2901 is_anonymous=is_anonymous,
2902 surname=patient.get_surname() if patient else "",
2903 forename=patient.get_forename() if patient else "",
2904 dob=patient.get_dob() if patient else None,
2905 sex=patient.get_sex() if patient else None,
2906 idnum_objects=patient.get_idnum_objects() if patient else None,
2907 creation_datetime=self.get_creation_datetime(),
2908 basetable=self.tablename,
2909 serverpk=self._pk,
2910 )
2912 def write_pdf_to_disk(self, req: "CamcopsRequest", filename: str) -> None:
2913 """
2914 Writes the PDF to disk, using ``filename``.
2915 """
2916 pdffile = open(filename, "wb")
2917 pdffile.write(self.get_pdf(req))
2919 # -------------------------------------------------------------------------
2920 # Metadata for e.g. RiO
2921 # -------------------------------------------------------------------------
2923 def get_rio_metadata(
2924 self,
2925 req: "CamcopsRequest",
2926 which_idnum: int,
2927 uploading_user_id: str,
2928 document_type: str,
2929 ) -> str:
2930 """
2931 Returns metadata for the task that Servelec's RiO electronic patient
2932 record may want.
2934 Args:
2935 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2936 which_idnum: which CamCOPS ID number type corresponds to the RiO
2937 client ID?
2938 uploading_user_id: RiO user ID (string) of the user who will
2939 be recorded as uploading this information; see below
2940 document_type: a string indicating the RiO-defined document type
2941 (this is system-specific); see below
2943 Returns:
2944 a newline-terminated single line of CSV values; see below
2946 Called by
2947 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task`.
2949 From Servelec (Lee Meredith) to Rudolf Cardinal, 2014-12-04:
2951 .. code-block:: none
2953 Batch Document Upload
2955 The RiO batch document upload function can be used to upload
2956 documents in bulk automatically. RiO includes a Batch Upload
2957 windows service which monitors a designated folder for new files.
2958 Each file which is scanned must be placed in the designated folder
2959 along with a meta-data file which describes the document. So
2960 essentially if a document had been scanned in and was called
2961 ‘ThisIsANewReferralLetterForAPatient.pdf’ then there would also
2962 need to be a meta file in the same folder called
2963 ‘ThisIsANewReferralLetterForAPatient.metadata’. The contents of
2964 the meta file would need to include the following:
2966 Field Order; Field Name; Description; Data Mandatory (Y/N);
2967 Format
2969 1; ClientID; RiO Client ID which identifies the patient in RiO
2970 against which the document will be uploaded.; Y; 15
2971 Alphanumeric Characters
2973 2; UserID; User ID of the uploaded document, this is any user
2974 defined within the RiO system and can be a single system user
2975 called ‘AutomaticDocumentUploadUser’ for example.; Y; 10
2976 Alphanumeric Characters
2978 [NB example longer than that!]
2980 3; DocumentType; The RiO defined document type eg: APT; Y; 80
2981 Alphanumeric Characters
2983 4; Title; The title of the document; N; 40 Alphanumeric
2984 Characters
2986 5; Description; The document description.; N; 500 Alphanumeric
2987 Characters
2989 6; Author; The author of the document; N; 80 Alphanumeric
2990 Characters
2992 7; DocumentDate; The date of the document; N; dd/MM/yyyy HH:mm
2994 8; FinalRevision; The revision values are 0 Draft or 1 Final,
2995 this is defaulted to 1 which is Final revision.; N; 0 or 1
2997 As an example, this is what would be needed in a meta file:
2999 “1000001”,”TRUST1”,”APT”,”A title”, “A description of the
3000 document”, “An author”,”01/12/2012 09:45”,”1”
3002 (on one line)
3004 Clarification, from Lee Meredith to Rudolf Cardinal, 2015-02-18:
3006 - metadata files must be plain ASCII, not UTF-8
3008 - ... here and
3009 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task`
3011 - line terminator is <CR>
3013 - BUT see
3014 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task`
3016 - user name limit is 10 characters, despite incorrect example
3018 - search for ``RIO_MAX_USER_LEN``
3020 - DocumentType is a code that maps to a human-readable document
3021 type; for example, "APT" might map to "Appointment Letter". These
3022 mappings are specific to the local system. (We will probably want
3023 one that maps to "Clinical Correspondence" in the absence of
3024 anything more specific.)
3026 - RiO will delete the files after it's processed them.
3028 - Filenames should avoid spaces, but otherwise any other standard
3029 ASCII code is fine within filenames.
3031 - see
3032 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task`
3034 """ # noqa
3036 try:
3037 client_id = self.patient.get_idnum_value(which_idnum)
3038 except AttributeError:
3039 client_id = ""
3040 title = "CamCOPS_" + self.shortname
3041 description = self.longname(req)
3042 author = self.get_clinician_name() # may be blank
3043 document_date = format_datetime(
3044 self.when_created, DateFormat.RIO_EXPORT_UK
3045 )
3046 # This STRIPS the timezone information; i.e. it is in the local
3047 # timezone but doesn't tell you which timezone that is. (That's fine;
3048 # it should be local or users would be confused.)
3049 final_revision = 0 if self.is_live_on_tablet() else 1
3051 item_list = [
3052 client_id,
3053 uploading_user_id,
3054 document_type,
3055 title,
3056 description,
3057 author,
3058 document_date,
3059 final_revision,
3060 ]
3061 # UTF-8 is NOT supported by RiO for metadata. So:
3062 csv_line = ",".join(
3063 [f'"{mangle_unicode_to_ascii(x)}"' for x in item_list]
3064 )
3065 return csv_line + "\n"
3067 # -------------------------------------------------------------------------
3068 # HTML elements used by tasks
3069 # -------------------------------------------------------------------------
3071 # noinspection PyMethodMayBeStatic
3072 def get_standard_clinician_comments_block(
3073 self, req: "CamcopsRequest", comments: str
3074 ) -> str:
3075 """
3076 HTML DIV for clinician's comments.
3077 """
3078 return render(
3079 "clinician_comments.mako", dict(comment=comments), request=req
3080 )
3082 def get_is_complete_td_pair(self, req: "CamcopsRequest") -> str:
3083 """
3084 HTML to indicate whether task is complete or not, and to make it
3085 very obvious visually when it isn't.
3086 """
3087 c = self.is_complete()
3088 td_class = "" if c else f' class="{CssClass.INCOMPLETE}"'
3089 return (
3090 f"<td>Completed?</td>"
3091 f"<td{td_class}><b>{get_yes_no(req, c)}</b></td>"
3092 )
3094 def get_is_complete_tr(self, req: "CamcopsRequest") -> str:
3095 """
3096 HTML table row to indicate whether task is complete or not, and to
3097 make it very obvious visually when it isn't.
3098 """
3099 return f"<tr>{self.get_is_complete_td_pair(req)}</tr>"
3101 def get_twocol_val_row(
3102 self, fieldname: str, default: str = None, label: str = None
3103 ) -> str:
3104 """
3105 HTML table row, two columns, without web-safing of value.
3107 Args:
3108 fieldname: field (attribute) name; the value will be retrieved
3109 from this attribute
3110 default: default to show if the value is ``None``
3111 label: descriptive label
3113 Returns:
3114 two-column HTML table row (label, value)
3116 """
3117 val = getattr(self, fieldname)
3118 if val is None:
3119 val = default
3120 if label is None:
3121 label = fieldname
3122 return tr_qa(label, val)
3124 def get_twocol_string_row(self, fieldname: str, label: str = None) -> str:
3125 """
3126 HTML table row, two columns, with web-safing of value.
3128 Args:
3129 fieldname: field (attribute) name; the value will be retrieved
3130 from this attribute
3131 label: descriptive label
3133 Returns:
3134 two-column HTML table row (label, value)
3135 """
3136 if label is None:
3137 label = fieldname
3138 return tr_qa(label, getattr(self, fieldname))
3140 def get_twocol_bool_row(
3141 self, req: "CamcopsRequest", fieldname: str, label: str = None
3142 ) -> str:
3143 """
3144 HTML table row, two columns, with Boolean Y/N formatter for value.
3146 Args:
3147 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
3148 fieldname: field (attribute) name; the value will be retrieved
3149 from this attribute
3150 label: descriptive label
3152 Returns:
3153 two-column HTML table row (label, value)
3154 """
3155 if label is None:
3156 label = fieldname
3157 return tr_qa(label, get_yes_no_none(req, getattr(self, fieldname)))
3159 def get_twocol_bool_row_true_false(
3160 self, req: "CamcopsRequest", fieldname: str, label: str = None
3161 ) -> str:
3162 """
3163 HTML table row, two columns, with Boolean true/false formatter for
3164 value.
3166 Args:
3167 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
3168 fieldname: field (attribute) name; the value will be retrieved
3169 from this attribute
3170 label: descriptive label
3172 Returns:
3173 two-column HTML table row (label, value)
3174 """
3175 if label is None:
3176 label = fieldname
3177 return tr_qa(label, get_true_false_none(req, getattr(self, fieldname)))
3179 def get_twocol_bool_row_present_absent(
3180 self, req: "CamcopsRequest", fieldname: str, label: str = None
3181 ) -> str:
3182 """
3183 HTML table row, two columns, with Boolean present/absent formatter for
3184 value.
3186 Args:
3187 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
3188 fieldname: field (attribute) name; the value will be retrieved
3189 from this attribute
3190 label: descriptive label
3192 Returns:
3193 two-column HTML table row (label, value)
3194 """
3195 if label is None:
3196 label = fieldname
3197 return tr_qa(
3198 label, get_present_absent_none(req, getattr(self, fieldname))
3199 )
3201 @staticmethod
3202 def get_twocol_picture_row(blob: Optional[Blob], label: str) -> str:
3203 """
3204 HTML table row, two columns, with PNG on right.
3206 Args:
3207 blob: the :class:`camcops_server.cc_modules.cc_blob.Blob` object
3208 label: descriptive label
3210 Returns:
3211 two-column HTML table row (label, picture)
3212 """
3213 return tr(label, get_blob_img_html(blob))
3215 # -------------------------------------------------------------------------
3216 # Field helper functions for subclasses
3217 # -------------------------------------------------------------------------
3219 def get_values(self, fields: List[str]) -> List:
3220 """
3221 Get list of object's values from list of field names.
3222 """
3223 return [getattr(self, f) for f in fields]
3225 def is_field_not_none(self, field: str) -> bool:
3226 """
3227 Is the field not None?
3228 """
3229 return getattr(self, field) is not None
3231 def any_fields_none(self, fields: List[str]) -> bool:
3232 """
3233 Are any specified fields None?
3234 """
3235 for f in fields:
3236 if getattr(self, f) is None:
3237 return True
3238 return False
3240 def all_fields_not_none(self, fields: List[str]) -> bool:
3241 """
3242 Are all specified fields not None?
3243 """
3244 return not self.any_fields_none(fields)
3246 def any_fields_null_or_empty_str(self, fields: List[str]) -> bool:
3247 """
3248 Are any specified fields either None or the empty string?
3249 """
3250 for f in fields:
3251 v = getattr(self, f)
3252 if v is None or v == "":
3253 return True
3254 return False
3256 def are_all_fields_not_null_or_empty_str(self, fields: List[str]) -> bool:
3257 """
3258 Are all specified fields neither None nor the empty string?
3259 """
3260 return not self.any_fields_null_or_empty_str(fields)
3262 def n_fields_not_none(self, fields: List[str]) -> int:
3263 """
3264 How many of the specified fields are not None?
3265 """
3266 total = 0
3267 for f in fields:
3268 if getattr(self, f) is not None:
3269 total += 1
3270 return total
3272 def n_fields_none(self, fields: List[str]) -> int:
3273 """
3274 How many of the specified fields are None?
3275 """
3276 total = 0
3277 for f in fields:
3278 if getattr(self, f) is None:
3279 total += 1
3280 return total
3282 def count_booleans(self, fields: List[str]) -> int:
3283 """
3284 How many of the specified fields evaluate to True (are truthy)?
3285 """
3286 total = 0
3287 for f in fields:
3288 value = getattr(self, f)
3289 if value:
3290 total += 1
3291 return total
3293 def all_truthy(self, fields: List[str]) -> bool:
3294 """
3295 Do all the specified fields evaluate to True (are they all truthy)?
3296 """
3297 for f in fields:
3298 value = getattr(self, f)
3299 if not value:
3300 return False
3301 return True
3303 def count_where(self, fields: List[str], wherevalues: List[Any]) -> int:
3304 """
3305 Count how many values for the specified fields are in ``wherevalues``.
3306 """
3307 return sum(1 for x in self.get_values(fields) if x in wherevalues)
3309 def count_wherenot(self, fields: List[str], notvalues: List[Any]) -> int:
3310 """
3311 Count how many values for the specified fields are NOT in
3312 ``notvalues``.
3313 """
3314 return sum(1 for x in self.get_values(fields) if x not in notvalues)
3316 def sum_fields(
3317 self, fields: List[str], ignorevalue: Any = None
3318 ) -> Union[int, float]:
3319 """
3320 Sum values stored in all specified fields (skipping any whose value is
3321 ``ignorevalue``; treating fields containing ``None`` as zero).
3322 """
3323 total = 0
3324 for f in fields:
3325 value = getattr(self, f)
3326 if value == ignorevalue:
3327 continue
3328 total += value if value is not None else 0
3329 return total
3331 def mean_fields(
3332 self, fields: List[str], ignorevalue: Any = None
3333 ) -> Union[int, float, None]:
3334 """
3335 Return the mean of the values stored in all specified fields (skipping
3336 any whose value is ``ignorevalue``).
3337 """
3338 values = []
3339 for f in fields:
3340 value = getattr(self, f)
3341 if value != ignorevalue:
3342 values.append(value)
3343 try:
3344 return statistics.mean(values)
3345 except (TypeError, statistics.StatisticsError):
3346 return None
3348 @staticmethod
3349 def fieldnames_from_prefix(prefix: str, start: int, end: int) -> List[str]:
3350 """
3351 Returns a list of field (column, attribute) names from a prefix.
3352 For example, ``fieldnames_from_prefix("q", 1, 5)`` produces
3353 ``["q1", "q2", "q3", "q4", "q5"]``.
3355 Args:
3356 prefix: string prefix
3357 start: first value (inclusive)
3358 end: last value (inclusive
3360 Returns:
3361 list of fieldnames, as above
3363 """
3364 return [prefix + str(x) for x in range(start, end + 1)]
3366 @staticmethod
3367 def fieldnames_from_list(
3368 prefix: str, suffixes: Iterable[Any]
3369 ) -> List[str]:
3370 """
3371 Returns a list of fieldnames made by appending each suffix to the
3372 prefix.
3374 Args:
3375 prefix: string prefix
3376 suffixes: list of suffixes, which will be coerced to ``str``
3378 Returns:
3379 list of fieldnames, as above
3381 """
3382 return [prefix + str(x) for x in suffixes]
3384 # -------------------------------------------------------------------------
3385 # Extra strings
3386 # -------------------------------------------------------------------------
3388 def get_extrastring_taskname(self) -> str:
3389 """
3390 Get the taskname used as the top-level key for this task's extra
3391 strings (loaded by the server from XML files). By default, this is the
3392 task's primary tablename, but tasks may override that via
3393 ``extrastring_taskname``.
3394 """
3395 return self.extrastring_taskname or self.tablename
3397 def extrastrings_exist(self, req: "CamcopsRequest") -> bool:
3398 """
3399 Does the server have any extra strings for this task?
3400 """
3401 return req.task_extrastrings_exist(self.get_extrastring_taskname())
3403 def wxstring(
3404 self,
3405 req: "CamcopsRequest",
3406 name: str,
3407 defaultvalue: str = None,
3408 provide_default_if_none: bool = True,
3409 ) -> str:
3410 """
3411 Return a web-safe version of an extra string for this task.
3413 Args:
3414 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
3415 name: name (second-level key) of the string, within the set of
3416 this task's extra strings
3417 defaultvalue: default to return if the string is not found
3418 provide_default_if_none: if ``True`` and ``default is None``,
3419 return a helpful missing-string message in the style
3420 "string x.y not found"
3421 """
3422 if defaultvalue is None and provide_default_if_none:
3423 defaultvalue = f"[{self.get_extrastring_taskname()}: {name}]"
3424 return req.wxstring(
3425 self.get_extrastring_taskname(),
3426 name,
3427 defaultvalue,
3428 provide_default_if_none=provide_default_if_none,
3429 )
3431 def xstring(
3432 self,
3433 req: "CamcopsRequest",
3434 name: str,
3435 defaultvalue: str = None,
3436 provide_default_if_none: bool = True,
3437 ) -> str:
3438 """
3439 Return a raw (not necessarily web-safe) version of an extra string for
3440 this task.
3442 Args:
3443 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
3444 name: name (second-level key) of the string, within the set of
3445 this task's extra strings
3446 defaultvalue: default to return if the string is not found
3447 provide_default_if_none: if ``True`` and ``default is None``,
3448 return a helpful missing-string message in the style
3449 "string x.y not found"
3450 """
3451 if defaultvalue is None and provide_default_if_none:
3452 defaultvalue = f"[{self.get_extrastring_taskname()}: {name}]"
3453 return req.xstring(
3454 self.get_extrastring_taskname(),
3455 name,
3456 defaultvalue,
3457 provide_default_if_none=provide_default_if_none,
3458 )
3460 def make_options_from_xstrings(
3461 self,
3462 req: "CamcopsRequest",
3463 prefix: str,
3464 first: int,
3465 last: int,
3466 suffix: str = "",
3467 ) -> Dict[int, str]:
3468 """
3469 Creates a lookup dictionary from xstrings.
3471 Args:
3472 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
3473 prefix: prefix for xstring
3474 first: first value
3475 last: last value
3476 suffix: optional suffix
3478 Returns:
3479 dict: Each entry maps ``value`` to an xstring named
3480 ``<PREFIX><VALUE><SUFFIX>``.
3482 """
3483 d = {} # type: Dict[int, str]
3484 if first > last: # descending order
3485 for i in range(first, last - 1, -1):
3486 d[i] = self.xstring(req, f"{prefix}{i}{suffix}")
3487 else: # ascending order
3488 for i in range(first, last + 1):
3489 d[i] = self.xstring(req, f"{prefix}{i}{suffix}")
3490 return d
3492 @staticmethod
3493 def make_options_from_numbers(first: int, last: int) -> Dict[int, str]:
3494 """
3495 Creates a simple dictionary mapping numbers to string versions of those
3496 numbers. Usually for subsequent (more interesting) processing!
3498 Args:
3499 first: first value
3500 last: last value
3502 Returns:
3503 dict
3505 """
3506 d = {} # type: Dict[int, str]
3507 if first > last: # descending order
3508 for i in range(first, last - 1, -1):
3509 d[i] = str(i)
3510 else: # ascending order
3511 for i in range(first, last + 1):
3512 d[i] = str(i)
3513 return d
3516# =============================================================================
3517# Collating all task tables for specific purposes
3518# =============================================================================
3519# Function, staticmethod, classmethod?
3520# https://stackoverflow.com/questions/8108688/in-python-when-should-i-use-a-function-instead-of-a-method # noqa
3521# https://stackoverflow.com/questions/11788195/module-function-vs-staticmethod-vs-classmethod-vs-no-decorators-which-idiom-is # noqa
3522# https://stackoverflow.com/questions/15017734/using-static-methods-in-python-best-practice # noqa
3525def all_task_tables_with_min_client_version() -> Dict[str, Version]:
3526 """
3527 Across all tasks, return a mapping from each of their tables to the
3528 minimum client version.
3530 Used by
3531 :func:`camcops_server.cc_modules.client_api.all_tables_with_min_client_version`.
3533 """ # noqa
3534 d = {} # type: Dict[str, Version]
3535 classes = list(Task.gen_all_subclasses())
3536 for cls in classes:
3537 d.update(cls.all_tables_with_min_client_version())
3538 return d
3541@cache_region_static.cache_on_arguments(function_key_generator=fkg)
3542def tablename_to_task_class_dict() -> Dict[str, Type[Task]]:
3543 """
3544 Returns a mapping from task base tablenames to task classes.
3545 """
3546 d = {} # type: Dict[str, Type[Task]]
3547 for cls in Task.gen_all_subclasses():
3548 d[cls.tablename] = cls
3549 return d
3552@cache_region_static.cache_on_arguments(function_key_generator=fkg)
3553def all_task_tablenames() -> List[str]:
3554 """
3555 Returns all task base table names.
3556 """
3557 d = tablename_to_task_class_dict()
3558 return list(d.keys())
3561@cache_region_static.cache_on_arguments(function_key_generator=fkg)
3562def all_task_classes() -> List[Type[Task]]:
3563 """
3564 Returns all task base table names.
3565 """
3566 d = tablename_to_task_class_dict()
3567 return list(d.values())
3570# =============================================================================
3571# Support functions
3572# =============================================================================
3575def get_from_dict(d: Dict, key: Any, default: Any = INVALID_VALUE) -> Any:
3576 """
3577 Returns a value from a dictionary. This is not a very complex function...
3578 all it really does in practice is provide a default for ``default``.
3580 Args:
3581 d: the dictionary
3582 key: the key
3583 default: value to return if none is provided
3584 """
3585 return d.get(key, default)