Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_task.py 

5 

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

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

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

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

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

15 (at your option) any later version. 

16 

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

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

19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

20 GNU General Public License for more details. 

21 

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

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

24 

25=============================================================================== 

26 

27**Represents CamCOPS tasks.** 

28 

29Core task export methods: 

30 

31======= ======================================================================= 

32Format Comment 

33======= ======================================================================= 

34HTML The task in a user-friendly format. 

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

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

37 hyperlinks. 

38XML Centres on the task with its subdata integrated. 

39TSV Tab-separated value format. 

40SQL As part of an SQL or SQLite download. 

41======= ======================================================================= 

42 

43""" 

44 

45from collections import OrderedDict 

46import datetime 

47import logging 

48import statistics 

49from typing import (Any, Dict, Iterable, Generator, List, Optional, 

50 Tuple, Type, TYPE_CHECKING, Union) 

51 

52from cardinal_pythonlib.classes import classproperty 

53from cardinal_pythonlib.datetimefunc import ( 

54 convert_datetime_to_utc, 

55 format_datetime, 

56 pendulum_to_utc_datetime_without_tz, 

57) 

58from cardinal_pythonlib.logs import BraceStyleAdapter 

59from cardinal_pythonlib.sqlalchemy.orm_inspect import ( 

60 gen_columns, 

61 gen_orm_classes_from_base, 

62) 

63from cardinal_pythonlib.sqlalchemy.schema import is_sqlatype_string 

64from cardinal_pythonlib.stringfunc import mangle_unicode_to_ascii 

65from fhirclient.models.bundle import BundleEntry, BundleEntryRequest 

66from fhirclient.models.fhirreference import FHIRReference 

67from fhirclient.models.identifier import Identifier 

68from fhirclient.models.questionnaire import Questionnaire 

69from fhirclient.models.questionnaireresponse import QuestionnaireResponse 

70import hl7 

71from pendulum import Date, DateTime as Pendulum 

72from pyramid.renderers import render 

73from semantic_version import Version 

74from sqlalchemy.ext.declarative import declared_attr 

75from sqlalchemy.orm import relationship 

76from sqlalchemy.orm.relationships import RelationshipProperty 

77from sqlalchemy.sql.expression import not_, update 

78from sqlalchemy.sql.schema import Column 

79from sqlalchemy.sql.sqltypes import Boolean, DateTime, Float, Integer, Text 

80 

81# from camcops_server.cc_modules.cc_anon import get_cris_dd_rows_from_fieldspecs 

82from camcops_server.cc_modules.cc_audit import audit 

83from camcops_server.cc_modules.cc_blob import Blob, get_blob_img_html 

84from camcops_server.cc_modules.cc_cache import cache_region_static, fkg 

85from camcops_server.cc_modules.cc_constants import ( 

86 CssClass, 

87 CSS_PAGED_MEDIA, 

88 DateFormat, 

89 ERA_NOW, 

90 INVALID_VALUE, 

91) 

92from camcops_server.cc_modules.cc_db import ( 

93 GenericTabletRecordMixin, 

94 TFN_EDITING_TIME_S, 

95 TFN_FIRSTEXIT_IS_ABORT, 

96 TFN_FIRSTEXIT_IS_FINISH, 

97 TFN_WHEN_CREATED, 

98 TFN_WHEN_FIRSTEXIT, 

99) 

100from camcops_server.cc_modules.cc_filename import get_export_filename 

101from camcops_server.cc_modules.cc_hl7 import make_obr_segment, make_obx_segment 

102from camcops_server.cc_modules.cc_html import ( 

103 get_present_absent_none, 

104 get_true_false_none, 

105 get_yes_no, 

106 get_yes_no_none, 

107 tr, 

108 tr_qa, 

109) 

110from camcops_server.cc_modules.cc_pdf import pdf_from_html 

111from camcops_server.cc_modules.cc_pyramid import Routes, ViewArg 

112from camcops_server.cc_modules.cc_simpleobjects import TaskExportOptions 

113from camcops_server.cc_modules.cc_specialnote import SpecialNote 

114from camcops_server.cc_modules.cc_sqla_coltypes import ( 

115 CamcopsColumn, 

116 gen_ancillary_relationships, 

117 get_camcops_blob_column_attr_names, 

118 get_column_attr_names, 

119 PendulumDateTimeAsIsoTextColType, 

120 permitted_value_failure_msgs, 

121 permitted_values_ok, 

122 SemanticVersionColType, 

123 TableNameColType, 

124) 

125from camcops_server.cc_modules.cc_sqlalchemy import Base 

126from camcops_server.cc_modules.cc_summaryelement import ( 

127 ExtraSummaryTable, 

128 SummaryElement, 

129) 

130from camcops_server.cc_modules.cc_version import ( 

131 CAMCOPS_SERVER_VERSION, 

132 MINIMUM_TABLET_VERSION, 

133) 

134from camcops_server.cc_modules.cc_xml import ( 

135 get_xml_document, 

136 XML_COMMENT_ANCILLARY, 

137 XML_COMMENT_ANONYMOUS, 

138 XML_COMMENT_BLOBS, 

139 XML_COMMENT_CALCULATED, 

140 XML_COMMENT_PATIENT, 

141 XML_COMMENT_SNOMED_CT, 

142 XML_COMMENT_SPECIAL_NOTES, 

143 XML_NAME_SNOMED_CODES, 

144 XmlElement, 

145 XmlLiteral, 

146) 

147 

148if TYPE_CHECKING: 

149 from fhirclient.models.questionnaire import QuestionnaireItem 

150 from fhirclient.models.questionnaireresponse import QuestionnaireResponseItem # noqa E501 

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

152 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient # noqa: E501,F401 

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

154 from camcops_server.cc_modules.cc_patientidnum import PatientIdNum # noqa: E501,F401 

155 from camcops_server.cc_modules.cc_request import CamcopsRequest # noqa: E501,F401 

156 from camcops_server.cc_modules.cc_snomed import SnomedExpression # noqa: E501,F401 

157 from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo # noqa: E501,F401 

158 from camcops_server.cc_modules.cc_tsv import TsvPage # noqa: F401 

159 

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

161 

162ANCILLARY_FWD_REF = "Ancillary" 

163TASK_FWD_REF = "Task" 

164 

165SNOMED_TABLENAME = "_snomed_ct" 

166SNOMED_COLNAME_TASKTABLE = "task_tablename" 

167SNOMED_COLNAME_TASKPK = "task_pk" 

168SNOMED_COLNAME_WHENCREATED_UTC = "when_created" 

169SNOMED_COLNAME_EXPRESSION = "snomed_expression" 

170UNUSED_SNOMED_XML_NAME = "snomed_ct_expressions" 

171 

172 

173# ============================================================================= 

174# Patient mixin 

175# ============================================================================= 

176 

177class TaskHasPatientMixin(object): 

178 """ 

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

180 """ 

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

182 

183 # noinspection PyMethodParameters 

184 @declared_attr 

185 def patient_id(cls) -> Column: 

186 """ 

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

188 """ 

189 return Column( 

190 "patient_id", Integer, 

191 nullable=False, index=True, 

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

193 ) 

194 

195 # noinspection PyMethodParameters 

196 @declared_attr 

197 def patient(cls) -> RelationshipProperty: 

198 """ 

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

200 

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

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

203 

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

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

206 

207 """ 

208 return relationship( 

209 "Patient", 

210 primaryjoin=( 

211 "and_(" 

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

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

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

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

216 ")".format( 

217 task=cls.__name__, 

218 ) 

219 ), 

220 uselist=False, 

221 viewonly=True, 

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

223 # unique patients to xlsx 

224 # lazy="select" : 59.7s 

225 # lazy="joined" : 44.3s 

226 # lazy="subquery": 36.9s 

227 # lazy="selectin": 35.3s 

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

229 lazy="selectin" 

230 ) 

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

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

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

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

235 

236 # noinspection PyMethodParameters 

237 @classproperty 

238 def has_patient(cls) -> bool: 

239 """ 

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

241 """ 

242 return True 

243 

244 

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

246# Clinician mixin 

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

248 

249class TaskHasClinicianMixin(object): 

250 """ 

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

252 

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

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

255 """ 

256 # noinspection PyMethodParameters 

257 @declared_attr 

258 def clinician_specialty(cls) -> Column: 

259 return CamcopsColumn( 

260 "clinician_specialty", Text, 

261 exempt_from_anonymisation=True, 

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

263 "(e.g. Liaison Psychiatry)" 

264 ) 

265 

266 # noinspection PyMethodParameters 

267 @declared_attr 

268 def clinician_name(cls) -> Column: 

269 return CamcopsColumn( 

270 "clinician_name", Text, 

271 exempt_from_anonymisation=True, 

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

273 ) 

274 

275 # noinspection PyMethodParameters 

276 @declared_attr 

277 def clinician_professional_registration(cls) -> Column: 

278 return CamcopsColumn( 

279 "clinician_professional_registration", Text, 

280 exempt_from_anonymisation=True, 

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

282 "GMC# 12345)" 

283 ) 

284 

285 # noinspection PyMethodParameters 

286 @declared_attr 

287 def clinician_post(cls) -> Column: 

288 return CamcopsColumn( 

289 "clinician_post", Text, 

290 exempt_from_anonymisation=True, 

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

292 ) 

293 

294 # noinspection PyMethodParameters 

295 @declared_attr 

296 def clinician_service(cls) -> Column: 

297 return CamcopsColumn( 

298 "clinician_service", Text, 

299 exempt_from_anonymisation=True, 

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

301 "Service)" 

302 ) 

303 

304 # noinspection PyMethodParameters 

305 @declared_attr 

306 def clinician_contact_details(cls) -> Column: 

307 return CamcopsColumn( 

308 "clinician_contact_details", Text, 

309 exempt_from_anonymisation=True, 

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

311 "extension)" 

312 ) 

313 

314 # For field order, see also: 

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

316 

317 # noinspection PyMethodParameters 

318 @classproperty 

319 def has_clinician(cls) -> bool: 

320 """ 

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

322 """ 

323 return True 

324 

325 def get_clinician_name(self) -> str: 

326 """ 

327 Returns the clinician's name. 

328 """ 

329 return self.clinician_name or "" 

330 

331 

332# ============================================================================= 

333# Respondent mixin 

334# ============================================================================= 

335 

336class TaskHasRespondentMixin(object): 

337 """ 

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

339 

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

341 as a family member or carer. 

342 

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

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

345 

346 Notes: 

347 

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

349 doesn't work. 

350 """ 

351 

352 # noinspection PyMethodParameters 

353 @declared_attr 

354 def respondent_name(cls) -> Column: 

355 return CamcopsColumn( 

356 "respondent_name", Text, 

357 identifies_patient=True, 

358 comment="(RESPONDENT) Respondent's name" 

359 ) 

360 

361 # noinspection PyMethodParameters 

362 @declared_attr 

363 def respondent_relationship(cls) -> Column: 

364 return Column( 

365 "respondent_relationship", Text, 

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

367 ) 

368 

369 # noinspection PyMethodParameters 

370 @classproperty 

371 def has_respondent(cls) -> bool: 

372 """ 

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

374 """ 

375 return True 

376 

377 def is_respondent_complete(self) -> bool: 

378 """ 

379 Do we have sufficient information about the respondent? 

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

381 """ 

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

383 

384 

385# ============================================================================= 

386# Task base class 

387# ============================================================================= 

388 

389class Task(GenericTabletRecordMixin, Base): 

390 """ 

391 Abstract base class for all tasks. 

392 

393 Note: 

394 

395 - For column definitions: use 

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

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

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

399 things. 

400 

401 """ 

402 __abstract__ = True 

403 

404 # noinspection PyMethodParameters 

405 @declared_attr 

406 def __mapper_args__(cls): 

407 return { 

408 'polymorphic_identity': cls.__name__, 

409 'concrete': True, 

410 } 

411 

412 # ========================================================================= 

413 # PART 0: COLUMNS COMMON TO ALL TASKS 

414 # ========================================================================= 

415 

416 # Columns 

417 

418 # noinspection PyMethodParameters 

419 @declared_attr 

420 def when_created(cls) -> Column: 

421 """ 

422 Column representing the task's creation time. 

423 """ 

424 return Column( 

425 TFN_WHEN_CREATED, PendulumDateTimeAsIsoTextColType, 

426 nullable=False, 

427 comment="(TASK) Date/time this task instance was created (ISO 8601)" 

428 ) 

429 

430 # noinspection PyMethodParameters 

431 @declared_attr 

432 def when_firstexit(cls) -> Column: 

433 """ 

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

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

436 """ 

437 return Column( 

438 TFN_WHEN_FIRSTEXIT, PendulumDateTimeAsIsoTextColType, 

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

440 "(ISO 8601)" 

441 ) 

442 

443 # noinspection PyMethodParameters 

444 @declared_attr 

445 def firstexit_is_finish(cls) -> Column: 

446 """ 

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

448 """ 

449 return Column( 

450 TFN_FIRSTEXIT_IS_FINISH, Boolean, 

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

452 "finished (1)?" 

453 ) 

454 

455 # noinspection PyMethodParameters 

456 @declared_attr 

457 def firstexit_is_abort(cls) -> Column: 

458 """ 

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

460 """ 

461 return Column( 

462 TFN_FIRSTEXIT_IS_ABORT, Boolean, 

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

464 "aborted (1)?" 

465 ) 

466 

467 # noinspection PyMethodParameters 

468 @declared_attr 

469 def editing_time_s(cls) -> Column: 

470 """ 

471 How long has the user spent editing the task? 

472 (Calculated by the CamCOPS client.) 

473 """ 

474 return Column( 

475 TFN_EDITING_TIME_S, Float, 

476 comment="(TASK) Time spent editing (s)" 

477 ) 

478 

479 # Relationships 

480 

481 # noinspection PyMethodParameters 

482 @declared_attr 

483 def special_notes(cls) -> RelationshipProperty: 

484 """ 

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

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

487 """ 

488 return relationship( 

489 SpecialNote, 

490 primaryjoin=( 

491 "and_(" 

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

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

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

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

496 " not_(SpecialNote.hidden)" 

497 ")".format( 

498 task=cls.__name__, 

499 repr_task_tablename=repr(cls.__tablename__), 

500 ) 

501 ), 

502 uselist=True, 

503 order_by="SpecialNote.note_at", 

504 viewonly=True, # for now! 

505 ) 

506 

507 # ========================================================================= 

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

509 # ========================================================================= 

510 # 

511 # Notes: 

512 # 

513 # - for summaries, see GenericTabletRecordMixin.get_summaries 

514 

515 # ------------------------------------------------------------------------- 

516 # Attributes that must be provided 

517 # ------------------------------------------------------------------------- 

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

519 shortname = None # type: str 

520 

521 # ------------------------------------------------------------------------- 

522 # Attributes that can be overridden 

523 # ------------------------------------------------------------------------- 

524 extrastring_taskname = None # type: str # if None, tablename is used instead # noqa 

525 provides_trackers = False 

526 use_landscape_for_pdf = False 

527 dependent_classes = [] 

528 

529 prohibits_clinical = False 

530 prohibits_commercial = False 

531 prohibits_educational = False 

532 prohibits_research = False 

533 

534 @classmethod 

535 def prohibits_anything(cls) -> bool: 

536 return any([cls.prohibits_clinical, 

537 cls.prohibits_commercial, 

538 cls.prohibits_educational, 

539 cls.prohibits_research]) 

540 

541 # ------------------------------------------------------------------------- 

542 # Methods always overridden by the actual task 

543 # ------------------------------------------------------------------------- 

544 

545 @staticmethod 

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

547 """ 

548 Long name (in the relevant language). 

549 """ 

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

551 

552 def is_complete(self) -> bool: 

553 """ 

554 Is the task instance complete? 

555 

556 Must be overridden. 

557 """ 

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

559 

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

561 """ 

562 HTML for the main task content. 

563 

564 Must be overridden by derived classes. 

565 """ 

566 raise NotImplementedError( 

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

568 

569 # ------------------------------------------------------------------------- 

570 # Implement if you provide trackers 

571 # ------------------------------------------------------------------------- 

572 

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

574 """ 

575 Tasks that provide quantitative information for tracking over time 

576 should override this and return a list of 

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

578 objects, one per tracker. 

579 

580 The information is read by 

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

582 

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

584 """ # noqa 

585 return [] 

586 

587 # ------------------------------------------------------------------------- 

588 # Override to provide clinical text 

589 # ------------------------------------------------------------------------- 

590 

591 # noinspection PyMethodMayBeStatic 

592 def get_clinical_text(self, req: "CamcopsRequest") \ 

593 -> Optional[List["CtvInfo"]]: 

594 """ 

595 Tasks that provide clinical text information should override this 

596 to provide a list of 

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

598 

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

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

601 this particular instance, or a list of 

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

603 """ 

604 return None 

605 

606 # ------------------------------------------------------------------------- 

607 # Override some of these if you provide summaries 

608 # ------------------------------------------------------------------------- 

609 

610 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

611 def get_extra_summary_tables( 

612 self, req: "CamcopsRequest") -> List[ExtraSummaryTable]: 

613 """ 

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

615 summary columns to task/ancillary tables. 

616 

617 Return a list of 

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

619 objects. 

620 """ 

621 return [] 

622 

623 # ------------------------------------------------------------------------- 

624 # Implement if you provide SNOMED-CT codes 

625 # ------------------------------------------------------------------------- 

626 

627 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

628 def get_snomed_codes(self, 

629 req: "CamcopsRequest") -> List["SnomedExpression"]: 

630 """ 

631 Returns all SNOMED-CT codes for this task. 

632 

633 Args: 

634 req: the 

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

636 

637 Returns: 

638 a list of 

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

640 objects 

641 

642 """ 

643 return [] 

644 

645 # ========================================================================= 

646 # PART 2: INTERNALS 

647 # ========================================================================= 

648 

649 # ------------------------------------------------------------------------- 

650 # Representations 

651 # ------------------------------------------------------------------------- 

652 

653 def __str__(self) -> str: 

654 if self.is_anonymous: 

655 patient_str = "" 

656 else: 

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

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

659 t=self.tablename, 

660 pk=self.pk, 

661 wc=( 

662 format_datetime(self.when_created, DateFormat.ERA) 

663 if self.when_created else "None" 

664 ), 

665 patient=patient_str, 

666 ) 

667 

668 def __repr__(self) -> str: 

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

670 classname=self.__class__.__qualname__, 

671 pk=self.pk, 

672 wc=( 

673 format_datetime(self.when_created, DateFormat.ERA) 

674 if self.when_created else "None" 

675 ), 

676 ) 

677 

678 # ------------------------------------------------------------------------- 

679 # Way to fetch all task types 

680 # ------------------------------------------------------------------------- 

681 

682 @classmethod 

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

684 """ 

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

686 that is, all task classes. 

687 

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

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

690 

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

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

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

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

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

696 

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

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

699 ``__tablename__``. See 

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

701 """ # noqa 

702 # noinspection PyTypeChecker 

703 return gen_orm_classes_from_base(cls) 

704 

705 @classmethod 

706 @cache_region_static.cache_on_arguments(function_key_generator=fkg) 

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

708 """ 

709 Return all task classes, ordered by table name. 

710 """ 

711 classes = list(cls.gen_all_subclasses()) 

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

713 return classes 

714 

715 @classmethod 

716 @cache_region_static.cache_on_arguments(function_key_generator=fkg) 

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

718 """ 

719 Return all task classes, ordered by short name. 

720 """ 

721 classes = list(cls.gen_all_subclasses()) 

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

723 return classes 

724 

725 @classmethod 

726 def all_subclasses_by_longname( 

727 cls, req: "CamcopsRequest") -> List[Type[TASK_FWD_REF]]: 

728 """ 

729 Return all task classes, ordered by long name. 

730 """ 

731 classes = cls.all_subclasses_by_shortname() 

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

733 return classes 

734 

735 # ------------------------------------------------------------------------- 

736 # Methods that may be overridden by mixins 

737 # ------------------------------------------------------------------------- 

738 

739 # noinspection PyMethodParameters 

740 @classproperty 

741 def has_patient(cls) -> bool: 

742 """ 

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

744 

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

746 """ 

747 return False 

748 

749 # noinspection PyMethodParameters 

750 @classproperty 

751 def is_anonymous(cls) -> bool: 

752 """ 

753 Antonym for :attr:`has_patient`. 

754 """ 

755 return not cls.has_patient 

756 

757 # noinspection PyMethodParameters 

758 @classproperty 

759 def has_clinician(cls) -> bool: 

760 """ 

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

762 

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

764 """ 

765 return False 

766 

767 # noinspection PyMethodParameters 

768 @classproperty 

769 def has_respondent(cls) -> bool: 

770 """ 

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

772 

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

774 """ 

775 return False 

776 

777 # ------------------------------------------------------------------------- 

778 # Other classmethods 

779 # ------------------------------------------------------------------------- 

780 

781 # noinspection PyMethodParameters 

782 @classproperty 

783 def tablename(cls) -> str: 

784 """ 

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

786 """ 

787 return cls.__tablename__ 

788 

789 # noinspection PyMethodParameters 

790 @classproperty 

791 def minimum_client_version(cls) -> Version: 

792 """ 

793 Returns the minimum client version that provides this task. 

794 

795 Override this as you add tasks. 

796 

797 Used by 

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

799 

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

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

802 consequence and the version numbering system also changed, from 

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

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

805 that.) 

806 """ 

807 return MINIMUM_TABLET_VERSION 

808 

809 # noinspection PyMethodParameters 

810 @classmethod 

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

812 """ 

813 Returns a dictionary mapping all this task's tables (primary and 

814 ancillary) to the corresponding minimum client version. 

815 """ 

816 v = cls.minimum_client_version 

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

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

819 d[rel_cls.__tablename__] = v 

820 return d 

821 

822 # ------------------------------------------------------------------------- 

823 # More on fields 

824 # ------------------------------------------------------------------------- 

825 

826 @classmethod 

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

828 """ 

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

830 """ 

831 return get_column_attr_names(cls) 

832 

833 def field_contents_valid(self) -> bool: 

834 """ 

835 Checks field contents validity. 

836 

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

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

839 """ 

840 return permitted_values_ok(self) 

841 

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

843 """ 

844 Explains why contents are invalid. 

845 """ 

846 return permitted_value_failure_msgs(self) 

847 

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

849 """ 

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

851 """ 

852 return get_camcops_blob_column_attr_names(self) 

853 

854 # ------------------------------------------------------------------------- 

855 # Server field calculations 

856 # ------------------------------------------------------------------------- 

857 

858 def is_preserved(self) -> bool: 

859 """ 

860 Is the task preserved and erased from the tablet? 

861 """ 

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

863 

864 def was_forcibly_preserved(self) -> bool: 

865 """ 

866 Was this task forcibly preserved? 

867 """ 

868 return self._forcibly_preserved and self.is_preserved() 

869 

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

871 """ 

872 Creation datetime, or None. 

873 """ 

874 return self.when_created 

875 

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

877 """ 

878 Creation datetime in UTC, or None. 

879 """ 

880 localtime = self.get_creation_datetime() 

881 if localtime is None: 

882 return None 

883 return convert_datetime_to_utc(localtime) 

884 

885 def get_creation_datetime_utc_tz_unaware(self) -> \ 

886 Optional[datetime.datetime]: 

887 """ 

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

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

890 """ 

891 localtime = self.get_creation_datetime() 

892 if localtime is None: 

893 return None 

894 return pendulum_to_utc_datetime_without_tz(localtime) 

895 

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

897 """ 

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

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

900 """ 

901 if not self.firstexit_is_finish: 

902 return None 

903 start = self.get_creation_datetime() 

904 end = self.when_firstexit 

905 if not start or not end: 

906 return None 

907 diff = end - start 

908 return diff.total_seconds() 

909 

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

911 """ 

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

913 """ 

914 # noinspection PyTypeChecker 

915 return self._adding_user_id 

916 

917 def get_adding_user_username(self) -> str: 

918 """ 

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

920 """ 

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

922 

923 def get_removing_user_username(self) -> str: 

924 """ 

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

926 on the client and re-uploading). 

927 """ 

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

929 

930 def get_preserving_user_username(self) -> str: 

931 """ 

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

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

934 """ 

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

936 

937 def get_manually_erasing_user_username(self) -> str: 

938 """ 

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

940 server. 

941 """ 

942 return self._manually_erasing_user.username if self._manually_erasing_user else "" # noqa 

943 

944 # ------------------------------------------------------------------------- 

945 # Summary tables 

946 # ------------------------------------------------------------------------- 

947 

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

949 """ 

950 Returns summary fields/values provided by all tasks. 

951 """ 

952 return [ 

953 SummaryElement( 

954 name="is_complete", 

955 coltype=Boolean(), 

956 value=self.is_complete(), 

957 comment="(GENERIC) Task complete?" 

958 ), 

959 SummaryElement( 

960 name="seconds_from_creation_to_first_finish", 

961 coltype=Float(), 

962 value=self.get_seconds_from_creation_to_first_finish(), 

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

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

965 ), 

966 SummaryElement( 

967 name="camcops_server_version", 

968 coltype=SemanticVersionColType(), 

969 value=CAMCOPS_SERVER_VERSION, 

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

971 "summary information", 

972 ), 

973 ] 

974 

975 def get_all_summary_tables(self, req: "CamcopsRequest") \ 

976 -> List[ExtraSummaryTable]: 

977 """ 

978 Returns all 

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

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

981 SNOMED CT codes if enabled. 

982 """ 

983 tables = self.get_extra_summary_tables(req) 

984 if req.snomed_supported: 

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

986 return tables 

987 

988 def _get_snomed_extra_summary_table(self, req: "CamcopsRequest") \ 

989 -> ExtraSummaryTable: 

990 """ 

991 Returns a 

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

993 for this task's SNOMED CT codes. 

994 """ 

995 codes = self.get_snomed_codes(req) 

996 columns = [ 

997 Column(SNOMED_COLNAME_TASKTABLE, TableNameColType, 

998 comment="Task's base table name"), 

999 Column(SNOMED_COLNAME_TASKPK, Integer, 

1000 comment="Task's server primary key"), 

1001 Column(SNOMED_COLNAME_WHENCREATED_UTC, DateTime, 

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

1003 CamcopsColumn(SNOMED_COLNAME_EXPRESSION, Text, 

1004 exempt_from_anonymisation=True, 

1005 comment="SNOMED CT expression"), 

1006 ] 

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

1008 for code in codes: 

1009 d = OrderedDict([ 

1010 (SNOMED_COLNAME_TASKTABLE, self.tablename), 

1011 (SNOMED_COLNAME_TASKPK, self.pk), 

1012 (SNOMED_COLNAME_WHENCREATED_UTC, 

1013 self.get_creation_datetime_utc_tz_unaware()), 

1014 (SNOMED_COLNAME_EXPRESSION, code.as_string()), 

1015 ]) 

1016 rows.append(d) 

1017 return ExtraSummaryTable( 

1018 tablename=SNOMED_TABLENAME, 

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

1020 columns=columns, 

1021 rows=rows, 

1022 task=self 

1023 ) 

1024 

1025 # ------------------------------------------------------------------------- 

1026 # Testing 

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

1028 

1029 def dump(self) -> None: 

1030 """ 

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

1032 debugging. 

1033 """ 

1034 line_equals = "=" * 79 

1035 lines = ["", line_equals] 

1036 for f in self.get_fieldnames(): 

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

1038 lines.append(line_equals) 

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

1040 

1041 # ------------------------------------------------------------------------- 

1042 # Special notes 

1043 # ------------------------------------------------------------------------- 

1044 

1045 def apply_special_note(self, 

1046 req: "CamcopsRequest", 

1047 note: str, 

1048 from_console: bool = False) -> None: 

1049 """ 

1050 Manually applies a special note to a task. 

1051 

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

1053 WRITES TO THE DATABASE. 

1054 """ 

1055 sn = SpecialNote() 

1056 sn.basetable = self.tablename 

1057 sn.task_id = self.id 

1058 sn.device_id = self._device_id 

1059 sn.era = self._era 

1060 sn.note_at = req.now 

1061 sn.user_id = req.user_id 

1062 sn.note = note 

1063 dbsession = req.dbsession 

1064 dbsession.add(sn) 

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

1066 self.cancel_from_export_log(req, from_console) 

1067 

1068 # ------------------------------------------------------------------------- 

1069 # Clinician 

1070 # ------------------------------------------------------------------------- 

1071 

1072 # noinspection PyMethodMayBeStatic 

1073 def get_clinician_name(self) -> str: 

1074 """ 

1075 Get the clinician's name. 

1076 

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

1078 """ 

1079 return "" 

1080 

1081 # ------------------------------------------------------------------------- 

1082 # Respondent 

1083 # ------------------------------------------------------------------------- 

1084 

1085 # noinspection PyMethodMayBeStatic 

1086 def is_respondent_complete(self) -> bool: 

1087 """ 

1088 Is the respondent information complete? 

1089 

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

1091 """ 

1092 return False 

1093 

1094 # ------------------------------------------------------------------------- 

1095 # About the associated patient 

1096 # ------------------------------------------------------------------------- 

1097 

1098 @property 

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

1100 """ 

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

1102 this task. 

1103 

1104 Overridden by :class:`TaskHasPatientMixin`. 

1105 """ 

1106 return None 

1107 

1108 def is_female(self) -> bool: 

1109 """ 

1110 Is the patient female? 

1111 """ 

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

1113 

1114 def is_male(self) -> bool: 

1115 """ 

1116 Is the patient male? 

1117 """ 

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

1119 

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

1121 """ 

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

1123 """ 

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

1125 

1126 def get_patient_forename(self) -> str: 

1127 """ 

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

1129 """ 

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

1131 

1132 def get_patient_surname(self) -> str: 

1133 """ 

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

1135 """ 

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

1137 

1138 def get_patient_dob(self) -> Optional[Date]: 

1139 """ 

1140 Get the patient's DOB, or None. 

1141 """ 

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

1143 

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

1145 """ 

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

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

1148 """ 

1149 if not self.patient: 

1150 return None 

1151 dob_str = self.patient.get_dob_str() 

1152 if not dob_str: 

1153 return None 

1154 return dob_str[:11] 

1155 

1156 def get_patient_sex(self) -> str: 

1157 """ 

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

1159 """ 

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

1161 

1162 def get_patient_address(self) -> str: 

1163 """ 

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

1165 """ 

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

1167 

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

1169 """ 

1170 Gets all 

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

1172 for the patient. 

1173 """ 

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

1175 

1176 def get_patient_idnum_object(self, 

1177 which_idnum: int) -> Optional["PatientIdNum"]: 

1178 """ 

1179 Get the patient's 

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

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

1182 """ 

1183 return (self.patient.get_idnum_object(which_idnum) if self.patient 

1184 else None) 

1185 

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

1187 """ 

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

1189 

1190 Args: 

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

1192 """ 

1193 idnums = self.get_patient_idnum_objects() 

1194 for idnum in idnums: 

1195 if not idnum.is_fully_valid(req): 

1196 return True 

1197 return False 

1198 

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

1200 """ 

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

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

1203 """ 

1204 idobj = self.get_patient_idnum_object(which_idnum=which_idnum) 

1205 return idobj.idnum_value if idobj else None 

1206 

1207 def get_patient_hl7_pid_segment(self, 

1208 req: "CamcopsRequest", 

1209 recipient_def: "ExportRecipient") \ 

1210 -> Union[hl7.Segment, str]: 

1211 """ 

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

1213 """ 

1214 return (self.patient.get_hl7_pid_segment(req, recipient_def) 

1215 if self.patient else "") 

1216 

1217 # ------------------------------------------------------------------------- 

1218 # HL7 

1219 # ------------------------------------------------------------------------- 

1220 

1221 def get_hl7_data_segments(self, req: "CamcopsRequest", 

1222 recipient_def: "ExportRecipient") \ 

1223 -> List[hl7.Segment]: 

1224 """ 

1225 Returns a list of HL7 data segments. 

1226 

1227 These will be: 

1228 

1229 - OBR segment 

1230 - OBX segment 

1231 - any extra ones offered by the task 

1232 """ 

1233 obr_segment = make_obr_segment(self) 

1234 export_options = recipient_def.get_task_export_options() 

1235 obx_segment = make_obx_segment( 

1236 req, 

1237 self, 

1238 task_format=recipient_def.task_format, 

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

1240 observation_datetime=self.get_creation_datetime(), 

1241 responsible_observer=self.get_clinician_name(), 

1242 export_options=export_options, 

1243 ) 

1244 return [ 

1245 obr_segment, 

1246 obx_segment 

1247 ] + self.get_hl7_extra_data_segments(recipient_def) 

1248 

1249 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

1250 def get_hl7_extra_data_segments(self, recipient_def: "ExportRecipient") \ 

1251 -> List[hl7.Segment]: 

1252 """ 

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

1254 :func:`get_hl7_data_segments`.) 

1255 

1256 May be overridden. 

1257 """ 

1258 return [] 

1259 

1260 # ------------------------------------------------------------------------- 

1261 # FHIR 

1262 # ------------------------------------------------------------------------- 

1263 def get_fhir_bundle_entries(self, 

1264 req: "CamcopsRequest", 

1265 recipient: "ExportRecipient") -> List[Dict]: 

1266 return [ 

1267 self.get_fhir_questionnaire_bundle_entry(req, recipient), 

1268 self.get_fhir_questionnaire_response_bundle_entry(req, recipient), 

1269 ] 

1270 

1271 def get_fhir_questionnaire_bundle_entry( 

1272 self, 

1273 req: "CamcopsRequest", 

1274 recipient: "ExportRecipient") -> Dict: 

1275 questionnaire_url = req.route_url( 

1276 Routes.FHIR_QUESTIONNAIRE_ID, 

1277 ) 

1278 

1279 identifier = Identifier(jsondict={ 

1280 "system": questionnaire_url, 

1281 "value": self.tablename, 

1282 }) 

1283 

1284 questionnaire = Questionnaire(jsondict={ 

1285 "status": "active", # TODO: Support draft / retired / unknown 

1286 "identifier": [identifier.as_json()], 

1287 "item": self.get_fhir_questionnaire_items(req, recipient) 

1288 }) 

1289 

1290 bundle_request = BundleEntryRequest(jsondict={ 

1291 "method": "POST", 

1292 "url": "Questionnaire", 

1293 "ifNoneExist": f"identifier={identifier.system}|{identifier.value}", 

1294 }) 

1295 

1296 return BundleEntry( 

1297 jsondict={ 

1298 "resource": questionnaire.as_json(), 

1299 "request": bundle_request.as_json() 

1300 } 

1301 ).as_json() 

1302 

1303 def get_fhir_questionnaire_response_bundle_entry( 

1304 self, 

1305 req: "CamcopsRequest", 

1306 recipient: "ExportRecipient") -> Dict: 

1307 response_url = req.route_url( 

1308 Routes.FHIR_QUESTIONNAIRE_RESPONSE_ID, 

1309 tablename=self.tablename 

1310 ) 

1311 

1312 identifier = Identifier(jsondict={ 

1313 "system": response_url, 

1314 "value": str(self._pk), 

1315 }) 

1316 

1317 questionnaire_url = req.route_url( 

1318 Routes.FHIR_QUESTIONNAIRE_ID, 

1319 ) 

1320 

1321 jsondict = { 

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

1323 # form 

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

1325 # error is: 

1326 # Invalid resource reference found at 

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

1328 # unknown or not supported on this server 

1329 # - http://127.0.0.1:8000/fhir_questionnaire_id|phq9 

1330 

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

1332 "questionnaire": f"{questionnaire_url}|{self.tablename}", 

1333 "status": "completed" if self.is_complete() else "in-progress", 

1334 "identifier": identifier.as_json(), 

1335 "item": self.get_fhir_questionnaire_response_items(req, recipient) 

1336 } 

1337 

1338 if self.has_patient: 

1339 subject_identifier = self.patient.get_fhir_identifier( 

1340 req, recipient 

1341 ) 

1342 

1343 subject = FHIRReference(jsondict={ 

1344 "identifier": subject_identifier.as_json(), 

1345 "type": "Patient", 

1346 }) 

1347 

1348 jsondict["subject"] = subject.as_json() 

1349 

1350 response = QuestionnaireResponse(jsondict) 

1351 

1352 bundle_request = BundleEntryRequest(jsondict={ 

1353 "method": "POST", 

1354 "url": "QuestionnaireResponse", 

1355 "ifNoneExist": f"identifier={identifier.system}|{identifier.value}", 

1356 }) 

1357 

1358 return BundleEntry( 

1359 jsondict={ 

1360 "resource": response.as_json(), 

1361 "request": bundle_request.as_json() 

1362 } 

1363 ).as_json() 

1364 

1365 def get_fhir_questionnaire_items( 

1366 self, req: "CamcopsRequest", 

1367 recipient: "ExportRecipient") -> List["QuestionnaireItem"]: 

1368 """ 

1369 Return a list of FHIR QuestionnaireItem objects for this task. 

1370 https://www.hl7.org/fhir/questionnaire.html#resource 

1371 

1372 Must be overridden by derived classes. 

1373 """ 

1374 raise NotImplementedError( 

1375 "No get_fhir_questionnaire_items() for this task class!") 

1376 

1377 def get_fhir_questionnaire_response_items( 

1378 self, req: "CamcopsRequest", 

1379 recipient: "ExportRecipient") -> List["QuestionnaireResponseItem"]: 

1380 """ 

1381 Return a list of FHIR QuestionnaireResponseItem objects for this task. 

1382 https://www.hl7.org/fhir/questionnaireresponse.html#resource 

1383 

1384 Must be overridden by derived classes. 

1385 """ 

1386 raise NotImplementedError( 

1387 "No get_fhir_questionnaire_response_items() for this task class!") 

1388 

1389 def cancel_from_export_log(self, req: "CamcopsRequest", 

1390 from_console: bool = False) -> None: 

1391 """ 

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

1393 it will be resent. 

1394 """ 

1395 if self._pk is None: 

1396 return 

1397 from camcops_server.cc_modules.cc_exportmodels import ExportedTask # delayed import # noqa 

1398 # noinspection PyUnresolvedReferences 

1399 statement = ( 

1400 update(ExportedTask.__table__) 

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

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

1403 .where(not_(ExportedTask.cancelled) | 

1404 ExportedTask.cancelled.is_(None)) 

1405 .values(cancelled=1, 

1406 cancelled_at_utc=req.now_utc) 

1407 ) 

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

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

1410 req.dbsession.execute(statement) 

1411 self.audit( 

1412 req, 

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

1414 from_console 

1415 ) 

1416 

1417 # ------------------------------------------------------------------------- 

1418 # Audit 

1419 # ------------------------------------------------------------------------- 

1420 

1421 def audit(self, req: "CamcopsRequest", details: str, 

1422 from_console: bool = False) -> None: 

1423 """ 

1424 Audits actions to this task. 

1425 """ 

1426 audit(req, 

1427 details, 

1428 patient_server_pk=self.get_patient_server_pk(), 

1429 table=self.tablename, 

1430 server_pk=self._pk, 

1431 from_console=from_console) 

1432 

1433 # ------------------------------------------------------------------------- 

1434 # Erasure (wiping, leaving record as placeholder) 

1435 # ------------------------------------------------------------------------- 

1436 

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

1438 """ 

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

1440 Also erases linked non-current records. 

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

1442 

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

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

1445 """ 

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

1447 for task in self.get_lineage(): 

1448 task.manually_erase_with_dependants(req) 

1449 # Audit and clear HL7 message log 

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

1451 self.cancel_from_export_log(req) 

1452 

1453 def is_erased(self) -> bool: 

1454 """ 

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

1456 """ 

1457 return self._manually_erased 

1458 

1459 # ------------------------------------------------------------------------- 

1460 # Complete deletion 

1461 # ------------------------------------------------------------------------- 

1462 

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

1464 """ 

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

1466 """ 

1467 for task in self.get_lineage(): 

1468 task.delete_with_dependants(req) 

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

1470 

1471 # ------------------------------------------------------------------------- 

1472 # Viewing the task in the list of tasks 

1473 # ------------------------------------------------------------------------- 

1474 

1475 def is_live_on_tablet(self) -> bool: 

1476 """ 

1477 Is the task instance live on a tablet? 

1478 """ 

1479 return self._era == ERA_NOW 

1480 

1481 # ------------------------------------------------------------------------- 

1482 # Filtering tasks for the task list 

1483 # ------------------------------------------------------------------------- 

1484 

1485 @classmethod 

1486 def gen_text_filter_columns(cls) -> Generator[Tuple[str, Column], None, 

1487 None]: 

1488 """ 

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

1490 for text filtering. 

1491 """ 

1492 for attrname, column in gen_columns(cls): 

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

1494 continue 

1495 if not is_sqlatype_string(column.type): 

1496 continue 

1497 yield attrname, column 

1498 

1499 @classmethod 

1500 @cache_region_static.cache_on_arguments(function_key_generator=fkg) 

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

1502 """ 

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

1504 for text filtering. 

1505 """ 

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

1507 

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

1509 """ 

1510 Does this task contain the specified text? 

1511 

1512 Args: 

1513 text: 

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

1515 columns 

1516 

1517 Returns: 

1518 is the strings present? 

1519 """ 

1520 text = text.lower() 

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

1522 value = getattr(self, attrname) 

1523 if value is None: 

1524 continue 

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

1526 if text in value.lower(): 

1527 return True 

1528 return False 

1529 

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

1531 """ 

1532 Does this task contain all of the specified strings? 

1533 

1534 Args: 

1535 strings: 

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

1537 one of our text columns 

1538 

1539 Returns: 

1540 are all strings present? 

1541 """ 

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

1543 

1544 # ------------------------------------------------------------------------- 

1545 # TSV export for basic research dump 

1546 # ------------------------------------------------------------------------- 

1547 

1548 def get_tsv_pages(self, req: "CamcopsRequest") -> List["TsvPage"]: 

1549 """ 

1550 Returns information used for the basic research dump in TSV format. 

1551 """ 

1552 # 1. Our core fields, plus summary information 

1553 

1554 main_page = self._get_core_tsv_page(req) 

1555 # 2. Patient details. 

1556 

1557 if self.patient: 

1558 main_page.add_or_set_columns_from_page( 

1559 self.patient.get_tsv_page(req)) 

1560 tsv_pages = [main_page] 

1561 # 3. +/- Ancillary objects 

1562 for ancillary in self.gen_ancillary_instances(): # type: GenericTabletRecordMixin # noqa 

1563 page = ancillary._get_core_tsv_page(req) 

1564 tsv_pages.append(page) 

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

1566 for est in self.get_all_summary_tables(req): 

1567 tsv_pages.append(est.get_tsv_page()) 

1568 # Done 

1569 return tsv_pages 

1570 

1571 # ------------------------------------------------------------------------- 

1572 # XML view 

1573 # ------------------------------------------------------------------------- 

1574 

1575 def get_xml(self, 

1576 req: "CamcopsRequest", 

1577 options: TaskExportOptions = None, 

1578 indent_spaces: int = 4, 

1579 eol: str = '\n') -> str: 

1580 """ 

1581 Returns XML describing the task. 

1582 

1583 Args: 

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

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

1586 

1587 indent_spaces: number of spaces to indent formatted XML 

1588 eol: end-of-line string 

1589 

1590 Returns: 

1591 an XML UTF-8 document representing the task. 

1592 

1593 """ # noqa 

1594 options = options or TaskExportOptions() 

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

1596 return get_xml_document( 

1597 tree, 

1598 indent_spaces=indent_spaces, 

1599 eol=eol, 

1600 include_comments=options.xml_include_comments, 

1601 ) 

1602 

1603 def get_xml_root(self, 

1604 req: "CamcopsRequest", 

1605 options: TaskExportOptions) -> XmlElement: 

1606 """ 

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

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

1609 

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

1611 methods are insufficient. 

1612 

1613 Args: 

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

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

1616 """ # noqa 

1617 # Core (inc. core BLOBs) 

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

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

1620 return tree 

1621 

1622 def _get_xml_core_branches( 

1623 self, 

1624 req: "CamcopsRequest", 

1625 options: TaskExportOptions) -> List[XmlElement]: 

1626 """ 

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

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

1629 depending on the options. 

1630 

1631 Args: 

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

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

1634 """ # noqa 

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

1636 if options.xml_with_header_comments: 

1637 branches.append(comment) 

1638 

1639 options = options or TaskExportOptions(xml_include_plain_columns=True, 

1640 xml_include_ancillary=True, 

1641 include_blobs=False, 

1642 xml_include_calculated=True, 

1643 xml_include_patient=True, 

1644 xml_include_snomed=True) 

1645 

1646 # Stored values +/- calculated values 

1647 core_options = options.clone() 

1648 core_options.include_blobs = False 

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

1650 

1651 # SNOMED-CT codes 

1652 if options.xml_include_snomed and req.snomed_supported: 

1653 add_comment(XML_COMMENT_SNOMED_CT) 

1654 snomed_codes = self.get_snomed_codes(req) 

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

1656 for code in snomed_codes: 

1657 snomed_branches.append(code.xml_element()) 

1658 branches.append(XmlElement(name=XML_NAME_SNOMED_CODES, 

1659 value=snomed_branches)) 

1660 

1661 # Special notes 

1662 add_comment(XML_COMMENT_SPECIAL_NOTES) 

1663 for sn in self.special_notes: 

1664 branches.append(sn.get_xml_root()) 

1665 

1666 # Patient details 

1667 if self.is_anonymous: 

1668 add_comment(XML_COMMENT_ANONYMOUS) 

1669 elif options.xml_include_patient: 

1670 add_comment(XML_COMMENT_PATIENT) 

1671 patient_options = TaskExportOptions( 

1672 xml_include_plain_columns=True, 

1673 xml_with_header_comments=options.xml_with_header_comments) 

1674 if self.patient: 

1675 branches.append(self.patient.get_xml_root( 

1676 req, patient_options)) 

1677 

1678 # BLOBs 

1679 if options.include_blobs: 

1680 add_comment(XML_COMMENT_BLOBS) 

1681 blob_options = TaskExportOptions( 

1682 include_blobs=True, 

1683 xml_skip_fields=options.xml_skip_fields, 

1684 xml_sort_by_name=True, 

1685 xml_with_header_comments=False, 

1686 ) 

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

1688 

1689 # Ancillary objects 

1690 if options.xml_include_ancillary: 

1691 ancillary_options = TaskExportOptions( 

1692 xml_include_plain_columns=True, 

1693 xml_include_ancillary=True, 

1694 include_blobs=options.include_blobs, 

1695 xml_include_calculated=options.xml_include_calculated, 

1696 xml_sort_by_name=True, 

1697 xml_with_header_comments=options.xml_with_header_comments, 

1698 ) 

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

1700 found_ancillary = False 

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

1702 # we iterate through individual ancillaries but clustered by their 

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

1704 # collections). 

1705 for attrname, rel_prop, rel_cls in gen_ancillary_relationships(self): # noqa 

1706 if not found_ancillary: 

1707 add_comment(XML_COMMENT_ANCILLARY) 

1708 found_ancillary = True 

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

1710 if rel_prop.uselist: 

1711 ancillaries = getattr(self, attrname) # type: List[GenericTabletRecordMixin] # noqa 

1712 else: 

1713 ancillaries = [getattr(self, attrname)] # type: List[GenericTabletRecordMixin] # noqa 

1714 for ancillary in ancillaries: 

1715 itembranches.append( 

1716 ancillary._get_xml_root(req=req, 

1717 options=ancillary_options) 

1718 ) 

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

1720 item_collections.append(itemcollection) 

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

1722 branches += item_collections 

1723 

1724 # Completely separate additional summary tables 

1725 if options.xml_include_calculated: 

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

1727 found_est = False 

1728 for est in self.get_extra_summary_tables(req): 

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

1730 # differently, above 

1731 if not found_est and est.rows: 

1732 add_comment(XML_COMMENT_CALCULATED) 

1733 found_est = True 

1734 item_collections.append(est.get_xml_element()) 

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

1736 branches += item_collections 

1737 

1738 return branches 

1739 

1740 # ------------------------------------------------------------------------- 

1741 # HTML view 

1742 # ------------------------------------------------------------------------- 

1743 

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

1745 """ 

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

1747 

1748 Args: 

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

1750 anonymise: hide patient identifying details? 

1751 """ 

1752 req.prepare_for_html_figures() 

1753 return render("task.mako", 

1754 dict(task=self, 

1755 anonymise=anonymise, 

1756 signature=False, 

1757 viewtype=ViewArg.HTML), 

1758 request=req) 

1759 

1760 def title_for_html(self, req: "CamcopsRequest", 

1761 anonymise: bool = False) -> str: 

1762 """ 

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

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

1765 

1766 Should be plain text only. 

1767 

1768 Args: 

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

1770 anonymise: hide patient identifying details? 

1771 """ 

1772 if anonymise: 

1773 patient = "?" 

1774 elif self.patient: 

1775 patient = self.patient.prettystr(req) 

1776 else: 

1777 _ = req.gettext 

1778 patient = _("Anonymous") 

1779 tasktype = self.tablename 

1780 when = format_datetime(self.get_creation_datetime(), 

1781 DateFormat.ISO8601_HUMANIZED_TO_MINUTES, "") 

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

1783 

1784 # ------------------------------------------------------------------------- 

1785 # PDF view 

1786 # ------------------------------------------------------------------------- 

1787 

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

1789 """ 

1790 Returns a PDF representing the task. 

1791 

1792 Args: 

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

1794 anonymise: hide patient identifying details? 

1795 """ 

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

1797 if CSS_PAGED_MEDIA: 

1798 return pdf_from_html(req, html=html) 

1799 else: 

1800 return pdf_from_html( 

1801 req, 

1802 html=html, 

1803 header_html=render( 

1804 "wkhtmltopdf_header.mako", 

1805 dict(inner_text=render("task_page_header.mako", 

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

1807 request=req)), 

1808 request=req 

1809 ), 

1810 footer_html=render( 

1811 "wkhtmltopdf_footer.mako", 

1812 dict(inner_text=render("task_page_footer.mako", 

1813 dict(task=self), 

1814 request=req)), 

1815 request=req 

1816 ), 

1817 extra_wkhtmltopdf_options={ 

1818 "orientation": ("Landscape" if self.use_landscape_for_pdf 

1819 else "Portrait") 

1820 } 

1821 ) 

1822 

1823 def get_pdf_html(self, req: "CamcopsRequest", 

1824 anonymise: bool = False) -> str: 

1825 """ 

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

1827 used for the HTML view). 

1828 """ 

1829 req.prepare_for_pdf_figures() 

1830 return render("task.mako", 

1831 dict(task=self, 

1832 anonymise=anonymise, 

1833 pdf_landscape=self.use_landscape_for_pdf, 

1834 signature=self.has_clinician, 

1835 viewtype=ViewArg.PDF), 

1836 request=req) 

1837 

1838 def suggested_pdf_filename(self, req: "CamcopsRequest", 

1839 anonymise: bool = False) -> str: 

1840 """ 

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

1842 

1843 Args: 

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

1845 anonymise: hide patient identifying details? 

1846 """ 

1847 cfg = req.config 

1848 if anonymise: 

1849 is_anonymous = True 

1850 else: 

1851 is_anonymous = self.is_anonymous 

1852 patient = self.patient 

1853 return get_export_filename( 

1854 req=req, 

1855 patient_spec_if_anonymous=cfg.patient_spec_if_anonymous, 

1856 patient_spec=cfg.patient_spec, 

1857 filename_spec=cfg.task_filename_spec, 

1858 filetype=ViewArg.PDF, 

1859 is_anonymous=is_anonymous, 

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

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

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

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

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

1865 creation_datetime=self.get_creation_datetime(), 

1866 basetable=self.tablename, 

1867 serverpk=self._pk 

1868 ) 

1869 

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

1871 """ 

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

1873 """ 

1874 pdffile = open(filename, "wb") 

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

1876 

1877 # ------------------------------------------------------------------------- 

1878 # Metadata for e.g. RiO 

1879 # ------------------------------------------------------------------------- 

1880 

1881 def get_rio_metadata(self, 

1882 req: "CamcopsRequest", 

1883 which_idnum: int, 

1884 uploading_user_id: str, 

1885 document_type: str) -> str: 

1886 """ 

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

1888 record may want. 

1889 

1890 Args: 

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

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

1893 client ID? 

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

1895 be recorded as uploading this information; see below 

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

1897 (this is system-specific); see below 

1898 

1899 Returns: 

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

1901 

1902 Called by 

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

1904 

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

1906 

1907 .. code-block:: none 

1908 

1909 Batch Document Upload 

1910 

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

1912 documents in bulk automatically. RiO includes a Batch Upload 

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

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

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

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

1917 ‘ThisIsANewReferralLetterForAPatient.pdf’ then there would also 

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

1919 ‘ThisIsANewReferralLetterForAPatient.metadata’. The contents of 

1920 the meta file would need to include the following: 

1921 

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

1923 Format 

1924 

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

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

1927 Alphanumeric Characters 

1928 

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

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

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

1932 Alphanumeric Characters 

1933 

1934 [NB example longer than that!] 

1935 

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

1937 Alphanumeric Characters 

1938 

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

1940 Characters 

1941 

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

1943 Characters 

1944 

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

1946 Characters 

1947 

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

1949 

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

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

1952 

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

1954 

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

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

1957 

1958 (on one line) 

1959 

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

1961 

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

1963 

1964 - ... here and 

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

1966 

1967 - line terminator is <CR> 

1968 

1969 - BUT see 

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

1971 

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

1973 

1974 - search for ``RIO_MAX_USER_LEN`` 

1975 

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

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

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

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

1980 anything more specific.) 

1981 

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

1983 

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

1985 ASCII code is fine within filenames. 

1986 

1987 - see 

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

1989 

1990 """ # noqa 

1991 

1992 try: 

1993 client_id = self.patient.get_idnum_value(which_idnum) 

1994 except AttributeError: 

1995 client_id = "" 

1996 title = "CamCOPS_" + self.shortname 

1997 description = self.longname(req) 

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

1999 document_date = format_datetime(self.when_created, 

2000 DateFormat.RIO_EXPORT_UK) 

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

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

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

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

2005 

2006 item_list = [ 

2007 client_id, 

2008 uploading_user_id, 

2009 document_type, 

2010 title, 

2011 description, 

2012 author, 

2013 document_date, 

2014 final_revision 

2015 ] 

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

2017 csv_line = ",".join([f'"{mangle_unicode_to_ascii(x)}"' 

2018 for x in item_list]) 

2019 return csv_line + "\n" 

2020 

2021 # ------------------------------------------------------------------------- 

2022 # HTML elements used by tasks 

2023 # ------------------------------------------------------------------------- 

2024 

2025 # noinspection PyMethodMayBeStatic 

2026 def get_standard_clinician_comments_block(self, 

2027 req: "CamcopsRequest", 

2028 comments: str) -> str: 

2029 """ 

2030 HTML DIV for clinician's comments. 

2031 """ 

2032 return render("clinician_comments.mako", 

2033 dict(comment=comments), 

2034 request=req) 

2035 

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

2037 """ 

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

2039 very obvious visually when it isn't. 

2040 """ 

2041 c = self.is_complete() 

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

2043 return ( 

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

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

2046 ) 

2047 

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

2049 """ 

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

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

2052 """ 

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

2054 

2055 def get_twocol_val_row(self, 

2056 fieldname: str, 

2057 default: str = None, 

2058 label: str = None) -> str: 

2059 """ 

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

2061 

2062 Args: 

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

2064 from this attribute 

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

2066 label: descriptive label 

2067 

2068 Returns: 

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

2070 

2071 """ 

2072 val = getattr(self, fieldname) 

2073 if val is None: 

2074 val = default 

2075 if label is None: 

2076 label = fieldname 

2077 return tr_qa(label, val) 

2078 

2079 def get_twocol_string_row(self, 

2080 fieldname: str, 

2081 label: str = None) -> str: 

2082 """ 

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

2084 

2085 Args: 

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

2087 from this attribute 

2088 label: descriptive label 

2089 

2090 Returns: 

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

2092 """ 

2093 if label is None: 

2094 label = fieldname 

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

2096 

2097 def get_twocol_bool_row(self, 

2098 req: "CamcopsRequest", 

2099 fieldname: str, 

2100 label: str = None) -> str: 

2101 """ 

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

2103 

2104 Args: 

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

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

2107 from this attribute 

2108 label: descriptive label 

2109 

2110 Returns: 

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

2112 """ 

2113 if label is None: 

2114 label = fieldname 

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

2116 

2117 def get_twocol_bool_row_true_false(self, 

2118 req: "CamcopsRequest", 

2119 fieldname: str, 

2120 label: str = None) -> str: 

2121 """ 

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

2123 value. 

2124 

2125 Args: 

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

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

2128 from this attribute 

2129 label: descriptive label 

2130 

2131 Returns: 

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

2133 """ 

2134 if label is None: 

2135 label = fieldname 

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

2137 

2138 def get_twocol_bool_row_present_absent(self, 

2139 req: "CamcopsRequest", 

2140 fieldname: str, 

2141 label: str = None) -> str: 

2142 """ 

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

2144 value. 

2145 

2146 Args: 

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

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

2149 from this attribute 

2150 label: descriptive label 

2151 

2152 Returns: 

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

2154 """ 

2155 if label is None: 

2156 label = fieldname 

2157 return tr_qa(label, get_present_absent_none(req, 

2158 getattr(self, fieldname))) 

2159 

2160 @staticmethod 

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

2162 """ 

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

2164 

2165 Args: 

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

2167 label: descriptive label 

2168 

2169 Returns: 

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

2171 """ 

2172 return tr(label, get_blob_img_html(blob)) 

2173 

2174 # ------------------------------------------------------------------------- 

2175 # Field helper functions for subclasses 

2176 # ------------------------------------------------------------------------- 

2177 

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

2179 """ 

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

2181 """ 

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

2183 

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

2185 """ 

2186 Is the field not None? 

2187 """ 

2188 return getattr(self, field) is not None 

2189 

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

2191 """ 

2192 Are any specified fields None? 

2193 """ 

2194 for f in fields: 

2195 if getattr(self, f) is None: 

2196 return True 

2197 return False 

2198 

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

2200 """ 

2201 Are all specified fields not None? 

2202 """ 

2203 return not self.any_fields_none(fields) 

2204 

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

2206 """ 

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

2208 """ 

2209 for f in fields: 

2210 v = getattr(self, f) 

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

2212 return True 

2213 return False 

2214 

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

2216 """ 

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

2218 """ 

2219 return not self.any_fields_null_or_empty_str(fields) 

2220 

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

2222 """ 

2223 How many of the specified fields are not None? 

2224 """ 

2225 total = 0 

2226 for f in fields: 

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

2228 total += 1 

2229 return total 

2230 

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

2232 """ 

2233 How many of the specified fields are None? 

2234 """ 

2235 total = 0 

2236 for f in fields: 

2237 if getattr(self, f) is None: 

2238 total += 1 

2239 return total 

2240 

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

2242 """ 

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

2244 """ 

2245 total = 0 

2246 for f in fields: 

2247 value = getattr(self, f) 

2248 if value: 

2249 total += 1 

2250 return total 

2251 

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

2253 """ 

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

2255 """ 

2256 for f in fields: 

2257 value = getattr(self, f) 

2258 if not value: 

2259 return False 

2260 return True 

2261 

2262 def count_where(self, 

2263 fields: List[str], 

2264 wherevalues: List[Any]) -> int: 

2265 """ 

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

2267 """ 

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

2269 

2270 def count_wherenot(self, 

2271 fields: List[str], 

2272 notvalues: List[Any]) -> int: 

2273 """ 

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

2275 ``notvalues``. 

2276 """ 

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

2278 

2279 def sum_fields(self, 

2280 fields: List[str], 

2281 ignorevalue: Any = None) -> Union[int, float]: 

2282 """ 

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

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

2285 """ 

2286 total = 0 

2287 for f in fields: 

2288 value = getattr(self, f) 

2289 if value == ignorevalue: 

2290 continue 

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

2292 return total 

2293 

2294 def mean_fields(self, 

2295 fields: List[str], 

2296 ignorevalue: Any = None) -> Union[int, float, None]: 

2297 """ 

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

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

2300 """ 

2301 values = [] 

2302 for f in fields: 

2303 value = getattr(self, f) 

2304 if value != ignorevalue: 

2305 values.append(value) 

2306 try: 

2307 return statistics.mean(values) 

2308 except (TypeError, statistics.StatisticsError): 

2309 return None 

2310 

2311 @staticmethod 

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

2313 """ 

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

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

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

2317 

2318 Args: 

2319 prefix: string prefix 

2320 start: first value (inclusive) 

2321 end: last value (inclusive 

2322 

2323 Returns: 

2324 list of fieldnames, as above 

2325 

2326 """ 

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

2328 

2329 @staticmethod 

2330 def fieldnames_from_list(prefix: str, 

2331 suffixes: Iterable[Any]) -> List[str]: 

2332 """ 

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

2334 prefix. 

2335 

2336 Args: 

2337 prefix: string prefix 

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

2339 

2340 Returns: 

2341 list of fieldnames, as above 

2342 

2343 """ 

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

2345 

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

2347 # Extra strings 

2348 # ------------------------------------------------------------------------- 

2349 

2350 def get_extrastring_taskname(self) -> str: 

2351 """ 

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

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

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

2355 ``extrastring_taskname``. 

2356 """ 

2357 return self.extrastring_taskname or self.tablename 

2358 

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

2360 """ 

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

2362 """ 

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

2364 

2365 def wxstring(self, 

2366 req: "CamcopsRequest", 

2367 name: str, 

2368 defaultvalue: str = None, 

2369 provide_default_if_none: bool = True) -> str: 

2370 """ 

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

2372 

2373 Args: 

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

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

2376 this task's extra strings 

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

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

2379 return a helpful missing-string message in the style 

2380 "string x.y not found" 

2381 """ 

2382 if defaultvalue is None and provide_default_if_none: 

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

2384 return req.wxstring( 

2385 self.get_extrastring_taskname(), 

2386 name, 

2387 defaultvalue, 

2388 provide_default_if_none=provide_default_if_none) 

2389 

2390 def xstring(self, 

2391 req: "CamcopsRequest", 

2392 name: str, 

2393 defaultvalue: str = None, 

2394 provide_default_if_none: bool = True) -> str: 

2395 """ 

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

2397 this task. 

2398 

2399 Args: 

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

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

2402 this task's extra strings 

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

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

2405 return a helpful missing-string message in the style 

2406 "string x.y not found" 

2407 """ 

2408 if defaultvalue is None and provide_default_if_none: 

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

2410 return req.xstring( 

2411 self.get_extrastring_taskname(), 

2412 name, 

2413 defaultvalue, 

2414 provide_default_if_none=provide_default_if_none) 

2415 

2416 def make_options_from_xstrings(self, 

2417 req: "CamcopsRequest", 

2418 prefix: str, first: int, last: int, 

2419 suffix: str = "") -> Dict[int, str]: 

2420 """ 

2421 Creates a lookup dictionary from xstrings. 

2422 

2423 Args: 

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

2425 prefix: prefix for xstring 

2426 first: first value 

2427 last: last value 

2428 suffix: optional suffix 

2429 

2430 Returns: 

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

2432 ``<PREFIX><VALUE><SUFFIX>``. 

2433 

2434 """ 

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

2436 if first > last: # descending order 

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

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

2439 else: # ascending order 

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

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

2442 return d 

2443 

2444 @staticmethod 

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

2446 """ 

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

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

2449 

2450 Args: 

2451 first: first value 

2452 last: last value 

2453 

2454 Returns: 

2455 dict 

2456 

2457 """ 

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

2459 if first > last: # descending order 

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

2461 d[i] = str(i) 

2462 else: # ascending order 

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

2464 d[i] = str(i) 

2465 return d 

2466 

2467 

2468# ============================================================================= 

2469# Collating all task tables for specific purposes 

2470# ============================================================================= 

2471# Function, staticmethod, classmethod? 

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

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

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

2475 

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

2477 """ 

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

2479 minimum client version. 

2480 

2481 Used by 

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

2483 

2484 """ # noqa 

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

2486 classes = list(Task.gen_all_subclasses()) 

2487 for cls in classes: 

2488 d.update(cls.all_tables_with_min_client_version()) 

2489 return d 

2490 

2491 

2492@cache_region_static.cache_on_arguments(function_key_generator=fkg) 

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

2494 """ 

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

2496 """ 

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

2498 for cls in Task.gen_all_subclasses(): 

2499 d[cls.tablename] = cls 

2500 return d 

2501 

2502 

2503@cache_region_static.cache_on_arguments(function_key_generator=fkg) 

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

2505 """ 

2506 Returns all task base table names. 

2507 """ 

2508 d = tablename_to_task_class_dict() 

2509 return list(d.keys()) 

2510 

2511 

2512@cache_region_static.cache_on_arguments(function_key_generator=fkg) 

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

2514 """ 

2515 Returns all task base table names. 

2516 """ 

2517 d = tablename_to_task_class_dict() 

2518 return list(d.values()) 

2519 

2520 

2521# ============================================================================= 

2522# Support functions 

2523# ============================================================================= 

2524 

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

2526 """ 

2527 Returns a value from a dictionary. This is not a very complex function... 

2528 all it really does in practice is provide a default for ``default``. 

2529 

2530 Args: 

2531 d: the dictionary 

2532 key: the key 

2533 default: value to return if none is provided 

2534 """ 

2535 return d.get(key, default)