Coverage for cc_modules/cc_task.py : 45%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_task.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
12 CamCOPS is free software: you can redistribute it and/or modify
13 it under the terms of the GNU General Public License as published by
14 the Free Software Foundation, either version 3 of the License, or
15 (at your option) any later version.
17 CamCOPS is distributed in the hope that it will be useful,
18 but WITHOUT ANY WARRANTY; without even the implied warranty of
19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 GNU General Public License for more details.
22 You should have received a copy of the GNU General Public License
23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
25===============================================================================
27**Represents CamCOPS tasks.**
29Core task export methods:
31======= =======================================================================
32Format Comment
33======= =======================================================================
34HTML The task in a user-friendly format.
35PDF Essentially the HTML output, but with page headers and (for clinician
36 tasks) a signature block, and without additional HTML administrative
37 hyperlinks.
38XML Centres on the task with its subdata integrated.
39TSV Tab-separated value format.
40SQL As part of an SQL or SQLite download.
41======= =======================================================================
43"""
45from collections import OrderedDict
46import datetime
47import logging
48import statistics
49from typing import (Any, Dict, Iterable, Generator, List, Optional,
50 Tuple, Type, TYPE_CHECKING, Union)
52from cardinal_pythonlib.classes import classproperty
53from cardinal_pythonlib.datetimefunc import (
54 convert_datetime_to_utc,
55 format_datetime,
56 pendulum_to_utc_datetime_without_tz,
57)
58from cardinal_pythonlib.logs import BraceStyleAdapter
59from cardinal_pythonlib.sqlalchemy.orm_inspect import (
60 gen_columns,
61 gen_orm_classes_from_base,
62)
63from cardinal_pythonlib.sqlalchemy.schema import is_sqlatype_string
64from cardinal_pythonlib.stringfunc import mangle_unicode_to_ascii
65from fhirclient.models.bundle import BundleEntry, BundleEntryRequest
66from fhirclient.models.fhirreference import FHIRReference
67from fhirclient.models.identifier import Identifier
68from fhirclient.models.questionnaire import Questionnaire
69from fhirclient.models.questionnaireresponse import QuestionnaireResponse
70import hl7
71from pendulum import Date, DateTime as Pendulum
72from pyramid.renderers import render
73from semantic_version import Version
74from sqlalchemy.ext.declarative import declared_attr
75from sqlalchemy.orm import relationship
76from sqlalchemy.orm.relationships import RelationshipProperty
77from sqlalchemy.sql.expression import not_, update
78from sqlalchemy.sql.schema import Column
79from sqlalchemy.sql.sqltypes import Boolean, DateTime, Float, Integer, Text
81# from camcops_server.cc_modules.cc_anon import get_cris_dd_rows_from_fieldspecs
82from camcops_server.cc_modules.cc_audit import audit
83from camcops_server.cc_modules.cc_blob import Blob, get_blob_img_html
84from camcops_server.cc_modules.cc_cache import cache_region_static, fkg
85from camcops_server.cc_modules.cc_constants import (
86 CssClass,
87 CSS_PAGED_MEDIA,
88 DateFormat,
89 ERA_NOW,
90 INVALID_VALUE,
91)
92from camcops_server.cc_modules.cc_db import (
93 GenericTabletRecordMixin,
94 TFN_EDITING_TIME_S,
95 TFN_FIRSTEXIT_IS_ABORT,
96 TFN_FIRSTEXIT_IS_FINISH,
97 TFN_WHEN_CREATED,
98 TFN_WHEN_FIRSTEXIT,
99)
100from camcops_server.cc_modules.cc_filename import get_export_filename
101from camcops_server.cc_modules.cc_hl7 import make_obr_segment, make_obx_segment
102from camcops_server.cc_modules.cc_html import (
103 get_present_absent_none,
104 get_true_false_none,
105 get_yes_no,
106 get_yes_no_none,
107 tr,
108 tr_qa,
109)
110from camcops_server.cc_modules.cc_pdf import pdf_from_html
111from camcops_server.cc_modules.cc_pyramid import Routes, ViewArg
112from camcops_server.cc_modules.cc_simpleobjects import TaskExportOptions
113from camcops_server.cc_modules.cc_specialnote import SpecialNote
114from camcops_server.cc_modules.cc_sqla_coltypes import (
115 CamcopsColumn,
116 gen_ancillary_relationships,
117 get_camcops_blob_column_attr_names,
118 get_column_attr_names,
119 PendulumDateTimeAsIsoTextColType,
120 permitted_value_failure_msgs,
121 permitted_values_ok,
122 SemanticVersionColType,
123 TableNameColType,
124)
125from camcops_server.cc_modules.cc_sqlalchemy import Base
126from camcops_server.cc_modules.cc_summaryelement import (
127 ExtraSummaryTable,
128 SummaryElement,
129)
130from camcops_server.cc_modules.cc_version import (
131 CAMCOPS_SERVER_VERSION,
132 MINIMUM_TABLET_VERSION,
133)
134from camcops_server.cc_modules.cc_xml import (
135 get_xml_document,
136 XML_COMMENT_ANCILLARY,
137 XML_COMMENT_ANONYMOUS,
138 XML_COMMENT_BLOBS,
139 XML_COMMENT_CALCULATED,
140 XML_COMMENT_PATIENT,
141 XML_COMMENT_SNOMED_CT,
142 XML_COMMENT_SPECIAL_NOTES,
143 XML_NAME_SNOMED_CODES,
144 XmlElement,
145 XmlLiteral,
146)
148if TYPE_CHECKING:
149 from fhirclient.models.questionnaire import QuestionnaireItem
150 from fhirclient.models.questionnaireresponse import QuestionnaireResponseItem # noqa E501
151 from camcops_server.cc_modules.cc_ctvinfo import CtvInfo # noqa: F401
152 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient # noqa: E501,F401
153 from camcops_server.cc_modules.cc_patient import Patient # noqa: F401
154 from camcops_server.cc_modules.cc_patientidnum import PatientIdNum # noqa: E501,F401
155 from camcops_server.cc_modules.cc_request import CamcopsRequest # noqa: E501,F401
156 from camcops_server.cc_modules.cc_snomed import SnomedExpression # noqa: E501,F401
157 from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo # noqa: E501,F401
158 from camcops_server.cc_modules.cc_tsv import TsvPage # noqa: F401
160log = BraceStyleAdapter(logging.getLogger(__name__))
162ANCILLARY_FWD_REF = "Ancillary"
163TASK_FWD_REF = "Task"
165SNOMED_TABLENAME = "_snomed_ct"
166SNOMED_COLNAME_TASKTABLE = "task_tablename"
167SNOMED_COLNAME_TASKPK = "task_pk"
168SNOMED_COLNAME_WHENCREATED_UTC = "when_created"
169SNOMED_COLNAME_EXPRESSION = "snomed_expression"
170UNUSED_SNOMED_XML_NAME = "snomed_ct_expressions"
173# =============================================================================
174# Patient mixin
175# =============================================================================
177class TaskHasPatientMixin(object):
178 """
179 Mixin for tasks that have a patient (aren't anonymous).
180 """
181 # http://docs.sqlalchemy.org/en/latest/orm/extensions/declarative/mixins.html#using-advanced-relationship-arguments-e-g-primaryjoin-etc # noqa
183 # noinspection PyMethodParameters
184 @declared_attr
185 def patient_id(cls) -> Column:
186 """
187 SQLAlchemy :class:`Column` that is a foreign key to the patient table.
188 """
189 return Column(
190 "patient_id", Integer,
191 nullable=False, index=True,
192 comment="(TASK) Foreign key to patient.id (for this device/era)"
193 )
195 # noinspection PyMethodParameters
196 @declared_attr
197 def patient(cls) -> RelationshipProperty:
198 """
199 SQLAlchemy relationship: "the patient for this task".
201 Note that this refers to the CURRENT version of the patient. If there
202 is an editing chain, older patient versions are not retrieved.
204 Compare :func:`camcops_server.cc_modules.cc_blob.blob_relationship`,
205 which uses the same strategy, as do several other similar functions.
207 """
208 return relationship(
209 "Patient",
210 primaryjoin=(
211 "and_("
212 " remote(Patient.id) == foreign({task}.patient_id), "
213 " remote(Patient._device_id) == foreign({task}._device_id), "
214 " remote(Patient._era) == foreign({task}._era), "
215 " remote(Patient._current) == True "
216 ")".format(
217 task=cls.__name__,
218 )
219 ),
220 uselist=False,
221 viewonly=True,
222 # Profiling results 2019-10-14 exporting 4185 phq9 records with
223 # unique patients to xlsx
224 # lazy="select" : 59.7s
225 # lazy="joined" : 44.3s
226 # lazy="subquery": 36.9s
227 # lazy="selectin": 35.3s
228 # See also idnums relationship on Patient class (cc_patient.py)
229 lazy="selectin"
230 )
231 # NOTE: this retrieves the most recent (i.e. the current) information
232 # on that patient. Consequently, task version history doesn't show the
233 # history of patient edits. This is consistent with our relationship
234 # strategy throughout for the web front-end viewer.
236 # noinspection PyMethodParameters
237 @classproperty
238 def has_patient(cls) -> bool:
239 """
240 Does this task have a patient? (Yes.)
241 """
242 return True
245# =============================================================================
246# Clinician mixin
247# =============================================================================
249class TaskHasClinicianMixin(object):
250 """
251 Mixin to add clinician columns and override clinician-related methods.
253 Must be to the LEFT of ``Task`` in the class's base class list, i.e.
254 must have higher precedence than ``Task`` in the method resolution order.
255 """
256 # noinspection PyMethodParameters
257 @declared_attr
258 def clinician_specialty(cls) -> Column:
259 return CamcopsColumn(
260 "clinician_specialty", Text,
261 exempt_from_anonymisation=True,
262 comment="(CLINICIAN) Clinician's specialty "
263 "(e.g. Liaison Psychiatry)"
264 )
266 # noinspection PyMethodParameters
267 @declared_attr
268 def clinician_name(cls) -> Column:
269 return CamcopsColumn(
270 "clinician_name", Text,
271 exempt_from_anonymisation=True,
272 comment="(CLINICIAN) Clinician's name (e.g. Dr X)"
273 )
275 # noinspection PyMethodParameters
276 @declared_attr
277 def clinician_professional_registration(cls) -> Column:
278 return CamcopsColumn(
279 "clinician_professional_registration", Text,
280 exempt_from_anonymisation=True,
281 comment="(CLINICIAN) Clinician's professional registration (e.g. "
282 "GMC# 12345)"
283 )
285 # noinspection PyMethodParameters
286 @declared_attr
287 def clinician_post(cls) -> Column:
288 return CamcopsColumn(
289 "clinician_post", Text,
290 exempt_from_anonymisation=True,
291 comment="(CLINICIAN) Clinician's post (e.g. Consultant)"
292 )
294 # noinspection PyMethodParameters
295 @declared_attr
296 def clinician_service(cls) -> Column:
297 return CamcopsColumn(
298 "clinician_service", Text,
299 exempt_from_anonymisation=True,
300 comment="(CLINICIAN) Clinician's service (e.g. Liaison Psychiatry "
301 "Service)"
302 )
304 # noinspection PyMethodParameters
305 @declared_attr
306 def clinician_contact_details(cls) -> Column:
307 return CamcopsColumn(
308 "clinician_contact_details", Text,
309 exempt_from_anonymisation=True,
310 comment="(CLINICIAN) Clinician's contact details (e.g. bleep, "
311 "extension)"
312 )
314 # For field order, see also:
315 # https://stackoverflow.com/questions/3923910/sqlalchemy-move-mixin-columns-to-end # noqa
317 # noinspection PyMethodParameters
318 @classproperty
319 def has_clinician(cls) -> bool:
320 """
321 Does the task have a clinician? (Yes.)
322 """
323 return True
325 def get_clinician_name(self) -> str:
326 """
327 Returns the clinician's name.
328 """
329 return self.clinician_name or ""
332# =============================================================================
333# Respondent mixin
334# =============================================================================
336class TaskHasRespondentMixin(object):
337 """
338 Mixin to add respondent columns and override respondent-related methods.
340 A respondent is someone who isn't the patient and isn't a clinician, such
341 as a family member or carer.
343 Must be to the LEFT of ``Task`` in the class's base class list, i.e.
344 must have higher precedence than ``Task`` in the method resolution order.
346 Notes:
348 - If you don't use ``@declared_attr``, the ``comment`` property on columns
349 doesn't work.
350 """
352 # noinspection PyMethodParameters
353 @declared_attr
354 def respondent_name(cls) -> Column:
355 return CamcopsColumn(
356 "respondent_name", Text,
357 identifies_patient=True,
358 comment="(RESPONDENT) Respondent's name"
359 )
361 # noinspection PyMethodParameters
362 @declared_attr
363 def respondent_relationship(cls) -> Column:
364 return Column(
365 "respondent_relationship", Text,
366 comment="(RESPONDENT) Respondent's relationship to patient"
367 )
369 # noinspection PyMethodParameters
370 @classproperty
371 def has_respondent(cls) -> bool:
372 """
373 Does the class have a respondent? (Yes.)
374 """
375 return True
377 def is_respondent_complete(self) -> bool:
378 """
379 Do we have sufficient information about the respondent?
380 (That means: name, relationship to the patient.)
381 """
382 return all([self.respondent_name, self.respondent_relationship])
385# =============================================================================
386# Task base class
387# =============================================================================
389class Task(GenericTabletRecordMixin, Base):
390 """
391 Abstract base class for all tasks.
393 Note:
395 - For column definitions: use
396 :class:`camcops_server.cc_modules.cc_sqla_coltypes.CamcopsColumn`, not
397 :class:`Column`, if you have fields that need to define permitted values,
398 mark them as BLOB-referencing fields, or do other CamCOPS-specific
399 things.
401 """
402 __abstract__ = True
404 # noinspection PyMethodParameters
405 @declared_attr
406 def __mapper_args__(cls):
407 return {
408 'polymorphic_identity': cls.__name__,
409 'concrete': True,
410 }
412 # =========================================================================
413 # PART 0: COLUMNS COMMON TO ALL TASKS
414 # =========================================================================
416 # Columns
418 # noinspection PyMethodParameters
419 @declared_attr
420 def when_created(cls) -> Column:
421 """
422 Column representing the task's creation time.
423 """
424 return Column(
425 TFN_WHEN_CREATED, PendulumDateTimeAsIsoTextColType,
426 nullable=False,
427 comment="(TASK) Date/time this task instance was created (ISO 8601)"
428 )
430 # noinspection PyMethodParameters
431 @declared_attr
432 def when_firstexit(cls) -> Column:
433 """
434 Column representing when the user first exited the task's editor
435 (i.e. first "finish" or first "abort").
436 """
437 return Column(
438 TFN_WHEN_FIRSTEXIT, PendulumDateTimeAsIsoTextColType,
439 comment="(TASK) Date/time of the first exit from this task "
440 "(ISO 8601)"
441 )
443 # noinspection PyMethodParameters
444 @declared_attr
445 def firstexit_is_finish(cls) -> Column:
446 """
447 Was the first exit from the task's editor a successful "finish"?
448 """
449 return Column(
450 TFN_FIRSTEXIT_IS_FINISH, Boolean,
451 comment="(TASK) Was the first exit from the task because it was "
452 "finished (1)?"
453 )
455 # noinspection PyMethodParameters
456 @declared_attr
457 def firstexit_is_abort(cls) -> Column:
458 """
459 Was the first exit from the task's editor an "abort"?
460 """
461 return Column(
462 TFN_FIRSTEXIT_IS_ABORT, Boolean,
463 comment="(TASK) Was the first exit from this task because it was "
464 "aborted (1)?"
465 )
467 # noinspection PyMethodParameters
468 @declared_attr
469 def editing_time_s(cls) -> Column:
470 """
471 How long has the user spent editing the task?
472 (Calculated by the CamCOPS client.)
473 """
474 return Column(
475 TFN_EDITING_TIME_S, Float,
476 comment="(TASK) Time spent editing (s)"
477 )
479 # Relationships
481 # noinspection PyMethodParameters
482 @declared_attr
483 def special_notes(cls) -> RelationshipProperty:
484 """
485 List-style SQLAlchemy relationship to any :class:`SpecialNote` objects
486 attached to this class. Skips hidden (quasi-deleted) notes.
487 """
488 return relationship(
489 SpecialNote,
490 primaryjoin=(
491 "and_("
492 " remote(SpecialNote.basetable) == literal({repr_task_tablename}), " # noqa
493 " remote(SpecialNote.task_id) == foreign({task}.id), "
494 " remote(SpecialNote.device_id) == foreign({task}._device_id), " # noqa
495 " remote(SpecialNote.era) == foreign({task}._era), "
496 " not_(SpecialNote.hidden)"
497 ")".format(
498 task=cls.__name__,
499 repr_task_tablename=repr(cls.__tablename__),
500 )
501 ),
502 uselist=True,
503 order_by="SpecialNote.note_at",
504 viewonly=True, # for now!
505 )
507 # =========================================================================
508 # PART 1: THINGS THAT DERIVED CLASSES MAY CARE ABOUT
509 # =========================================================================
510 #
511 # Notes:
512 #
513 # - for summaries, see GenericTabletRecordMixin.get_summaries
515 # -------------------------------------------------------------------------
516 # Attributes that must be provided
517 # -------------------------------------------------------------------------
518 __tablename__ = None # type: str # also the SQLAlchemy table name
519 shortname = None # type: str
521 # -------------------------------------------------------------------------
522 # Attributes that can be overridden
523 # -------------------------------------------------------------------------
524 extrastring_taskname = None # type: str # if None, tablename is used instead # noqa
525 provides_trackers = False
526 use_landscape_for_pdf = False
527 dependent_classes = []
529 prohibits_clinical = False
530 prohibits_commercial = False
531 prohibits_educational = False
532 prohibits_research = False
534 @classmethod
535 def prohibits_anything(cls) -> bool:
536 return any([cls.prohibits_clinical,
537 cls.prohibits_commercial,
538 cls.prohibits_educational,
539 cls.prohibits_research])
541 # -------------------------------------------------------------------------
542 # Methods always overridden by the actual task
543 # -------------------------------------------------------------------------
545 @staticmethod
546 def longname(req: "CamcopsRequest") -> str:
547 """
548 Long name (in the relevant language).
549 """
550 raise NotImplementedError("Task.longname must be overridden")
552 def is_complete(self) -> bool:
553 """
554 Is the task instance complete?
556 Must be overridden.
557 """
558 raise NotImplementedError("Task.is_complete must be overridden")
560 def get_task_html(self, req: "CamcopsRequest") -> str:
561 """
562 HTML for the main task content.
564 Must be overridden by derived classes.
565 """
566 raise NotImplementedError(
567 "No get_task_html() HTML generator for this task class!")
569 # -------------------------------------------------------------------------
570 # Implement if you provide trackers
571 # -------------------------------------------------------------------------
573 def get_trackers(self, req: "CamcopsRequest") -> List["TrackerInfo"]:
574 """
575 Tasks that provide quantitative information for tracking over time
576 should override this and return a list of
577 :class:`camcops_server.cc_modules.cc_trackerhelpers.TrackerInfo`
578 objects, one per tracker.
580 The information is read by
581 :meth:`camcops_server.cc_modules.cc_tracker.Tracker.get_all_plots_for_one_task_html`.
583 Time information will be retrieved using :func:`get_creation_datetime`.
584 """ # noqa
585 return []
587 # -------------------------------------------------------------------------
588 # Override to provide clinical text
589 # -------------------------------------------------------------------------
591 # noinspection PyMethodMayBeStatic
592 def get_clinical_text(self, req: "CamcopsRequest") \
593 -> Optional[List["CtvInfo"]]:
594 """
595 Tasks that provide clinical text information should override this
596 to provide a list of
597 :class:`camcops_server.cc_modules.cc_ctvinfo.CtvInfo` objects.
599 Return ``None`` (default) for a task that doesn't provide clinical
600 text, or ``[]`` for one that does in general but has no information for
601 this particular instance, or a list of
602 :class:`camcops_server.cc_modules.cc_ctvinfo.CtvInfo` objects.
603 """
604 return None
606 # -------------------------------------------------------------------------
607 # Override some of these if you provide summaries
608 # -------------------------------------------------------------------------
610 # noinspection PyMethodMayBeStatic,PyUnusedLocal
611 def get_extra_summary_tables(
612 self, req: "CamcopsRequest") -> List[ExtraSummaryTable]:
613 """
614 Override if you wish to create extra summary tables, not just add
615 summary columns to task/ancillary tables.
617 Return a list of
618 :class:`camcops_server.cc_modules.cc_summaryelement.ExtraSummaryTable`
619 objects.
620 """
621 return []
623 # -------------------------------------------------------------------------
624 # Implement if you provide SNOMED-CT codes
625 # -------------------------------------------------------------------------
627 # noinspection PyMethodMayBeStatic,PyUnusedLocal
628 def get_snomed_codes(self,
629 req: "CamcopsRequest") -> List["SnomedExpression"]:
630 """
631 Returns all SNOMED-CT codes for this task.
633 Args:
634 req: the
635 :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
637 Returns:
638 a list of
639 :class:`camcops_server.cc_modules.cc_snomed.SnomedExpression`
640 objects
642 """
643 return []
645 # =========================================================================
646 # PART 2: INTERNALS
647 # =========================================================================
649 # -------------------------------------------------------------------------
650 # Representations
651 # -------------------------------------------------------------------------
653 def __str__(self) -> str:
654 if self.is_anonymous:
655 patient_str = ""
656 else:
657 patient_str = f", patient={self.patient}"
658 return "{t} (_pk={pk}, when_created={wc}{patient})".format(
659 t=self.tablename,
660 pk=self.pk,
661 wc=(
662 format_datetime(self.when_created, DateFormat.ERA)
663 if self.when_created else "None"
664 ),
665 patient=patient_str,
666 )
668 def __repr__(self) -> str:
669 return "<{classname}(_pk={pk}, when_created={wc})>".format(
670 classname=self.__class__.__qualname__,
671 pk=self.pk,
672 wc=(
673 format_datetime(self.when_created, DateFormat.ERA)
674 if self.when_created else "None"
675 ),
676 )
678 # -------------------------------------------------------------------------
679 # Way to fetch all task types
680 # -------------------------------------------------------------------------
682 @classmethod
683 def gen_all_subclasses(cls) -> Generator[Type[TASK_FWD_REF], None, None]:
684 """
685 Generate all non-abstract SQLAlchemy ORM subclasses of :class:`Task` --
686 that is, all task classes.
688 We require that actual tasks are subclasses of both :class:`Task` and
689 :class:`camcops_server.cc_modules.cc_sqlalchemy.Base`.
691 OLD WAY (ignore): this means we can (a) inherit from Task to make an
692 abstract base class for actual tasks, as with PCL, HADS, HoNOS, etc.;
693 and (b) not have those intermediate classes appear in the task list.
694 Since all actual classes must be SQLAlchemy ORM objects inheriting from
695 Base, that common inheritance is an excellent way to define them.
697 NEW WAY: things now inherit from Base/Task without necessarily
698 being actual tasks; we discriminate using ``__abstract__`` and/or
699 ``__tablename__``. See
700 https://docs.sqlalchemy.org/en/latest/orm/inheritance.html#abstract-concrete-classes
701 """ # noqa
702 # noinspection PyTypeChecker
703 return gen_orm_classes_from_base(cls)
705 @classmethod
706 @cache_region_static.cache_on_arguments(function_key_generator=fkg)
707 def all_subclasses_by_tablename(cls) -> List[Type[TASK_FWD_REF]]:
708 """
709 Return all task classes, ordered by table name.
710 """
711 classes = list(cls.gen_all_subclasses())
712 classes.sort(key=lambda c: c.tablename)
713 return classes
715 @classmethod
716 @cache_region_static.cache_on_arguments(function_key_generator=fkg)
717 def all_subclasses_by_shortname(cls) -> List[Type[TASK_FWD_REF]]:
718 """
719 Return all task classes, ordered by short name.
720 """
721 classes = list(cls.gen_all_subclasses())
722 classes.sort(key=lambda c: c.shortname)
723 return classes
725 @classmethod
726 def all_subclasses_by_longname(
727 cls, req: "CamcopsRequest") -> List[Type[TASK_FWD_REF]]:
728 """
729 Return all task classes, ordered by long name.
730 """
731 classes = cls.all_subclasses_by_shortname()
732 classes.sort(key=lambda c: c.longname(req))
733 return classes
735 # -------------------------------------------------------------------------
736 # Methods that may be overridden by mixins
737 # -------------------------------------------------------------------------
739 # noinspection PyMethodParameters
740 @classproperty
741 def has_patient(cls) -> bool:
742 """
743 Does the task have a patient? (No.)
745 May be overridden by :class:`TaskHasPatientMixin`.
746 """
747 return False
749 # noinspection PyMethodParameters
750 @classproperty
751 def is_anonymous(cls) -> bool:
752 """
753 Antonym for :attr:`has_patient`.
754 """
755 return not cls.has_patient
757 # noinspection PyMethodParameters
758 @classproperty
759 def has_clinician(cls) -> bool:
760 """
761 Does the task have a clinician? (No.)
763 May be overridden by :class:`TaskHasClinicianMixin`.
764 """
765 return False
767 # noinspection PyMethodParameters
768 @classproperty
769 def has_respondent(cls) -> bool:
770 """
771 Does the task have a respondent? (No.)
773 May be overridden by :class:`TaskHasRespondentMixin`.
774 """
775 return False
777 # -------------------------------------------------------------------------
778 # Other classmethods
779 # -------------------------------------------------------------------------
781 # noinspection PyMethodParameters
782 @classproperty
783 def tablename(cls) -> str:
784 """
785 Returns the database table name for the task's primary table.
786 """
787 return cls.__tablename__
789 # noinspection PyMethodParameters
790 @classproperty
791 def minimum_client_version(cls) -> Version:
792 """
793 Returns the minimum client version that provides this task.
795 Override this as you add tasks.
797 Used by
798 :func:`camcops_server.cc_modules.client_api.ensure_valid_table_name`.
800 (There are some pre-C++ client versions for which the default is not
801 exactly accurate, and the tasks do not override, but this is of no
802 consequence and the version numbering system also changed, from
803 something legible as a float -- e.g. ``1.2 > 1.14`` -- to something
804 interpreted as a semantic version -- e.g. ``1.2 < 1.14``. So we ignore
805 that.)
806 """
807 return MINIMUM_TABLET_VERSION
809 # noinspection PyMethodParameters
810 @classmethod
811 def all_tables_with_min_client_version(cls) -> Dict[str, Version]:
812 """
813 Returns a dictionary mapping all this task's tables (primary and
814 ancillary) to the corresponding minimum client version.
815 """
816 v = cls.minimum_client_version
817 d = {cls.__tablename__: v} # type: Dict[str, Version]
818 for _, _, rel_cls in gen_ancillary_relationships(cls):
819 d[rel_cls.__tablename__] = v
820 return d
822 # -------------------------------------------------------------------------
823 # More on fields
824 # -------------------------------------------------------------------------
826 @classmethod
827 def get_fieldnames(cls) -> List[str]:
828 """
829 Returns all field (column) names for this task's primary table.
830 """
831 return get_column_attr_names(cls)
833 def field_contents_valid(self) -> bool:
834 """
835 Checks field contents validity.
837 This is a high-speed function that doesn't bother with explanations,
838 since we use it for lots of task :func:`is_complete` calculations.
839 """
840 return permitted_values_ok(self)
842 def field_contents_invalid_because(self) -> List[str]:
843 """
844 Explains why contents are invalid.
845 """
846 return permitted_value_failure_msgs(self)
848 def get_blob_fields(self) -> List[str]:
849 """
850 Returns field (column) names for all BLOB fields in this class.
851 """
852 return get_camcops_blob_column_attr_names(self)
854 # -------------------------------------------------------------------------
855 # Server field calculations
856 # -------------------------------------------------------------------------
858 def is_preserved(self) -> bool:
859 """
860 Is the task preserved and erased from the tablet?
861 """
862 return self._pk is not None and self._era != ERA_NOW
864 def was_forcibly_preserved(self) -> bool:
865 """
866 Was this task forcibly preserved?
867 """
868 return self._forcibly_preserved and self.is_preserved()
870 def get_creation_datetime(self) -> Optional[Pendulum]:
871 """
872 Creation datetime, or None.
873 """
874 return self.when_created
876 def get_creation_datetime_utc(self) -> Optional[Pendulum]:
877 """
878 Creation datetime in UTC, or None.
879 """
880 localtime = self.get_creation_datetime()
881 if localtime is None:
882 return None
883 return convert_datetime_to_utc(localtime)
885 def get_creation_datetime_utc_tz_unaware(self) -> \
886 Optional[datetime.datetime]:
887 """
888 Creation time as a :class:`datetime.datetime` object on UTC with no
889 timezone (i.e. an "offset-naive" datetime), or None.
890 """
891 localtime = self.get_creation_datetime()
892 if localtime is None:
893 return None
894 return pendulum_to_utc_datetime_without_tz(localtime)
896 def get_seconds_from_creation_to_first_finish(self) -> Optional[float]:
897 """
898 Time in seconds from creation time to first finish (i.e. first exit
899 if the first exit was a finish rather than an abort), or None.
900 """
901 if not self.firstexit_is_finish:
902 return None
903 start = self.get_creation_datetime()
904 end = self.when_firstexit
905 if not start or not end:
906 return None
907 diff = end - start
908 return diff.total_seconds()
910 def get_adding_user_id(self) -> Optional[int]:
911 """
912 Returns the user ID of the user who uploaded this task.
913 """
914 # noinspection PyTypeChecker
915 return self._adding_user_id
917 def get_adding_user_username(self) -> str:
918 """
919 Returns the username of the user who uploaded this task.
920 """
921 return self._adding_user.username if self._adding_user else ""
923 def get_removing_user_username(self) -> str:
924 """
925 Returns the username of the user who deleted this task (by removing it
926 on the client and re-uploading).
927 """
928 return self._removing_user.username if self._removing_user else ""
930 def get_preserving_user_username(self) -> str:
931 """
932 Returns the username of the user who "preserved" this task (marking it
933 to be saved on the server and then deleting it from the client).
934 """
935 return self._preserving_user.username if self._preserving_user else ""
937 def get_manually_erasing_user_username(self) -> str:
938 """
939 Returns the username of the user who erased this task manually on the
940 server.
941 """
942 return self._manually_erasing_user.username if self._manually_erasing_user else "" # noqa
944 # -------------------------------------------------------------------------
945 # Summary tables
946 # -------------------------------------------------------------------------
948 def standard_task_summary_fields(self) -> List[SummaryElement]:
949 """
950 Returns summary fields/values provided by all tasks.
951 """
952 return [
953 SummaryElement(
954 name="is_complete",
955 coltype=Boolean(),
956 value=self.is_complete(),
957 comment="(GENERIC) Task complete?"
958 ),
959 SummaryElement(
960 name="seconds_from_creation_to_first_finish",
961 coltype=Float(),
962 value=self.get_seconds_from_creation_to_first_finish(),
963 comment="(GENERIC) Time (in seconds) from record creation to "
964 "first exit, if that was a finish not an abort",
965 ),
966 SummaryElement(
967 name="camcops_server_version",
968 coltype=SemanticVersionColType(),
969 value=CAMCOPS_SERVER_VERSION,
970 comment="(GENERIC) CamCOPS server version that created the "
971 "summary information",
972 ),
973 ]
975 def get_all_summary_tables(self, req: "CamcopsRequest") \
976 -> List[ExtraSummaryTable]:
977 """
978 Returns all
979 :class:`camcops_server.cc_modules.cc_summaryelement.ExtraSummaryTable`
980 objects for this class, including any provided by subclasses, plus
981 SNOMED CT codes if enabled.
982 """
983 tables = self.get_extra_summary_tables(req)
984 if req.snomed_supported:
985 tables.append(self._get_snomed_extra_summary_table(req))
986 return tables
988 def _get_snomed_extra_summary_table(self, req: "CamcopsRequest") \
989 -> ExtraSummaryTable:
990 """
991 Returns a
992 :class:`camcops_server.cc_modules.cc_summaryelement.ExtraSummaryTable`
993 for this task's SNOMED CT codes.
994 """
995 codes = self.get_snomed_codes(req)
996 columns = [
997 Column(SNOMED_COLNAME_TASKTABLE, TableNameColType,
998 comment="Task's base table name"),
999 Column(SNOMED_COLNAME_TASKPK, Integer,
1000 comment="Task's server primary key"),
1001 Column(SNOMED_COLNAME_WHENCREATED_UTC, DateTime,
1002 comment="Task's creation date/time (UTC)"),
1003 CamcopsColumn(SNOMED_COLNAME_EXPRESSION, Text,
1004 exempt_from_anonymisation=True,
1005 comment="SNOMED CT expression"),
1006 ]
1007 rows = [] # type: List[Dict[str, Any]]
1008 for code in codes:
1009 d = OrderedDict([
1010 (SNOMED_COLNAME_TASKTABLE, self.tablename),
1011 (SNOMED_COLNAME_TASKPK, self.pk),
1012 (SNOMED_COLNAME_WHENCREATED_UTC,
1013 self.get_creation_datetime_utc_tz_unaware()),
1014 (SNOMED_COLNAME_EXPRESSION, code.as_string()),
1015 ])
1016 rows.append(d)
1017 return ExtraSummaryTable(
1018 tablename=SNOMED_TABLENAME,
1019 xmlname=UNUSED_SNOMED_XML_NAME, # though actual XML doesn't use this route # noqa
1020 columns=columns,
1021 rows=rows,
1022 task=self
1023 )
1025 # -------------------------------------------------------------------------
1026 # Testing
1027 # -------------------------------------------------------------------------
1029 def dump(self) -> None:
1030 """
1031 Dump a description of the task instance to the Python log, for
1032 debugging.
1033 """
1034 line_equals = "=" * 79
1035 lines = ["", line_equals]
1036 for f in self.get_fieldnames():
1037 lines.append(f"{f}: {getattr(self, f)!r}")
1038 lines.append(line_equals)
1039 log.info("\n".join(lines))
1041 # -------------------------------------------------------------------------
1042 # Special notes
1043 # -------------------------------------------------------------------------
1045 def apply_special_note(self,
1046 req: "CamcopsRequest",
1047 note: str,
1048 from_console: bool = False) -> None:
1049 """
1050 Manually applies a special note to a task.
1052 Applies it to all predecessor/successor versions as well.
1053 WRITES TO THE DATABASE.
1054 """
1055 sn = SpecialNote()
1056 sn.basetable = self.tablename
1057 sn.task_id = self.id
1058 sn.device_id = self._device_id
1059 sn.era = self._era
1060 sn.note_at = req.now
1061 sn.user_id = req.user_id
1062 sn.note = note
1063 dbsession = req.dbsession
1064 dbsession.add(sn)
1065 self.audit(req, "Special note applied manually", from_console)
1066 self.cancel_from_export_log(req, from_console)
1068 # -------------------------------------------------------------------------
1069 # Clinician
1070 # -------------------------------------------------------------------------
1072 # noinspection PyMethodMayBeStatic
1073 def get_clinician_name(self) -> str:
1074 """
1075 Get the clinician's name.
1077 May be overridden by :class:`TaskHasClinicianMixin`.
1078 """
1079 return ""
1081 # -------------------------------------------------------------------------
1082 # Respondent
1083 # -------------------------------------------------------------------------
1085 # noinspection PyMethodMayBeStatic
1086 def is_respondent_complete(self) -> bool:
1087 """
1088 Is the respondent information complete?
1090 May be overridden by :class:`TaskHasRespondentMixin`.
1091 """
1092 return False
1094 # -------------------------------------------------------------------------
1095 # About the associated patient
1096 # -------------------------------------------------------------------------
1098 @property
1099 def patient(self) -> Optional["Patient"]:
1100 """
1101 Returns the :class:`camcops_server.cc_modules.cc_patient.Patient` for
1102 this task.
1104 Overridden by :class:`TaskHasPatientMixin`.
1105 """
1106 return None
1108 def is_female(self) -> bool:
1109 """
1110 Is the patient female?
1111 """
1112 return self.patient.is_female() if self.patient else False
1114 def is_male(self) -> bool:
1115 """
1116 Is the patient male?
1117 """
1118 return self.patient.is_male() if self.patient else False
1120 def get_patient_server_pk(self) -> Optional[int]:
1121 """
1122 Get the server PK of the patient, or None.
1123 """
1124 return self.patient.pk if self.patient else None
1126 def get_patient_forename(self) -> str:
1127 """
1128 Get the patient's forename, in upper case, or "".
1129 """
1130 return self.patient.get_forename() if self.patient else ""
1132 def get_patient_surname(self) -> str:
1133 """
1134 Get the patient's surname, in upper case, or "".
1135 """
1136 return self.patient.get_surname() if self.patient else ""
1138 def get_patient_dob(self) -> Optional[Date]:
1139 """
1140 Get the patient's DOB, or None.
1141 """
1142 return self.patient.get_dob() if self.patient else None
1144 def get_patient_dob_first11chars(self) -> Optional[str]:
1145 """
1146 Gets the patient's date of birth in an 11-character human-readable
1147 short format. For example: ``29 Dec 1999``.
1148 """
1149 if not self.patient:
1150 return None
1151 dob_str = self.patient.get_dob_str()
1152 if not dob_str:
1153 return None
1154 return dob_str[:11]
1156 def get_patient_sex(self) -> str:
1157 """
1158 Get the patient's sex, or "".
1159 """
1160 return self.patient.get_sex() if self.patient else ""
1162 def get_patient_address(self) -> str:
1163 """
1164 Get the patient's address, or "".
1165 """
1166 return self.patient.get_address() if self.patient else ""
1168 def get_patient_idnum_objects(self) -> List["PatientIdNum"]:
1169 """
1170 Gets all
1171 :class:`camcops_server.cc_modules.cc_patientidnum.PatientIdNum` objects
1172 for the patient.
1173 """
1174 return self.patient.get_idnum_objects() if self.patient else []
1176 def get_patient_idnum_object(self,
1177 which_idnum: int) -> Optional["PatientIdNum"]:
1178 """
1179 Get the patient's
1180 :class:`camcops_server.cc_modules.cc_patientidnum.PatientIdNum` for the
1181 specified ID number type (``which_idnum``), or None.
1182 """
1183 return (self.patient.get_idnum_object(which_idnum) if self.patient
1184 else None)
1186 def any_patient_idnums_invalid(self, req: "CamcopsRequest") -> bool:
1187 """
1188 Do we have a patient who has any invalid ID numbers?
1190 Args:
1191 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1192 """
1193 idnums = self.get_patient_idnum_objects()
1194 for idnum in idnums:
1195 if not idnum.is_fully_valid(req):
1196 return True
1197 return False
1199 def get_patient_idnum_value(self, which_idnum: int) -> Optional[int]:
1200 """
1201 Get the patient's ID number value for the specified ID number
1202 type (``which_idnum``), or None.
1203 """
1204 idobj = self.get_patient_idnum_object(which_idnum=which_idnum)
1205 return idobj.idnum_value if idobj else None
1207 def get_patient_hl7_pid_segment(self,
1208 req: "CamcopsRequest",
1209 recipient_def: "ExportRecipient") \
1210 -> Union[hl7.Segment, str]:
1211 """
1212 Get an HL7 PID segment for the patient, or "".
1213 """
1214 return (self.patient.get_hl7_pid_segment(req, recipient_def)
1215 if self.patient else "")
1217 # -------------------------------------------------------------------------
1218 # HL7
1219 # -------------------------------------------------------------------------
1221 def get_hl7_data_segments(self, req: "CamcopsRequest",
1222 recipient_def: "ExportRecipient") \
1223 -> List[hl7.Segment]:
1224 """
1225 Returns a list of HL7 data segments.
1227 These will be:
1229 - OBR segment
1230 - OBX segment
1231 - any extra ones offered by the task
1232 """
1233 obr_segment = make_obr_segment(self)
1234 export_options = recipient_def.get_task_export_options()
1235 obx_segment = make_obx_segment(
1236 req,
1237 self,
1238 task_format=recipient_def.task_format,
1239 observation_identifier=self.tablename + "_" + str(self._pk),
1240 observation_datetime=self.get_creation_datetime(),
1241 responsible_observer=self.get_clinician_name(),
1242 export_options=export_options,
1243 )
1244 return [
1245 obr_segment,
1246 obx_segment
1247 ] + self.get_hl7_extra_data_segments(recipient_def)
1249 # noinspection PyMethodMayBeStatic,PyUnusedLocal
1250 def get_hl7_extra_data_segments(self, recipient_def: "ExportRecipient") \
1251 -> List[hl7.Segment]:
1252 """
1253 Return a list of any extra HL7 data segments. (See
1254 :func:`get_hl7_data_segments`.)
1256 May be overridden.
1257 """
1258 return []
1260 # -------------------------------------------------------------------------
1261 # FHIR
1262 # -------------------------------------------------------------------------
1263 def get_fhir_bundle_entries(self,
1264 req: "CamcopsRequest",
1265 recipient: "ExportRecipient") -> List[Dict]:
1266 return [
1267 self.get_fhir_questionnaire_bundle_entry(req, recipient),
1268 self.get_fhir_questionnaire_response_bundle_entry(req, recipient),
1269 ]
1271 def get_fhir_questionnaire_bundle_entry(
1272 self,
1273 req: "CamcopsRequest",
1274 recipient: "ExportRecipient") -> Dict:
1275 questionnaire_url = req.route_url(
1276 Routes.FHIR_QUESTIONNAIRE_ID,
1277 )
1279 identifier = Identifier(jsondict={
1280 "system": questionnaire_url,
1281 "value": self.tablename,
1282 })
1284 questionnaire = Questionnaire(jsondict={
1285 "status": "active", # TODO: Support draft / retired / unknown
1286 "identifier": [identifier.as_json()],
1287 "item": self.get_fhir_questionnaire_items(req, recipient)
1288 })
1290 bundle_request = BundleEntryRequest(jsondict={
1291 "method": "POST",
1292 "url": "Questionnaire",
1293 "ifNoneExist": f"identifier={identifier.system}|{identifier.value}",
1294 })
1296 return BundleEntry(
1297 jsondict={
1298 "resource": questionnaire.as_json(),
1299 "request": bundle_request.as_json()
1300 }
1301 ).as_json()
1303 def get_fhir_questionnaire_response_bundle_entry(
1304 self,
1305 req: "CamcopsRequest",
1306 recipient: "ExportRecipient") -> Dict:
1307 response_url = req.route_url(
1308 Routes.FHIR_QUESTIONNAIRE_RESPONSE_ID,
1309 tablename=self.tablename
1310 )
1312 identifier = Identifier(jsondict={
1313 "system": response_url,
1314 "value": str(self._pk),
1315 })
1317 questionnaire_url = req.route_url(
1318 Routes.FHIR_QUESTIONNAIRE_ID,
1319 )
1321 subject_identifier = self.patient.get_fhir_identifier(req, recipient)
1323 subject = FHIRReference(jsondict={
1324 "identifier": subject_identifier.as_json(),
1325 "type": "Patient",
1326 })
1328 response = QuestionnaireResponse(jsondict={
1329 # https://r4.smarthealthit.org does not like "questionnaire" in this
1330 # form
1331 # FHIR Server; FHIR 4.0.0/R4; HAPI FHIR 4.0.0-SNAPSHOT)
1332 # error is:
1333 # Invalid resource reference found at
1334 # path[QuestionnaireResponse.questionnaire]- Resource type is
1335 # unknown or not supported on this server
1336 # - http://127.0.0.1:8000/fhir_questionnaire_id|phq9
1338 # http://hapi.fhir.org/baseR4/ (4.0.1 (R4)) is OK
1339 "questionnaire": f"{questionnaire_url}|{self.tablename}",
1340 "subject": subject.as_json(),
1341 "status": "completed" if self.is_complete() else "in-progress",
1342 "identifier": identifier.as_json(),
1343 "item": self.get_fhir_questionnaire_response_items(req, recipient)
1344 })
1346 bundle_request = BundleEntryRequest(jsondict={
1347 "method": "POST",
1348 "url": "QuestionnaireResponse",
1349 "ifNoneExist": f"identifier={identifier.system}|{identifier.value}",
1350 })
1352 return BundleEntry(
1353 jsondict={
1354 "resource": response.as_json(),
1355 "request": bundle_request.as_json()
1356 }
1357 ).as_json()
1359 def get_fhir_questionnaire_items(
1360 self, req: "CamcopsRequest",
1361 recipient: "ExportRecipient") -> List["QuestionnaireItem"]:
1362 """
1363 Return a list of FHIR QuestionnaireItem objects for this task.
1364 https://www.hl7.org/fhir/questionnaire.html#resource
1366 Must be overridden by derived classes.
1367 """
1368 raise NotImplementedError(
1369 "No get_fhir_questionnaire_items() for this task class!")
1371 def get_fhir_questionnaire_response_items(
1372 self, req: "CamcopsRequest",
1373 recipient: "ExportRecipient") -> List["QuestionnaireResponseItem"]:
1374 """
1375 Return a list of FHIR QuestionnaireResponseItem objects for this task.
1376 https://www.hl7.org/fhir/questionnaireresponse.html#resource
1378 Must be overridden by derived classes.
1379 """
1380 raise NotImplementedError(
1381 "No get_fhir_questionnaire_response_items() for this task class!")
1383 def cancel_from_export_log(self, req: "CamcopsRequest",
1384 from_console: bool = False) -> None:
1385 """
1386 Marks all instances of this task as "cancelled" in the export log, so
1387 it will be resent.
1388 """
1389 if self._pk is None:
1390 return
1391 from camcops_server.cc_modules.cc_exportmodels import ExportedTask # delayed import # noqa
1392 # noinspection PyUnresolvedReferences
1393 statement = (
1394 update(ExportedTask.__table__)
1395 .where(ExportedTask.basetable == self.tablename)
1396 .where(ExportedTask.task_server_pk == self._pk)
1397 .where(not_(ExportedTask.cancelled) |
1398 ExportedTask.cancelled.is_(None))
1399 .values(cancelled=1,
1400 cancelled_at_utc=req.now_utc)
1401 )
1402 # ... this bit: ... AND (NOT cancelled OR cancelled IS NULL) ...:
1403 # https://stackoverflow.com/questions/37445041/sqlalchemy-how-to-filter-column-which-contains-both-null-and-integer-values # noqa
1404 req.dbsession.execute(statement)
1405 self.audit(
1406 req,
1407 "Task cancelled in export log (may trigger resending)",
1408 from_console
1409 )
1411 # -------------------------------------------------------------------------
1412 # Audit
1413 # -------------------------------------------------------------------------
1415 def audit(self, req: "CamcopsRequest", details: str,
1416 from_console: bool = False) -> None:
1417 """
1418 Audits actions to this task.
1419 """
1420 audit(req,
1421 details,
1422 patient_server_pk=self.get_patient_server_pk(),
1423 table=self.tablename,
1424 server_pk=self._pk,
1425 from_console=from_console)
1427 # -------------------------------------------------------------------------
1428 # Erasure (wiping, leaving record as placeholder)
1429 # -------------------------------------------------------------------------
1431 def manually_erase(self, req: "CamcopsRequest") -> None:
1432 """
1433 Manually erases a task (including sub-tables).
1434 Also erases linked non-current records.
1435 This WIPES THE CONTENTS but LEAVES THE RECORD AS A PLACEHOLDER.
1437 Audits the erasure. Propagates erase through to the HL7 log, so those
1438 records will be re-sent. WRITES TO DATABASE.
1439 """
1440 # Erase ourself and any other in our "family"
1441 for task in self.get_lineage():
1442 task.manually_erase_with_dependants(req)
1443 # Audit and clear HL7 message log
1444 self.audit(req, "Task details erased manually")
1445 self.cancel_from_export_log(req)
1447 def is_erased(self) -> bool:
1448 """
1449 Has the task been manually erased? See :func:`manually_erase`.
1450 """
1451 return self._manually_erased
1453 # -------------------------------------------------------------------------
1454 # Complete deletion
1455 # -------------------------------------------------------------------------
1457 def delete_entirely(self, req: "CamcopsRequest") -> None:
1458 """
1459 Completely delete this task, its lineage, and its dependants.
1460 """
1461 for task in self.get_lineage():
1462 task.delete_with_dependants(req)
1463 self.audit(req, "Task deleted")
1465 # -------------------------------------------------------------------------
1466 # Viewing the task in the list of tasks
1467 # -------------------------------------------------------------------------
1469 def is_live_on_tablet(self) -> bool:
1470 """
1471 Is the task instance live on a tablet?
1472 """
1473 return self._era == ERA_NOW
1475 # -------------------------------------------------------------------------
1476 # Filtering tasks for the task list
1477 # -------------------------------------------------------------------------
1479 @classmethod
1480 def gen_text_filter_columns(cls) -> Generator[Tuple[str, Column], None,
1481 None]:
1482 """
1483 Yields tuples of ``attrname, column``, for columns that are suitable
1484 for text filtering.
1485 """
1486 for attrname, column in gen_columns(cls):
1487 if attrname.startswith("_"): # system field
1488 continue
1489 if not is_sqlatype_string(column.type):
1490 continue
1491 yield attrname, column
1493 @classmethod
1494 @cache_region_static.cache_on_arguments(function_key_generator=fkg)
1495 def get_text_filter_columns(cls) -> List[Column]:
1496 """
1497 Cached function to return a list of SQLAlchemy Column objects suitable
1498 for text filtering.
1499 """
1500 return [col for _, col in cls.gen_text_filter_columns()]
1502 def contains_text(self, text: str) -> bool:
1503 """
1504 Does this task contain the specified text?
1506 Args:
1507 text:
1508 string that must be present in at least one of our text
1509 columns
1511 Returns:
1512 is the strings present?
1513 """
1514 text = text.lower()
1515 for attrname, _ in self.gen_text_filter_columns():
1516 value = getattr(self, attrname)
1517 if value is None:
1518 continue
1519 assert isinstance(value, str), "Internal bug in contains_text"
1520 if text in value.lower():
1521 return True
1522 return False
1524 def contains_all_strings(self, strings: List[str]) -> bool:
1525 """
1526 Does this task contain all of the specified strings?
1528 Args:
1529 strings:
1530 list of strings; each string must be present in at least
1531 one of our text columns
1533 Returns:
1534 are all strings present?
1535 """
1536 return all(self.contains_text(text) for text in strings)
1538 # -------------------------------------------------------------------------
1539 # TSV export for basic research dump
1540 # -------------------------------------------------------------------------
1542 def get_tsv_pages(self, req: "CamcopsRequest") -> List["TsvPage"]:
1543 """
1544 Returns information used for the basic research dump in TSV format.
1545 """
1546 # 1. Our core fields, plus summary information
1548 main_page = self._get_core_tsv_page(req)
1549 # 2. Patient details.
1551 if self.patient:
1552 main_page.add_or_set_columns_from_page(
1553 self.patient.get_tsv_page(req))
1554 tsv_pages = [main_page]
1555 # 3. +/- Ancillary objects
1556 for ancillary in self.gen_ancillary_instances(): # type: GenericTabletRecordMixin # noqa
1557 page = ancillary._get_core_tsv_page(req)
1558 tsv_pages.append(page)
1559 # 4. +/- Extra summary tables (inc. SNOMED)
1560 for est in self.get_all_summary_tables(req):
1561 tsv_pages.append(est.get_tsv_page())
1562 # Done
1563 return tsv_pages
1565 # -------------------------------------------------------------------------
1566 # XML view
1567 # -------------------------------------------------------------------------
1569 def get_xml(self,
1570 req: "CamcopsRequest",
1571 options: TaskExportOptions = None,
1572 indent_spaces: int = 4,
1573 eol: str = '\n') -> str:
1574 """
1575 Returns XML describing the task.
1577 Args:
1578 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1579 options: a :class:`camcops_server.cc_modules.cc_simpleobjects.TaskExportOptions`
1581 indent_spaces: number of spaces to indent formatted XML
1582 eol: end-of-line string
1584 Returns:
1585 an XML UTF-8 document representing the task.
1587 """ # noqa
1588 options = options or TaskExportOptions()
1589 tree = self.get_xml_root(req=req, options=options)
1590 return get_xml_document(
1591 tree,
1592 indent_spaces=indent_spaces,
1593 eol=eol,
1594 include_comments=options.xml_include_comments,
1595 )
1597 def get_xml_root(self,
1598 req: "CamcopsRequest",
1599 options: TaskExportOptions) -> XmlElement:
1600 """
1601 Returns an XML tree. The return value is the root
1602 :class:`camcops_server.cc_modules.cc_xml.XmlElement`.
1604 Override to include other tables, or to deal with BLOBs, if the default
1605 methods are insufficient.
1607 Args:
1608 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1609 options: a :class:`camcops_server.cc_modules.cc_simpleobjects.TaskExportOptions`
1610 """ # noqa
1611 # Core (inc. core BLOBs)
1612 branches = self._get_xml_core_branches(req=req, options=options)
1613 tree = XmlElement(name=self.tablename, value=branches)
1614 return tree
1616 def _get_xml_core_branches(
1617 self,
1618 req: "CamcopsRequest",
1619 options: TaskExportOptions) -> List[XmlElement]:
1620 """
1621 Returns a list of :class:`camcops_server.cc_modules.cc_xml.XmlElement`
1622 elements representing stored, calculated, patient, and/or BLOB fields,
1623 depending on the options.
1625 Args:
1626 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1627 options: a :class:`camcops_server.cc_modules.cc_simpleobjects.TaskExportOptions`
1628 """ # noqa
1629 def add_comment(comment: XmlLiteral) -> None:
1630 if options.xml_with_header_comments:
1631 branches.append(comment)
1633 options = options or TaskExportOptions(xml_include_plain_columns=True,
1634 xml_include_ancillary=True,
1635 include_blobs=False,
1636 xml_include_calculated=True,
1637 xml_include_patient=True,
1638 xml_include_snomed=True)
1640 # Stored values +/- calculated values
1641 core_options = options.clone()
1642 core_options.include_blobs = False
1643 branches = self._get_xml_branches(req=req, options=core_options)
1645 # SNOMED-CT codes
1646 if options.xml_include_snomed and req.snomed_supported:
1647 add_comment(XML_COMMENT_SNOMED_CT)
1648 snomed_codes = self.get_snomed_codes(req)
1649 snomed_branches = [] # type: List[XmlElement]
1650 for code in snomed_codes:
1651 snomed_branches.append(code.xml_element())
1652 branches.append(XmlElement(name=XML_NAME_SNOMED_CODES,
1653 value=snomed_branches))
1655 # Special notes
1656 add_comment(XML_COMMENT_SPECIAL_NOTES)
1657 for sn in self.special_notes:
1658 branches.append(sn.get_xml_root())
1660 # Patient details
1661 if self.is_anonymous:
1662 add_comment(XML_COMMENT_ANONYMOUS)
1663 elif options.xml_include_patient:
1664 add_comment(XML_COMMENT_PATIENT)
1665 patient_options = TaskExportOptions(
1666 xml_include_plain_columns=True,
1667 xml_with_header_comments=options.xml_with_header_comments)
1668 if self.patient:
1669 branches.append(self.patient.get_xml_root(
1670 req, patient_options))
1672 # BLOBs
1673 if options.include_blobs:
1674 add_comment(XML_COMMENT_BLOBS)
1675 blob_options = TaskExportOptions(
1676 include_blobs=True,
1677 xml_skip_fields=options.xml_skip_fields,
1678 xml_sort_by_name=True,
1679 xml_with_header_comments=False,
1680 )
1681 branches += self._get_xml_branches(req=req, options=blob_options)
1683 # Ancillary objects
1684 if options.xml_include_ancillary:
1685 ancillary_options = TaskExportOptions(
1686 xml_include_plain_columns=True,
1687 xml_include_ancillary=True,
1688 include_blobs=options.include_blobs,
1689 xml_include_calculated=options.xml_include_calculated,
1690 xml_sort_by_name=True,
1691 xml_with_header_comments=options.xml_with_header_comments,
1692 )
1693 item_collections = [] # type: List[XmlElement]
1694 found_ancillary = False
1695 # We use a slightly more manual iteration process here so that
1696 # we iterate through individual ancillaries but clustered by their
1697 # name (e.g. if we have 50 trials and 5 groups, we do them in
1698 # collections).
1699 for attrname, rel_prop, rel_cls in gen_ancillary_relationships(self): # noqa
1700 if not found_ancillary:
1701 add_comment(XML_COMMENT_ANCILLARY)
1702 found_ancillary = True
1703 itembranches = [] # type: List[XmlElement]
1704 if rel_prop.uselist:
1705 ancillaries = getattr(self, attrname) # type: List[GenericTabletRecordMixin] # noqa
1706 else:
1707 ancillaries = [getattr(self, attrname)] # type: List[GenericTabletRecordMixin] # noqa
1708 for ancillary in ancillaries:
1709 itembranches.append(
1710 ancillary._get_xml_root(req=req,
1711 options=ancillary_options)
1712 )
1713 itemcollection = XmlElement(name=attrname, value=itembranches)
1714 item_collections.append(itemcollection)
1715 item_collections.sort(key=lambda el: el.name)
1716 branches += item_collections
1718 # Completely separate additional summary tables
1719 if options.xml_include_calculated:
1720 item_collections = [] # type: List[XmlElement]
1721 found_est = False
1722 for est in self.get_extra_summary_tables(req):
1723 # ... not get_all_summary_tables(); we handled SNOMED
1724 # differently, above
1725 if not found_est and est.rows:
1726 add_comment(XML_COMMENT_CALCULATED)
1727 found_est = True
1728 item_collections.append(est.get_xml_element())
1729 item_collections.sort(key=lambda el: el.name)
1730 branches += item_collections
1732 return branches
1734 # -------------------------------------------------------------------------
1735 # HTML view
1736 # -------------------------------------------------------------------------
1738 def get_html(self, req: "CamcopsRequest", anonymise: bool = False) -> str:
1739 """
1740 Returns HTML representing the task, for our HTML view.
1742 Args:
1743 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1744 anonymise: hide patient identifying details?
1745 """
1746 req.prepare_for_html_figures()
1747 return render("task.mako",
1748 dict(task=self,
1749 anonymise=anonymise,
1750 signature=False,
1751 viewtype=ViewArg.HTML),
1752 request=req)
1754 def title_for_html(self, req: "CamcopsRequest",
1755 anonymise: bool = False) -> str:
1756 """
1757 Returns the plain text used for the HTML ``<title>`` block (by
1758 ``task.mako``), and also for the PDF title for PDF exports.
1760 Should be plain text only.
1762 Args:
1763 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1764 anonymise: hide patient identifying details?
1765 """
1766 if anonymise:
1767 patient = "?"
1768 elif self.patient:
1769 patient = self.patient.prettystr(req)
1770 else:
1771 _ = req.gettext
1772 patient = _("Anonymous")
1773 tasktype = self.tablename
1774 when = format_datetime(self.get_creation_datetime(),
1775 DateFormat.ISO8601_HUMANIZED_TO_MINUTES, "")
1776 return f"CamCOPS: {patient}; {tasktype}; {when}"
1778 # -------------------------------------------------------------------------
1779 # PDF view
1780 # -------------------------------------------------------------------------
1782 def get_pdf(self, req: "CamcopsRequest", anonymise: bool = False) -> bytes:
1783 """
1784 Returns a PDF representing the task.
1786 Args:
1787 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1788 anonymise: hide patient identifying details?
1789 """
1790 html = self.get_pdf_html(req, anonymise=anonymise) # main content
1791 if CSS_PAGED_MEDIA:
1792 return pdf_from_html(req, html=html)
1793 else:
1794 return pdf_from_html(
1795 req,
1796 html=html,
1797 header_html=render(
1798 "wkhtmltopdf_header.mako",
1799 dict(inner_text=render("task_page_header.mako",
1800 dict(task=self, anonymise=anonymise),
1801 request=req)),
1802 request=req
1803 ),
1804 footer_html=render(
1805 "wkhtmltopdf_footer.mako",
1806 dict(inner_text=render("task_page_footer.mako",
1807 dict(task=self),
1808 request=req)),
1809 request=req
1810 ),
1811 extra_wkhtmltopdf_options={
1812 "orientation": ("Landscape" if self.use_landscape_for_pdf
1813 else "Portrait")
1814 }
1815 )
1817 def get_pdf_html(self, req: "CamcopsRequest",
1818 anonymise: bool = False) -> str:
1819 """
1820 Gets the HTML used to make the PDF (slightly different from the HTML
1821 used for the HTML view).
1822 """
1823 req.prepare_for_pdf_figures()
1824 return render("task.mako",
1825 dict(task=self,
1826 anonymise=anonymise,
1827 pdf_landscape=self.use_landscape_for_pdf,
1828 signature=self.has_clinician,
1829 viewtype=ViewArg.PDF),
1830 request=req)
1832 def suggested_pdf_filename(self, req: "CamcopsRequest",
1833 anonymise: bool = False) -> str:
1834 """
1835 Suggested filename for the PDF copy (for downloads).
1837 Args:
1838 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1839 anonymise: hide patient identifying details?
1840 """
1841 cfg = req.config
1842 if anonymise:
1843 is_anonymous = True
1844 else:
1845 is_anonymous = self.is_anonymous
1846 patient = self.patient
1847 return get_export_filename(
1848 req=req,
1849 patient_spec_if_anonymous=cfg.patient_spec_if_anonymous,
1850 patient_spec=cfg.patient_spec,
1851 filename_spec=cfg.task_filename_spec,
1852 filetype=ViewArg.PDF,
1853 is_anonymous=is_anonymous,
1854 surname=patient.get_surname() if patient else "",
1855 forename=patient.get_forename() if patient else "",
1856 dob=patient.get_dob() if patient else None,
1857 sex=patient.get_sex() if patient else None,
1858 idnum_objects=patient.get_idnum_objects() if patient else None,
1859 creation_datetime=self.get_creation_datetime(),
1860 basetable=self.tablename,
1861 serverpk=self._pk
1862 )
1864 def write_pdf_to_disk(self, req: "CamcopsRequest", filename: str) -> None:
1865 """
1866 Writes the PDF to disk, using ``filename``.
1867 """
1868 pdffile = open(filename, "wb")
1869 pdffile.write(self.get_pdf(req))
1871 # -------------------------------------------------------------------------
1872 # Metadata for e.g. RiO
1873 # -------------------------------------------------------------------------
1875 def get_rio_metadata(self,
1876 req: "CamcopsRequest",
1877 which_idnum: int,
1878 uploading_user_id: str,
1879 document_type: str) -> str:
1880 """
1881 Returns metadata for the task that Servelec's RiO electronic patient
1882 record may want.
1884 Args:
1885 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1886 which_idnum: which CamCOPS ID number type corresponds to the RiO
1887 client ID?
1888 uploading_user_id: RiO user ID (string) of the user who will
1889 be recorded as uploading this information; see below
1890 document_type: a string indicating the RiO-defined document type
1891 (this is system-specific); see below
1893 Returns:
1894 a newline-terminated single line of CSV values; see below
1896 Called by
1897 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task`.
1899 From Servelec (Lee Meredith) to Rudolf Cardinal, 2014-12-04:
1901 .. code-block:: none
1903 Batch Document Upload
1905 The RiO batch document upload function can be used to upload
1906 documents in bulk automatically. RiO includes a Batch Upload
1907 windows service which monitors a designated folder for new files.
1908 Each file which is scanned must be placed in the designated folder
1909 along with a meta-data file which describes the document. So
1910 essentially if a document had been scanned in and was called
1911 ‘ThisIsANewReferralLetterForAPatient.pdf’ then there would also
1912 need to be a meta file in the same folder called
1913 ‘ThisIsANewReferralLetterForAPatient.metadata’. The contents of
1914 the meta file would need to include the following:
1916 Field Order; Field Name; Description; Data Mandatory (Y/N);
1917 Format
1919 1; ClientID; RiO Client ID which identifies the patient in RiO
1920 against which the document will be uploaded.; Y; 15
1921 Alphanumeric Characters
1923 2; UserID; User ID of the uploaded document, this is any user
1924 defined within the RiO system and can be a single system user
1925 called ‘AutomaticDocumentUploadUser’ for example.; Y; 10
1926 Alphanumeric Characters
1928 [NB example longer than that!]
1930 3; DocumentType; The RiO defined document type eg: APT; Y; 80
1931 Alphanumeric Characters
1933 4; Title; The title of the document; N; 40 Alphanumeric
1934 Characters
1936 5; Description; The document description.; N; 500 Alphanumeric
1937 Characters
1939 6; Author; The author of the document; N; 80 Alphanumeric
1940 Characters
1942 7; DocumentDate; The date of the document; N; dd/MM/yyyy HH:mm
1944 8; FinalRevision; The revision values are 0 Draft or 1 Final,
1945 this is defaulted to 1 which is Final revision.; N; 0 or 1
1947 As an example, this is what would be needed in a meta file:
1949 “1000001”,”TRUST1”,”APT”,”A title”, “A description of the
1950 document”, “An author”,”01/12/2012 09:45”,”1”
1952 (on one line)
1954 Clarification, from Lee Meredith to Rudolf Cardinal, 2015-02-18:
1956 - metadata files must be plain ASCII, not UTF-8
1958 - ... here and
1959 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task`
1961 - line terminator is <CR>
1963 - BUT see
1964 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task`
1966 - user name limit is 10 characters, despite incorrect example
1968 - search for ``RIO_MAX_USER_LEN``
1970 - DocumentType is a code that maps to a human-readable document
1971 type; for example, "APT" might map to "Appointment Letter". These
1972 mappings are specific to the local system. (We will probably want
1973 one that maps to "Clinical Correspondence" in the absence of
1974 anything more specific.)
1976 - RiO will delete the files after it's processed them.
1978 - Filenames should avoid spaces, but otherwise any other standard
1979 ASCII code is fine within filenames.
1981 - see
1982 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task`
1984 """ # noqa
1986 try:
1987 client_id = self.patient.get_idnum_value(which_idnum)
1988 except AttributeError:
1989 client_id = ""
1990 title = "CamCOPS_" + self.shortname
1991 description = self.longname(req)
1992 author = self.get_clinician_name() # may be blank
1993 document_date = format_datetime(self.when_created,
1994 DateFormat.RIO_EXPORT_UK)
1995 # This STRIPS the timezone information; i.e. it is in the local
1996 # timezone but doesn't tell you which timezone that is. (That's fine;
1997 # it should be local or users would be confused.)
1998 final_revision = (0 if self.is_live_on_tablet() else 1)
2000 item_list = [
2001 client_id,
2002 uploading_user_id,
2003 document_type,
2004 title,
2005 description,
2006 author,
2007 document_date,
2008 final_revision
2009 ]
2010 # UTF-8 is NOT supported by RiO for metadata. So:
2011 csv_line = ",".join([f'"{mangle_unicode_to_ascii(x)}"'
2012 for x in item_list])
2013 return csv_line + "\n"
2015 # -------------------------------------------------------------------------
2016 # HTML elements used by tasks
2017 # -------------------------------------------------------------------------
2019 # noinspection PyMethodMayBeStatic
2020 def get_standard_clinician_comments_block(self,
2021 req: "CamcopsRequest",
2022 comments: str) -> str:
2023 """
2024 HTML DIV for clinician's comments.
2025 """
2026 return render("clinician_comments.mako",
2027 dict(comment=comments),
2028 request=req)
2030 def get_is_complete_td_pair(self, req: "CamcopsRequest") -> str:
2031 """
2032 HTML to indicate whether task is complete or not, and to make it
2033 very obvious visually when it isn't.
2034 """
2035 c = self.is_complete()
2036 td_class = "" if c else f' class="{CssClass.INCOMPLETE}"'
2037 return (
2038 f"<td>Completed?</td>"
2039 f"<td{td_class}><b>{get_yes_no(req, c)}</b></td>"
2040 )
2042 def get_is_complete_tr(self, req: "CamcopsRequest") -> str:
2043 """
2044 HTML table row to indicate whether task is complete or not, and to
2045 make it very obvious visually when it isn't.
2046 """
2047 return f"<tr>{self.get_is_complete_td_pair(req)}</tr>"
2049 def get_twocol_val_row(self,
2050 fieldname: str,
2051 default: str = None,
2052 label: str = None) -> str:
2053 """
2054 HTML table row, two columns, without web-safing of value.
2056 Args:
2057 fieldname: field (attribute) name; the value will be retrieved
2058 from this attribute
2059 default: default to show if the value is ``None``
2060 label: descriptive label
2062 Returns:
2063 two-column HTML table row (label, value)
2065 """
2066 val = getattr(self, fieldname)
2067 if val is None:
2068 val = default
2069 if label is None:
2070 label = fieldname
2071 return tr_qa(label, val)
2073 def get_twocol_string_row(self,
2074 fieldname: str,
2075 label: str = None) -> str:
2076 """
2077 HTML table row, two columns, with web-safing of value.
2079 Args:
2080 fieldname: field (attribute) name; the value will be retrieved
2081 from this attribute
2082 label: descriptive label
2084 Returns:
2085 two-column HTML table row (label, value)
2086 """
2087 if label is None:
2088 label = fieldname
2089 return tr_qa(label, getattr(self, fieldname))
2091 def get_twocol_bool_row(self,
2092 req: "CamcopsRequest",
2093 fieldname: str,
2094 label: str = None) -> str:
2095 """
2096 HTML table row, two columns, with Boolean Y/N formatter for value.
2098 Args:
2099 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2100 fieldname: field (attribute) name; the value will be retrieved
2101 from this attribute
2102 label: descriptive label
2104 Returns:
2105 two-column HTML table row (label, value)
2106 """
2107 if label is None:
2108 label = fieldname
2109 return tr_qa(label, get_yes_no_none(req, getattr(self, fieldname)))
2111 def get_twocol_bool_row_true_false(self,
2112 req: "CamcopsRequest",
2113 fieldname: str,
2114 label: str = None) -> str:
2115 """
2116 HTML table row, two columns, with Boolean true/false formatter for
2117 value.
2119 Args:
2120 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2121 fieldname: field (attribute) name; the value will be retrieved
2122 from this attribute
2123 label: descriptive label
2125 Returns:
2126 two-column HTML table row (label, value)
2127 """
2128 if label is None:
2129 label = fieldname
2130 return tr_qa(label, get_true_false_none(req, getattr(self, fieldname)))
2132 def get_twocol_bool_row_present_absent(self,
2133 req: "CamcopsRequest",
2134 fieldname: str,
2135 label: str = None) -> str:
2136 """
2137 HTML table row, two columns, with Boolean present/absent formatter for
2138 value.
2140 Args:
2141 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2142 fieldname: field (attribute) name; the value will be retrieved
2143 from this attribute
2144 label: descriptive label
2146 Returns:
2147 two-column HTML table row (label, value)
2148 """
2149 if label is None:
2150 label = fieldname
2151 return tr_qa(label, get_present_absent_none(req,
2152 getattr(self, fieldname)))
2154 @staticmethod
2155 def get_twocol_picture_row(blob: Optional[Blob], label: str) -> str:
2156 """
2157 HTML table row, two columns, with PNG on right.
2159 Args:
2160 blob: the :class:`camcops_server.cc_modules.cc_blob.Blob` object
2161 label: descriptive label
2163 Returns:
2164 two-column HTML table row (label, picture)
2165 """
2166 return tr(label, get_blob_img_html(blob))
2168 # -------------------------------------------------------------------------
2169 # Field helper functions for subclasses
2170 # -------------------------------------------------------------------------
2172 def get_values(self, fields: List[str]) -> List:
2173 """
2174 Get list of object's values from list of field names.
2175 """
2176 return [getattr(self, f) for f in fields]
2178 def is_field_not_none(self, field: str) -> bool:
2179 """
2180 Is the field not None?
2181 """
2182 return getattr(self, field) is not None
2184 def any_fields_none(self, fields: List[str]) -> bool:
2185 """
2186 Are any specified fields None?
2187 """
2188 for f in fields:
2189 if getattr(self, f) is None:
2190 return True
2191 return False
2193 def all_fields_not_none(self, fields: List[str]) -> bool:
2194 """
2195 Are all specified fields not None?
2196 """
2197 return not self.any_fields_none(fields)
2199 def any_fields_null_or_empty_str(self, fields: List[str]) -> bool:
2200 """
2201 Are any specified fields either None or the empty string?
2202 """
2203 for f in fields:
2204 v = getattr(self, f)
2205 if v is None or v == "":
2206 return True
2207 return False
2209 def are_all_fields_not_null_or_empty_str(self, fields: List[str]) -> bool:
2210 """
2211 Are all specified fields neither None nor the empty string?
2212 """
2213 return not self.any_fields_null_or_empty_str(fields)
2215 def n_fields_not_none(self, fields: List[str]) -> int:
2216 """
2217 How many of the specified fields are not None?
2218 """
2219 total = 0
2220 for f in fields:
2221 if getattr(self, f) is not None:
2222 total += 1
2223 return total
2225 def n_fields_none(self, fields: List[str]) -> int:
2226 """
2227 How many of the specified fields are None?
2228 """
2229 total = 0
2230 for f in fields:
2231 if getattr(self, f) is None:
2232 total += 1
2233 return total
2235 def count_booleans(self, fields: List[str]) -> int:
2236 """
2237 How many of the specified fields evaluate to True (are truthy)?
2238 """
2239 total = 0
2240 for f in fields:
2241 value = getattr(self, f)
2242 if value:
2243 total += 1
2244 return total
2246 def all_truthy(self, fields: List[str]) -> bool:
2247 """
2248 Do all the specified fields evaluate to True (are they all truthy)?
2249 """
2250 for f in fields:
2251 value = getattr(self, f)
2252 if not value:
2253 return False
2254 return True
2256 def count_where(self,
2257 fields: List[str],
2258 wherevalues: List[Any]) -> int:
2259 """
2260 Count how many values for the specified fields are in ``wherevalues``.
2261 """
2262 return sum(1 for x in self.get_values(fields) if x in wherevalues)
2264 def count_wherenot(self,
2265 fields: List[str],
2266 notvalues: List[Any]) -> int:
2267 """
2268 Count how many values for the specified fields are NOT in
2269 ``notvalues``.
2270 """
2271 return sum(1 for x in self.get_values(fields) if x not in notvalues)
2273 def sum_fields(self,
2274 fields: List[str],
2275 ignorevalue: Any = None) -> Union[int, float]:
2276 """
2277 Sum values stored in all specified fields (skipping any whose value is
2278 ``ignorevalue``; treating fields containing ``None`` as zero).
2279 """
2280 total = 0
2281 for f in fields:
2282 value = getattr(self, f)
2283 if value == ignorevalue:
2284 continue
2285 total += value if value is not None else 0
2286 return total
2288 def mean_fields(self,
2289 fields: List[str],
2290 ignorevalue: Any = None) -> Union[int, float, None]:
2291 """
2292 Return the mean of the values stored in all specified fields (skipping
2293 any whose value is ``ignorevalue``).
2294 """
2295 values = []
2296 for f in fields:
2297 value = getattr(self, f)
2298 if value != ignorevalue:
2299 values.append(value)
2300 try:
2301 return statistics.mean(values)
2302 except (TypeError, statistics.StatisticsError):
2303 return None
2305 @staticmethod
2306 def fieldnames_from_prefix(prefix: str, start: int, end: int) -> List[str]:
2307 """
2308 Returns a list of field (column, attribute) names from a prefix.
2309 For example, ``fieldnames_from_prefix("q", 1, 5)`` produces
2310 ``["q1", "q2", "q3", "q4", "q5"]``.
2312 Args:
2313 prefix: string prefix
2314 start: first value (inclusive)
2315 end: last value (inclusive
2317 Returns:
2318 list of fieldnames, as above
2320 """
2321 return [prefix + str(x) for x in range(start, end + 1)]
2323 @staticmethod
2324 def fieldnames_from_list(prefix: str,
2325 suffixes: Iterable[Any]) -> List[str]:
2326 """
2327 Returns a list of fieldnames made by appending each suffix to the
2328 prefix.
2330 Args:
2331 prefix: string prefix
2332 suffixes: list of suffixes, which will be coerced to ``str``
2334 Returns:
2335 list of fieldnames, as above
2337 """
2338 return [prefix + str(x) for x in suffixes]
2340 # -------------------------------------------------------------------------
2341 # Extra strings
2342 # -------------------------------------------------------------------------
2344 def get_extrastring_taskname(self) -> str:
2345 """
2346 Get the taskname used as the top-level key for this task's extra
2347 strings (loaded by the server from XML files). By default this is the
2348 task's primary tablename, but tasks may override that via
2349 ``extrastring_taskname``.
2350 """
2351 return self.extrastring_taskname or self.tablename
2353 def extrastrings_exist(self, req: "CamcopsRequest") -> bool:
2354 """
2355 Does the server have any extra strings for this task?
2356 """
2357 return req.task_extrastrings_exist(self.get_extrastring_taskname())
2359 def wxstring(self,
2360 req: "CamcopsRequest",
2361 name: str,
2362 defaultvalue: str = None,
2363 provide_default_if_none: bool = True) -> str:
2364 """
2365 Return a web-safe version of an extra string for this task.
2367 Args:
2368 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2369 name: name (second-level key) of the string, within the set of
2370 this task's extra strings
2371 defaultvalue: default to return if the string is not found
2372 provide_default_if_none: if ``True`` and ``default is None``,
2373 return a helpful missing-string message in the style
2374 "string x.y not found"
2375 """
2376 if defaultvalue is None and provide_default_if_none:
2377 defaultvalue = f"[{self.get_extrastring_taskname()}: {name}]"
2378 return req.wxstring(
2379 self.get_extrastring_taskname(),
2380 name,
2381 defaultvalue,
2382 provide_default_if_none=provide_default_if_none)
2384 def xstring(self,
2385 req: "CamcopsRequest",
2386 name: str,
2387 defaultvalue: str = None,
2388 provide_default_if_none: bool = True) -> str:
2389 """
2390 Return a raw (not necessarily web-safe) version of an extra string for
2391 this task.
2393 Args:
2394 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2395 name: name (second-level key) of the string, within the set of
2396 this task's extra strings
2397 defaultvalue: default to return if the string is not found
2398 provide_default_if_none: if ``True`` and ``default is None``,
2399 return a helpful missing-string message in the style
2400 "string x.y not found"
2401 """
2402 if defaultvalue is None and provide_default_if_none:
2403 defaultvalue = f"[{self.get_extrastring_taskname()}: {name}]"
2404 return req.xstring(
2405 self.get_extrastring_taskname(),
2406 name,
2407 defaultvalue,
2408 provide_default_if_none=provide_default_if_none)
2410 def make_options_from_xstrings(self,
2411 req: "CamcopsRequest",
2412 prefix: str, first: int, last: int,
2413 suffix: str = "") -> Dict[int, str]:
2414 """
2415 Creates a lookup dictionary from xstrings.
2417 Args:
2418 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2419 prefix: prefix for xstring
2420 first: first value
2421 last: last value
2422 suffix: optional suffix
2424 Returns:
2425 dict: Each entry maps ``value`` to an xstring named
2426 ``<PREFIX><VALUE><SUFFIX>``.
2428 """
2429 d = {} # type: Dict[int, str]
2430 if first > last: # descending order
2431 for i in range(first, last - 1, -1):
2432 d[i] = self.xstring(req, f"{prefix}{i}{suffix}")
2433 else: # ascending order
2434 for i in range(first, last + 1):
2435 d[i] = self.xstring(req, f"{prefix}{i}{suffix}")
2436 return d
2438 @staticmethod
2439 def make_options_from_numbers(first: int, last: int) -> Dict[int, str]:
2440 """
2441 Creates a simple dictionary mapping numbers to string versions of those
2442 numbers. Usually for subsequent (more interesting) processing!
2444 Args:
2445 first: first value
2446 last: last value
2448 Returns:
2449 dict
2451 """
2452 d = {} # type: Dict[int, str]
2453 if first > last: # descending order
2454 for i in range(first, last - 1, -1):
2455 d[i] = str(i)
2456 else: # ascending order
2457 for i in range(first, last + 1):
2458 d[i] = str(i)
2459 return d
2462# =============================================================================
2463# Collating all task tables for specific purposes
2464# =============================================================================
2465# Function, staticmethod, classmethod?
2466# https://stackoverflow.com/questions/8108688/in-python-when-should-i-use-a-function-instead-of-a-method # noqa
2467# https://stackoverflow.com/questions/11788195/module-function-vs-staticmethod-vs-classmethod-vs-no-decorators-which-idiom-is # noqa
2468# https://stackoverflow.com/questions/15017734/using-static-methods-in-python-best-practice # noqa
2470def all_task_tables_with_min_client_version() -> Dict[str, Version]:
2471 """
2472 Across all tasks, return a mapping from each of their tables to the
2473 minimum client version.
2475 Used by
2476 :func:`camcops_server.cc_modules.client_api.all_tables_with_min_client_version`.
2478 """ # noqa
2479 d = {} # type: Dict[str, Version]
2480 classes = list(Task.gen_all_subclasses())
2481 for cls in classes:
2482 d.update(cls.all_tables_with_min_client_version())
2483 return d
2486@cache_region_static.cache_on_arguments(function_key_generator=fkg)
2487def tablename_to_task_class_dict() -> Dict[str, Type[Task]]:
2488 """
2489 Returns a mapping from task base tablenames to task classes.
2490 """
2491 d = {} # type: Dict[str, Type[Task]]
2492 for cls in Task.gen_all_subclasses():
2493 d[cls.tablename] = cls
2494 return d
2497@cache_region_static.cache_on_arguments(function_key_generator=fkg)
2498def all_task_tablenames() -> List[str]:
2499 """
2500 Returns all task base table names.
2501 """
2502 d = tablename_to_task_class_dict()
2503 return list(d.keys())
2506@cache_region_static.cache_on_arguments(function_key_generator=fkg)
2507def all_task_classes() -> List[Type[Task]]:
2508 """
2509 Returns all task base table names.
2510 """
2511 d = tablename_to_task_class_dict()
2512 return list(d.values())
2515# =============================================================================
2516# Support functions
2517# =============================================================================
2519def get_from_dict(d: Dict, key: Any, default: Any = INVALID_VALUE) -> Any:
2520 """
2521 Returns a value from a dictionary. This is not a very complex function...
2522 all it really does in practice is provide a default for ``default``.
2524 Args:
2525 d: the dictionary
2526 key: the key
2527 default: value to return if none is provided
2528 """
2529 return d.get(key, default)