Coverage for cc_modules/cc_task.py : 46%

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 jsondict = {
1322 # https://r4.smarthealthit.org does not like "questionnaire" in this
1323 # form
1324 # FHIR Server; FHIR 4.0.0/R4; HAPI FHIR 4.0.0-SNAPSHOT)
1325 # error is:
1326 # Invalid resource reference found at
1327 # path[QuestionnaireResponse.questionnaire]- Resource type is
1328 # unknown or not supported on this server
1329 # - http://127.0.0.1:8000/fhir_questionnaire_id|phq9
1331 # http://hapi.fhir.org/baseR4/ (4.0.1 (R4)) is OK
1332 "questionnaire": f"{questionnaire_url}|{self.tablename}",
1333 "status": "completed" if self.is_complete() else "in-progress",
1334 "identifier": identifier.as_json(),
1335 "item": self.get_fhir_questionnaire_response_items(req, recipient)
1336 }
1338 if self.has_patient:
1339 subject_identifier = self.patient.get_fhir_identifier(
1340 req, recipient
1341 )
1343 subject = FHIRReference(jsondict={
1344 "identifier": subject_identifier.as_json(),
1345 "type": "Patient",
1346 })
1348 jsondict["subject"] = subject.as_json()
1350 response = QuestionnaireResponse(jsondict)
1352 bundle_request = BundleEntryRequest(jsondict={
1353 "method": "POST",
1354 "url": "QuestionnaireResponse",
1355 "ifNoneExist": f"identifier={identifier.system}|{identifier.value}",
1356 })
1358 return BundleEntry(
1359 jsondict={
1360 "resource": response.as_json(),
1361 "request": bundle_request.as_json()
1362 }
1363 ).as_json()
1365 def get_fhir_questionnaire_items(
1366 self, req: "CamcopsRequest",
1367 recipient: "ExportRecipient") -> List["QuestionnaireItem"]:
1368 """
1369 Return a list of FHIR QuestionnaireItem objects for this task.
1370 https://www.hl7.org/fhir/questionnaire.html#resource
1372 Must be overridden by derived classes.
1373 """
1374 raise NotImplementedError(
1375 "No get_fhir_questionnaire_items() for this task class!")
1377 def get_fhir_questionnaire_response_items(
1378 self, req: "CamcopsRequest",
1379 recipient: "ExportRecipient") -> List["QuestionnaireResponseItem"]:
1380 """
1381 Return a list of FHIR QuestionnaireResponseItem objects for this task.
1382 https://www.hl7.org/fhir/questionnaireresponse.html#resource
1384 Must be overridden by derived classes.
1385 """
1386 raise NotImplementedError(
1387 "No get_fhir_questionnaire_response_items() for this task class!")
1389 def cancel_from_export_log(self, req: "CamcopsRequest",
1390 from_console: bool = False) -> None:
1391 """
1392 Marks all instances of this task as "cancelled" in the export log, so
1393 it will be resent.
1394 """
1395 if self._pk is None:
1396 return
1397 from camcops_server.cc_modules.cc_exportmodels import ExportedTask # delayed import # noqa
1398 # noinspection PyUnresolvedReferences
1399 statement = (
1400 update(ExportedTask.__table__)
1401 .where(ExportedTask.basetable == self.tablename)
1402 .where(ExportedTask.task_server_pk == self._pk)
1403 .where(not_(ExportedTask.cancelled) |
1404 ExportedTask.cancelled.is_(None))
1405 .values(cancelled=1,
1406 cancelled_at_utc=req.now_utc)
1407 )
1408 # ... this bit: ... AND (NOT cancelled OR cancelled IS NULL) ...:
1409 # https://stackoverflow.com/questions/37445041/sqlalchemy-how-to-filter-column-which-contains-both-null-and-integer-values # noqa
1410 req.dbsession.execute(statement)
1411 self.audit(
1412 req,
1413 "Task cancelled in export log (may trigger resending)",
1414 from_console
1415 )
1417 # -------------------------------------------------------------------------
1418 # Audit
1419 # -------------------------------------------------------------------------
1421 def audit(self, req: "CamcopsRequest", details: str,
1422 from_console: bool = False) -> None:
1423 """
1424 Audits actions to this task.
1425 """
1426 audit(req,
1427 details,
1428 patient_server_pk=self.get_patient_server_pk(),
1429 table=self.tablename,
1430 server_pk=self._pk,
1431 from_console=from_console)
1433 # -------------------------------------------------------------------------
1434 # Erasure (wiping, leaving record as placeholder)
1435 # -------------------------------------------------------------------------
1437 def manually_erase(self, req: "CamcopsRequest") -> None:
1438 """
1439 Manually erases a task (including sub-tables).
1440 Also erases linked non-current records.
1441 This WIPES THE CONTENTS but LEAVES THE RECORD AS A PLACEHOLDER.
1443 Audits the erasure. Propagates erase through to the HL7 log, so those
1444 records will be re-sent. WRITES TO DATABASE.
1445 """
1446 # Erase ourself and any other in our "family"
1447 for task in self.get_lineage():
1448 task.manually_erase_with_dependants(req)
1449 # Audit and clear HL7 message log
1450 self.audit(req, "Task details erased manually")
1451 self.cancel_from_export_log(req)
1453 def is_erased(self) -> bool:
1454 """
1455 Has the task been manually erased? See :func:`manually_erase`.
1456 """
1457 return self._manually_erased
1459 # -------------------------------------------------------------------------
1460 # Complete deletion
1461 # -------------------------------------------------------------------------
1463 def delete_entirely(self, req: "CamcopsRequest") -> None:
1464 """
1465 Completely delete this task, its lineage, and its dependants.
1466 """
1467 for task in self.get_lineage():
1468 task.delete_with_dependants(req)
1469 self.audit(req, "Task deleted")
1471 # -------------------------------------------------------------------------
1472 # Viewing the task in the list of tasks
1473 # -------------------------------------------------------------------------
1475 def is_live_on_tablet(self) -> bool:
1476 """
1477 Is the task instance live on a tablet?
1478 """
1479 return self._era == ERA_NOW
1481 # -------------------------------------------------------------------------
1482 # Filtering tasks for the task list
1483 # -------------------------------------------------------------------------
1485 @classmethod
1486 def gen_text_filter_columns(cls) -> Generator[Tuple[str, Column], None,
1487 None]:
1488 """
1489 Yields tuples of ``attrname, column``, for columns that are suitable
1490 for text filtering.
1491 """
1492 for attrname, column in gen_columns(cls):
1493 if attrname.startswith("_"): # system field
1494 continue
1495 if not is_sqlatype_string(column.type):
1496 continue
1497 yield attrname, column
1499 @classmethod
1500 @cache_region_static.cache_on_arguments(function_key_generator=fkg)
1501 def get_text_filter_columns(cls) -> List[Column]:
1502 """
1503 Cached function to return a list of SQLAlchemy Column objects suitable
1504 for text filtering.
1505 """
1506 return [col for _, col in cls.gen_text_filter_columns()]
1508 def contains_text(self, text: str) -> bool:
1509 """
1510 Does this task contain the specified text?
1512 Args:
1513 text:
1514 string that must be present in at least one of our text
1515 columns
1517 Returns:
1518 is the strings present?
1519 """
1520 text = text.lower()
1521 for attrname, _ in self.gen_text_filter_columns():
1522 value = getattr(self, attrname)
1523 if value is None:
1524 continue
1525 assert isinstance(value, str), "Internal bug in contains_text"
1526 if text in value.lower():
1527 return True
1528 return False
1530 def contains_all_strings(self, strings: List[str]) -> bool:
1531 """
1532 Does this task contain all of the specified strings?
1534 Args:
1535 strings:
1536 list of strings; each string must be present in at least
1537 one of our text columns
1539 Returns:
1540 are all strings present?
1541 """
1542 return all(self.contains_text(text) for text in strings)
1544 # -------------------------------------------------------------------------
1545 # TSV export for basic research dump
1546 # -------------------------------------------------------------------------
1548 def get_tsv_pages(self, req: "CamcopsRequest") -> List["TsvPage"]:
1549 """
1550 Returns information used for the basic research dump in TSV format.
1551 """
1552 # 1. Our core fields, plus summary information
1554 main_page = self._get_core_tsv_page(req)
1555 # 2. Patient details.
1557 if self.patient:
1558 main_page.add_or_set_columns_from_page(
1559 self.patient.get_tsv_page(req))
1560 tsv_pages = [main_page]
1561 # 3. +/- Ancillary objects
1562 for ancillary in self.gen_ancillary_instances(): # type: GenericTabletRecordMixin # noqa
1563 page = ancillary._get_core_tsv_page(req)
1564 tsv_pages.append(page)
1565 # 4. +/- Extra summary tables (inc. SNOMED)
1566 for est in self.get_all_summary_tables(req):
1567 tsv_pages.append(est.get_tsv_page())
1568 # Done
1569 return tsv_pages
1571 # -------------------------------------------------------------------------
1572 # XML view
1573 # -------------------------------------------------------------------------
1575 def get_xml(self,
1576 req: "CamcopsRequest",
1577 options: TaskExportOptions = None,
1578 indent_spaces: int = 4,
1579 eol: str = '\n') -> str:
1580 """
1581 Returns XML describing the task.
1583 Args:
1584 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1585 options: a :class:`camcops_server.cc_modules.cc_simpleobjects.TaskExportOptions`
1587 indent_spaces: number of spaces to indent formatted XML
1588 eol: end-of-line string
1590 Returns:
1591 an XML UTF-8 document representing the task.
1593 """ # noqa
1594 options = options or TaskExportOptions()
1595 tree = self.get_xml_root(req=req, options=options)
1596 return get_xml_document(
1597 tree,
1598 indent_spaces=indent_spaces,
1599 eol=eol,
1600 include_comments=options.xml_include_comments,
1601 )
1603 def get_xml_root(self,
1604 req: "CamcopsRequest",
1605 options: TaskExportOptions) -> XmlElement:
1606 """
1607 Returns an XML tree. The return value is the root
1608 :class:`camcops_server.cc_modules.cc_xml.XmlElement`.
1610 Override to include other tables, or to deal with BLOBs, if the default
1611 methods are insufficient.
1613 Args:
1614 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1615 options: a :class:`camcops_server.cc_modules.cc_simpleobjects.TaskExportOptions`
1616 """ # noqa
1617 # Core (inc. core BLOBs)
1618 branches = self._get_xml_core_branches(req=req, options=options)
1619 tree = XmlElement(name=self.tablename, value=branches)
1620 return tree
1622 def _get_xml_core_branches(
1623 self,
1624 req: "CamcopsRequest",
1625 options: TaskExportOptions) -> List[XmlElement]:
1626 """
1627 Returns a list of :class:`camcops_server.cc_modules.cc_xml.XmlElement`
1628 elements representing stored, calculated, patient, and/or BLOB fields,
1629 depending on the options.
1631 Args:
1632 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1633 options: a :class:`camcops_server.cc_modules.cc_simpleobjects.TaskExportOptions`
1634 """ # noqa
1635 def add_comment(comment: XmlLiteral) -> None:
1636 if options.xml_with_header_comments:
1637 branches.append(comment)
1639 options = options or TaskExportOptions(xml_include_plain_columns=True,
1640 xml_include_ancillary=True,
1641 include_blobs=False,
1642 xml_include_calculated=True,
1643 xml_include_patient=True,
1644 xml_include_snomed=True)
1646 # Stored values +/- calculated values
1647 core_options = options.clone()
1648 core_options.include_blobs = False
1649 branches = self._get_xml_branches(req=req, options=core_options)
1651 # SNOMED-CT codes
1652 if options.xml_include_snomed and req.snomed_supported:
1653 add_comment(XML_COMMENT_SNOMED_CT)
1654 snomed_codes = self.get_snomed_codes(req)
1655 snomed_branches = [] # type: List[XmlElement]
1656 for code in snomed_codes:
1657 snomed_branches.append(code.xml_element())
1658 branches.append(XmlElement(name=XML_NAME_SNOMED_CODES,
1659 value=snomed_branches))
1661 # Special notes
1662 add_comment(XML_COMMENT_SPECIAL_NOTES)
1663 for sn in self.special_notes:
1664 branches.append(sn.get_xml_root())
1666 # Patient details
1667 if self.is_anonymous:
1668 add_comment(XML_COMMENT_ANONYMOUS)
1669 elif options.xml_include_patient:
1670 add_comment(XML_COMMENT_PATIENT)
1671 patient_options = TaskExportOptions(
1672 xml_include_plain_columns=True,
1673 xml_with_header_comments=options.xml_with_header_comments)
1674 if self.patient:
1675 branches.append(self.patient.get_xml_root(
1676 req, patient_options))
1678 # BLOBs
1679 if options.include_blobs:
1680 add_comment(XML_COMMENT_BLOBS)
1681 blob_options = TaskExportOptions(
1682 include_blobs=True,
1683 xml_skip_fields=options.xml_skip_fields,
1684 xml_sort_by_name=True,
1685 xml_with_header_comments=False,
1686 )
1687 branches += self._get_xml_branches(req=req, options=blob_options)
1689 # Ancillary objects
1690 if options.xml_include_ancillary:
1691 ancillary_options = TaskExportOptions(
1692 xml_include_plain_columns=True,
1693 xml_include_ancillary=True,
1694 include_blobs=options.include_blobs,
1695 xml_include_calculated=options.xml_include_calculated,
1696 xml_sort_by_name=True,
1697 xml_with_header_comments=options.xml_with_header_comments,
1698 )
1699 item_collections = [] # type: List[XmlElement]
1700 found_ancillary = False
1701 # We use a slightly more manual iteration process here so that
1702 # we iterate through individual ancillaries but clustered by their
1703 # name (e.g. if we have 50 trials and 5 groups, we do them in
1704 # collections).
1705 for attrname, rel_prop, rel_cls in gen_ancillary_relationships(self): # noqa
1706 if not found_ancillary:
1707 add_comment(XML_COMMENT_ANCILLARY)
1708 found_ancillary = True
1709 itembranches = [] # type: List[XmlElement]
1710 if rel_prop.uselist:
1711 ancillaries = getattr(self, attrname) # type: List[GenericTabletRecordMixin] # noqa
1712 else:
1713 ancillaries = [getattr(self, attrname)] # type: List[GenericTabletRecordMixin] # noqa
1714 for ancillary in ancillaries:
1715 itembranches.append(
1716 ancillary._get_xml_root(req=req,
1717 options=ancillary_options)
1718 )
1719 itemcollection = XmlElement(name=attrname, value=itembranches)
1720 item_collections.append(itemcollection)
1721 item_collections.sort(key=lambda el: el.name)
1722 branches += item_collections
1724 # Completely separate additional summary tables
1725 if options.xml_include_calculated:
1726 item_collections = [] # type: List[XmlElement]
1727 found_est = False
1728 for est in self.get_extra_summary_tables(req):
1729 # ... not get_all_summary_tables(); we handled SNOMED
1730 # differently, above
1731 if not found_est and est.rows:
1732 add_comment(XML_COMMENT_CALCULATED)
1733 found_est = True
1734 item_collections.append(est.get_xml_element())
1735 item_collections.sort(key=lambda el: el.name)
1736 branches += item_collections
1738 return branches
1740 # -------------------------------------------------------------------------
1741 # HTML view
1742 # -------------------------------------------------------------------------
1744 def get_html(self, req: "CamcopsRequest", anonymise: bool = False) -> str:
1745 """
1746 Returns HTML representing the task, for our HTML view.
1748 Args:
1749 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1750 anonymise: hide patient identifying details?
1751 """
1752 req.prepare_for_html_figures()
1753 return render("task.mako",
1754 dict(task=self,
1755 anonymise=anonymise,
1756 signature=False,
1757 viewtype=ViewArg.HTML),
1758 request=req)
1760 def title_for_html(self, req: "CamcopsRequest",
1761 anonymise: bool = False) -> str:
1762 """
1763 Returns the plain text used for the HTML ``<title>`` block (by
1764 ``task.mako``), and also for the PDF title for PDF exports.
1766 Should be plain text only.
1768 Args:
1769 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1770 anonymise: hide patient identifying details?
1771 """
1772 if anonymise:
1773 patient = "?"
1774 elif self.patient:
1775 patient = self.patient.prettystr(req)
1776 else:
1777 _ = req.gettext
1778 patient = _("Anonymous")
1779 tasktype = self.tablename
1780 when = format_datetime(self.get_creation_datetime(),
1781 DateFormat.ISO8601_HUMANIZED_TO_MINUTES, "")
1782 return f"CamCOPS: {patient}; {tasktype}; {when}"
1784 # -------------------------------------------------------------------------
1785 # PDF view
1786 # -------------------------------------------------------------------------
1788 def get_pdf(self, req: "CamcopsRequest", anonymise: bool = False) -> bytes:
1789 """
1790 Returns a PDF representing the task.
1792 Args:
1793 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1794 anonymise: hide patient identifying details?
1795 """
1796 html = self.get_pdf_html(req, anonymise=anonymise) # main content
1797 if CSS_PAGED_MEDIA:
1798 return pdf_from_html(req, html=html)
1799 else:
1800 return pdf_from_html(
1801 req,
1802 html=html,
1803 header_html=render(
1804 "wkhtmltopdf_header.mako",
1805 dict(inner_text=render("task_page_header.mako",
1806 dict(task=self, anonymise=anonymise),
1807 request=req)),
1808 request=req
1809 ),
1810 footer_html=render(
1811 "wkhtmltopdf_footer.mako",
1812 dict(inner_text=render("task_page_footer.mako",
1813 dict(task=self),
1814 request=req)),
1815 request=req
1816 ),
1817 extra_wkhtmltopdf_options={
1818 "orientation": ("Landscape" if self.use_landscape_for_pdf
1819 else "Portrait")
1820 }
1821 )
1823 def get_pdf_html(self, req: "CamcopsRequest",
1824 anonymise: bool = False) -> str:
1825 """
1826 Gets the HTML used to make the PDF (slightly different from the HTML
1827 used for the HTML view).
1828 """
1829 req.prepare_for_pdf_figures()
1830 return render("task.mako",
1831 dict(task=self,
1832 anonymise=anonymise,
1833 pdf_landscape=self.use_landscape_for_pdf,
1834 signature=self.has_clinician,
1835 viewtype=ViewArg.PDF),
1836 request=req)
1838 def suggested_pdf_filename(self, req: "CamcopsRequest",
1839 anonymise: bool = False) -> str:
1840 """
1841 Suggested filename for the PDF copy (for downloads).
1843 Args:
1844 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1845 anonymise: hide patient identifying details?
1846 """
1847 cfg = req.config
1848 if anonymise:
1849 is_anonymous = True
1850 else:
1851 is_anonymous = self.is_anonymous
1852 patient = self.patient
1853 return get_export_filename(
1854 req=req,
1855 patient_spec_if_anonymous=cfg.patient_spec_if_anonymous,
1856 patient_spec=cfg.patient_spec,
1857 filename_spec=cfg.task_filename_spec,
1858 filetype=ViewArg.PDF,
1859 is_anonymous=is_anonymous,
1860 surname=patient.get_surname() if patient else "",
1861 forename=patient.get_forename() if patient else "",
1862 dob=patient.get_dob() if patient else None,
1863 sex=patient.get_sex() if patient else None,
1864 idnum_objects=patient.get_idnum_objects() if patient else None,
1865 creation_datetime=self.get_creation_datetime(),
1866 basetable=self.tablename,
1867 serverpk=self._pk
1868 )
1870 def write_pdf_to_disk(self, req: "CamcopsRequest", filename: str) -> None:
1871 """
1872 Writes the PDF to disk, using ``filename``.
1873 """
1874 pdffile = open(filename, "wb")
1875 pdffile.write(self.get_pdf(req))
1877 # -------------------------------------------------------------------------
1878 # Metadata for e.g. RiO
1879 # -------------------------------------------------------------------------
1881 def get_rio_metadata(self,
1882 req: "CamcopsRequest",
1883 which_idnum: int,
1884 uploading_user_id: str,
1885 document_type: str) -> str:
1886 """
1887 Returns metadata for the task that Servelec's RiO electronic patient
1888 record may want.
1890 Args:
1891 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1892 which_idnum: which CamCOPS ID number type corresponds to the RiO
1893 client ID?
1894 uploading_user_id: RiO user ID (string) of the user who will
1895 be recorded as uploading this information; see below
1896 document_type: a string indicating the RiO-defined document type
1897 (this is system-specific); see below
1899 Returns:
1900 a newline-terminated single line of CSV values; see below
1902 Called by
1903 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task`.
1905 From Servelec (Lee Meredith) to Rudolf Cardinal, 2014-12-04:
1907 .. code-block:: none
1909 Batch Document Upload
1911 The RiO batch document upload function can be used to upload
1912 documents in bulk automatically. RiO includes a Batch Upload
1913 windows service which monitors a designated folder for new files.
1914 Each file which is scanned must be placed in the designated folder
1915 along with a meta-data file which describes the document. So
1916 essentially if a document had been scanned in and was called
1917 ‘ThisIsANewReferralLetterForAPatient.pdf’ then there would also
1918 need to be a meta file in the same folder called
1919 ‘ThisIsANewReferralLetterForAPatient.metadata’. The contents of
1920 the meta file would need to include the following:
1922 Field Order; Field Name; Description; Data Mandatory (Y/N);
1923 Format
1925 1; ClientID; RiO Client ID which identifies the patient in RiO
1926 against which the document will be uploaded.; Y; 15
1927 Alphanumeric Characters
1929 2; UserID; User ID of the uploaded document, this is any user
1930 defined within the RiO system and can be a single system user
1931 called ‘AutomaticDocumentUploadUser’ for example.; Y; 10
1932 Alphanumeric Characters
1934 [NB example longer than that!]
1936 3; DocumentType; The RiO defined document type eg: APT; Y; 80
1937 Alphanumeric Characters
1939 4; Title; The title of the document; N; 40 Alphanumeric
1940 Characters
1942 5; Description; The document description.; N; 500 Alphanumeric
1943 Characters
1945 6; Author; The author of the document; N; 80 Alphanumeric
1946 Characters
1948 7; DocumentDate; The date of the document; N; dd/MM/yyyy HH:mm
1950 8; FinalRevision; The revision values are 0 Draft or 1 Final,
1951 this is defaulted to 1 which is Final revision.; N; 0 or 1
1953 As an example, this is what would be needed in a meta file:
1955 “1000001”,”TRUST1”,”APT”,”A title”, “A description of the
1956 document”, “An author”,”01/12/2012 09:45”,”1”
1958 (on one line)
1960 Clarification, from Lee Meredith to Rudolf Cardinal, 2015-02-18:
1962 - metadata files must be plain ASCII, not UTF-8
1964 - ... here and
1965 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task`
1967 - line terminator is <CR>
1969 - BUT see
1970 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task`
1972 - user name limit is 10 characters, despite incorrect example
1974 - search for ``RIO_MAX_USER_LEN``
1976 - DocumentType is a code that maps to a human-readable document
1977 type; for example, "APT" might map to "Appointment Letter". These
1978 mappings are specific to the local system. (We will probably want
1979 one that maps to "Clinical Correspondence" in the absence of
1980 anything more specific.)
1982 - RiO will delete the files after it's processed them.
1984 - Filenames should avoid spaces, but otherwise any other standard
1985 ASCII code is fine within filenames.
1987 - see
1988 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task`
1990 """ # noqa
1992 try:
1993 client_id = self.patient.get_idnum_value(which_idnum)
1994 except AttributeError:
1995 client_id = ""
1996 title = "CamCOPS_" + self.shortname
1997 description = self.longname(req)
1998 author = self.get_clinician_name() # may be blank
1999 document_date = format_datetime(self.when_created,
2000 DateFormat.RIO_EXPORT_UK)
2001 # This STRIPS the timezone information; i.e. it is in the local
2002 # timezone but doesn't tell you which timezone that is. (That's fine;
2003 # it should be local or users would be confused.)
2004 final_revision = (0 if self.is_live_on_tablet() else 1)
2006 item_list = [
2007 client_id,
2008 uploading_user_id,
2009 document_type,
2010 title,
2011 description,
2012 author,
2013 document_date,
2014 final_revision
2015 ]
2016 # UTF-8 is NOT supported by RiO for metadata. So:
2017 csv_line = ",".join([f'"{mangle_unicode_to_ascii(x)}"'
2018 for x in item_list])
2019 return csv_line + "\n"
2021 # -------------------------------------------------------------------------
2022 # HTML elements used by tasks
2023 # -------------------------------------------------------------------------
2025 # noinspection PyMethodMayBeStatic
2026 def get_standard_clinician_comments_block(self,
2027 req: "CamcopsRequest",
2028 comments: str) -> str:
2029 """
2030 HTML DIV for clinician's comments.
2031 """
2032 return render("clinician_comments.mako",
2033 dict(comment=comments),
2034 request=req)
2036 def get_is_complete_td_pair(self, req: "CamcopsRequest") -> str:
2037 """
2038 HTML to indicate whether task is complete or not, and to make it
2039 very obvious visually when it isn't.
2040 """
2041 c = self.is_complete()
2042 td_class = "" if c else f' class="{CssClass.INCOMPLETE}"'
2043 return (
2044 f"<td>Completed?</td>"
2045 f"<td{td_class}><b>{get_yes_no(req, c)}</b></td>"
2046 )
2048 def get_is_complete_tr(self, req: "CamcopsRequest") -> str:
2049 """
2050 HTML table row to indicate whether task is complete or not, and to
2051 make it very obvious visually when it isn't.
2052 """
2053 return f"<tr>{self.get_is_complete_td_pair(req)}</tr>"
2055 def get_twocol_val_row(self,
2056 fieldname: str,
2057 default: str = None,
2058 label: str = None) -> str:
2059 """
2060 HTML table row, two columns, without web-safing of value.
2062 Args:
2063 fieldname: field (attribute) name; the value will be retrieved
2064 from this attribute
2065 default: default to show if the value is ``None``
2066 label: descriptive label
2068 Returns:
2069 two-column HTML table row (label, value)
2071 """
2072 val = getattr(self, fieldname)
2073 if val is None:
2074 val = default
2075 if label is None:
2076 label = fieldname
2077 return tr_qa(label, val)
2079 def get_twocol_string_row(self,
2080 fieldname: str,
2081 label: str = None) -> str:
2082 """
2083 HTML table row, two columns, with web-safing of value.
2085 Args:
2086 fieldname: field (attribute) name; the value will be retrieved
2087 from this attribute
2088 label: descriptive label
2090 Returns:
2091 two-column HTML table row (label, value)
2092 """
2093 if label is None:
2094 label = fieldname
2095 return tr_qa(label, getattr(self, fieldname))
2097 def get_twocol_bool_row(self,
2098 req: "CamcopsRequest",
2099 fieldname: str,
2100 label: str = None) -> str:
2101 """
2102 HTML table row, two columns, with Boolean Y/N formatter for value.
2104 Args:
2105 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2106 fieldname: field (attribute) name; the value will be retrieved
2107 from this attribute
2108 label: descriptive label
2110 Returns:
2111 two-column HTML table row (label, value)
2112 """
2113 if label is None:
2114 label = fieldname
2115 return tr_qa(label, get_yes_no_none(req, getattr(self, fieldname)))
2117 def get_twocol_bool_row_true_false(self,
2118 req: "CamcopsRequest",
2119 fieldname: str,
2120 label: str = None) -> str:
2121 """
2122 HTML table row, two columns, with Boolean true/false formatter for
2123 value.
2125 Args:
2126 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2127 fieldname: field (attribute) name; the value will be retrieved
2128 from this attribute
2129 label: descriptive label
2131 Returns:
2132 two-column HTML table row (label, value)
2133 """
2134 if label is None:
2135 label = fieldname
2136 return tr_qa(label, get_true_false_none(req, getattr(self, fieldname)))
2138 def get_twocol_bool_row_present_absent(self,
2139 req: "CamcopsRequest",
2140 fieldname: str,
2141 label: str = None) -> str:
2142 """
2143 HTML table row, two columns, with Boolean present/absent formatter for
2144 value.
2146 Args:
2147 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2148 fieldname: field (attribute) name; the value will be retrieved
2149 from this attribute
2150 label: descriptive label
2152 Returns:
2153 two-column HTML table row (label, value)
2154 """
2155 if label is None:
2156 label = fieldname
2157 return tr_qa(label, get_present_absent_none(req,
2158 getattr(self, fieldname)))
2160 @staticmethod
2161 def get_twocol_picture_row(blob: Optional[Blob], label: str) -> str:
2162 """
2163 HTML table row, two columns, with PNG on right.
2165 Args:
2166 blob: the :class:`camcops_server.cc_modules.cc_blob.Blob` object
2167 label: descriptive label
2169 Returns:
2170 two-column HTML table row (label, picture)
2171 """
2172 return tr(label, get_blob_img_html(blob))
2174 # -------------------------------------------------------------------------
2175 # Field helper functions for subclasses
2176 # -------------------------------------------------------------------------
2178 def get_values(self, fields: List[str]) -> List:
2179 """
2180 Get list of object's values from list of field names.
2181 """
2182 return [getattr(self, f) for f in fields]
2184 def is_field_not_none(self, field: str) -> bool:
2185 """
2186 Is the field not None?
2187 """
2188 return getattr(self, field) is not None
2190 def any_fields_none(self, fields: List[str]) -> bool:
2191 """
2192 Are any specified fields None?
2193 """
2194 for f in fields:
2195 if getattr(self, f) is None:
2196 return True
2197 return False
2199 def all_fields_not_none(self, fields: List[str]) -> bool:
2200 """
2201 Are all specified fields not None?
2202 """
2203 return not self.any_fields_none(fields)
2205 def any_fields_null_or_empty_str(self, fields: List[str]) -> bool:
2206 """
2207 Are any specified fields either None or the empty string?
2208 """
2209 for f in fields:
2210 v = getattr(self, f)
2211 if v is None or v == "":
2212 return True
2213 return False
2215 def are_all_fields_not_null_or_empty_str(self, fields: List[str]) -> bool:
2216 """
2217 Are all specified fields neither None nor the empty string?
2218 """
2219 return not self.any_fields_null_or_empty_str(fields)
2221 def n_fields_not_none(self, fields: List[str]) -> int:
2222 """
2223 How many of the specified fields are not None?
2224 """
2225 total = 0
2226 for f in fields:
2227 if getattr(self, f) is not None:
2228 total += 1
2229 return total
2231 def n_fields_none(self, fields: List[str]) -> int:
2232 """
2233 How many of the specified fields are None?
2234 """
2235 total = 0
2236 for f in fields:
2237 if getattr(self, f) is None:
2238 total += 1
2239 return total
2241 def count_booleans(self, fields: List[str]) -> int:
2242 """
2243 How many of the specified fields evaluate to True (are truthy)?
2244 """
2245 total = 0
2246 for f in fields:
2247 value = getattr(self, f)
2248 if value:
2249 total += 1
2250 return total
2252 def all_truthy(self, fields: List[str]) -> bool:
2253 """
2254 Do all the specified fields evaluate to True (are they all truthy)?
2255 """
2256 for f in fields:
2257 value = getattr(self, f)
2258 if not value:
2259 return False
2260 return True
2262 def count_where(self,
2263 fields: List[str],
2264 wherevalues: List[Any]) -> int:
2265 """
2266 Count how many values for the specified fields are in ``wherevalues``.
2267 """
2268 return sum(1 for x in self.get_values(fields) if x in wherevalues)
2270 def count_wherenot(self,
2271 fields: List[str],
2272 notvalues: List[Any]) -> int:
2273 """
2274 Count how many values for the specified fields are NOT in
2275 ``notvalues``.
2276 """
2277 return sum(1 for x in self.get_values(fields) if x not in notvalues)
2279 def sum_fields(self,
2280 fields: List[str],
2281 ignorevalue: Any = None) -> Union[int, float]:
2282 """
2283 Sum values stored in all specified fields (skipping any whose value is
2284 ``ignorevalue``; treating fields containing ``None`` as zero).
2285 """
2286 total = 0
2287 for f in fields:
2288 value = getattr(self, f)
2289 if value == ignorevalue:
2290 continue
2291 total += value if value is not None else 0
2292 return total
2294 def mean_fields(self,
2295 fields: List[str],
2296 ignorevalue: Any = None) -> Union[int, float, None]:
2297 """
2298 Return the mean of the values stored in all specified fields (skipping
2299 any whose value is ``ignorevalue``).
2300 """
2301 values = []
2302 for f in fields:
2303 value = getattr(self, f)
2304 if value != ignorevalue:
2305 values.append(value)
2306 try:
2307 return statistics.mean(values)
2308 except (TypeError, statistics.StatisticsError):
2309 return None
2311 @staticmethod
2312 def fieldnames_from_prefix(prefix: str, start: int, end: int) -> List[str]:
2313 """
2314 Returns a list of field (column, attribute) names from a prefix.
2315 For example, ``fieldnames_from_prefix("q", 1, 5)`` produces
2316 ``["q1", "q2", "q3", "q4", "q5"]``.
2318 Args:
2319 prefix: string prefix
2320 start: first value (inclusive)
2321 end: last value (inclusive
2323 Returns:
2324 list of fieldnames, as above
2326 """
2327 return [prefix + str(x) for x in range(start, end + 1)]
2329 @staticmethod
2330 def fieldnames_from_list(prefix: str,
2331 suffixes: Iterable[Any]) -> List[str]:
2332 """
2333 Returns a list of fieldnames made by appending each suffix to the
2334 prefix.
2336 Args:
2337 prefix: string prefix
2338 suffixes: list of suffixes, which will be coerced to ``str``
2340 Returns:
2341 list of fieldnames, as above
2343 """
2344 return [prefix + str(x) for x in suffixes]
2346 # -------------------------------------------------------------------------
2347 # Extra strings
2348 # -------------------------------------------------------------------------
2350 def get_extrastring_taskname(self) -> str:
2351 """
2352 Get the taskname used as the top-level key for this task's extra
2353 strings (loaded by the server from XML files). By default this is the
2354 task's primary tablename, but tasks may override that via
2355 ``extrastring_taskname``.
2356 """
2357 return self.extrastring_taskname or self.tablename
2359 def extrastrings_exist(self, req: "CamcopsRequest") -> bool:
2360 """
2361 Does the server have any extra strings for this task?
2362 """
2363 return req.task_extrastrings_exist(self.get_extrastring_taskname())
2365 def wxstring(self,
2366 req: "CamcopsRequest",
2367 name: str,
2368 defaultvalue: str = None,
2369 provide_default_if_none: bool = True) -> str:
2370 """
2371 Return a web-safe version of an extra string for this task.
2373 Args:
2374 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2375 name: name (second-level key) of the string, within the set of
2376 this task's extra strings
2377 defaultvalue: default to return if the string is not found
2378 provide_default_if_none: if ``True`` and ``default is None``,
2379 return a helpful missing-string message in the style
2380 "string x.y not found"
2381 """
2382 if defaultvalue is None and provide_default_if_none:
2383 defaultvalue = f"[{self.get_extrastring_taskname()}: {name}]"
2384 return req.wxstring(
2385 self.get_extrastring_taskname(),
2386 name,
2387 defaultvalue,
2388 provide_default_if_none=provide_default_if_none)
2390 def xstring(self,
2391 req: "CamcopsRequest",
2392 name: str,
2393 defaultvalue: str = None,
2394 provide_default_if_none: bool = True) -> str:
2395 """
2396 Return a raw (not necessarily web-safe) version of an extra string for
2397 this task.
2399 Args:
2400 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2401 name: name (second-level key) of the string, within the set of
2402 this task's extra strings
2403 defaultvalue: default to return if the string is not found
2404 provide_default_if_none: if ``True`` and ``default is None``,
2405 return a helpful missing-string message in the style
2406 "string x.y not found"
2407 """
2408 if defaultvalue is None and provide_default_if_none:
2409 defaultvalue = f"[{self.get_extrastring_taskname()}: {name}]"
2410 return req.xstring(
2411 self.get_extrastring_taskname(),
2412 name,
2413 defaultvalue,
2414 provide_default_if_none=provide_default_if_none)
2416 def make_options_from_xstrings(self,
2417 req: "CamcopsRequest",
2418 prefix: str, first: int, last: int,
2419 suffix: str = "") -> Dict[int, str]:
2420 """
2421 Creates a lookup dictionary from xstrings.
2423 Args:
2424 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
2425 prefix: prefix for xstring
2426 first: first value
2427 last: last value
2428 suffix: optional suffix
2430 Returns:
2431 dict: Each entry maps ``value`` to an xstring named
2432 ``<PREFIX><VALUE><SUFFIX>``.
2434 """
2435 d = {} # type: Dict[int, str]
2436 if first > last: # descending order
2437 for i in range(first, last - 1, -1):
2438 d[i] = self.xstring(req, f"{prefix}{i}{suffix}")
2439 else: # ascending order
2440 for i in range(first, last + 1):
2441 d[i] = self.xstring(req, f"{prefix}{i}{suffix}")
2442 return d
2444 @staticmethod
2445 def make_options_from_numbers(first: int, last: int) -> Dict[int, str]:
2446 """
2447 Creates a simple dictionary mapping numbers to string versions of those
2448 numbers. Usually for subsequent (more interesting) processing!
2450 Args:
2451 first: first value
2452 last: last value
2454 Returns:
2455 dict
2457 """
2458 d = {} # type: Dict[int, str]
2459 if first > last: # descending order
2460 for i in range(first, last - 1, -1):
2461 d[i] = str(i)
2462 else: # ascending order
2463 for i in range(first, last + 1):
2464 d[i] = str(i)
2465 return d
2468# =============================================================================
2469# Collating all task tables for specific purposes
2470# =============================================================================
2471# Function, staticmethod, classmethod?
2472# https://stackoverflow.com/questions/8108688/in-python-when-should-i-use-a-function-instead-of-a-method # noqa
2473# https://stackoverflow.com/questions/11788195/module-function-vs-staticmethod-vs-classmethod-vs-no-decorators-which-idiom-is # noqa
2474# https://stackoverflow.com/questions/15017734/using-static-methods-in-python-best-practice # noqa
2476def all_task_tables_with_min_client_version() -> Dict[str, Version]:
2477 """
2478 Across all tasks, return a mapping from each of their tables to the
2479 minimum client version.
2481 Used by
2482 :func:`camcops_server.cc_modules.client_api.all_tables_with_min_client_version`.
2484 """ # noqa
2485 d = {} # type: Dict[str, Version]
2486 classes = list(Task.gen_all_subclasses())
2487 for cls in classes:
2488 d.update(cls.all_tables_with_min_client_version())
2489 return d
2492@cache_region_static.cache_on_arguments(function_key_generator=fkg)
2493def tablename_to_task_class_dict() -> Dict[str, Type[Task]]:
2494 """
2495 Returns a mapping from task base tablenames to task classes.
2496 """
2497 d = {} # type: Dict[str, Type[Task]]
2498 for cls in Task.gen_all_subclasses():
2499 d[cls.tablename] = cls
2500 return d
2503@cache_region_static.cache_on_arguments(function_key_generator=fkg)
2504def all_task_tablenames() -> List[str]:
2505 """
2506 Returns all task base table names.
2507 """
2508 d = tablename_to_task_class_dict()
2509 return list(d.keys())
2512@cache_region_static.cache_on_arguments(function_key_generator=fkg)
2513def all_task_classes() -> List[Type[Task]]:
2514 """
2515 Returns all task base table names.
2516 """
2517 d = tablename_to_task_class_dict()
2518 return list(d.values())
2521# =============================================================================
2522# Support functions
2523# =============================================================================
2525def get_from_dict(d: Dict, key: Any, default: Any = INVALID_VALUE) -> Any:
2526 """
2527 Returns a value from a dictionary. This is not a very complex function...
2528 all it really does in practice is provide a default for ``default``.
2530 Args:
2531 d: the dictionary
2532 key: the key
2533 default: value to return if none is provided
2534 """
2535 return d.get(key, default)