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 subject_identifier = self.patient.get_fhir_identifier(req, recipient) 

1322 

1323 subject = FHIRReference(jsondict={ 

1324 "identifier": subject_identifier.as_json(), 

1325 "type": "Patient", 

1326 }) 

1327 

1328 response = QuestionnaireResponse(jsondict={ 

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

1330 # form 

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

1332 # error is: 

1333 # Invalid resource reference found at 

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

1335 # unknown or not supported on this server 

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

1337 

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

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

1340 "subject": subject.as_json(), 

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

1342 "identifier": identifier.as_json(), 

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

1344 }) 

1345 

1346 bundle_request = BundleEntryRequest(jsondict={ 

1347 "method": "POST", 

1348 "url": "QuestionnaireResponse", 

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

1350 }) 

1351 

1352 return BundleEntry( 

1353 jsondict={ 

1354 "resource": response.as_json(), 

1355 "request": bundle_request.as_json() 

1356 } 

1357 ).as_json() 

1358 

1359 def get_fhir_questionnaire_items( 

1360 self, req: "CamcopsRequest", 

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

1362 """ 

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

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

1365 

1366 Must be overridden by derived classes. 

1367 """ 

1368 raise NotImplementedError( 

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

1370 

1371 def get_fhir_questionnaire_response_items( 

1372 self, req: "CamcopsRequest", 

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

1374 """ 

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

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

1377 

1378 Must be overridden by derived classes. 

1379 """ 

1380 raise NotImplementedError( 

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

1382 

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

1384 from_console: bool = False) -> None: 

1385 """ 

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

1387 it will be resent. 

1388 """ 

1389 if self._pk is None: 

1390 return 

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

1392 # noinspection PyUnresolvedReferences 

1393 statement = ( 

1394 update(ExportedTask.__table__) 

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

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

1397 .where(not_(ExportedTask.cancelled) | 

1398 ExportedTask.cancelled.is_(None)) 

1399 .values(cancelled=1, 

1400 cancelled_at_utc=req.now_utc) 

1401 ) 

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

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

1404 req.dbsession.execute(statement) 

1405 self.audit( 

1406 req, 

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

1408 from_console 

1409 ) 

1410 

1411 # ------------------------------------------------------------------------- 

1412 # Audit 

1413 # ------------------------------------------------------------------------- 

1414 

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

1416 from_console: bool = False) -> None: 

1417 """ 

1418 Audits actions to this task. 

1419 """ 

1420 audit(req, 

1421 details, 

1422 patient_server_pk=self.get_patient_server_pk(), 

1423 table=self.tablename, 

1424 server_pk=self._pk, 

1425 from_console=from_console) 

1426 

1427 # ------------------------------------------------------------------------- 

1428 # Erasure (wiping, leaving record as placeholder) 

1429 # ------------------------------------------------------------------------- 

1430 

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

1432 """ 

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

1434 Also erases linked non-current records. 

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

1436 

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

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

1439 """ 

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

1441 for task in self.get_lineage(): 

1442 task.manually_erase_with_dependants(req) 

1443 # Audit and clear HL7 message log 

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

1445 self.cancel_from_export_log(req) 

1446 

1447 def is_erased(self) -> bool: 

1448 """ 

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

1450 """ 

1451 return self._manually_erased 

1452 

1453 # ------------------------------------------------------------------------- 

1454 # Complete deletion 

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

1456 

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

1458 """ 

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

1460 """ 

1461 for task in self.get_lineage(): 

1462 task.delete_with_dependants(req) 

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

1464 

1465 # ------------------------------------------------------------------------- 

1466 # Viewing the task in the list of tasks 

1467 # ------------------------------------------------------------------------- 

1468 

1469 def is_live_on_tablet(self) -> bool: 

1470 """ 

1471 Is the task instance live on a tablet? 

1472 """ 

1473 return self._era == ERA_NOW 

1474 

1475 # ------------------------------------------------------------------------- 

1476 # Filtering tasks for the task list 

1477 # ------------------------------------------------------------------------- 

1478 

1479 @classmethod 

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

1481 None]: 

1482 """ 

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

1484 for text filtering. 

1485 """ 

1486 for attrname, column in gen_columns(cls): 

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

1488 continue 

1489 if not is_sqlatype_string(column.type): 

1490 continue 

1491 yield attrname, column 

1492 

1493 @classmethod 

1494 @cache_region_static.cache_on_arguments(function_key_generator=fkg) 

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

1496 """ 

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

1498 for text filtering. 

1499 """ 

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

1501 

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

1503 """ 

1504 Does this task contain the specified text? 

1505 

1506 Args: 

1507 text: 

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

1509 columns 

1510 

1511 Returns: 

1512 is the strings present? 

1513 """ 

1514 text = text.lower() 

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

1516 value = getattr(self, attrname) 

1517 if value is None: 

1518 continue 

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

1520 if text in value.lower(): 

1521 return True 

1522 return False 

1523 

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

1525 """ 

1526 Does this task contain all of the specified strings? 

1527 

1528 Args: 

1529 strings: 

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

1531 one of our text columns 

1532 

1533 Returns: 

1534 are all strings present? 

1535 """ 

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

1537 

1538 # ------------------------------------------------------------------------- 

1539 # TSV export for basic research dump 

1540 # ------------------------------------------------------------------------- 

1541 

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

1543 """ 

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

1545 """ 

1546 # 1. Our core fields, plus summary information 

1547 

1548 main_page = self._get_core_tsv_page(req) 

1549 # 2. Patient details. 

1550 

1551 if self.patient: 

1552 main_page.add_or_set_columns_from_page( 

1553 self.patient.get_tsv_page(req)) 

1554 tsv_pages = [main_page] 

1555 # 3. +/- Ancillary objects 

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

1557 page = ancillary._get_core_tsv_page(req) 

1558 tsv_pages.append(page) 

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

1560 for est in self.get_all_summary_tables(req): 

1561 tsv_pages.append(est.get_tsv_page()) 

1562 # Done 

1563 return tsv_pages 

1564 

1565 # ------------------------------------------------------------------------- 

1566 # XML view 

1567 # ------------------------------------------------------------------------- 

1568 

1569 def get_xml(self, 

1570 req: "CamcopsRequest", 

1571 options: TaskExportOptions = None, 

1572 indent_spaces: int = 4, 

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

1574 """ 

1575 Returns XML describing the task. 

1576 

1577 Args: 

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

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

1580 

1581 indent_spaces: number of spaces to indent formatted XML 

1582 eol: end-of-line string 

1583 

1584 Returns: 

1585 an XML UTF-8 document representing the task. 

1586 

1587 """ # noqa 

1588 options = options or TaskExportOptions() 

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

1590 return get_xml_document( 

1591 tree, 

1592 indent_spaces=indent_spaces, 

1593 eol=eol, 

1594 include_comments=options.xml_include_comments, 

1595 ) 

1596 

1597 def get_xml_root(self, 

1598 req: "CamcopsRequest", 

1599 options: TaskExportOptions) -> XmlElement: 

1600 """ 

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

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

1603 

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

1605 methods are insufficient. 

1606 

1607 Args: 

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

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

1610 """ # noqa 

1611 # Core (inc. core BLOBs) 

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

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

1614 return tree 

1615 

1616 def _get_xml_core_branches( 

1617 self, 

1618 req: "CamcopsRequest", 

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

1620 """ 

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

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

1623 depending on the options. 

1624 

1625 Args: 

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

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

1628 """ # noqa 

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

1630 if options.xml_with_header_comments: 

1631 branches.append(comment) 

1632 

1633 options = options or TaskExportOptions(xml_include_plain_columns=True, 

1634 xml_include_ancillary=True, 

1635 include_blobs=False, 

1636 xml_include_calculated=True, 

1637 xml_include_patient=True, 

1638 xml_include_snomed=True) 

1639 

1640 # Stored values +/- calculated values 

1641 core_options = options.clone() 

1642 core_options.include_blobs = False 

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

1644 

1645 # SNOMED-CT codes 

1646 if options.xml_include_snomed and req.snomed_supported: 

1647 add_comment(XML_COMMENT_SNOMED_CT) 

1648 snomed_codes = self.get_snomed_codes(req) 

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

1650 for code in snomed_codes: 

1651 snomed_branches.append(code.xml_element()) 

1652 branches.append(XmlElement(name=XML_NAME_SNOMED_CODES, 

1653 value=snomed_branches)) 

1654 

1655 # Special notes 

1656 add_comment(XML_COMMENT_SPECIAL_NOTES) 

1657 for sn in self.special_notes: 

1658 branches.append(sn.get_xml_root()) 

1659 

1660 # Patient details 

1661 if self.is_anonymous: 

1662 add_comment(XML_COMMENT_ANONYMOUS) 

1663 elif options.xml_include_patient: 

1664 add_comment(XML_COMMENT_PATIENT) 

1665 patient_options = TaskExportOptions( 

1666 xml_include_plain_columns=True, 

1667 xml_with_header_comments=options.xml_with_header_comments) 

1668 if self.patient: 

1669 branches.append(self.patient.get_xml_root( 

1670 req, patient_options)) 

1671 

1672 # BLOBs 

1673 if options.include_blobs: 

1674 add_comment(XML_COMMENT_BLOBS) 

1675 blob_options = TaskExportOptions( 

1676 include_blobs=True, 

1677 xml_skip_fields=options.xml_skip_fields, 

1678 xml_sort_by_name=True, 

1679 xml_with_header_comments=False, 

1680 ) 

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

1682 

1683 # Ancillary objects 

1684 if options.xml_include_ancillary: 

1685 ancillary_options = TaskExportOptions( 

1686 xml_include_plain_columns=True, 

1687 xml_include_ancillary=True, 

1688 include_blobs=options.include_blobs, 

1689 xml_include_calculated=options.xml_include_calculated, 

1690 xml_sort_by_name=True, 

1691 xml_with_header_comments=options.xml_with_header_comments, 

1692 ) 

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

1694 found_ancillary = False 

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

1696 # we iterate through individual ancillaries but clustered by their 

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

1698 # collections). 

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

1700 if not found_ancillary: 

1701 add_comment(XML_COMMENT_ANCILLARY) 

1702 found_ancillary = True 

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

1704 if rel_prop.uselist: 

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

1706 else: 

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

1708 for ancillary in ancillaries: 

1709 itembranches.append( 

1710 ancillary._get_xml_root(req=req, 

1711 options=ancillary_options) 

1712 ) 

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

1714 item_collections.append(itemcollection) 

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

1716 branches += item_collections 

1717 

1718 # Completely separate additional summary tables 

1719 if options.xml_include_calculated: 

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

1721 found_est = False 

1722 for est in self.get_extra_summary_tables(req): 

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

1724 # differently, above 

1725 if not found_est and est.rows: 

1726 add_comment(XML_COMMENT_CALCULATED) 

1727 found_est = True 

1728 item_collections.append(est.get_xml_element()) 

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

1730 branches += item_collections 

1731 

1732 return branches 

1733 

1734 # ------------------------------------------------------------------------- 

1735 # HTML view 

1736 # ------------------------------------------------------------------------- 

1737 

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

1739 """ 

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

1741 

1742 Args: 

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

1744 anonymise: hide patient identifying details? 

1745 """ 

1746 req.prepare_for_html_figures() 

1747 return render("task.mako", 

1748 dict(task=self, 

1749 anonymise=anonymise, 

1750 signature=False, 

1751 viewtype=ViewArg.HTML), 

1752 request=req) 

1753 

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

1755 anonymise: bool = False) -> str: 

1756 """ 

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

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

1759 

1760 Should be plain text only. 

1761 

1762 Args: 

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

1764 anonymise: hide patient identifying details? 

1765 """ 

1766 if anonymise: 

1767 patient = "?" 

1768 elif self.patient: 

1769 patient = self.patient.prettystr(req) 

1770 else: 

1771 _ = req.gettext 

1772 patient = _("Anonymous") 

1773 tasktype = self.tablename 

1774 when = format_datetime(self.get_creation_datetime(), 

1775 DateFormat.ISO8601_HUMANIZED_TO_MINUTES, "") 

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

1777 

1778 # ------------------------------------------------------------------------- 

1779 # PDF view 

1780 # ------------------------------------------------------------------------- 

1781 

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

1783 """ 

1784 Returns a PDF representing the task. 

1785 

1786 Args: 

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

1788 anonymise: hide patient identifying details? 

1789 """ 

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

1791 if CSS_PAGED_MEDIA: 

1792 return pdf_from_html(req, html=html) 

1793 else: 

1794 return pdf_from_html( 

1795 req, 

1796 html=html, 

1797 header_html=render( 

1798 "wkhtmltopdf_header.mako", 

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

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

1801 request=req)), 

1802 request=req 

1803 ), 

1804 footer_html=render( 

1805 "wkhtmltopdf_footer.mako", 

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

1807 dict(task=self), 

1808 request=req)), 

1809 request=req 

1810 ), 

1811 extra_wkhtmltopdf_options={ 

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

1813 else "Portrait") 

1814 } 

1815 ) 

1816 

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

1818 anonymise: bool = False) -> str: 

1819 """ 

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

1821 used for the HTML view). 

1822 """ 

1823 req.prepare_for_pdf_figures() 

1824 return render("task.mako", 

1825 dict(task=self, 

1826 anonymise=anonymise, 

1827 pdf_landscape=self.use_landscape_for_pdf, 

1828 signature=self.has_clinician, 

1829 viewtype=ViewArg.PDF), 

1830 request=req) 

1831 

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

1833 anonymise: bool = False) -> str: 

1834 """ 

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

1836 

1837 Args: 

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

1839 anonymise: hide patient identifying details? 

1840 """ 

1841 cfg = req.config 

1842 if anonymise: 

1843 is_anonymous = True 

1844 else: 

1845 is_anonymous = self.is_anonymous 

1846 patient = self.patient 

1847 return get_export_filename( 

1848 req=req, 

1849 patient_spec_if_anonymous=cfg.patient_spec_if_anonymous, 

1850 patient_spec=cfg.patient_spec, 

1851 filename_spec=cfg.task_filename_spec, 

1852 filetype=ViewArg.PDF, 

1853 is_anonymous=is_anonymous, 

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

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

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

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

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

1859 creation_datetime=self.get_creation_datetime(), 

1860 basetable=self.tablename, 

1861 serverpk=self._pk 

1862 ) 

1863 

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

1865 """ 

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

1867 """ 

1868 pdffile = open(filename, "wb") 

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

1870 

1871 # ------------------------------------------------------------------------- 

1872 # Metadata for e.g. RiO 

1873 # ------------------------------------------------------------------------- 

1874 

1875 def get_rio_metadata(self, 

1876 req: "CamcopsRequest", 

1877 which_idnum: int, 

1878 uploading_user_id: str, 

1879 document_type: str) -> str: 

1880 """ 

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

1882 record may want. 

1883 

1884 Args: 

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

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

1887 client ID? 

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

1889 be recorded as uploading this information; see below 

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

1891 (this is system-specific); see below 

1892 

1893 Returns: 

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

1895 

1896 Called by 

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

1898 

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

1900 

1901 .. code-block:: none 

1902 

1903 Batch Document Upload 

1904 

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

1906 documents in bulk automatically. RiO includes a Batch Upload 

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

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

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

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

1911 ‘ThisIsANewReferralLetterForAPatient.pdf’ then there would also 

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

1913 ‘ThisIsANewReferralLetterForAPatient.metadata’. The contents of 

1914 the meta file would need to include the following: 

1915 

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

1917 Format 

1918 

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

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

1921 Alphanumeric Characters 

1922 

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

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

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

1926 Alphanumeric Characters 

1927 

1928 [NB example longer than that!] 

1929 

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

1931 Alphanumeric Characters 

1932 

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

1934 Characters 

1935 

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

1937 Characters 

1938 

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

1940 Characters 

1941 

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

1943 

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

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

1946 

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

1948 

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

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

1951 

1952 (on one line) 

1953 

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

1955 

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

1957 

1958 - ... here and 

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

1960 

1961 - line terminator is <CR> 

1962 

1963 - BUT see 

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

1965 

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

1967 

1968 - search for ``RIO_MAX_USER_LEN`` 

1969 

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

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

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

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

1974 anything more specific.) 

1975 

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

1977 

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

1979 ASCII code is fine within filenames. 

1980 

1981 - see 

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

1983 

1984 """ # noqa 

1985 

1986 try: 

1987 client_id = self.patient.get_idnum_value(which_idnum) 

1988 except AttributeError: 

1989 client_id = "" 

1990 title = "CamCOPS_" + self.shortname 

1991 description = self.longname(req) 

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

1993 document_date = format_datetime(self.when_created, 

1994 DateFormat.RIO_EXPORT_UK) 

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

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

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

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

1999 

2000 item_list = [ 

2001 client_id, 

2002 uploading_user_id, 

2003 document_type, 

2004 title, 

2005 description, 

2006 author, 

2007 document_date, 

2008 final_revision 

2009 ] 

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

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

2012 for x in item_list]) 

2013 return csv_line + "\n" 

2014 

2015 # ------------------------------------------------------------------------- 

2016 # HTML elements used by tasks 

2017 # ------------------------------------------------------------------------- 

2018 

2019 # noinspection PyMethodMayBeStatic 

2020 def get_standard_clinician_comments_block(self, 

2021 req: "CamcopsRequest", 

2022 comments: str) -> str: 

2023 """ 

2024 HTML DIV for clinician's comments. 

2025 """ 

2026 return render("clinician_comments.mako", 

2027 dict(comment=comments), 

2028 request=req) 

2029 

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

2031 """ 

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

2033 very obvious visually when it isn't. 

2034 """ 

2035 c = self.is_complete() 

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

2037 return ( 

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

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

2040 ) 

2041 

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

2043 """ 

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

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

2046 """ 

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

2048 

2049 def get_twocol_val_row(self, 

2050 fieldname: str, 

2051 default: str = None, 

2052 label: str = None) -> str: 

2053 """ 

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

2055 

2056 Args: 

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

2058 from this attribute 

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

2060 label: descriptive label 

2061 

2062 Returns: 

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

2064 

2065 """ 

2066 val = getattr(self, fieldname) 

2067 if val is None: 

2068 val = default 

2069 if label is None: 

2070 label = fieldname 

2071 return tr_qa(label, val) 

2072 

2073 def get_twocol_string_row(self, 

2074 fieldname: str, 

2075 label: str = None) -> str: 

2076 """ 

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

2078 

2079 Args: 

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

2081 from this attribute 

2082 label: descriptive label 

2083 

2084 Returns: 

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

2086 """ 

2087 if label is None: 

2088 label = fieldname 

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

2090 

2091 def get_twocol_bool_row(self, 

2092 req: "CamcopsRequest", 

2093 fieldname: str, 

2094 label: str = None) -> str: 

2095 """ 

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

2097 

2098 Args: 

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

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

2101 from this attribute 

2102 label: descriptive label 

2103 

2104 Returns: 

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

2106 """ 

2107 if label is None: 

2108 label = fieldname 

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

2110 

2111 def get_twocol_bool_row_true_false(self, 

2112 req: "CamcopsRequest", 

2113 fieldname: str, 

2114 label: str = None) -> str: 

2115 """ 

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

2117 value. 

2118 

2119 Args: 

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

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

2122 from this attribute 

2123 label: descriptive label 

2124 

2125 Returns: 

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

2127 """ 

2128 if label is None: 

2129 label = fieldname 

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

2131 

2132 def get_twocol_bool_row_present_absent(self, 

2133 req: "CamcopsRequest", 

2134 fieldname: str, 

2135 label: str = None) -> str: 

2136 """ 

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

2138 value. 

2139 

2140 Args: 

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

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

2143 from this attribute 

2144 label: descriptive label 

2145 

2146 Returns: 

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

2148 """ 

2149 if label is None: 

2150 label = fieldname 

2151 return tr_qa(label, get_present_absent_none(req, 

2152 getattr(self, fieldname))) 

2153 

2154 @staticmethod 

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

2156 """ 

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

2158 

2159 Args: 

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

2161 label: descriptive label 

2162 

2163 Returns: 

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

2165 """ 

2166 return tr(label, get_blob_img_html(blob)) 

2167 

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

2169 # Field helper functions for subclasses 

2170 # ------------------------------------------------------------------------- 

2171 

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

2173 """ 

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

2175 """ 

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

2177 

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

2179 """ 

2180 Is the field not None? 

2181 """ 

2182 return getattr(self, field) is not None 

2183 

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

2185 """ 

2186 Are any specified fields None? 

2187 """ 

2188 for f in fields: 

2189 if getattr(self, f) is None: 

2190 return True 

2191 return False 

2192 

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

2194 """ 

2195 Are all specified fields not None? 

2196 """ 

2197 return not self.any_fields_none(fields) 

2198 

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

2200 """ 

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

2202 """ 

2203 for f in fields: 

2204 v = getattr(self, f) 

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

2206 return True 

2207 return False 

2208 

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

2210 """ 

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

2212 """ 

2213 return not self.any_fields_null_or_empty_str(fields) 

2214 

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

2216 """ 

2217 How many of the specified fields are not None? 

2218 """ 

2219 total = 0 

2220 for f in fields: 

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

2222 total += 1 

2223 return total 

2224 

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

2226 """ 

2227 How many of the specified fields are None? 

2228 """ 

2229 total = 0 

2230 for f in fields: 

2231 if getattr(self, f) is None: 

2232 total += 1 

2233 return total 

2234 

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

2236 """ 

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

2238 """ 

2239 total = 0 

2240 for f in fields: 

2241 value = getattr(self, f) 

2242 if value: 

2243 total += 1 

2244 return total 

2245 

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

2247 """ 

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

2249 """ 

2250 for f in fields: 

2251 value = getattr(self, f) 

2252 if not value: 

2253 return False 

2254 return True 

2255 

2256 def count_where(self, 

2257 fields: List[str], 

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

2259 """ 

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

2261 """ 

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

2263 

2264 def count_wherenot(self, 

2265 fields: List[str], 

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

2267 """ 

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

2269 ``notvalues``. 

2270 """ 

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

2272 

2273 def sum_fields(self, 

2274 fields: List[str], 

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

2276 """ 

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

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

2279 """ 

2280 total = 0 

2281 for f in fields: 

2282 value = getattr(self, f) 

2283 if value == ignorevalue: 

2284 continue 

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

2286 return total 

2287 

2288 def mean_fields(self, 

2289 fields: List[str], 

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

2291 """ 

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

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

2294 """ 

2295 values = [] 

2296 for f in fields: 

2297 value = getattr(self, f) 

2298 if value != ignorevalue: 

2299 values.append(value) 

2300 try: 

2301 return statistics.mean(values) 

2302 except (TypeError, statistics.StatisticsError): 

2303 return None 

2304 

2305 @staticmethod 

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

2307 """ 

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

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

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

2311 

2312 Args: 

2313 prefix: string prefix 

2314 start: first value (inclusive) 

2315 end: last value (inclusive 

2316 

2317 Returns: 

2318 list of fieldnames, as above 

2319 

2320 """ 

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

2322 

2323 @staticmethod 

2324 def fieldnames_from_list(prefix: str, 

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

2326 """ 

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

2328 prefix. 

2329 

2330 Args: 

2331 prefix: string prefix 

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

2333 

2334 Returns: 

2335 list of fieldnames, as above 

2336 

2337 """ 

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

2339 

2340 # ------------------------------------------------------------------------- 

2341 # Extra strings 

2342 # ------------------------------------------------------------------------- 

2343 

2344 def get_extrastring_taskname(self) -> str: 

2345 """ 

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

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

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

2349 ``extrastring_taskname``. 

2350 """ 

2351 return self.extrastring_taskname or self.tablename 

2352 

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

2354 """ 

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

2356 """ 

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

2358 

2359 def wxstring(self, 

2360 req: "CamcopsRequest", 

2361 name: str, 

2362 defaultvalue: str = None, 

2363 provide_default_if_none: bool = True) -> str: 

2364 """ 

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

2366 

2367 Args: 

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

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

2370 this task's extra strings 

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

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

2373 return a helpful missing-string message in the style 

2374 "string x.y not found" 

2375 """ 

2376 if defaultvalue is None and provide_default_if_none: 

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

2378 return req.wxstring( 

2379 self.get_extrastring_taskname(), 

2380 name, 

2381 defaultvalue, 

2382 provide_default_if_none=provide_default_if_none) 

2383 

2384 def xstring(self, 

2385 req: "CamcopsRequest", 

2386 name: str, 

2387 defaultvalue: str = None, 

2388 provide_default_if_none: bool = True) -> str: 

2389 """ 

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

2391 this task. 

2392 

2393 Args: 

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

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

2396 this task's extra strings 

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

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

2399 return a helpful missing-string message in the style 

2400 "string x.y not found" 

2401 """ 

2402 if defaultvalue is None and provide_default_if_none: 

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

2404 return req.xstring( 

2405 self.get_extrastring_taskname(), 

2406 name, 

2407 defaultvalue, 

2408 provide_default_if_none=provide_default_if_none) 

2409 

2410 def make_options_from_xstrings(self, 

2411 req: "CamcopsRequest", 

2412 prefix: str, first: int, last: int, 

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

2414 """ 

2415 Creates a lookup dictionary from xstrings. 

2416 

2417 Args: 

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

2419 prefix: prefix for xstring 

2420 first: first value 

2421 last: last value 

2422 suffix: optional suffix 

2423 

2424 Returns: 

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

2426 ``<PREFIX><VALUE><SUFFIX>``. 

2427 

2428 """ 

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

2430 if first > last: # descending order 

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

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

2433 else: # ascending order 

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

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

2436 return d 

2437 

2438 @staticmethod 

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

2440 """ 

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

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

2443 

2444 Args: 

2445 first: first value 

2446 last: last value 

2447 

2448 Returns: 

2449 dict 

2450 

2451 """ 

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

2453 if first > last: # descending order 

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

2455 d[i] = str(i) 

2456 else: # ascending order 

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

2458 d[i] = str(i) 

2459 return d 

2460 

2461 

2462# ============================================================================= 

2463# Collating all task tables for specific purposes 

2464# ============================================================================= 

2465# Function, staticmethod, classmethod? 

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

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

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

2469 

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

2471 """ 

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

2473 minimum client version. 

2474 

2475 Used by 

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

2477 

2478 """ # noqa 

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

2480 classes = list(Task.gen_all_subclasses()) 

2481 for cls in classes: 

2482 d.update(cls.all_tables_with_min_client_version()) 

2483 return d 

2484 

2485 

2486@cache_region_static.cache_on_arguments(function_key_generator=fkg) 

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

2488 """ 

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

2490 """ 

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

2492 for cls in Task.gen_all_subclasses(): 

2493 d[cls.tablename] = cls 

2494 return d 

2495 

2496 

2497@cache_region_static.cache_on_arguments(function_key_generator=fkg) 

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

2499 """ 

2500 Returns all task base table names. 

2501 """ 

2502 d = tablename_to_task_class_dict() 

2503 return list(d.keys()) 

2504 

2505 

2506@cache_region_static.cache_on_arguments(function_key_generator=fkg) 

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

2508 """ 

2509 Returns all task base table names. 

2510 """ 

2511 d = tablename_to_task_class_dict() 

2512 return list(d.values()) 

2513 

2514 

2515# ============================================================================= 

2516# Support functions 

2517# ============================================================================= 

2518 

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

2520 """ 

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

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

2523 

2524 Args: 

2525 d: the dictionary 

2526 key: the key 

2527 default: value to return if none is provided 

2528 """ 

2529 return d.get(key, default)