Coverage for cc_modules/cc_task.py: 36%

920 statements  

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

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_task.py 

5 

6=============================================================================== 

7 

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

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

10 

11 This file is part of CamCOPS. 

12 

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

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

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

16 (at your option) any later version. 

17 

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

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

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

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

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

25 

26=============================================================================== 

27 

28**Represents CamCOPS tasks.** 

29 

30Core task export methods: 

31 

32======= ======================================================================= 

33Format Comment 

34======= ======================================================================= 

35HTML The task in a user-friendly format. 

36PDF Essentially the HTML output, but with page headers and (for clinician 

37 tasks) a signature block, and without additional HTML administrative 

38 hyperlinks. 

39XML Centres on the task with its subdata integrated. 

40TSV Tab-separated value format. 

41SQL As part of an SQL or SQLite download. 

42======= ======================================================================= 

43 

44""" 

45 

46from base64 import b64encode 

47from collections import Counter, OrderedDict 

48import datetime 

49import logging 

50import statistics 

51from typing import ( 

52 Any, 

53 Dict, 

54 Iterable, 

55 Generator, 

56 List, 

57 Optional, 

58 Set, 

59 Tuple, 

60 Type, 

61 TYPE_CHECKING, 

62 Union, 

63) 

64 

65from cardinal_pythonlib.classes import classproperty 

66from cardinal_pythonlib.datetimefunc import ( 

67 convert_datetime_to_utc, 

68 format_datetime, 

69 pendulum_to_utc_datetime_without_tz, 

70) 

71from cardinal_pythonlib.httpconst import MimeType 

72from cardinal_pythonlib.logs import BraceStyleAdapter 

73from cardinal_pythonlib.sqlalchemy.dialect import SqlaDialectName 

74from cardinal_pythonlib.sqlalchemy.orm_inspect import ( 

75 gen_columns, 

76 gen_orm_classes_from_base, 

77) 

78from cardinal_pythonlib.sqlalchemy.schema import ( 

79 is_sqlatype_binary, 

80 is_sqlatype_string, 

81) 

82from cardinal_pythonlib.stringfunc import mangle_unicode_to_ascii 

83from fhirclient.models.attachment import Attachment 

84from fhirclient.models.bundle import Bundle 

85from fhirclient.models.codeableconcept import CodeableConcept 

86from fhirclient.models.coding import Coding 

87from fhirclient.models.contactpoint import ContactPoint 

88from fhirclient.models.documentreference import ( 

89 DocumentReference, 

90 DocumentReferenceContent, 

91) 

92from fhirclient.models.fhirreference import FHIRReference 

93from fhirclient.models.humanname import HumanName 

94from fhirclient.models.identifier import Identifier 

95from fhirclient.models.observation import Observation 

96from fhirclient.models.practitioner import Practitioner 

97from fhirclient.models.questionnaire import Questionnaire 

98from fhirclient.models.questionnaireresponse import QuestionnaireResponse 

99import hl7 

100from pendulum import Date as PendulumDate, DateTime as Pendulum 

101from pyramid.renderers import render 

102from semantic_version import Version 

103from sqlalchemy.ext.declarative import declared_attr 

104from sqlalchemy.orm import relationship 

105from sqlalchemy.orm.relationships import RelationshipProperty 

106from sqlalchemy.sql.expression import not_, update 

107from sqlalchemy.sql.schema import Column, Table 

108from sqlalchemy.sql.sqltypes import ( 

109 Boolean, 

110 Date as DateColType, 

111 DateTime, 

112 Float, 

113 Integer, 

114 Numeric, 

115 String, 

116 Text, 

117 Time, 

118) 

119 

120from camcops_server.cc_modules.cc_audit import audit 

121from camcops_server.cc_modules.cc_baseconstants import DOCUMENTATION_URL 

122from camcops_server.cc_modules.cc_blob import Blob, get_blob_img_html 

123from camcops_server.cc_modules.cc_cache import cache_region_static, fkg 

124from camcops_server.cc_modules.cc_constants import ( 

125 ASCII, 

126 CssClass, 

127 CSS_PAGED_MEDIA, 

128 DateFormat, 

129 FHIRConst as Fc, 

130 FileType, 

131 ERA_NOW, 

132 INVALID_VALUE, 

133 UTF8, 

134) 

135from camcops_server.cc_modules.cc_dataclasses import SummarySchemaInfo 

136from camcops_server.cc_modules.cc_db import ( 

137 GenericTabletRecordMixin, 

138 SFN_CAMCOPS_SERVER_VERSION, 

139 SFN_IS_COMPLETE, 

140 SFN_SECONDS_CREATION_TO_FIRST_FINISH, 

141 TASK_FREQUENT_FIELDS, 

142 TFN_CLINICIAN_CONTACT_DETAILS, 

143 TFN_CLINICIAN_NAME, 

144 TFN_CLINICIAN_POST, 

145 TFN_CLINICIAN_PROFESSIONAL_REGISTRATION, 

146 TFN_CLINICIAN_SERVICE, 

147 TFN_CLINICIAN_SPECIALTY, 

148 TFN_EDITING_TIME_S, 

149 TFN_FIRSTEXIT_IS_ABORT, 

150 TFN_FIRSTEXIT_IS_FINISH, 

151 TFN_PATIENT_ID, 

152 TFN_RESPONDENT_NAME, 

153 TFN_RESPONDENT_RELATIONSHIP, 

154 TFN_WHEN_CREATED, 

155 TFN_WHEN_FIRSTEXIT, 

156) 

157from camcops_server.cc_modules.cc_exception import FhirExportException 

158from camcops_server.cc_modules.cc_fhir import ( 

159 fhir_observation_component_from_snomed, 

160 fhir_system_value, 

161 fhir_sysval_from_id, 

162 FHIRAnsweredQuestion, 

163 FHIRAnswerType, 

164 FHIRQuestionType, 

165 make_fhir_bundle_entry, 

166) 

167from camcops_server.cc_modules.cc_filename import get_export_filename 

168from camcops_server.cc_modules.cc_hl7 import make_obr_segment, make_obx_segment 

169from camcops_server.cc_modules.cc_html import ( 

170 get_present_absent_none, 

171 get_true_false_none, 

172 get_yes_no, 

173 get_yes_no_none, 

174 tr, 

175 tr_qa, 

176) 

177from camcops_server.cc_modules.cc_pdf import pdf_from_html 

178from camcops_server.cc_modules.cc_pyramid import Routes, ViewArg 

179from camcops_server.cc_modules.cc_simpleobjects import TaskExportOptions 

180from camcops_server.cc_modules.cc_snomed import SnomedLookup 

181from camcops_server.cc_modules.cc_specialnote import SpecialNote 

182from camcops_server.cc_modules.cc_sqla_coltypes import ( 

183 BoolColumn, 

184 CamcopsColumn, 

185 COLATTR_PERMITTED_VALUE_CHECKER, 

186 gen_ancillary_relationships, 

187 get_camcops_blob_column_attr_names, 

188 get_column_attr_names, 

189 PendulumDateTimeAsIsoTextColType, 

190 permitted_value_failure_msgs, 

191 permitted_values_ok, 

192 PermittedValueChecker, 

193 SemanticVersionColType, 

194 TableNameColType, 

195) 

196from camcops_server.cc_modules.cc_sqlalchemy import Base, get_table_ddl 

197from camcops_server.cc_modules.cc_summaryelement import ( 

198 ExtraSummaryTable, 

199 SummaryElement, 

200) 

201from camcops_server.cc_modules.cc_version import ( 

202 CAMCOPS_SERVER_VERSION, 

203 CAMCOPS_SERVER_VERSION_STRING, 

204 MINIMUM_TABLET_VERSION, 

205) 

206from camcops_server.cc_modules.cc_xml import ( 

207 get_xml_document, 

208 XML_COMMENT_ANCILLARY, 

209 XML_COMMENT_ANONYMOUS, 

210 XML_COMMENT_BLOBS, 

211 XML_COMMENT_CALCULATED, 

212 XML_COMMENT_PATIENT, 

213 XML_COMMENT_SNOMED_CT, 

214 XML_COMMENT_SPECIAL_NOTES, 

215 XML_NAME_SNOMED_CODES, 

216 XmlElement, 

217 XmlLiteral, 

218) 

219 

220if TYPE_CHECKING: 

221 from camcops_server.cc_modules.cc_ctvinfo import CtvInfo # noqa: F401 

222 from camcops_server.cc_modules.cc_exportrecipient import ( # noqa: F401 

223 ExportRecipient, 

224 ) 

225 from camcops_server.cc_modules.cc_patient import Patient # noqa: F401 

226 from camcops_server.cc_modules.cc_patientidnum import ( # noqa: F401 

227 PatientIdNum, 

228 ) 

229 from camcops_server.cc_modules.cc_request import ( # noqa: F401 

230 CamcopsRequest, 

231 ) 

232 from camcops_server.cc_modules.cc_snomed import ( # noqa: F401 

233 SnomedExpression, 

234 ) 

235 from camcops_server.cc_modules.cc_trackerhelpers import ( # noqa: F401 

236 TrackerInfo, 

237 ) 

238 from camcops_server.cc_modules.cc_spreadsheet import ( # noqa: F401 

239 SpreadsheetPage, 

240 ) 

241 

242log = BraceStyleAdapter(logging.getLogger(__name__)) 

243 

244 

245# ============================================================================= 

246# Debugging options 

247# ============================================================================= 

248 

249DEBUG_SKIP_FHIR_DOCS = False 

250DEBUG_SHOW_FHIR_QUESTIONNAIRE = False 

251 

252if any([DEBUG_SKIP_FHIR_DOCS, DEBUG_SHOW_FHIR_QUESTIONNAIRE]): 

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

254 

255 

256# ============================================================================= 

257# Constants 

258# ============================================================================= 

259 

260ANCILLARY_FWD_REF = "Ancillary" 

261TASK_FWD_REF = "Task" 

262 

263FHIR_UNKNOWN_TEXT = "[?]" 

264 

265SNOMED_TABLENAME = "_snomed_ct" 

266SNOMED_COLNAME_TASKTABLE = "task_tablename" 

267SNOMED_COLNAME_TASKPK = "task_pk" 

268SNOMED_COLNAME_WHENCREATED_UTC = "when_created" 

269SNOMED_COLNAME_EXPRESSION = "snomed_expression" 

270UNUSED_SNOMED_XML_NAME = "snomed_ct_expressions" 

271 

272 

273# ============================================================================= 

274# Patient mixin 

275# ============================================================================= 

276 

277 

278class TaskHasPatientMixin(object): 

279 """ 

280 Mixin for tasks that have a patient (aren't anonymous). 

281 """ 

282 

283 # https://docs.sqlalchemy.org/en/latest/orm/extensions/declarative/mixins.html#using-advanced-relationship-arguments-e-g-primaryjoin-etc # noqa 

284 

285 # noinspection PyMethodParameters 

286 @declared_attr 

287 def patient_id(cls) -> Column: 

288 """ 

289 SQLAlchemy :class:`Column` that is a foreign key to the patient table. 

290 """ 

291 return Column( 

292 TFN_PATIENT_ID, 

293 Integer, 

294 nullable=False, 

295 index=True, 

296 comment="(TASK) Foreign key to patient.id (for this device/era)", 

297 ) 

298 

299 # noinspection PyMethodParameters 

300 @declared_attr 

301 def patient(cls) -> RelationshipProperty: 

302 """ 

303 SQLAlchemy relationship: "the patient for this task". 

304 

305 Note that this refers to the CURRENT version of the patient. If there 

306 is an editing chain, older patient versions are not retrieved. 

307 

308 Compare :func:`camcops_server.cc_modules.cc_blob.blob_relationship`, 

309 which uses the same strategy, as do several other similar functions. 

310 

311 """ 

312 return relationship( 

313 "Patient", 

314 primaryjoin=( 

315 "and_(" 

316 " remote(Patient.id) == foreign({task}.patient_id), " 

317 " remote(Patient._device_id) == foreign({task}._device_id), " 

318 " remote(Patient._era) == foreign({task}._era), " 

319 " remote(Patient._current) == True " 

320 ")".format(task=cls.__name__) 

321 ), 

322 uselist=False, 

323 viewonly=True, 

324 # Profiling results 2019-10-14 exporting 4185 phq9 records with 

325 # unique patients to xlsx 

326 # lazy="select" : 59.7s 

327 # lazy="joined" : 44.3s 

328 # lazy="subquery": 36.9s 

329 # lazy="selectin": 35.3s 

330 # See also idnums relationship on Patient class (cc_patient.py) 

331 lazy="selectin", 

332 ) 

333 # NOTE: this retrieves the most recent (i.e. the current) information 

334 # on that patient. Consequently, task version history doesn't show the 

335 # history of patient edits. This is consistent with our relationship 

336 # strategy throughout for the web front-end viewer. 

337 

338 # noinspection PyMethodParameters 

339 @classproperty 

340 def has_patient(cls) -> bool: 

341 """ 

342 Does this task have a patient? (Yes.) 

343 """ 

344 return True 

345 

346 

347# ============================================================================= 

348# Clinician mixin 

349# ============================================================================= 

350 

351 

352class TaskHasClinicianMixin(object): 

353 """ 

354 Mixin to add clinician columns and override clinician-related methods. 

355 

356 Must be to the LEFT of ``Task`` in the class's base class list, i.e. 

357 must have higher precedence than ``Task`` in the method resolution order. 

358 """ 

359 

360 # noinspection PyMethodParameters 

361 @declared_attr 

362 def clinician_specialty(cls) -> Column: 

363 return CamcopsColumn( 

364 TFN_CLINICIAN_SPECIALTY, 

365 Text, 

366 exempt_from_anonymisation=True, 

367 comment="(CLINICIAN) Clinician's specialty " 

368 "(e.g. Liaison Psychiatry)", 

369 ) 

370 

371 # noinspection PyMethodParameters 

372 @declared_attr 

373 def clinician_name(cls) -> Column: 

374 return CamcopsColumn( 

375 TFN_CLINICIAN_NAME, 

376 Text, 

377 exempt_from_anonymisation=True, 

378 comment="(CLINICIAN) Clinician's name (e.g. Dr X)", 

379 ) 

380 

381 # noinspection PyMethodParameters 

382 @declared_attr 

383 def clinician_professional_registration(cls) -> Column: 

384 return CamcopsColumn( 

385 TFN_CLINICIAN_PROFESSIONAL_REGISTRATION, 

386 Text, 

387 exempt_from_anonymisation=True, 

388 comment="(CLINICIAN) Clinician's professional registration (e.g. " 

389 "GMC# 12345)", 

390 ) 

391 

392 # noinspection PyMethodParameters 

393 @declared_attr 

394 def clinician_post(cls) -> Column: 

395 return CamcopsColumn( 

396 TFN_CLINICIAN_POST, 

397 Text, 

398 exempt_from_anonymisation=True, 

399 comment="(CLINICIAN) Clinician's post (e.g. Consultant)", 

400 ) 

401 

402 # noinspection PyMethodParameters 

403 @declared_attr 

404 def clinician_service(cls) -> Column: 

405 return CamcopsColumn( 

406 TFN_CLINICIAN_SERVICE, 

407 Text, 

408 exempt_from_anonymisation=True, 

409 comment="(CLINICIAN) Clinician's service (e.g. Liaison Psychiatry " 

410 "Service)", 

411 ) 

412 

413 # noinspection PyMethodParameters 

414 @declared_attr 

415 def clinician_contact_details(cls) -> Column: 

416 return CamcopsColumn( 

417 TFN_CLINICIAN_CONTACT_DETAILS, 

418 Text, 

419 exempt_from_anonymisation=True, 

420 comment="(CLINICIAN) Clinician's contact details (e.g. bleep, " 

421 "extension)", 

422 ) 

423 

424 # For field order, see also: 

425 # https://stackoverflow.com/questions/3923910/sqlalchemy-move-mixin-columns-to-end # noqa 

426 

427 # noinspection PyMethodParameters 

428 @classproperty 

429 def has_clinician(cls) -> bool: 

430 """ 

431 Does the task have a clinician? (Yes.) 

432 """ 

433 return True 

434 

435 def get_clinician_name(self) -> str: 

436 """ 

437 Returns the clinician's name. 

438 """ 

439 return self.clinician_name or "" 

440 

441 def get_clinician_fhir_telecom_other(self, req: "CamcopsRequest") -> str: 

442 """ 

443 Return a mishmash of information that doesn't fit neatly into a FHIR 

444 Practitioner object, but people might actually want to know. 

445 """ 

446 _ = req.gettext 

447 components = [] # type: List[str] 

448 # In sequence, e.g.: 

449 # - Consultant 

450 if self.clinician_post: 

451 components.append(f'{_("Post:")} {self.clinician_post}') 

452 # - Liaison Psychiatry 

453 if self.clinician_specialty: 

454 components.append(f'{_("Specialty:")} {self.clinician_specialty}') 

455 # - GMC# 12345 

456 if self.clinician_professional_registration: 

457 components.append( 

458 f'{_("Professional registration:")} ' 

459 f"{self.clinician_professional_registration}" 

460 ) 

461 # - Liaison Psychiatry Service 

462 if self.clinician_service: 

463 components.append(f'{_("Service:")} {self.clinician_service}') 

464 # - tel. x12345 

465 if self.clinician_contact_details: 

466 components.append( 

467 f'{_("Contact details:")} ' f"{self.clinician_contact_details}" 

468 ) 

469 return " | ".join(components) 

470 

471 

472# ============================================================================= 

473# Respondent mixin 

474# ============================================================================= 

475 

476 

477class TaskHasRespondentMixin(object): 

478 """ 

479 Mixin to add respondent columns and override respondent-related methods. 

480 

481 A respondent is someone who isn't the patient and isn't a clinician, such 

482 as a family member or carer. 

483 

484 Must be to the LEFT of ``Task`` in the class's base class list, i.e. 

485 must have higher precedence than ``Task`` in the method resolution order. 

486 

487 Notes: 

488 

489 - If you don't use ``@declared_attr``, the ``comment`` property on columns 

490 doesn't work. 

491 """ 

492 

493 # noinspection PyMethodParameters 

494 @declared_attr 

495 def respondent_name(cls) -> Column: 

496 return CamcopsColumn( 

497 TFN_RESPONDENT_NAME, 

498 Text, 

499 identifies_patient=True, 

500 comment="(RESPONDENT) Respondent's name", 

501 ) 

502 

503 # noinspection PyMethodParameters 

504 @declared_attr 

505 def respondent_relationship(cls) -> Column: 

506 return Column( 

507 TFN_RESPONDENT_RELATIONSHIP, 

508 Text, 

509 comment="(RESPONDENT) Respondent's relationship to patient", 

510 ) 

511 

512 # noinspection PyMethodParameters 

513 @classproperty 

514 def has_respondent(cls) -> bool: 

515 """ 

516 Does the class have a respondent? (Yes.) 

517 """ 

518 return True 

519 

520 def is_respondent_complete(self) -> bool: 

521 """ 

522 Do we have sufficient information about the respondent? 

523 (That means: name, relationship to the patient.) 

524 """ 

525 return all([self.respondent_name, self.respondent_relationship]) 

526 

527 

528# ============================================================================= 

529# Task base class 

530# ============================================================================= 

531 

532 

533class Task(GenericTabletRecordMixin, Base): 

534 """ 

535 Abstract base class for all tasks. 

536 

537 Note: 

538 

539 - For column definitions: use 

540 :class:`camcops_server.cc_modules.cc_sqla_coltypes.CamcopsColumn`, not 

541 :class:`Column`, if you have fields that need to define permitted values, 

542 mark them as BLOB-referencing fields, or do other CamCOPS-specific 

543 things. 

544 

545 """ 

546 

547 __abstract__ = True 

548 

549 # noinspection PyMethodParameters 

550 @declared_attr 

551 def __mapper_args__(cls): 

552 return {"polymorphic_identity": cls.__name__, "concrete": True} 

553 

554 # ========================================================================= 

555 # PART 0: COLUMNS COMMON TO ALL TASKS 

556 # ========================================================================= 

557 

558 # Columns 

559 

560 # noinspection PyMethodParameters 

561 @declared_attr 

562 def when_created(cls) -> Column: 

563 """ 

564 Column representing the task's creation time. 

565 """ 

566 return Column( 

567 TFN_WHEN_CREATED, 

568 PendulumDateTimeAsIsoTextColType, 

569 nullable=False, 

570 comment="(TASK) Date/time this task instance was created " 

571 "(ISO 8601)", 

572 ) 

573 

574 # noinspection PyMethodParameters 

575 @declared_attr 

576 def when_firstexit(cls) -> Column: 

577 """ 

578 Column representing when the user first exited the task's editor 

579 (i.e. first "finish" or first "abort"). 

580 """ 

581 return Column( 

582 TFN_WHEN_FIRSTEXIT, 

583 PendulumDateTimeAsIsoTextColType, 

584 comment="(TASK) Date/time of the first exit from this task " 

585 "(ISO 8601)", 

586 ) 

587 

588 # noinspection PyMethodParameters 

589 @declared_attr 

590 def firstexit_is_finish(cls) -> Column: 

591 """ 

592 Was the first exit from the task's editor a successful "finish"? 

593 """ 

594 return Column( 

595 TFN_FIRSTEXIT_IS_FINISH, 

596 Boolean, 

597 comment="(TASK) Was the first exit from the task because it was " 

598 "finished (1)?", 

599 ) 

600 

601 # noinspection PyMethodParameters 

602 @declared_attr 

603 def firstexit_is_abort(cls) -> Column: 

604 """ 

605 Was the first exit from the task's editor an "abort"? 

606 """ 

607 return Column( 

608 TFN_FIRSTEXIT_IS_ABORT, 

609 Boolean, 

610 comment="(TASK) Was the first exit from this task because it was " 

611 "aborted (1)?", 

612 ) 

613 

614 # noinspection PyMethodParameters 

615 @declared_attr 

616 def editing_time_s(cls) -> Column: 

617 """ 

618 How long has the user spent editing the task? 

619 (Calculated by the CamCOPS client.) 

620 """ 

621 return Column( 

622 TFN_EDITING_TIME_S, Float, comment="(TASK) Time spent editing (s)" 

623 ) 

624 

625 # Relationships 

626 

627 # noinspection PyMethodParameters 

628 @declared_attr 

629 def special_notes(cls) -> RelationshipProperty: 

630 """ 

631 List-style SQLAlchemy relationship to any :class:`SpecialNote` objects 

632 attached to this class. Skips hidden (quasi-deleted) notes. 

633 """ 

634 return relationship( 

635 SpecialNote, 

636 primaryjoin=( 

637 "and_(" 

638 " remote(SpecialNote.basetable) == literal({repr_task_tablename}), " # noqa 

639 " remote(SpecialNote.task_id) == foreign({task}.id), " 

640 " remote(SpecialNote.device_id) == foreign({task}._device_id), " # noqa 

641 " remote(SpecialNote.era) == foreign({task}._era), " 

642 " not_(SpecialNote.hidden)" 

643 ")".format( 

644 task=cls.__name__, 

645 repr_task_tablename=repr(cls.__tablename__), 

646 ) 

647 ), 

648 uselist=True, 

649 order_by="SpecialNote.note_at", 

650 viewonly=True, # for now! 

651 ) 

652 

653 # ========================================================================= 

654 # PART 1: THINGS THAT DERIVED CLASSES MAY CARE ABOUT 

655 # ========================================================================= 

656 # 

657 # Notes: 

658 # 

659 # - for summaries, see GenericTabletRecordMixin.get_summaries 

660 

661 # ------------------------------------------------------------------------- 

662 # Attributes that must be provided 

663 # ------------------------------------------------------------------------- 

664 __tablename__ = None # type: str # also the SQLAlchemy table name 

665 shortname = None # type: str 

666 

667 # ------------------------------------------------------------------------- 

668 # Attributes that can be overridden 

669 # ------------------------------------------------------------------------- 

670 extrastring_taskname = ( 

671 None 

672 ) # type: str # if None, tablename is used instead # noqa 

673 info_filename_stem = ( 

674 None 

675 ) # type: str # if None, tablename is used instead # noqa 

676 provides_trackers = False 

677 use_landscape_for_pdf = False 

678 dependent_classes = [] 

679 

680 prohibits_clinical = False 

681 prohibits_commercial = False 

682 prohibits_educational = False 

683 prohibits_research = False 

684 

685 @classmethod 

686 def prohibits_anything(cls) -> bool: 

687 return any( 

688 [ 

689 cls.prohibits_clinical, 

690 cls.prohibits_commercial, 

691 cls.prohibits_educational, 

692 cls.prohibits_research, 

693 ] 

694 ) 

695 

696 # ------------------------------------------------------------------------- 

697 # Methods always overridden by the actual task 

698 # ------------------------------------------------------------------------- 

699 

700 @staticmethod 

701 def longname(req: "CamcopsRequest") -> str: 

702 """ 

703 Long name (in the relevant language). 

704 """ 

705 raise NotImplementedError("Task.longname must be overridden") 

706 

707 def is_complete(self) -> bool: 

708 """ 

709 Is the task instance complete? 

710 

711 Must be overridden. 

712 """ 

713 raise NotImplementedError("Task.is_complete must be overridden") 

714 

715 def get_task_html(self, req: "CamcopsRequest") -> str: 

716 """ 

717 HTML for the main task content. 

718 

719 Must be overridden by derived classes. 

720 """ 

721 raise NotImplementedError( 

722 "No get_task_html() HTML generator for this task class!" 

723 ) 

724 

725 # ------------------------------------------------------------------------- 

726 # Implement if you provide trackers 

727 # ------------------------------------------------------------------------- 

728 

729 def get_trackers(self, req: "CamcopsRequest") -> List["TrackerInfo"]: 

730 """ 

731 Tasks that provide quantitative information for tracking over time 

732 should override this and return a list of 

733 :class:`camcops_server.cc_modules.cc_trackerhelpers.TrackerInfo` 

734 objects, one per tracker. 

735 

736 The information is read by 

737 :meth:`camcops_server.cc_modules.cc_tracker.Tracker.get_all_plots_for_one_task_html`. 

738 

739 Time information will be retrieved using :func:`get_creation_datetime`. 

740 """ # noqa 

741 return [] 

742 

743 # ------------------------------------------------------------------------- 

744 # Override to provide clinical text 

745 # ------------------------------------------------------------------------- 

746 

747 # noinspection PyMethodMayBeStatic 

748 def get_clinical_text( 

749 self, req: "CamcopsRequest" 

750 ) -> Optional[List["CtvInfo"]]: 

751 """ 

752 Tasks that provide clinical text information should override this 

753 to provide a list of 

754 :class:`camcops_server.cc_modules.cc_ctvinfo.CtvInfo` objects. 

755 

756 Return ``None`` (default) for a task that doesn't provide clinical 

757 text, or ``[]`` for one that does in general but has no information for 

758 this particular instance, or a list of 

759 :class:`camcops_server.cc_modules.cc_ctvinfo.CtvInfo` objects. 

760 """ 

761 return None 

762 

763 # ------------------------------------------------------------------------- 

764 # Override some of these if you provide summaries 

765 # ------------------------------------------------------------------------- 

766 

767 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

768 def get_extra_summary_tables( 

769 self, req: "CamcopsRequest" 

770 ) -> List[ExtraSummaryTable]: 

771 """ 

772 Override if you wish to create extra summary tables, not just add 

773 summary columns to task/ancillary tables. 

774 

775 Return a list of 

776 :class:`camcops_server.cc_modules.cc_summaryelement.ExtraSummaryTable` 

777 objects. 

778 """ 

779 return [] 

780 

781 # ------------------------------------------------------------------------- 

782 # Implement if you provide SNOMED-CT codes 

783 # ------------------------------------------------------------------------- 

784 

785 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

786 def get_snomed_codes( 

787 self, req: "CamcopsRequest" 

788 ) -> List["SnomedExpression"]: 

789 """ 

790 Returns all SNOMED-CT codes for this task. 

791 

792 Args: 

793 req: the 

794 :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

795 

796 Returns: 

797 a list of 

798 :class:`camcops_server.cc_modules.cc_snomed.SnomedExpression` 

799 objects 

800 

801 """ 

802 return [] 

803 

804 # ========================================================================= 

805 # PART 2: INTERNALS 

806 # ========================================================================= 

807 

808 # ------------------------------------------------------------------------- 

809 # Representations 

810 # ------------------------------------------------------------------------- 

811 

812 def __str__(self) -> str: 

813 if self.is_anonymous: 

814 patient_str = "" 

815 else: 

816 patient_str = f", patient={self.patient}" 

817 return "{t} (_pk={pk}, when_created={wc}{patient})".format( 

818 t=self.tablename, 

819 pk=self.pk, 

820 wc=( 

821 format_datetime(self.when_created, DateFormat.ERA) 

822 if self.when_created 

823 else "None" 

824 ), 

825 patient=patient_str, 

826 ) 

827 

828 def __repr__(self) -> str: 

829 return "<{classname}(_pk={pk}, when_created={wc})>".format( 

830 classname=self.__class__.__qualname__, 

831 pk=self.pk, 

832 wc=( 

833 format_datetime(self.when_created, DateFormat.ERA) 

834 if self.when_created 

835 else "None" 

836 ), 

837 ) 

838 

839 # ------------------------------------------------------------------------- 

840 # Way to fetch all task types 

841 # ------------------------------------------------------------------------- 

842 

843 @classmethod 

844 def gen_all_subclasses(cls) -> Generator[Type[TASK_FWD_REF], None, None]: 

845 """ 

846 Generate all non-abstract SQLAlchemy ORM subclasses of :class:`Task` -- 

847 that is, all task classes. 

848 

849 We require that actual tasks are subclasses of both :class:`Task` and 

850 :class:`camcops_server.cc_modules.cc_sqlalchemy.Base`. 

851 

852 OLD WAY (ignore): this means we can (a) inherit from Task to make an 

853 abstract base class for actual tasks, as with PCL, HADS, HoNOS, etc.; 

854 and (b) not have those intermediate classes appear in the task list. 

855 Since all actual classes must be SQLAlchemy ORM objects inheriting from 

856 Base, that common inheritance is an excellent way to define them. 

857 

858 NEW WAY: things now inherit from Base/Task without necessarily 

859 being actual tasks; we discriminate using ``__abstract__`` and/or 

860 ``__tablename__``. See 

861 https://docs.sqlalchemy.org/en/latest/orm/inheritance.html#abstract-concrete-classes 

862 """ # noqa 

863 # noinspection PyTypeChecker 

864 return gen_orm_classes_from_base(cls) 

865 

866 @classmethod 

867 @cache_region_static.cache_on_arguments(function_key_generator=fkg) 

868 def all_subclasses_by_tablename(cls) -> List[Type[TASK_FWD_REF]]: 

869 """ 

870 Return all task classes, ordered by table name. 

871 """ 

872 classes = list(cls.gen_all_subclasses()) 

873 classes.sort(key=lambda c: c.tablename) 

874 return classes 

875 

876 @classmethod 

877 @cache_region_static.cache_on_arguments(function_key_generator=fkg) 

878 def all_subclasses_by_shortname(cls) -> List[Type[TASK_FWD_REF]]: 

879 """ 

880 Return all task classes, ordered by short name. 

881 """ 

882 classes = list(cls.gen_all_subclasses()) 

883 classes.sort(key=lambda c: c.shortname) 

884 return classes 

885 

886 @classmethod 

887 def all_subclasses_by_longname( 

888 cls, req: "CamcopsRequest" 

889 ) -> List[Type[TASK_FWD_REF]]: 

890 """ 

891 Return all task classes, ordered by long name. 

892 """ 

893 classes = cls.all_subclasses_by_shortname() 

894 classes.sort(key=lambda c: c.longname(req)) 

895 return classes 

896 

897 # ------------------------------------------------------------------------- 

898 # Methods that may be overridden by mixins 

899 # ------------------------------------------------------------------------- 

900 

901 # noinspection PyMethodParameters 

902 @classproperty 

903 def has_patient(cls) -> bool: 

904 """ 

905 Does the task have a patient? (No.) 

906 

907 May be overridden by :class:`TaskHasPatientMixin`. 

908 """ 

909 return False 

910 

911 # noinspection PyMethodParameters 

912 @classproperty 

913 def is_anonymous(cls) -> bool: 

914 """ 

915 Antonym for :attr:`has_patient`. 

916 """ 

917 return not cls.has_patient 

918 

919 # noinspection PyMethodParameters 

920 @classproperty 

921 def has_clinician(cls) -> bool: 

922 """ 

923 Does the task have a clinician? (No.) 

924 

925 May be overridden by :class:`TaskHasClinicianMixin`. 

926 """ 

927 return False 

928 

929 # noinspection PyMethodParameters 

930 @classproperty 

931 def has_respondent(cls) -> bool: 

932 """ 

933 Does the task have a respondent? (No.) 

934 

935 May be overridden by :class:`TaskHasRespondentMixin`. 

936 """ 

937 return False 

938 

939 # ------------------------------------------------------------------------- 

940 # Other classmethods 

941 # ------------------------------------------------------------------------- 

942 

943 # noinspection PyMethodParameters 

944 @classproperty 

945 def tablename(cls) -> str: 

946 """ 

947 Returns the database table name for the task's primary table. 

948 """ 

949 return cls.__tablename__ 

950 

951 # noinspection PyMethodParameters 

952 @classproperty 

953 def minimum_client_version(cls) -> Version: 

954 """ 

955 Returns the minimum client version that provides this task. 

956 

957 Override this as you add tasks. 

958 

959 Used by 

960 :func:`camcops_server.cc_modules.client_api.ensure_valid_table_name`. 

961 

962 (There are some pre-C++ client versions for which the default is not 

963 exactly accurate, and the tasks do not override, but this is of no 

964 consequence and the version numbering system also changed, from 

965 something legible as a float -- e.g. ``1.2 > 1.14`` -- to something 

966 interpreted as a semantic version -- e.g. ``1.2 < 1.14``. So we ignore 

967 that.) 

968 """ 

969 return MINIMUM_TABLET_VERSION 

970 

971 # noinspection PyMethodParameters 

972 @classmethod 

973 def all_tables_with_min_client_version(cls) -> Dict[str, Version]: 

974 """ 

975 Returns a dictionary mapping all this task's table names (primary and 

976 ancillary) to the corresponding minimum client version. 

977 """ 

978 v = cls.minimum_client_version 

979 d = {cls.__tablename__: v} # type: Dict[str, Version] 

980 for _, _, rel_cls in gen_ancillary_relationships(cls): 

981 d[rel_cls.__tablename__] = v 

982 return d 

983 

984 @classmethod 

985 def all_tables(cls) -> List[Table]: 

986 """ 

987 Returns all table classes (primary table plus any ancillary tables). 

988 """ 

989 # noinspection PyUnresolvedReferences 

990 return [cls.__table__] + [ 

991 rel_cls.__table__ 

992 for _, _, rel_cls in gen_ancillary_relationships(cls) 

993 ] 

994 

995 @classmethod 

996 def get_ddl(cls, dialect_name: str = SqlaDialectName.MYSQL) -> str: 

997 """ 

998 Returns DDL for the primary and any ancillary tables. 

999 """ 

1000 return "\n\n".join( 

1001 get_table_ddl(t, dialect_name).strip() for t in cls.all_tables() 

1002 ) 

1003 

1004 @classmethod 

1005 def help_url(cls) -> str: 

1006 """ 

1007 Returns the URL for task-specific online help. 

1008 

1009 By default, this is based on the tablename -- e.g. ``phq9``, giving 

1010 ``phq9.html`` in the documentation (from ``phq9.rst`` in the source). 

1011 However, some tasks override this -- which they may do by writing 

1012 

1013 .. code-block:: python 

1014 

1015 info_filename_stem = "XXX" 

1016 

1017 In the C++ code, compare infoFilenameStem() for individual tasks and 

1018 urlconst::taskDocUrl() overall. 

1019 

1020 The online help is presently only in English. 

1021 """ 

1022 basename = cls.info_filename_stem or cls.tablename 

1023 language = "en" 

1024 # DOCUMENTATION_URL has a trailing slash already 

1025 return f"{DOCUMENTATION_URL}{language}/latest/tasks/{basename}.html" 

1026 

1027 # ------------------------------------------------------------------------- 

1028 # More on fields 

1029 # ------------------------------------------------------------------------- 

1030 

1031 @classmethod 

1032 def get_fieldnames(cls) -> List[str]: 

1033 """ 

1034 Returns all field (column) names for this task's primary table. 

1035 """ 

1036 return get_column_attr_names(cls) 

1037 

1038 def field_contents_valid(self) -> bool: 

1039 """ 

1040 Checks field contents validity. 

1041 

1042 This is a high-speed function that doesn't bother with explanations, 

1043 since we use it for lots of task :func:`is_complete` calculations. 

1044 """ 

1045 return permitted_values_ok(self) 

1046 

1047 def field_contents_invalid_because(self) -> List[str]: 

1048 """ 

1049 Explains why contents are invalid. 

1050 """ 

1051 return permitted_value_failure_msgs(self) 

1052 

1053 def get_blob_fields(self) -> List[str]: 

1054 """ 

1055 Returns field (column) names for all BLOB fields in this class. 

1056 """ 

1057 return get_camcops_blob_column_attr_names(self) 

1058 

1059 # ------------------------------------------------------------------------- 

1060 # Server field calculations 

1061 # ------------------------------------------------------------------------- 

1062 

1063 def is_preserved(self) -> bool: 

1064 """ 

1065 Is the task preserved and erased from the tablet? 

1066 """ 

1067 return self._pk is not None and self._era != ERA_NOW 

1068 

1069 def was_forcibly_preserved(self) -> bool: 

1070 """ 

1071 Was this task forcibly preserved? 

1072 """ 

1073 return self._forcibly_preserved and self.is_preserved() 

1074 

1075 def get_creation_datetime(self) -> Optional[Pendulum]: 

1076 """ 

1077 Creation datetime, or None. 

1078 """ 

1079 return self.when_created 

1080 

1081 def get_creation_datetime_utc(self) -> Optional[Pendulum]: 

1082 """ 

1083 Creation datetime in UTC, or None. 

1084 """ 

1085 localtime = self.get_creation_datetime() 

1086 if localtime is None: 

1087 return None 

1088 return convert_datetime_to_utc(localtime) 

1089 

1090 def get_creation_datetime_utc_tz_unaware( 

1091 self, 

1092 ) -> Optional[datetime.datetime]: 

1093 """ 

1094 Creation time as a :class:`datetime.datetime` object on UTC with no 

1095 timezone (i.e. an "offset-naive" datetime), or None. 

1096 """ 

1097 localtime = self.get_creation_datetime() 

1098 if localtime is None: 

1099 return None 

1100 return pendulum_to_utc_datetime_without_tz(localtime) 

1101 

1102 def get_seconds_from_creation_to_first_finish(self) -> Optional[float]: 

1103 """ 

1104 Time in seconds from creation time to first finish (i.e. first exit 

1105 if the first exit was a finish rather than an abort), or None. 

1106 """ 

1107 if not self.firstexit_is_finish: 

1108 return None 

1109 start = self.get_creation_datetime() 

1110 end = self.when_firstexit 

1111 if not start or not end: 

1112 return None 

1113 diff = end - start 

1114 return diff.total_seconds() 

1115 

1116 def get_adding_user_id(self) -> Optional[int]: 

1117 """ 

1118 Returns the user ID of the user who uploaded this task. 

1119 """ 

1120 # noinspection PyTypeChecker 

1121 return self._adding_user_id 

1122 

1123 def get_adding_user_username(self) -> str: 

1124 """ 

1125 Returns the username of the user who uploaded this task. 

1126 """ 

1127 return self._adding_user.username if self._adding_user else "" 

1128 

1129 def get_removing_user_username(self) -> str: 

1130 """ 

1131 Returns the username of the user who deleted this task (by removing it 

1132 on the client and re-uploading). 

1133 """ 

1134 return self._removing_user.username if self._removing_user else "" 

1135 

1136 def get_preserving_user_username(self) -> str: 

1137 """ 

1138 Returns the username of the user who "preserved" this task (marking it 

1139 to be saved on the server and then deleting it from the client). 

1140 """ 

1141 return self._preserving_user.username if self._preserving_user else "" 

1142 

1143 def get_manually_erasing_user_username(self) -> str: 

1144 """ 

1145 Returns the username of the user who erased this task manually on the 

1146 server. 

1147 """ 

1148 return ( 

1149 self._manually_erasing_user.username 

1150 if self._manually_erasing_user 

1151 else "" 

1152 ) 

1153 

1154 # ------------------------------------------------------------------------- 

1155 # Summary tables 

1156 # ------------------------------------------------------------------------- 

1157 

1158 def standard_task_summary_fields(self) -> List[SummaryElement]: 

1159 """ 

1160 Returns summary fields/values provided by all tasks. 

1161 """ 

1162 return [ 

1163 SummaryElement( 

1164 name=SFN_IS_COMPLETE, 

1165 coltype=Boolean(), 

1166 value=self.is_complete(), 

1167 comment="(GENERIC) Task complete?", 

1168 ), 

1169 SummaryElement( 

1170 name=SFN_SECONDS_CREATION_TO_FIRST_FINISH, 

1171 coltype=Float(), 

1172 value=self.get_seconds_from_creation_to_first_finish(), 

1173 comment="(GENERIC) Time (in seconds) from record creation to " 

1174 "first exit, if that was a finish not an abort", 

1175 ), 

1176 SummaryElement( 

1177 name=SFN_CAMCOPS_SERVER_VERSION, 

1178 coltype=SemanticVersionColType(), 

1179 value=CAMCOPS_SERVER_VERSION, 

1180 comment="(GENERIC) CamCOPS server version that created the " 

1181 "summary information", 

1182 ), 

1183 ] 

1184 

1185 def get_all_summary_tables( 

1186 self, req: "CamcopsRequest" 

1187 ) -> List[ExtraSummaryTable]: 

1188 """ 

1189 Returns all 

1190 :class:`camcops_server.cc_modules.cc_summaryelement.ExtraSummaryTable` 

1191 objects for this class, including any provided by subclasses, plus 

1192 SNOMED CT codes if enabled. 

1193 """ 

1194 tables = self.get_extra_summary_tables(req) 

1195 if req.snomed_supported: 

1196 tables.append(self._get_snomed_extra_summary_table(req)) 

1197 return tables 

1198 

1199 def _get_snomed_extra_summary_table( 

1200 self, req: "CamcopsRequest" 

1201 ) -> ExtraSummaryTable: 

1202 """ 

1203 Returns a 

1204 :class:`camcops_server.cc_modules.cc_summaryelement.ExtraSummaryTable` 

1205 for this task's SNOMED CT codes. 

1206 """ 

1207 codes = self.get_snomed_codes(req) 

1208 columns = [ 

1209 Column( 

1210 SNOMED_COLNAME_TASKTABLE, 

1211 TableNameColType, 

1212 comment="Task's base table name", 

1213 ), 

1214 Column( 

1215 SNOMED_COLNAME_TASKPK, 

1216 Integer, 

1217 comment="Task's server primary key", 

1218 ), 

1219 Column( 

1220 SNOMED_COLNAME_WHENCREATED_UTC, 

1221 DateTime, 

1222 comment="Task's creation date/time (UTC)", 

1223 ), 

1224 CamcopsColumn( 

1225 SNOMED_COLNAME_EXPRESSION, 

1226 Text, 

1227 exempt_from_anonymisation=True, 

1228 comment="SNOMED CT expression", 

1229 ), 

1230 ] 

1231 rows = [] # type: List[Dict[str, Any]] 

1232 for code in codes: 

1233 d = OrderedDict( 

1234 [ 

1235 (SNOMED_COLNAME_TASKTABLE, self.tablename), 

1236 (SNOMED_COLNAME_TASKPK, self.pk), 

1237 ( 

1238 SNOMED_COLNAME_WHENCREATED_UTC, 

1239 self.get_creation_datetime_utc_tz_unaware(), 

1240 ), 

1241 (SNOMED_COLNAME_EXPRESSION, code.as_string()), 

1242 ] 

1243 ) 

1244 rows.append(d) 

1245 return ExtraSummaryTable( 

1246 tablename=SNOMED_TABLENAME, 

1247 xmlname=UNUSED_SNOMED_XML_NAME, # though actual XML doesn't use this route # noqa 

1248 columns=columns, 

1249 rows=rows, 

1250 task=self, 

1251 ) 

1252 

1253 # ------------------------------------------------------------------------- 

1254 # Testing 

1255 # ------------------------------------------------------------------------- 

1256 

1257 def dump(self) -> None: 

1258 """ 

1259 Dump a description of the task instance to the Python log, for 

1260 debugging. 

1261 """ 

1262 line_equals = "=" * 79 

1263 lines = ["", line_equals] 

1264 for f in self.get_fieldnames(): 

1265 lines.append(f"{f}: {getattr(self, f)!r}") 

1266 lines.append(line_equals) 

1267 log.info("\n".join(lines)) 

1268 

1269 # ------------------------------------------------------------------------- 

1270 # Special notes 

1271 # ------------------------------------------------------------------------- 

1272 

1273 def apply_special_note( 

1274 self, req: "CamcopsRequest", note: str, from_console: bool = False 

1275 ) -> None: 

1276 """ 

1277 Manually applies a special note to a task. 

1278 

1279 Applies it to all predecessor/successor versions as well. 

1280 WRITES TO THE DATABASE. 

1281 """ 

1282 sn = SpecialNote() 

1283 sn.basetable = self.tablename 

1284 sn.task_id = self.id 

1285 sn.device_id = self._device_id 

1286 sn.era = self._era 

1287 sn.note_at = req.now 

1288 sn.user_id = req.user_id 

1289 sn.note = note 

1290 dbsession = req.dbsession 

1291 dbsession.add(sn) 

1292 self.audit(req, "Special note applied manually", from_console) 

1293 self.cancel_from_export_log(req, from_console) 

1294 

1295 # ------------------------------------------------------------------------- 

1296 # Clinician 

1297 # ------------------------------------------------------------------------- 

1298 

1299 # noinspection PyMethodMayBeStatic 

1300 def get_clinician_name(self) -> str: 

1301 """ 

1302 May be overridden by :class:`TaskHasClinicianMixin`; q.v. 

1303 """ 

1304 return "" 

1305 

1306 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

1307 def get_clinician_fhir_telecom_other(self, req: "CamcopsRequest") -> str: 

1308 """ 

1309 May be overridden by :class:`TaskHasClinicianMixin`; q.v. 

1310 """ 

1311 return "" 

1312 

1313 # ------------------------------------------------------------------------- 

1314 # Respondent 

1315 # ------------------------------------------------------------------------- 

1316 

1317 # noinspection PyMethodMayBeStatic 

1318 def is_respondent_complete(self) -> bool: 

1319 """ 

1320 Is the respondent information complete? 

1321 

1322 May be overridden by :class:`TaskHasRespondentMixin`. 

1323 """ 

1324 return False 

1325 

1326 # ------------------------------------------------------------------------- 

1327 # About the associated patient 

1328 # ------------------------------------------------------------------------- 

1329 

1330 @property 

1331 def patient(self) -> Optional["Patient"]: 

1332 """ 

1333 Returns the :class:`camcops_server.cc_modules.cc_patient.Patient` for 

1334 this task. 

1335 

1336 Overridden by :class:`TaskHasPatientMixin`. 

1337 """ 

1338 return None 

1339 

1340 def is_female(self) -> bool: 

1341 """ 

1342 Is the patient female? 

1343 """ 

1344 return self.patient.is_female() if self.patient else False 

1345 

1346 def is_male(self) -> bool: 

1347 """ 

1348 Is the patient male? 

1349 """ 

1350 return self.patient.is_male() if self.patient else False 

1351 

1352 def get_patient_server_pk(self) -> Optional[int]: 

1353 """ 

1354 Get the server PK of the patient, or None. 

1355 """ 

1356 return self.patient.pk if self.patient else None 

1357 

1358 def get_patient_forename(self) -> str: 

1359 """ 

1360 Get the patient's forename, in upper case, or "". 

1361 """ 

1362 return self.patient.get_forename() if self.patient else "" 

1363 

1364 def get_patient_surname(self) -> str: 

1365 """ 

1366 Get the patient's surname, in upper case, or "". 

1367 """ 

1368 return self.patient.get_surname() if self.patient else "" 

1369 

1370 def get_patient_dob(self) -> Optional[PendulumDate]: 

1371 """ 

1372 Get the patient's DOB, or None. 

1373 """ 

1374 return self.patient.get_dob() if self.patient else None 

1375 

1376 def get_patient_dob_first11chars(self) -> Optional[str]: 

1377 """ 

1378 Gets the patient's date of birth in an 11-character human-readable 

1379 short format. For example: ``29 Dec 1999``. 

1380 """ 

1381 if not self.patient: 

1382 return None 

1383 dob_str = self.patient.get_dob_str() 

1384 if not dob_str: 

1385 return None 

1386 return dob_str[:11] 

1387 

1388 def get_patient_sex(self) -> str: 

1389 """ 

1390 Get the patient's sex, or "". 

1391 """ 

1392 return self.patient.get_sex() if self.patient else "" 

1393 

1394 def get_patient_address(self) -> str: 

1395 """ 

1396 Get the patient's address, or "". 

1397 """ 

1398 return self.patient.get_address() if self.patient else "" 

1399 

1400 def get_patient_idnum_objects(self) -> List["PatientIdNum"]: 

1401 """ 

1402 Gets all 

1403 :class:`camcops_server.cc_modules.cc_patientidnum.PatientIdNum` objects 

1404 for the patient. 

1405 """ 

1406 return self.patient.get_idnum_objects() if self.patient else [] 

1407 

1408 def get_patient_idnum_object( 

1409 self, which_idnum: int 

1410 ) -> Optional["PatientIdNum"]: 

1411 """ 

1412 Get the patient's 

1413 :class:`camcops_server.cc_modules.cc_patientidnum.PatientIdNum` for the 

1414 specified ID number type (``which_idnum``), or None. 

1415 """ 

1416 return ( 

1417 self.patient.get_idnum_object(which_idnum) 

1418 if self.patient 

1419 else None 

1420 ) 

1421 

1422 def any_patient_idnums_invalid(self, req: "CamcopsRequest") -> bool: 

1423 """ 

1424 Do we have a patient who has any invalid ID numbers? 

1425 

1426 Args: 

1427 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

1428 """ 

1429 idnums = self.get_patient_idnum_objects() 

1430 for idnum in idnums: 

1431 if not idnum.is_fully_valid(req): 

1432 return True 

1433 return False 

1434 

1435 def get_patient_idnum_value(self, which_idnum: int) -> Optional[int]: 

1436 """ 

1437 Get the patient's ID number value for the specified ID number 

1438 type (``which_idnum``), or None. 

1439 """ 

1440 idobj = self.get_patient_idnum_object(which_idnum=which_idnum) 

1441 return idobj.idnum_value if idobj else None 

1442 

1443 def get_patient_hl7_pid_segment( 

1444 self, req: "CamcopsRequest", recipient_def: "ExportRecipient" 

1445 ) -> Union[hl7.Segment, str]: 

1446 """ 

1447 Get an HL7 PID segment for the patient, or "". 

1448 """ 

1449 return ( 

1450 self.patient.get_hl7_pid_segment(req, recipient_def) 

1451 if self.patient 

1452 else "" 

1453 ) 

1454 

1455 # ------------------------------------------------------------------------- 

1456 # HL7 v2 

1457 # ------------------------------------------------------------------------- 

1458 

1459 def get_hl7_data_segments( 

1460 self, req: "CamcopsRequest", recipient_def: "ExportRecipient" 

1461 ) -> List[hl7.Segment]: 

1462 """ 

1463 Returns a list of HL7 data segments. 

1464 

1465 These will be: 

1466 

1467 - observation request (OBR) segment 

1468 - observation result (OBX) segment 

1469 - any extra ones offered by the task 

1470 """ 

1471 obr_segment = make_obr_segment(self) 

1472 export_options = recipient_def.get_task_export_options() 

1473 obx_segment = make_obx_segment( 

1474 req, 

1475 self, 

1476 task_format=recipient_def.task_format, 

1477 observation_identifier=self.tablename + "_" + str(self._pk), 

1478 observation_datetime=self.get_creation_datetime(), 

1479 responsible_observer=self.get_clinician_name(), 

1480 export_options=export_options, 

1481 ) 

1482 return [obr_segment, obx_segment] + self.get_hl7_extra_data_segments( 

1483 recipient_def 

1484 ) 

1485 

1486 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

1487 def get_hl7_extra_data_segments( 

1488 self, recipient_def: "ExportRecipient" 

1489 ) -> List[hl7.Segment]: 

1490 """ 

1491 Return a list of any extra HL7 data segments. (See 

1492 :func:`get_hl7_data_segments`, which calls this function.) 

1493 

1494 May be overridden. 

1495 """ 

1496 return [] 

1497 

1498 # ------------------------------------------------------------------------- 

1499 # FHIR: framework 

1500 # ------------------------------------------------------------------------- 

1501 

1502 def get_fhir_bundle( 

1503 self, 

1504 req: "CamcopsRequest", 

1505 recipient: "ExportRecipient", 

1506 skip_docs_if_other_content: bool = DEBUG_SKIP_FHIR_DOCS, 

1507 ) -> Bundle: 

1508 """ 

1509 Get a single FHIR Bundle with all entries. See 

1510 :meth:`get_fhir_bundle_entries`. 

1511 """ 

1512 # Get the content: 

1513 bundle_entries = self.get_fhir_bundle_entries( 

1514 req, 

1515 recipient, 

1516 skip_docs_if_other_content=skip_docs_if_other_content, 

1517 ) 

1518 # ... may raise FhirExportException 

1519 

1520 # Sanity checks: 

1521 id_counter = Counter() 

1522 for entry in bundle_entries: 

1523 assert ( 

1524 Fc.RESOURCE in entry 

1525 ), f"Bundle entry has no resource: {entry}" # just wrong 

1526 resource = entry[Fc.RESOURCE] 

1527 assert Fc.IDENTIFIER in resource, ( 

1528 f"Bundle entry has no identifier for its resource: " 

1529 f"{resource}" 

1530 ) # might succeed, but would insert an unidentified resource 

1531 identifier = resource[Fc.IDENTIFIER] 

1532 if not isinstance(identifier, list): 

1533 identifier = [identifier] 

1534 for id_ in identifier: 

1535 system = id_[Fc.SYSTEM] 

1536 value = id_[Fc.VALUE] 

1537 id_counter.update([fhir_system_value(system, value)]) 

1538 most_common = id_counter.most_common(1)[0] 

1539 assert ( 

1540 most_common[1] == 1 

1541 ), f"Resources have duplicate IDs: {most_common[0]}" 

1542 

1543 # Bundle up the content into a transaction bundle: 

1544 return Bundle( 

1545 jsondict={Fc.TYPE: Fc.TRANSACTION, Fc.ENTRY: bundle_entries} 

1546 ) 

1547 # This is one of the few FHIR objects that we don't return with 

1548 # ".as_json()", because Bundle objects have useful methods for talking 

1549 # to the FHIR server. 

1550 

1551 def get_fhir_bundle_entries( 

1552 self, 

1553 req: "CamcopsRequest", 

1554 recipient: "ExportRecipient", 

1555 skip_docs_if_other_content: bool = DEBUG_SKIP_FHIR_DOCS, 

1556 ) -> List[Dict]: 

1557 """ 

1558 Get all FHIR bundle entries. This is the "top-level" function to 

1559 provide all FHIR information for the task. That information includes: 

1560 

1561 - the Patient, if applicable; 

1562 - the Questionnaire (task) itself; 

1563 - multiple QuestionnaireResponse entries for the specific answers from 

1564 this task instance. 

1565 

1566 If the task refuses to support FHIR, raises :exc:`FhirExportException`. 

1567 

1568 Args: 

1569 req: 

1570 a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

1571 recipient: 

1572 an 

1573 :class:`camcops_server.cc_modules.cc_exportrecipient.ExportRecipient` 

1574 skip_docs_if_other_content: 

1575 A debugging option: skip the document (e.g. PDF, HTML, XML), 

1576 making the FHIR output smaller and more legible for debugging. 

1577 However, if the task offers no other content, this will raise 

1578 :exc:`FhirExportException`. 

1579 """ # noqa 

1580 bundle_entries = [] # type: List[Dict] 

1581 

1582 # Patient (0 or 1) 

1583 if self.has_patient: 

1584 bundle_entries.append( 

1585 self.patient.get_fhir_bundle_entry(req, recipient) 

1586 ) 

1587 

1588 # Clinician (0 or 1) 

1589 if self.has_clinician: 

1590 bundle_entries.append(self._get_fhir_clinician_bundle_entry(req)) 

1591 

1592 # Questionnaire, QuestionnaireResponse 

1593 q_bundle_entry, qr_bundle_entry = self._get_fhir_q_qr_bundle_entries( 

1594 req, recipient 

1595 ) 

1596 if q_bundle_entry and qr_bundle_entry: 

1597 bundle_entries += [ 

1598 # Questionnaire 

1599 q_bundle_entry, 

1600 # Collection of QuestionnaireResponse entries 

1601 qr_bundle_entry, 

1602 ] 

1603 

1604 # Observation (0 or more) -- includes Coding 

1605 bundle_entries += self._get_fhir_detail_bundle_entries(req, recipient) 

1606 

1607 # DocumentReference (0-1; always 1 in normal use ) 

1608 if skip_docs_if_other_content: 

1609 if not bundle_entries: 

1610 # We can't have nothing! 

1611 raise FhirExportException( 

1612 "Skipping task because DEBUG_SKIP_FHIR_DOCS set and no " 

1613 "other content" 

1614 ) 

1615 else: 

1616 bundle_entries.append( 

1617 self._get_fhir_docref_bundle_entry(req, recipient) 

1618 ) 

1619 

1620 return bundle_entries 

1621 

1622 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1623 # Generic 

1624 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1625 

1626 @property 

1627 def fhir_when_task_created(self) -> str: 

1628 """ 

1629 Time of task creation, in a FHIR-compatible format. 

1630 """ 

1631 return self.when_created.isoformat() 

1632 

1633 def _get_fhir_detail_bundle_entries( 

1634 self, req: "CamcopsRequest", recipient: "ExportRecipient" 

1635 ) -> List[Dict]: 

1636 """ 

1637 Returns a list of bundle entries (0-1 of them) for Observation objects, 

1638 which may each contain several ObservationComponent objects. This 

1639 includes any SNOMED codes offered, and any extras. 

1640 

1641 See: 

1642 

1643 - https://www.hl7.org/fhir/terminologies-systems.html 

1644 - https://www.hl7.org/fhir/observation.html#code-interop 

1645 - https://www.hl7.org/fhir/observation.html#gr-comp 

1646 

1647 In particular, whether information should be grouped into one 

1648 Observation (via ObservationComponent objects) or as separate 

1649 observations depends on whether it is conceptually independent. For 

1650 example, for BMI, height and weight should be separate. 

1651 """ 

1652 bundle_entries = [] # type: List[Dict] 

1653 

1654 # SNOMED, as one observation with several components: 

1655 if req.snomed_supported: 

1656 snomed_components = [] # type: List[Dict] 

1657 for expr in self.get_snomed_codes(req): 

1658 snomed_components.append( 

1659 fhir_observation_component_from_snomed(req, expr) 

1660 ) 

1661 if snomed_components: 

1662 observable_entity = req.snomed(SnomedLookup.OBSERVABLE_ENTITY) 

1663 snomed_observation = self._get_fhir_observation( 

1664 req, 

1665 recipient, 

1666 obs_dict={ 

1667 # "code" is mandatory even if there are components. 

1668 Fc.CODE: CodeableConcept( 

1669 jsondict={ 

1670 Fc.CODING: [ 

1671 Coding( 

1672 jsondict={ 

1673 Fc.SYSTEM: Fc.CODE_SYSTEM_SNOMED_CT, # noqa 

1674 Fc.CODE: str( 

1675 observable_entity.identifier 

1676 ), 

1677 Fc.DISPLAY: observable_entity.as_string( # noqa 

1678 longform=True 

1679 ), 

1680 Fc.USER_SELECTED: False, 

1681 } 

1682 ).as_json() 

1683 ], 

1684 Fc.TEXT: observable_entity.term, 

1685 } 

1686 ).as_json(), 

1687 Fc.COMPONENT: snomed_components, 

1688 }, 

1689 ) 

1690 bundle_entries.append( 

1691 make_fhir_bundle_entry( 

1692 resource_type_url=Fc.RESOURCE_TYPE_OBSERVATION, 

1693 identifier=self._get_fhir_observation_id( 

1694 req, name="snomed" 

1695 ), 

1696 resource=snomed_observation, 

1697 ) 

1698 ) 

1699 

1700 # Extra -- these can be very varied: 

1701 bundle_entries += self.get_fhir_extra_bundle_entries(req, recipient) 

1702 

1703 # Done 

1704 return bundle_entries 

1705 

1706 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1707 # Identifiers 

1708 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1709 

1710 # Generic: 

1711 

1712 def _get_fhir_id_this_task_class( 

1713 self, 

1714 req: "CamcopsRequest", 

1715 route_name: str, 

1716 value_within_task_class: Union[int, str], 

1717 ) -> Identifier: 

1718 """ 

1719 For when we want to refer to something within a specific task class, in 

1720 the abstract. The URL refers to the task class, not the task instance. 

1721 """ 

1722 return Identifier( 

1723 jsondict={ 

1724 Fc.SYSTEM: req.route_url( 

1725 route_name, 

1726 table_name=self.tablename, # to match ViewParam.TABLE_NAME 

1727 ), 

1728 Fc.VALUE: str(value_within_task_class), 

1729 } 

1730 ) 

1731 

1732 def _get_fhir_id_this_task_instance( 

1733 self, 

1734 req: "CamcopsRequest", 

1735 route_name: str, 

1736 value_within_task_instance: Union[int, str], 

1737 ) -> Identifier: 

1738 """ 

1739 A number of FHIR identifiers refer to "this task" and nothing very much 

1740 more specific (because they represent a type of thing of which there 

1741 can only be one per task), but do so through a range of different route 

1742 names that make the FHIR URLs look sensible. This is a convenience 

1743 function for them. The intention is to route to the specific task 

1744 instance concerned. 

1745 """ 

1746 return Identifier( 

1747 jsondict={ 

1748 Fc.SYSTEM: req.route_url( 

1749 route_name, 

1750 table_name=self.tablename, # to match ViewParam.TABLE_NAME 

1751 server_pk=str(self._pk), # to match ViewParam.SERVER_PK 

1752 ), 

1753 Fc.VALUE: str(value_within_task_instance), 

1754 } 

1755 ) 

1756 

1757 # Specific: 

1758 

1759 def _get_fhir_condition_id( 

1760 self, req: "CamcopsRequest", name: Union[int, str] 

1761 ) -> Identifier: 

1762 """ 

1763 Returns a FHIR Identifier for an Observation, representing this task 

1764 instance and a named observation within it. 

1765 """ 

1766 return self._get_fhir_id_this_task_instance( 

1767 req, Routes.FHIR_CONDITION, name 

1768 ) 

1769 

1770 def _get_fhir_docref_id( 

1771 self, req: "CamcopsRequest", task_format: str 

1772 ) -> Identifier: 

1773 """ 

1774 Returns a FHIR Identifier (e.g. for a DocumentReference collection) 

1775 representing the view of this task. 

1776 """ 

1777 return self._get_fhir_id_this_task_instance( 

1778 req, Routes.FHIR_DOCUMENT_REFERENCE, task_format 

1779 ) 

1780 

1781 def _get_fhir_observation_id( 

1782 self, req: "CamcopsRequest", name: str 

1783 ) -> Identifier: 

1784 """ 

1785 Returns a FHIR Identifier for an Observation, representing this task 

1786 instance and a named observation within it. 

1787 """ 

1788 return self._get_fhir_id_this_task_instance( 

1789 req, Routes.FHIR_OBSERVATION, name 

1790 ) 

1791 

1792 def _get_fhir_practitioner_id(self, req: "CamcopsRequest") -> Identifier: 

1793 """ 

1794 Returns a FHIR Identifier for the clinician. (Clinicians are not 

1795 sensibly made unique across tasks, but are task-specific.) 

1796 """ 

1797 return self._get_fhir_id_this_task_instance( 

1798 req, 

1799 Routes.FHIR_PRACTITIONER, 

1800 Fc.CAMCOPS_VALUE_CLINICIAN_WITHIN_TASK, 

1801 ) 

1802 

1803 def _get_fhir_questionnaire_id(self, req: "CamcopsRequest") -> Identifier: 

1804 """ 

1805 Returns a FHIR Identifier (e.g. for a Questionnaire) representing this 

1806 task, in the abstract. 

1807 

1808 Incorporates the CamCOPS version, so that if aspects (even the 

1809 formatting of question text) changes, a new version will be stored 

1810 despite the "ifNoneExist" clause. 

1811 """ 

1812 return Identifier( 

1813 jsondict={ 

1814 Fc.SYSTEM: req.route_url(Routes.FHIR_QUESTIONNAIRE_SYSTEM), 

1815 Fc.VALUE: f"{self.tablename}/{CAMCOPS_SERVER_VERSION_STRING}", 

1816 } 

1817 ) 

1818 

1819 def _get_fhir_questionnaire_response_id( 

1820 self, req: "CamcopsRequest" 

1821 ) -> Identifier: 

1822 """ 

1823 Returns a FHIR Identifier (e.g. for a QuestionnaireResponse collection) 

1824 representing this task instance. QuestionnaireResponse items are 

1825 specific answers, not abstract descriptions. 

1826 """ 

1827 return self._get_fhir_id_this_task_instance( 

1828 req, 

1829 Routes.FHIR_QUESTIONNAIRE_RESPONSE, 

1830 Fc.CAMCOPS_VALUE_QUESTIONNAIRE_RESPONSE_WITHIN_TASK, 

1831 ) 

1832 

1833 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1834 # References to identifiers 

1835 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1836 

1837 def _get_fhir_subject_ref( 

1838 self, req: "CamcopsRequest", recipient: "ExportRecipient" 

1839 ) -> Dict: 

1840 """ 

1841 Returns a reference to the patient, for "subject" fields. 

1842 """ 

1843 assert ( 

1844 self.has_patient 

1845 ), "Don't call Task._get_fhir_subject_ref() for anonymous tasks" 

1846 return self.patient.get_fhir_subject_ref(req, recipient) 

1847 

1848 def _get_fhir_practitioner_ref(self, req: "CamcopsRequest") -> Dict: 

1849 """ 

1850 Returns a reference to the clinician, for "practitioner" fields. 

1851 """ 

1852 assert self.has_clinician, ( 

1853 "Don't call Task._get_fhir_clinician_ref() " 

1854 "for tasks without a clinician" 

1855 ) 

1856 return FHIRReference( 

1857 jsondict={ 

1858 Fc.TYPE: Fc.RESOURCE_TYPE_PRACTITIONER, 

1859 Fc.IDENTIFIER: self._get_fhir_practitioner_id(req).as_json(), 

1860 } 

1861 ).as_json() 

1862 

1863 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1864 # DocumentReference 

1865 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1866 

1867 def _get_fhir_docref_bundle_entry( 

1868 self, 

1869 req: "CamcopsRequest", 

1870 recipient: "ExportRecipient", 

1871 text_encoding: str = UTF8, 

1872 ) -> Dict: 

1873 """ 

1874 Returns bundle entries for an attached document, which is a full 

1875 representation of the task according to the selected task format (e.g. 

1876 PDF). 

1877 

1878 This requires a DocumentReference, which can (in theory) either embed 

1879 the data, or refer via a URL to an associated Binary object. We do it 

1880 directly. 

1881 

1882 See: 

1883 

1884 - https://fhirblog.com/2013/11/06/fhir-and-xds-submitting-a-document-from-a-document-source/ 

1885 - https://fhirblog.com/2013/11/12/the-fhir-documentreference-resource/ 

1886 - https://build.fhir.org/ig/HL7/US-Core/StructureDefinition-us-core-documentreference.html 

1887 - https://build.fhir.org/ig/HL7/US-Core/clinical-notes-guidance.html 

1888 """ # noqa 

1889 

1890 # Establish content_type and binary_data 

1891 task_format = recipient.task_format 

1892 if task_format == FileType.PDF: 

1893 binary_data = self.get_pdf(req) 

1894 content_type = MimeType.PDF 

1895 else: 

1896 if task_format == FileType.XML: 

1897 txt = self.get_xml( 

1898 req, 

1899 options=TaskExportOptions( 

1900 include_blobs=False, 

1901 xml_include_ancillary=True, 

1902 xml_include_calculated=True, 

1903 xml_include_comments=True, 

1904 xml_include_patient=True, 

1905 xml_include_plain_columns=True, 

1906 xml_include_snomed=True, 

1907 xml_with_header_comments=True, 

1908 ), 

1909 ) 

1910 content_type = MimeType.XML 

1911 elif task_format == FileType.HTML: 

1912 txt = self.get_html(req) 

1913 content_type = MimeType.HTML 

1914 else: 

1915 raise ValueError(f"Unknown task format: {task_format!r}") 

1916 binary_data = txt.encode(text_encoding) 

1917 b64_encoded_bytes = b64encode(binary_data) # type: bytes 

1918 b64_encoded_str = b64_encoded_bytes.decode(ASCII) 

1919 

1920 # Build the DocumentReference 

1921 docref_id = self._get_fhir_docref_id(req, task_format) 

1922 dr_dict = { 

1923 # Metadata: 

1924 Fc.DATE: self.fhir_when_task_created, 

1925 Fc.DESCRIPTION: self.longname(req), 

1926 Fc.DOCSTATUS: ( 

1927 Fc.DOCSTATUS_FINAL 

1928 if self.is_finalized() 

1929 else Fc.DOCSTATUS_PRELIMINARY 

1930 ), 

1931 Fc.MASTER_IDENTIFIER: docref_id.as_json(), 

1932 Fc.STATUS: Fc.DOCSTATUS_CURRENT, 

1933 # And the content: 

1934 Fc.CONTENT: [ 

1935 DocumentReferenceContent( 

1936 jsondict={ 

1937 Fc.ATTACHMENT: Attachment( 

1938 jsondict={ 

1939 Fc.CONTENT_TYPE: content_type, 

1940 Fc.DATA: b64_encoded_str, 

1941 } 

1942 ).as_json() 

1943 } 

1944 ).as_json() 

1945 ], 

1946 } 

1947 # Optional metadata: 

1948 if self.has_clinician: 

1949 dr_dict[Fc.AUTHOR] = [self._get_fhir_practitioner_ref(req)] 

1950 if self.has_patient: 

1951 dr_dict[Fc.SUBJECT] = self._get_fhir_subject_ref(req, recipient) 

1952 

1953 # DocumentReference 

1954 return make_fhir_bundle_entry( 

1955 resource_type_url=Fc.RESOURCE_TYPE_DOCUMENT_REFERENCE, 

1956 identifier=docref_id, 

1957 resource=DocumentReference(jsondict=dr_dict).as_json(), 

1958 ) 

1959 

1960 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1961 # Observation 

1962 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1963 

1964 def _get_fhir_observation( 

1965 self, 

1966 req: "CamcopsRequest", 

1967 recipient: "ExportRecipient", 

1968 obs_dict: Dict, 

1969 ) -> Dict: 

1970 """ 

1971 Given a starting dictionary for an Observation, complete it for this 

1972 task (by adding "when", "who", and status information) and return the 

1973 Observation (as a dict in JSON format). 

1974 """ 

1975 obs_dict.update( 

1976 { 

1977 Fc.EFFECTIVE_DATE_TIME: self.fhir_when_task_created, 

1978 Fc.STATUS: ( 

1979 Fc.OBSSTATUS_FINAL 

1980 if self.is_finalized() 

1981 else Fc.OBSSTATUS_PRELIMINARY 

1982 ), 

1983 } 

1984 ) 

1985 if self.has_patient: 

1986 obs_dict[Fc.SUBJECT] = self._get_fhir_subject_ref(req, recipient) 

1987 return Observation(jsondict=obs_dict).as_json() 

1988 

1989 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1990 # Practitioner (clinician) 

1991 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

1992 

1993 def _get_fhir_clinician_bundle_entry(self, req: "CamcopsRequest") -> Dict: 

1994 """ 

1995 Supplies information on the clinician associated with this task, as a 

1996 FHIR Practitioner object (within a bundle). 

1997 """ 

1998 assert self.has_clinician, ( 

1999 "Don't call Task._get_fhir_practitioner_bundle_entry() " 

2000 "for tasks without a clinician" 

2001 ) 

2002 practitioner = Practitioner( 

2003 jsondict={ 

2004 Fc.NAME: [ 

2005 HumanName( 

2006 jsondict={Fc.TEXT: self.get_clinician_name()} 

2007 ).as_json() 

2008 ], 

2009 # "qualification" is too structured. 

2010 # There isn't anywhere to represent our other information, so 

2011 # we jam it in to "telecom"/"other". 

2012 Fc.TELECOM: [ 

2013 ContactPoint( 

2014 jsondict={ 

2015 Fc.SYSTEM: Fc.TELECOM_SYSTEM_OTHER, 

2016 Fc.VALUE: self.get_clinician_fhir_telecom_other( 

2017 req 

2018 ), 

2019 } 

2020 ).as_json() 

2021 ], 

2022 } 

2023 ).as_json() 

2024 return make_fhir_bundle_entry( 

2025 resource_type_url=Fc.RESOURCE_TYPE_PRACTITIONER, 

2026 identifier=self._get_fhir_practitioner_id(req), 

2027 resource=practitioner, 

2028 ) 

2029 

2030 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

2031 # Questionnaire, QuestionnaireResponse 

2032 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

2033 

2034 def _get_fhir_q_qr_bundle_entries( 

2035 self, req: "CamcopsRequest", recipient: "ExportRecipient" 

2036 ) -> Tuple[Optional[Dict], Optional[Dict]]: 

2037 """ 

2038 Get a tuple of FHIR bundles: ``questionnaire_bundle_entry, 

2039 questionnaire_response_bundle_entry``. 

2040 

2041 A Questionnaire object represents the task in the abstract; 

2042 QuestionnaireReponse items represent each answered question for a 

2043 specific task instance. 

2044 """ 

2045 # Ask the task for its details (which it may provide directly, by 

2046 # overriding, or rely on autodiscovery for the default). 

2047 aq_items = self.get_fhir_questionnaire(req) 

2048 if DEBUG_SHOW_FHIR_QUESTIONNAIRE: 

2049 if aq_items: 

2050 qa_str = "\n".join(f"- {str(x)}" for x in aq_items) 

2051 log.debug(f"FHIR questions/answers:\n{qa_str}") 

2052 else: 

2053 log.debug("No FHIR questionnaire data") 

2054 

2055 # Do we have data? 

2056 if not aq_items: 

2057 return None, None 

2058 

2059 # Now finish off: 

2060 q_items = [aq.questionnaire_item() for aq in aq_items] 

2061 qr_items = [aq.questionnaire_response_item() for aq in aq_items] 

2062 q_bundle_entry = self._make_fhir_questionnaire_bundle_entry( 

2063 req, q_items 

2064 ) 

2065 qr_bundle_entry = self._make_fhir_questionnaire_response_bundle_entry( 

2066 req, recipient, qr_items 

2067 ) 

2068 return q_bundle_entry, qr_bundle_entry 

2069 

2070 def _make_fhir_questionnaire_bundle_entry( 

2071 self, req: "CamcopsRequest", q_items: List[Dict] 

2072 ) -> Optional[Dict]: 

2073 """ 

2074 Make a FHIR bundle entry describing this task, as a FHIR Questionnaire, 

2075 from supplied Questionnaire items. Note: here we mean "abstract task", 

2076 not "task instance". 

2077 """ 

2078 # FHIR supports versioning of questionnaires. Might be useful if the 

2079 # wording of questions change. Could either use FHIR's version 

2080 # field or include the version in the identifier below. Either way 

2081 # we'd need the version in the 'ifNoneExist' part of the request. 

2082 q_identifier = self._get_fhir_questionnaire_id(req) 

2083 

2084 # Other things we could add: 

2085 # https://www.hl7.org/fhir/questionnaire.html 

2086 # 

2087 # date: Date last changed 

2088 # useContext: https://www.hl7.org/fhir/metadatatypes.html#UsageContext 

2089 help_url = self.help_url() 

2090 questionnaire = Questionnaire( 

2091 jsondict={ 

2092 Fc.NAME: self.shortname, # Computer-friendly name 

2093 Fc.TITLE: self.longname(req), # Human name 

2094 Fc.DESCRIPTION: help_url, # Natural language description of the questionnaire # noqa 

2095 Fc.COPYRIGHT: help_url, # Use and/or publishing restrictions 

2096 Fc.VERSION: CAMCOPS_SERVER_VERSION_STRING, 

2097 Fc.STATUS: Fc.QSTATUS_ACTIVE, # Could also be: draft, retired, unknown # noqa 

2098 Fc.ITEM: q_items, 

2099 } 

2100 ) 

2101 return make_fhir_bundle_entry( 

2102 resource_type_url=Fc.RESOURCE_TYPE_QUESTIONNAIRE, 

2103 identifier=q_identifier, 

2104 resource=questionnaire.as_json(), 

2105 ) 

2106 

2107 def _make_fhir_questionnaire_response_bundle_entry( 

2108 self, 

2109 req: "CamcopsRequest", 

2110 recipient: "ExportRecipient", 

2111 qr_items: List[Dict], 

2112 ) -> Dict: 

2113 """ 

2114 Make a bundle entry from FHIR QuestionnaireResponse items (e.g. one for 

2115 the response to each question in a quesionnaire-style task). 

2116 """ 

2117 q_identifier = self._get_fhir_questionnaire_id(req) 

2118 qr_identifier = self._get_fhir_questionnaire_response_id(req) 

2119 

2120 # Status: 

2121 # https://www.hl7.org/fhir/valueset-questionnaire-answers-status.html 

2122 # It is probably undesirable to export tasks that are incomplete in the 

2123 # sense of "not finalized". The user can control this (via the 

2124 # FINALIZED_ONLY config option for exports). However, we also need to 

2125 # handle finalized but incomplete data. 

2126 if self.is_complete(): 

2127 status = Fc.QSTATUS_COMPLETED 

2128 elif self.is_live_on_tablet(): 

2129 status = Fc.QSTATUS_IN_PROGRESS 

2130 else: 

2131 # Incomplete, but finalized. 

2132 status = Fc.QSTATUS_STOPPED 

2133 

2134 qr_jsondict = { 

2135 # https://r4.smarthealthit.org does not like "questionnaire" in 

2136 # this form: 

2137 # FHIR Server; FHIR 4.0.0/R4; HAPI FHIR 4.0.0-SNAPSHOT) 

2138 # error is: 

2139 # Invalid resource reference found at 

2140 # path[QuestionnaireResponse.questionnaire]- Resource type is 

2141 # unknown or not supported on this server 

2142 # - http://127.0.0.1:8000/fhir_questionnaire|phq9 

2143 # http://hapi.fhir.org/baseR4/ (4.0.1 (R4)) is OK 

2144 Fc.QUESTIONNAIRE: fhir_sysval_from_id(q_identifier), 

2145 Fc.AUTHORED: self.fhir_when_task_created, 

2146 Fc.STATUS: status, 

2147 # TODO: Could also add: 

2148 # https://www.hl7.org/fhir/questionnaireresponse.html 

2149 # author: Person who received and recorded the answers 

2150 # source: The person who answered the questions 

2151 Fc.ITEM: qr_items, 

2152 } 

2153 

2154 if self.has_patient: 

2155 qr_jsondict[Fc.SUBJECT] = self._get_fhir_subject_ref( 

2156 req, recipient 

2157 ) 

2158 

2159 return make_fhir_bundle_entry( 

2160 resource_type_url=Fc.RESOURCE_TYPE_QUESTIONNAIRE_RESPONSE, 

2161 identifier=qr_identifier, 

2162 resource=QuestionnaireResponse(qr_jsondict).as_json(), 

2163 identifier_is_list=False, 

2164 ) 

2165 

2166 # ------------------------------------------------------------------------- 

2167 # FHIR: functions to override if desired 

2168 # ------------------------------------------------------------------------- 

2169 

2170 def get_fhir_questionnaire( 

2171 self, req: "CamcopsRequest" 

2172 ) -> List[FHIRAnsweredQuestion]: 

2173 """ 

2174 Return FHIR information about a questionnaire: both about the task in 

2175 the abstract (the questions) and the answers for this specific 

2176 instance. 

2177 

2178 May be overridden. 

2179 """ 

2180 return self._fhir_autodiscover(req) 

2181 

2182 def get_fhir_extra_bundle_entries( 

2183 self, req: "CamcopsRequest", recipient: "ExportRecipient" 

2184 ) -> List[Dict]: 

2185 """ 

2186 Return a list of extra FHIR bundle entries, if relevant. (SNOMED-CT 

2187 codes are done automatically; don't repeat those.) 

2188 """ 

2189 return [] 

2190 

2191 def get_qtext(self, req: "CamcopsRequest", attrname: str) -> Optional[str]: 

2192 """ 

2193 Returns the text associated with a particular question. 

2194 The default implementation is a guess. 

2195 

2196 Args: 

2197 req: 

2198 A :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`. 

2199 attrname: 

2200 Name of the attribute (field) on this task that represents the 

2201 question. 

2202 """ 

2203 return self.xstring(req, attrname, provide_default_if_none=False) 

2204 

2205 def get_atext( 

2206 self, req: "CamcopsRequest", attrname: str, answer_value: int 

2207 ) -> Optional[str]: 

2208 """ 

2209 Returns the text associated with a particular answer to a question. 

2210 The default implementation is a guess. 

2211 

2212 Args: 

2213 req: 

2214 A :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`. 

2215 attrname: 

2216 Name of the attribute (field) on this task that represents the 

2217 question. 

2218 answer_value: 

2219 Answer value. 

2220 """ 

2221 stringname = f"{attrname}_a{answer_value}" 

2222 return self.xstring(req, stringname, provide_default_if_none=False) 

2223 

2224 # ------------------------------------------------------------------------- 

2225 # FHIR automatic interrogation 

2226 # ------------------------------------------------------------------------- 

2227 

2228 def _fhir_autodiscover( 

2229 self, req: "CamcopsRequest" 

2230 ) -> List[FHIRAnsweredQuestion]: 

2231 """ 

2232 Inspect this task instance and create information about both the task 

2233 in the abstract and the answers for this specific instance. 

2234 """ 

2235 qa_items = [] # type: List[FHIRAnsweredQuestion] 

2236 

2237 skip_fields = TASK_FREQUENT_FIELDS 

2238 for attrname, column in gen_columns(self): 

2239 if attrname in skip_fields: 

2240 continue 

2241 comment = column.comment 

2242 coltype = column.type 

2243 

2244 # Question text: 

2245 retrieved_qtext = self.get_qtext(req, attrname) 

2246 qtext_components = [] 

2247 if retrieved_qtext: 

2248 qtext_components.append(retrieved_qtext) 

2249 if comment: 

2250 qtext_components.append(f"[{comment}]") 

2251 if not qtext_components: 

2252 qtext_components = (attrname,) 

2253 if not qtext_components: 

2254 qtext_components = (FHIR_UNKNOWN_TEXT,) 

2255 qtext = " ".join(qtext_components) 

2256 # Note that it's good to get the column comment in somewhere; these 

2257 # often explain the meaning of the field quite well. It may or may 

2258 # not be possible to get it into the option values -- many answer 

2259 # types don't permit those. QuestionnaireItem records don't have a 

2260 # comment field (see 

2261 # https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item), # noqa 

2262 # so the best we can do is probably to stuff it into the question 

2263 # text, even if that causes some visual duplication. 

2264 

2265 # Thinking about types: 

2266 int_type = isinstance(coltype, Integer) 

2267 bool_type = ( 

2268 is_sqlatype_binary(coltype) 

2269 or isinstance(coltype, BoolColumn) 

2270 or isinstance(coltype, Boolean) 

2271 # For booleans represented as integers: it is better to be as 

2272 # constraining as possible and say that only 0/1 options are 

2273 # present by marking these as Boolean, which is less 

2274 # complicated for the recipient than "integer but with possible 

2275 # options 0 or 1". We will *also* show the possible options, 

2276 # just to be clear. 

2277 ) 

2278 if int_type: 

2279 qtype = FHIRQuestionType.INTEGER 

2280 atype = FHIRAnswerType.INTEGER 

2281 elif isinstance(coltype, String): # includes its subclass, Text 

2282 qtype = FHIRQuestionType.STRING 

2283 atype = FHIRAnswerType.STRING 

2284 elif isinstance(coltype, Numeric): # includes Float, Decimal 

2285 qtype = FHIRQuestionType.QUANTITY 

2286 atype = FHIRAnswerType.QUANTITY 

2287 elif isinstance( 

2288 coltype, (DateTime, PendulumDateTimeAsIsoTextColType) 

2289 ): 

2290 qtype = FHIRQuestionType.DATETIME 

2291 atype = FHIRAnswerType.DATETIME 

2292 elif isinstance(coltype, DateColType): 

2293 qtype = FHIRQuestionType.DATE 

2294 atype = FHIRAnswerType.DATE 

2295 elif isinstance(coltype, Time): 

2296 qtype = FHIRQuestionType.TIME 

2297 atype = FHIRAnswerType.TIME 

2298 elif bool_type: 

2299 qtype = FHIRQuestionType.BOOLEAN 

2300 atype = FHIRAnswerType.BOOLEAN 

2301 else: 

2302 raise NotImplementedError(f"Unknown column type: {coltype!r}") 

2303 

2304 # Thinking about MCQ options: 

2305 answer_options = None # type: Optional[Dict[Any, str]] 

2306 if (int_type or bool_type) and hasattr( 

2307 column, COLATTR_PERMITTED_VALUE_CHECKER 

2308 ): 

2309 pvc = getattr( 

2310 column, COLATTR_PERMITTED_VALUE_CHECKER 

2311 ) # type: PermittedValueChecker # noqa 

2312 if pvc is not None: 

2313 pv = pvc.permitted_values_inc_minmax() 

2314 if pv: 

2315 qtype = FHIRQuestionType.CHOICE 

2316 # ... has to be of type "choice" to transmit the 

2317 # possible values. 

2318 answer_options = {} 

2319 for v in pv: 

2320 answer_options[v] = ( 

2321 self.get_atext(req, attrname, v) 

2322 or comment 

2323 or FHIR_UNKNOWN_TEXT 

2324 ) 

2325 

2326 # Assemble: 

2327 qa_items.append( 

2328 FHIRAnsweredQuestion( 

2329 qname=attrname, 

2330 qtext=qtext, 

2331 qtype=qtype, 

2332 answer_type=atype, 

2333 answer=getattr(self, attrname), 

2334 answer_options=answer_options, 

2335 ) 

2336 ) 

2337 

2338 # We don't currently put any summary information into FHIR exports. I 

2339 # think that isn't within the spirit of the system, but am not sure. 

2340 # todo: Check if summary information should go into FHIR exports. 

2341 

2342 return qa_items 

2343 

2344 # ------------------------------------------------------------------------- 

2345 # Export (generically) 

2346 # ------------------------------------------------------------------------- 

2347 

2348 def cancel_from_export_log( 

2349 self, req: "CamcopsRequest", from_console: bool = False 

2350 ) -> None: 

2351 """ 

2352 Marks all instances of this task as "cancelled" in the export log, so 

2353 it will be resent. 

2354 """ 

2355 if self._pk is None: 

2356 return 

2357 from camcops_server.cc_modules.cc_exportmodels import ( 

2358 ExportedTask, 

2359 ) # delayed import 

2360 

2361 # noinspection PyUnresolvedReferences 

2362 statement = ( 

2363 update(ExportedTask.__table__) 

2364 .where(ExportedTask.basetable == self.tablename) 

2365 .where(ExportedTask.task_server_pk == self._pk) 

2366 .where( 

2367 not_(ExportedTask.cancelled) | ExportedTask.cancelled.is_(None) 

2368 ) 

2369 .values(cancelled=1, cancelled_at_utc=req.now_utc) 

2370 ) 

2371 # ... this bit: ... AND (NOT cancelled OR cancelled IS NULL) ...: 

2372 # https://stackoverflow.com/questions/37445041/sqlalchemy-how-to-filter-column-which-contains-both-null-and-integer-values # noqa 

2373 req.dbsession.execute(statement) 

2374 self.audit( 

2375 req, 

2376 "Task cancelled in export log (may trigger resending)", 

2377 from_console, 

2378 ) 

2379 

2380 # ------------------------------------------------------------------------- 

2381 # Audit 

2382 # ------------------------------------------------------------------------- 

2383 

2384 def audit( 

2385 self, req: "CamcopsRequest", details: str, from_console: bool = False 

2386 ) -> None: 

2387 """ 

2388 Audits actions to this task. 

2389 """ 

2390 audit( 

2391 req, 

2392 details, 

2393 patient_server_pk=self.get_patient_server_pk(), 

2394 table=self.tablename, 

2395 server_pk=self._pk, 

2396 from_console=from_console, 

2397 ) 

2398 

2399 # ------------------------------------------------------------------------- 

2400 # Erasure (wiping, leaving record as placeholder) 

2401 # ------------------------------------------------------------------------- 

2402 

2403 def manually_erase(self, req: "CamcopsRequest") -> None: 

2404 """ 

2405 Manually erases a task (including sub-tables). 

2406 Also erases linked non-current records. 

2407 This WIPES THE CONTENTS but LEAVES THE RECORD AS A PLACEHOLDER. 

2408 

2409 Audits the erasure. Propagates erase through to the HL7 log, so those 

2410 records will be re-sent. WRITES TO DATABASE. 

2411 """ 

2412 # Erase ourself and any other in our "family" 

2413 for task in self.get_lineage(): 

2414 task.manually_erase_with_dependants(req) 

2415 # Audit and clear HL7 message log 

2416 self.audit(req, "Task details erased manually") 

2417 self.cancel_from_export_log(req) 

2418 

2419 def is_erased(self) -> bool: 

2420 """ 

2421 Has the task been manually erased? See :func:`manually_erase`. 

2422 """ 

2423 return self._manually_erased 

2424 

2425 # ------------------------------------------------------------------------- 

2426 # Complete deletion 

2427 # ------------------------------------------------------------------------- 

2428 

2429 def delete_entirely(self, req: "CamcopsRequest") -> None: 

2430 """ 

2431 Completely delete this task, its lineage, and its dependants. 

2432 """ 

2433 for task in self.get_lineage(): 

2434 task.delete_with_dependants(req) 

2435 self.audit(req, "Task deleted") 

2436 

2437 # ------------------------------------------------------------------------- 

2438 # Filtering tasks for the task list 

2439 # ------------------------------------------------------------------------- 

2440 

2441 @classmethod 

2442 def gen_text_filter_columns( 

2443 cls, 

2444 ) -> Generator[Tuple[str, Column], None, None]: 

2445 """ 

2446 Yields tuples of ``attrname, column``, for columns that are suitable 

2447 for text filtering. 

2448 """ 

2449 for attrname, column in gen_columns(cls): 

2450 if attrname.startswith("_"): # system field 

2451 continue 

2452 if not is_sqlatype_string(column.type): 

2453 continue 

2454 yield attrname, column 

2455 

2456 @classmethod 

2457 @cache_region_static.cache_on_arguments(function_key_generator=fkg) 

2458 def get_text_filter_columns(cls) -> List[Column]: 

2459 """ 

2460 Cached function to return a list of SQLAlchemy Column objects suitable 

2461 for text filtering. 

2462 """ 

2463 return [col for _, col in cls.gen_text_filter_columns()] 

2464 

2465 def contains_text(self, text: str) -> bool: 

2466 """ 

2467 Does this task contain the specified text? 

2468 

2469 Args: 

2470 text: 

2471 string that must be present in at least one of our text 

2472 columns 

2473 

2474 Returns: 

2475 is the strings present? 

2476 """ 

2477 text = text.lower() 

2478 for attrname, _ in self.gen_text_filter_columns(): 

2479 value = getattr(self, attrname) 

2480 if value is None: 

2481 continue 

2482 assert isinstance(value, str), "Internal bug in contains_text" 

2483 if text in value.lower(): 

2484 return True 

2485 return False 

2486 

2487 def contains_all_strings(self, strings: List[str]) -> bool: 

2488 """ 

2489 Does this task contain all the specified strings? 

2490 

2491 Args: 

2492 strings: 

2493 list of strings; each string must be present in at least 

2494 one of our text columns 

2495 

2496 Returns: 

2497 are all strings present? 

2498 """ 

2499 return all(self.contains_text(text) for text in strings) 

2500 

2501 # ------------------------------------------------------------------------- 

2502 # Spreadsheet export for basic research dump 

2503 # ------------------------------------------------------------------------- 

2504 

2505 def get_spreadsheet_pages( 

2506 self, req: "CamcopsRequest" 

2507 ) -> List["SpreadsheetPage"]: 

2508 """ 

2509 Returns information used for the basic research dump in (e.g.) TSV 

2510 format. 

2511 """ 

2512 # 1. Our core fields, plus summary information 

2513 main_page = self._get_core_spreadsheet_page(req) 

2514 

2515 # 2. Patient details. 

2516 if self.patient: 

2517 main_page.add_or_set_columns_from_page( 

2518 self.patient.get_spreadsheet_page(req) 

2519 ) 

2520 pages = [main_page] 

2521 

2522 # 3. +/- Ancillary objects 

2523 for ( 

2524 ancillary 

2525 ) in ( 

2526 self.gen_ancillary_instances() 

2527 ): # type: GenericTabletRecordMixin # noqa 

2528 page = ancillary._get_core_spreadsheet_page(req) 

2529 pages.append(page) 

2530 

2531 # 4. +/- Extra summary tables (inc. SNOMED) 

2532 for est in self.get_all_summary_tables(req): 

2533 pages.append(est.get_spreadsheet_page()) 

2534 

2535 # Done 

2536 return pages 

2537 

2538 def get_spreadsheet_schema_elements( 

2539 self, req: "CamcopsRequest" 

2540 ) -> Set[SummarySchemaInfo]: 

2541 """ 

2542 Returns schema information used for spreadsheets -- more than just 

2543 the database columns, and in the same format as the spreadsheets. 

2544 """ 

2545 table_name = self.__tablename__ 

2546 

2547 # 1(a). Database columns: main table 

2548 items = self._get_core_spreadsheet_schema() 

2549 # 1(b). Summary information. 

2550 for summary in self.get_summaries(req): 

2551 items.add( 

2552 SummarySchemaInfo.from_summary_element(table_name, summary) 

2553 ) 

2554 

2555 # 2. Patient details 

2556 if self.patient: 

2557 items.update( 

2558 self.patient.get_spreadsheet_schema_elements(req, table_name) 

2559 ) 

2560 

2561 # 3. Ancillary objects 

2562 for ( 

2563 ancillary 

2564 ) in ( 

2565 self.gen_ancillary_instances() 

2566 ): # type: GenericTabletRecordMixin # noqa 

2567 items.update(ancillary._get_core_spreadsheet_schema()) 

2568 

2569 # 4. Extra summary tables 

2570 for est in self.get_all_summary_tables(req): 

2571 items.update(est.get_spreadsheet_schema_elements()) 

2572 

2573 return items 

2574 

2575 # ------------------------------------------------------------------------- 

2576 # XML view 

2577 # ------------------------------------------------------------------------- 

2578 

2579 def get_xml( 

2580 self, 

2581 req: "CamcopsRequest", 

2582 options: TaskExportOptions = None, 

2583 indent_spaces: int = 4, 

2584 eol: str = "\n", 

2585 ) -> str: 

2586 """ 

2587 Returns XML describing the task. 

2588 

2589 Args: 

2590 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

2591 options: a :class:`camcops_server.cc_modules.cc_simpleobjects.TaskExportOptions` 

2592 

2593 indent_spaces: number of spaces to indent formatted XML 

2594 eol: end-of-line string 

2595 

2596 Returns: 

2597 an XML UTF-8 document representing the task. 

2598 

2599 """ # noqa 

2600 options = options or TaskExportOptions() 

2601 tree = self.get_xml_root(req=req, options=options) 

2602 return get_xml_document( 

2603 tree, 

2604 indent_spaces=indent_spaces, 

2605 eol=eol, 

2606 include_comments=options.xml_include_comments, 

2607 ) 

2608 

2609 def get_xml_root( 

2610 self, req: "CamcopsRequest", options: TaskExportOptions 

2611 ) -> XmlElement: 

2612 """ 

2613 Returns an XML tree. The return value is the root 

2614 :class:`camcops_server.cc_modules.cc_xml.XmlElement`. 

2615 

2616 Override to include other tables, or to deal with BLOBs, if the default 

2617 methods are insufficient. 

2618 

2619 Args: 

2620 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

2621 options: a :class:`camcops_server.cc_modules.cc_simpleobjects.TaskExportOptions` 

2622 """ # noqa 

2623 # Core (inc. core BLOBs) 

2624 branches = self._get_xml_core_branches(req=req, options=options) 

2625 tree = XmlElement(name=self.tablename, value=branches) 

2626 return tree 

2627 

2628 def _get_xml_core_branches( 

2629 self, req: "CamcopsRequest", options: TaskExportOptions = None 

2630 ) -> List[XmlElement]: 

2631 """ 

2632 Returns a list of :class:`camcops_server.cc_modules.cc_xml.XmlElement` 

2633 elements representing stored, calculated, patient, and/or BLOB fields, 

2634 depending on the options. 

2635 

2636 Args: 

2637 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

2638 options: a :class:`camcops_server.cc_modules.cc_simpleobjects.TaskExportOptions` 

2639 """ # noqa 

2640 options = options or TaskExportOptions( 

2641 xml_include_plain_columns=True, 

2642 xml_include_ancillary=True, 

2643 include_blobs=False, 

2644 xml_include_calculated=True, 

2645 xml_include_patient=True, 

2646 xml_include_snomed=True, 

2647 ) 

2648 

2649 def add_comment(comment: XmlLiteral) -> None: 

2650 if options.xml_with_header_comments: 

2651 branches.append(comment) 

2652 

2653 # Stored values +/- calculated values 

2654 core_options = options.clone() 

2655 core_options.include_blobs = False 

2656 branches = self._get_xml_branches(req=req, options=core_options) 

2657 

2658 # SNOMED-CT codes 

2659 if options.xml_include_snomed and req.snomed_supported: 

2660 add_comment(XML_COMMENT_SNOMED_CT) 

2661 snomed_codes = self.get_snomed_codes(req) 

2662 snomed_branches = [] # type: List[XmlElement] 

2663 for code in snomed_codes: 

2664 snomed_branches.append(code.xml_element()) 

2665 branches.append( 

2666 XmlElement(name=XML_NAME_SNOMED_CODES, value=snomed_branches) 

2667 ) 

2668 

2669 # Special notes 

2670 add_comment(XML_COMMENT_SPECIAL_NOTES) 

2671 for sn in self.special_notes: 

2672 branches.append(sn.get_xml_root()) 

2673 

2674 # Patient details 

2675 if self.is_anonymous: 

2676 add_comment(XML_COMMENT_ANONYMOUS) 

2677 elif options.xml_include_patient: 

2678 add_comment(XML_COMMENT_PATIENT) 

2679 patient_options = TaskExportOptions( 

2680 xml_include_plain_columns=True, 

2681 xml_with_header_comments=options.xml_with_header_comments, 

2682 ) 

2683 if self.patient: 

2684 branches.append( 

2685 self.patient.get_xml_root(req, patient_options) 

2686 ) 

2687 

2688 # BLOBs 

2689 if options.include_blobs: 

2690 add_comment(XML_COMMENT_BLOBS) 

2691 blob_options = TaskExportOptions( 

2692 include_blobs=True, 

2693 xml_skip_fields=options.xml_skip_fields, 

2694 xml_sort_by_name=True, 

2695 xml_with_header_comments=False, 

2696 ) 

2697 branches += self._get_xml_branches(req=req, options=blob_options) 

2698 

2699 # Ancillary objects 

2700 if options.xml_include_ancillary: 

2701 ancillary_options = TaskExportOptions( 

2702 xml_include_plain_columns=True, 

2703 xml_include_ancillary=True, 

2704 include_blobs=options.include_blobs, 

2705 xml_include_calculated=options.xml_include_calculated, 

2706 xml_sort_by_name=True, 

2707 xml_with_header_comments=options.xml_with_header_comments, 

2708 ) 

2709 item_collections = [] # type: List[XmlElement] 

2710 found_ancillary = False 

2711 # We use a slightly more manual iteration process here so that 

2712 # we iterate through individual ancillaries but clustered by their 

2713 # name (e.g. if we have 50 trials and 5 groups, we do them in 

2714 # collections). 

2715 for attrname, rel_prop, rel_cls in gen_ancillary_relationships( 

2716 self 

2717 ): 

2718 if not found_ancillary: 

2719 add_comment(XML_COMMENT_ANCILLARY) 

2720 found_ancillary = True 

2721 itembranches = [] # type: List[XmlElement] 

2722 if rel_prop.uselist: 

2723 ancillaries = getattr( 

2724 self, attrname 

2725 ) # type: List[GenericTabletRecordMixin] # noqa 

2726 else: 

2727 ancillaries = [ 

2728 getattr(self, attrname) 

2729 ] # type: List[GenericTabletRecordMixin] # noqa 

2730 for ancillary in ancillaries: 

2731 itembranches.append( 

2732 ancillary._get_xml_root( 

2733 req=req, options=ancillary_options 

2734 ) 

2735 ) 

2736 itemcollection = XmlElement(name=attrname, value=itembranches) 

2737 item_collections.append(itemcollection) 

2738 item_collections.sort(key=lambda el: el.name) 

2739 branches += item_collections 

2740 

2741 # Completely separate additional summary tables 

2742 if options.xml_include_calculated: 

2743 item_collections = [] # type: List[XmlElement] 

2744 found_est = False 

2745 for est in self.get_extra_summary_tables(req): 

2746 # ... not get_all_summary_tables(); we handled SNOMED 

2747 # differently, above 

2748 if not found_est and est.rows: 

2749 add_comment(XML_COMMENT_CALCULATED) 

2750 found_est = True 

2751 item_collections.append(est.get_xml_element()) 

2752 item_collections.sort(key=lambda el: el.name) 

2753 branches += item_collections 

2754 

2755 return branches 

2756 

2757 # ------------------------------------------------------------------------- 

2758 # HTML view 

2759 # ------------------------------------------------------------------------- 

2760 

2761 def get_html(self, req: "CamcopsRequest", anonymise: bool = False) -> str: 

2762 """ 

2763 Returns HTML representing the task, for our HTML view. 

2764 

2765 Args: 

2766 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

2767 anonymise: hide patient identifying details? 

2768 """ 

2769 req.prepare_for_html_figures() 

2770 return render( 

2771 "task.mako", 

2772 dict( 

2773 task=self, 

2774 anonymise=anonymise, 

2775 signature=False, 

2776 viewtype=ViewArg.HTML, 

2777 ), 

2778 request=req, 

2779 ) 

2780 

2781 def title_for_html( 

2782 self, req: "CamcopsRequest", anonymise: bool = False 

2783 ) -> str: 

2784 """ 

2785 Returns the plain text used for the HTML ``<title>`` block (by 

2786 ``task.mako``), and also for the PDF title for PDF exports. 

2787 

2788 Should be plain text only. 

2789 

2790 Args: 

2791 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

2792 anonymise: hide patient identifying details? 

2793 """ 

2794 if anonymise: 

2795 patient = "?" 

2796 elif self.patient: 

2797 patient = self.patient.prettystr(req) 

2798 else: 

2799 _ = req.gettext 

2800 patient = _("Anonymous") 

2801 tasktype = self.tablename 

2802 when = format_datetime( 

2803 self.get_creation_datetime(), 

2804 DateFormat.ISO8601_HUMANIZED_TO_MINUTES, 

2805 "", 

2806 ) 

2807 return f"CamCOPS: {patient}; {tasktype}; {when}" 

2808 

2809 # ------------------------------------------------------------------------- 

2810 # PDF view 

2811 # ------------------------------------------------------------------------- 

2812 

2813 def get_pdf(self, req: "CamcopsRequest", anonymise: bool = False) -> bytes: 

2814 """ 

2815 Returns a PDF representing the task. 

2816 

2817 Args: 

2818 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

2819 anonymise: hide patient identifying details? 

2820 """ 

2821 html = self.get_pdf_html(req, anonymise=anonymise) # main content 

2822 if CSS_PAGED_MEDIA: 

2823 return pdf_from_html(req, html=html) 

2824 else: 

2825 return pdf_from_html( 

2826 req, 

2827 html=html, 

2828 header_html=render( 

2829 "wkhtmltopdf_header.mako", 

2830 dict( 

2831 inner_text=render( 

2832 "task_page_header.mako", 

2833 dict(task=self, anonymise=anonymise), 

2834 request=req, 

2835 ) 

2836 ), 

2837 request=req, 

2838 ), 

2839 footer_html=render( 

2840 "wkhtmltopdf_footer.mako", 

2841 dict( 

2842 inner_text=render( 

2843 "task_page_footer.mako", 

2844 dict(task=self), 

2845 request=req, 

2846 ) 

2847 ), 

2848 request=req, 

2849 ), 

2850 extra_wkhtmltopdf_options={ 

2851 "orientation": ( 

2852 "Landscape" 

2853 if self.use_landscape_for_pdf 

2854 else "Portrait" 

2855 ) 

2856 }, 

2857 ) 

2858 

2859 def get_pdf_html( 

2860 self, req: "CamcopsRequest", anonymise: bool = False 

2861 ) -> str: 

2862 """ 

2863 Gets the HTML used to make the PDF (slightly different from the HTML 

2864 used for the HTML view). 

2865 """ 

2866 req.prepare_for_pdf_figures() 

2867 return render( 

2868 "task.mako", 

2869 dict( 

2870 task=self, 

2871 anonymise=anonymise, 

2872 pdf_landscape=self.use_landscape_for_pdf, 

2873 signature=self.has_clinician, 

2874 viewtype=ViewArg.PDF, 

2875 ), 

2876 request=req, 

2877 ) 

2878 

2879 def suggested_pdf_filename( 

2880 self, req: "CamcopsRequest", anonymise: bool = False 

2881 ) -> str: 

2882 """ 

2883 Suggested filename for the PDF copy (for downloads). 

2884 

2885 Args: 

2886 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

2887 anonymise: hide patient identifying details? 

2888 """ 

2889 cfg = req.config 

2890 if anonymise: 

2891 is_anonymous = True 

2892 else: 

2893 is_anonymous = self.is_anonymous 

2894 patient = self.patient 

2895 return get_export_filename( 

2896 req=req, 

2897 patient_spec_if_anonymous=cfg.patient_spec_if_anonymous, 

2898 patient_spec=cfg.patient_spec, 

2899 filename_spec=cfg.task_filename_spec, 

2900 filetype=ViewArg.PDF, 

2901 is_anonymous=is_anonymous, 

2902 surname=patient.get_surname() if patient else "", 

2903 forename=patient.get_forename() if patient else "", 

2904 dob=patient.get_dob() if patient else None, 

2905 sex=patient.get_sex() if patient else None, 

2906 idnum_objects=patient.get_idnum_objects() if patient else None, 

2907 creation_datetime=self.get_creation_datetime(), 

2908 basetable=self.tablename, 

2909 serverpk=self._pk, 

2910 ) 

2911 

2912 def write_pdf_to_disk(self, req: "CamcopsRequest", filename: str) -> None: 

2913 """ 

2914 Writes the PDF to disk, using ``filename``. 

2915 """ 

2916 pdffile = open(filename, "wb") 

2917 pdffile.write(self.get_pdf(req)) 

2918 

2919 # ------------------------------------------------------------------------- 

2920 # Metadata for e.g. RiO 

2921 # ------------------------------------------------------------------------- 

2922 

2923 def get_rio_metadata( 

2924 self, 

2925 req: "CamcopsRequest", 

2926 which_idnum: int, 

2927 uploading_user_id: str, 

2928 document_type: str, 

2929 ) -> str: 

2930 """ 

2931 Returns metadata for the task that Servelec's RiO electronic patient 

2932 record may want. 

2933 

2934 Args: 

2935 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

2936 which_idnum: which CamCOPS ID number type corresponds to the RiO 

2937 client ID? 

2938 uploading_user_id: RiO user ID (string) of the user who will 

2939 be recorded as uploading this information; see below 

2940 document_type: a string indicating the RiO-defined document type 

2941 (this is system-specific); see below 

2942 

2943 Returns: 

2944 a newline-terminated single line of CSV values; see below 

2945 

2946 Called by 

2947 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task`. 

2948 

2949 From Servelec (Lee Meredith) to Rudolf Cardinal, 2014-12-04: 

2950 

2951 .. code-block:: none 

2952 

2953 Batch Document Upload 

2954 

2955 The RiO batch document upload function can be used to upload 

2956 documents in bulk automatically. RiO includes a Batch Upload 

2957 windows service which monitors a designated folder for new files. 

2958 Each file which is scanned must be placed in the designated folder 

2959 along with a meta-data file which describes the document. So 

2960 essentially if a document had been scanned in and was called 

2961 ‘ThisIsANewReferralLetterForAPatient.pdf’ then there would also 

2962 need to be a meta file in the same folder called 

2963 ‘ThisIsANewReferralLetterForAPatient.metadata’. The contents of 

2964 the meta file would need to include the following: 

2965 

2966 Field Order; Field Name; Description; Data Mandatory (Y/N); 

2967 Format 

2968 

2969 1; ClientID; RiO Client ID which identifies the patient in RiO 

2970 against which the document will be uploaded.; Y; 15 

2971 Alphanumeric Characters 

2972 

2973 2; UserID; User ID of the uploaded document, this is any user 

2974 defined within the RiO system and can be a single system user 

2975 called ‘AutomaticDocumentUploadUser’ for example.; Y; 10 

2976 Alphanumeric Characters 

2977 

2978 [NB example longer than that!] 

2979 

2980 3; DocumentType; The RiO defined document type eg: APT; Y; 80 

2981 Alphanumeric Characters 

2982 

2983 4; Title; The title of the document; N; 40 Alphanumeric 

2984 Characters 

2985 

2986 5; Description; The document description.; N; 500 Alphanumeric 

2987 Characters 

2988 

2989 6; Author; The author of the document; N; 80 Alphanumeric 

2990 Characters 

2991 

2992 7; DocumentDate; The date of the document; N; dd/MM/yyyy HH:mm 

2993 

2994 8; FinalRevision; The revision values are 0 Draft or 1 Final, 

2995 this is defaulted to 1 which is Final revision.; N; 0 or 1 

2996 

2997 As an example, this is what would be needed in a meta file: 

2998 

2999 “1000001”,”TRUST1”,”APT”,”A title”, “A description of the 

3000 document”, “An author”,”01/12/2012 09:45”,”1” 

3001 

3002 (on one line) 

3003 

3004 Clarification, from Lee Meredith to Rudolf Cardinal, 2015-02-18: 

3005 

3006 - metadata files must be plain ASCII, not UTF-8 

3007 

3008 - ... here and 

3009 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task` 

3010 

3011 - line terminator is <CR> 

3012 

3013 - BUT see 

3014 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task` 

3015 

3016 - user name limit is 10 characters, despite incorrect example 

3017 

3018 - search for ``RIO_MAX_USER_LEN`` 

3019 

3020 - DocumentType is a code that maps to a human-readable document 

3021 type; for example, "APT" might map to "Appointment Letter". These 

3022 mappings are specific to the local system. (We will probably want 

3023 one that maps to "Clinical Correspondence" in the absence of 

3024 anything more specific.) 

3025 

3026 - RiO will delete the files after it's processed them. 

3027 

3028 - Filenames should avoid spaces, but otherwise any other standard 

3029 ASCII code is fine within filenames. 

3030 

3031 - see 

3032 :meth:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskFileGroup.export_task` 

3033 

3034 """ # noqa 

3035 

3036 try: 

3037 client_id = self.patient.get_idnum_value(which_idnum) 

3038 except AttributeError: 

3039 client_id = "" 

3040 title = "CamCOPS_" + self.shortname 

3041 description = self.longname(req) 

3042 author = self.get_clinician_name() # may be blank 

3043 document_date = format_datetime( 

3044 self.when_created, DateFormat.RIO_EXPORT_UK 

3045 ) 

3046 # This STRIPS the timezone information; i.e. it is in the local 

3047 # timezone but doesn't tell you which timezone that is. (That's fine; 

3048 # it should be local or users would be confused.) 

3049 final_revision = 0 if self.is_live_on_tablet() else 1 

3050 

3051 item_list = [ 

3052 client_id, 

3053 uploading_user_id, 

3054 document_type, 

3055 title, 

3056 description, 

3057 author, 

3058 document_date, 

3059 final_revision, 

3060 ] 

3061 # UTF-8 is NOT supported by RiO for metadata. So: 

3062 csv_line = ",".join( 

3063 [f'"{mangle_unicode_to_ascii(x)}"' for x in item_list] 

3064 ) 

3065 return csv_line + "\n" 

3066 

3067 # ------------------------------------------------------------------------- 

3068 # HTML elements used by tasks 

3069 # ------------------------------------------------------------------------- 

3070 

3071 # noinspection PyMethodMayBeStatic 

3072 def get_standard_clinician_comments_block( 

3073 self, req: "CamcopsRequest", comments: str 

3074 ) -> str: 

3075 """ 

3076 HTML DIV for clinician's comments. 

3077 """ 

3078 return render( 

3079 "clinician_comments.mako", dict(comment=comments), request=req 

3080 ) 

3081 

3082 def get_is_complete_td_pair(self, req: "CamcopsRequest") -> str: 

3083 """ 

3084 HTML to indicate whether task is complete or not, and to make it 

3085 very obvious visually when it isn't. 

3086 """ 

3087 c = self.is_complete() 

3088 td_class = "" if c else f' class="{CssClass.INCOMPLETE}"' 

3089 return ( 

3090 f"<td>Completed?</td>" 

3091 f"<td{td_class}><b>{get_yes_no(req, c)}</b></td>" 

3092 ) 

3093 

3094 def get_is_complete_tr(self, req: "CamcopsRequest") -> str: 

3095 """ 

3096 HTML table row to indicate whether task is complete or not, and to 

3097 make it very obvious visually when it isn't. 

3098 """ 

3099 return f"<tr>{self.get_is_complete_td_pair(req)}</tr>" 

3100 

3101 def get_twocol_val_row( 

3102 self, fieldname: str, default: str = None, label: str = None 

3103 ) -> str: 

3104 """ 

3105 HTML table row, two columns, without web-safing of value. 

3106 

3107 Args: 

3108 fieldname: field (attribute) name; the value will be retrieved 

3109 from this attribute 

3110 default: default to show if the value is ``None`` 

3111 label: descriptive label 

3112 

3113 Returns: 

3114 two-column HTML table row (label, value) 

3115 

3116 """ 

3117 val = getattr(self, fieldname) 

3118 if val is None: 

3119 val = default 

3120 if label is None: 

3121 label = fieldname 

3122 return tr_qa(label, val) 

3123 

3124 def get_twocol_string_row(self, fieldname: str, label: str = None) -> str: 

3125 """ 

3126 HTML table row, two columns, with web-safing of value. 

3127 

3128 Args: 

3129 fieldname: field (attribute) name; the value will be retrieved 

3130 from this attribute 

3131 label: descriptive label 

3132 

3133 Returns: 

3134 two-column HTML table row (label, value) 

3135 """ 

3136 if label is None: 

3137 label = fieldname 

3138 return tr_qa(label, getattr(self, fieldname)) 

3139 

3140 def get_twocol_bool_row( 

3141 self, req: "CamcopsRequest", fieldname: str, label: str = None 

3142 ) -> str: 

3143 """ 

3144 HTML table row, two columns, with Boolean Y/N formatter for value. 

3145 

3146 Args: 

3147 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

3148 fieldname: field (attribute) name; the value will be retrieved 

3149 from this attribute 

3150 label: descriptive label 

3151 

3152 Returns: 

3153 two-column HTML table row (label, value) 

3154 """ 

3155 if label is None: 

3156 label = fieldname 

3157 return tr_qa(label, get_yes_no_none(req, getattr(self, fieldname))) 

3158 

3159 def get_twocol_bool_row_true_false( 

3160 self, req: "CamcopsRequest", fieldname: str, label: str = None 

3161 ) -> str: 

3162 """ 

3163 HTML table row, two columns, with Boolean true/false formatter for 

3164 value. 

3165 

3166 Args: 

3167 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

3168 fieldname: field (attribute) name; the value will be retrieved 

3169 from this attribute 

3170 label: descriptive label 

3171 

3172 Returns: 

3173 two-column HTML table row (label, value) 

3174 """ 

3175 if label is None: 

3176 label = fieldname 

3177 return tr_qa(label, get_true_false_none(req, getattr(self, fieldname))) 

3178 

3179 def get_twocol_bool_row_present_absent( 

3180 self, req: "CamcopsRequest", fieldname: str, label: str = None 

3181 ) -> str: 

3182 """ 

3183 HTML table row, two columns, with Boolean present/absent formatter for 

3184 value. 

3185 

3186 Args: 

3187 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

3188 fieldname: field (attribute) name; the value will be retrieved 

3189 from this attribute 

3190 label: descriptive label 

3191 

3192 Returns: 

3193 two-column HTML table row (label, value) 

3194 """ 

3195 if label is None: 

3196 label = fieldname 

3197 return tr_qa( 

3198 label, get_present_absent_none(req, getattr(self, fieldname)) 

3199 ) 

3200 

3201 @staticmethod 

3202 def get_twocol_picture_row(blob: Optional[Blob], label: str) -> str: 

3203 """ 

3204 HTML table row, two columns, with PNG on right. 

3205 

3206 Args: 

3207 blob: the :class:`camcops_server.cc_modules.cc_blob.Blob` object 

3208 label: descriptive label 

3209 

3210 Returns: 

3211 two-column HTML table row (label, picture) 

3212 """ 

3213 return tr(label, get_blob_img_html(blob)) 

3214 

3215 # ------------------------------------------------------------------------- 

3216 # Field helper functions for subclasses 

3217 # ------------------------------------------------------------------------- 

3218 

3219 def get_values(self, fields: List[str]) -> List: 

3220 """ 

3221 Get list of object's values from list of field names. 

3222 """ 

3223 return [getattr(self, f) for f in fields] 

3224 

3225 def is_field_not_none(self, field: str) -> bool: 

3226 """ 

3227 Is the field not None? 

3228 """ 

3229 return getattr(self, field) is not None 

3230 

3231 def any_fields_none(self, fields: List[str]) -> bool: 

3232 """ 

3233 Are any specified fields None? 

3234 """ 

3235 for f in fields: 

3236 if getattr(self, f) is None: 

3237 return True 

3238 return False 

3239 

3240 def all_fields_not_none(self, fields: List[str]) -> bool: 

3241 """ 

3242 Are all specified fields not None? 

3243 """ 

3244 return not self.any_fields_none(fields) 

3245 

3246 def any_fields_null_or_empty_str(self, fields: List[str]) -> bool: 

3247 """ 

3248 Are any specified fields either None or the empty string? 

3249 """ 

3250 for f in fields: 

3251 v = getattr(self, f) 

3252 if v is None or v == "": 

3253 return True 

3254 return False 

3255 

3256 def are_all_fields_not_null_or_empty_str(self, fields: List[str]) -> bool: 

3257 """ 

3258 Are all specified fields neither None nor the empty string? 

3259 """ 

3260 return not self.any_fields_null_or_empty_str(fields) 

3261 

3262 def n_fields_not_none(self, fields: List[str]) -> int: 

3263 """ 

3264 How many of the specified fields are not None? 

3265 """ 

3266 total = 0 

3267 for f in fields: 

3268 if getattr(self, f) is not None: 

3269 total += 1 

3270 return total 

3271 

3272 def n_fields_none(self, fields: List[str]) -> int: 

3273 """ 

3274 How many of the specified fields are None? 

3275 """ 

3276 total = 0 

3277 for f in fields: 

3278 if getattr(self, f) is None: 

3279 total += 1 

3280 return total 

3281 

3282 def count_booleans(self, fields: List[str]) -> int: 

3283 """ 

3284 How many of the specified fields evaluate to True (are truthy)? 

3285 """ 

3286 total = 0 

3287 for f in fields: 

3288 value = getattr(self, f) 

3289 if value: 

3290 total += 1 

3291 return total 

3292 

3293 def all_truthy(self, fields: List[str]) -> bool: 

3294 """ 

3295 Do all the specified fields evaluate to True (are they all truthy)? 

3296 """ 

3297 for f in fields: 

3298 value = getattr(self, f) 

3299 if not value: 

3300 return False 

3301 return True 

3302 

3303 def count_where(self, fields: List[str], wherevalues: List[Any]) -> int: 

3304 """ 

3305 Count how many values for the specified fields are in ``wherevalues``. 

3306 """ 

3307 return sum(1 for x in self.get_values(fields) if x in wherevalues) 

3308 

3309 def count_wherenot(self, fields: List[str], notvalues: List[Any]) -> int: 

3310 """ 

3311 Count how many values for the specified fields are NOT in 

3312 ``notvalues``. 

3313 """ 

3314 return sum(1 for x in self.get_values(fields) if x not in notvalues) 

3315 

3316 def sum_fields( 

3317 self, fields: List[str], ignorevalue: Any = None 

3318 ) -> Union[int, float]: 

3319 """ 

3320 Sum values stored in all specified fields (skipping any whose value is 

3321 ``ignorevalue``; treating fields containing ``None`` as zero). 

3322 """ 

3323 total = 0 

3324 for f in fields: 

3325 value = getattr(self, f) 

3326 if value == ignorevalue: 

3327 continue 

3328 total += value if value is not None else 0 

3329 return total 

3330 

3331 def mean_fields( 

3332 self, fields: List[str], ignorevalue: Any = None 

3333 ) -> Union[int, float, None]: 

3334 """ 

3335 Return the mean of the values stored in all specified fields (skipping 

3336 any whose value is ``ignorevalue``). 

3337 """ 

3338 values = [] 

3339 for f in fields: 

3340 value = getattr(self, f) 

3341 if value != ignorevalue: 

3342 values.append(value) 

3343 try: 

3344 return statistics.mean(values) 

3345 except (TypeError, statistics.StatisticsError): 

3346 return None 

3347 

3348 @staticmethod 

3349 def fieldnames_from_prefix(prefix: str, start: int, end: int) -> List[str]: 

3350 """ 

3351 Returns a list of field (column, attribute) names from a prefix. 

3352 For example, ``fieldnames_from_prefix("q", 1, 5)`` produces 

3353 ``["q1", "q2", "q3", "q4", "q5"]``. 

3354 

3355 Args: 

3356 prefix: string prefix 

3357 start: first value (inclusive) 

3358 end: last value (inclusive 

3359 

3360 Returns: 

3361 list of fieldnames, as above 

3362 

3363 """ 

3364 return [prefix + str(x) for x in range(start, end + 1)] 

3365 

3366 @staticmethod 

3367 def fieldnames_from_list( 

3368 prefix: str, suffixes: Iterable[Any] 

3369 ) -> List[str]: 

3370 """ 

3371 Returns a list of fieldnames made by appending each suffix to the 

3372 prefix. 

3373 

3374 Args: 

3375 prefix: string prefix 

3376 suffixes: list of suffixes, which will be coerced to ``str`` 

3377 

3378 Returns: 

3379 list of fieldnames, as above 

3380 

3381 """ 

3382 return [prefix + str(x) for x in suffixes] 

3383 

3384 # ------------------------------------------------------------------------- 

3385 # Extra strings 

3386 # ------------------------------------------------------------------------- 

3387 

3388 def get_extrastring_taskname(self) -> str: 

3389 """ 

3390 Get the taskname used as the top-level key for this task's extra 

3391 strings (loaded by the server from XML files). By default, this is the 

3392 task's primary tablename, but tasks may override that via 

3393 ``extrastring_taskname``. 

3394 """ 

3395 return self.extrastring_taskname or self.tablename 

3396 

3397 def extrastrings_exist(self, req: "CamcopsRequest") -> bool: 

3398 """ 

3399 Does the server have any extra strings for this task? 

3400 """ 

3401 return req.task_extrastrings_exist(self.get_extrastring_taskname()) 

3402 

3403 def wxstring( 

3404 self, 

3405 req: "CamcopsRequest", 

3406 name: str, 

3407 defaultvalue: str = None, 

3408 provide_default_if_none: bool = True, 

3409 ) -> str: 

3410 """ 

3411 Return a web-safe version of an extra string for this task. 

3412 

3413 Args: 

3414 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

3415 name: name (second-level key) of the string, within the set of 

3416 this task's extra strings 

3417 defaultvalue: default to return if the string is not found 

3418 provide_default_if_none: if ``True`` and ``default is None``, 

3419 return a helpful missing-string message in the style 

3420 "string x.y not found" 

3421 """ 

3422 if defaultvalue is None and provide_default_if_none: 

3423 defaultvalue = f"[{self.get_extrastring_taskname()}: {name}]" 

3424 return req.wxstring( 

3425 self.get_extrastring_taskname(), 

3426 name, 

3427 defaultvalue, 

3428 provide_default_if_none=provide_default_if_none, 

3429 ) 

3430 

3431 def xstring( 

3432 self, 

3433 req: "CamcopsRequest", 

3434 name: str, 

3435 defaultvalue: str = None, 

3436 provide_default_if_none: bool = True, 

3437 ) -> str: 

3438 """ 

3439 Return a raw (not necessarily web-safe) version of an extra string for 

3440 this task. 

3441 

3442 Args: 

3443 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

3444 name: name (second-level key) of the string, within the set of 

3445 this task's extra strings 

3446 defaultvalue: default to return if the string is not found 

3447 provide_default_if_none: if ``True`` and ``default is None``, 

3448 return a helpful missing-string message in the style 

3449 "string x.y not found" 

3450 """ 

3451 if defaultvalue is None and provide_default_if_none: 

3452 defaultvalue = f"[{self.get_extrastring_taskname()}: {name}]" 

3453 return req.xstring( 

3454 self.get_extrastring_taskname(), 

3455 name, 

3456 defaultvalue, 

3457 provide_default_if_none=provide_default_if_none, 

3458 ) 

3459 

3460 def make_options_from_xstrings( 

3461 self, 

3462 req: "CamcopsRequest", 

3463 prefix: str, 

3464 first: int, 

3465 last: int, 

3466 suffix: str = "", 

3467 ) -> Dict[int, str]: 

3468 """ 

3469 Creates a lookup dictionary from xstrings. 

3470 

3471 Args: 

3472 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

3473 prefix: prefix for xstring 

3474 first: first value 

3475 last: last value 

3476 suffix: optional suffix 

3477 

3478 Returns: 

3479 dict: Each entry maps ``value`` to an xstring named 

3480 ``<PREFIX><VALUE><SUFFIX>``. 

3481 

3482 """ 

3483 d = {} # type: Dict[int, str] 

3484 if first > last: # descending order 

3485 for i in range(first, last - 1, -1): 

3486 d[i] = self.xstring(req, f"{prefix}{i}{suffix}") 

3487 else: # ascending order 

3488 for i in range(first, last + 1): 

3489 d[i] = self.xstring(req, f"{prefix}{i}{suffix}") 

3490 return d 

3491 

3492 @staticmethod 

3493 def make_options_from_numbers(first: int, last: int) -> Dict[int, str]: 

3494 """ 

3495 Creates a simple dictionary mapping numbers to string versions of those 

3496 numbers. Usually for subsequent (more interesting) processing! 

3497 

3498 Args: 

3499 first: first value 

3500 last: last value 

3501 

3502 Returns: 

3503 dict 

3504 

3505 """ 

3506 d = {} # type: Dict[int, str] 

3507 if first > last: # descending order 

3508 for i in range(first, last - 1, -1): 

3509 d[i] = str(i) 

3510 else: # ascending order 

3511 for i in range(first, last + 1): 

3512 d[i] = str(i) 

3513 return d 

3514 

3515 

3516# ============================================================================= 

3517# Collating all task tables for specific purposes 

3518# ============================================================================= 

3519# Function, staticmethod, classmethod? 

3520# https://stackoverflow.com/questions/8108688/in-python-when-should-i-use-a-function-instead-of-a-method # noqa 

3521# https://stackoverflow.com/questions/11788195/module-function-vs-staticmethod-vs-classmethod-vs-no-decorators-which-idiom-is # noqa 

3522# https://stackoverflow.com/questions/15017734/using-static-methods-in-python-best-practice # noqa 

3523 

3524 

3525def all_task_tables_with_min_client_version() -> Dict[str, Version]: 

3526 """ 

3527 Across all tasks, return a mapping from each of their tables to the 

3528 minimum client version. 

3529 

3530 Used by 

3531 :func:`camcops_server.cc_modules.client_api.all_tables_with_min_client_version`. 

3532 

3533 """ # noqa 

3534 d = {} # type: Dict[str, Version] 

3535 classes = list(Task.gen_all_subclasses()) 

3536 for cls in classes: 

3537 d.update(cls.all_tables_with_min_client_version()) 

3538 return d 

3539 

3540 

3541@cache_region_static.cache_on_arguments(function_key_generator=fkg) 

3542def tablename_to_task_class_dict() -> Dict[str, Type[Task]]: 

3543 """ 

3544 Returns a mapping from task base tablenames to task classes. 

3545 """ 

3546 d = {} # type: Dict[str, Type[Task]] 

3547 for cls in Task.gen_all_subclasses(): 

3548 d[cls.tablename] = cls 

3549 return d 

3550 

3551 

3552@cache_region_static.cache_on_arguments(function_key_generator=fkg) 

3553def all_task_tablenames() -> List[str]: 

3554 """ 

3555 Returns all task base table names. 

3556 """ 

3557 d = tablename_to_task_class_dict() 

3558 return list(d.keys()) 

3559 

3560 

3561@cache_region_static.cache_on_arguments(function_key_generator=fkg) 

3562def all_task_classes() -> List[Type[Task]]: 

3563 """ 

3564 Returns all task base table names. 

3565 """ 

3566 d = tablename_to_task_class_dict() 

3567 return list(d.values()) 

3568 

3569 

3570# ============================================================================= 

3571# Support functions 

3572# ============================================================================= 

3573 

3574 

3575def get_from_dict(d: Dict, key: Any, default: Any = INVALID_VALUE) -> Any: 

3576 """ 

3577 Returns a value from a dictionary. This is not a very complex function... 

3578 all it really does in practice is provide a default for ``default``. 

3579 

3580 Args: 

3581 d: the dictionary 

3582 key: the key 

3583 default: value to return if none is provided 

3584 """ 

3585 return d.get(key, default)