Coverage for cc_modules/cc_exportrecipientinfo.py: 33%

404 statements  

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

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_exportrecipient.py 

5 

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

7 

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

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

10 

11 This file is part of CamCOPS. 

12 

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

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

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

16 (at your option) any later version. 

17 

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

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

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

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

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

25 

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

27 

28**ExportRecipientInfo class.** 

29 

30The purpose of this is to capture information without using an SQLAlchemy 

31class. The :class:`camcops_server.cc_modules.cc_config.CamcopsConfig` class 

32uses this, as it needs to be readable in the absence of a database connection 

33(q.v.). 

34 

35""" 

36 

37import configparser 

38import datetime 

39import logging 

40from typing import List, NoReturn, Optional, TYPE_CHECKING 

41 

42from cardinal_pythonlib.configfiles import ( 

43 get_config_parameter, 

44 get_config_parameter_boolean, 

45 get_config_parameter_multiline, 

46) 

47from cardinal_pythonlib.datetimefunc import ( 

48 coerce_to_pendulum, 

49 pendulum_to_utc_datetime_without_tz, 

50) 

51from cardinal_pythonlib.logs import BraceStyleAdapter 

52from cardinal_pythonlib.reprfunc import simple_repr 

53 

54from camcops_server.cc_modules.cc_constants import ( 

55 CAMCOPS_DEFAULT_FHIR_APP_ID, 

56 CONFIG_FILE_SITE_SECTION, 

57 ConfigDefaults, 

58 ConfigParamExportRecipient, 

59 ConfigParamSite, 

60 FileType, 

61) 

62from camcops_server.cc_modules.cc_filename import ( 

63 filename_spec_is_valid, 

64 get_export_filename, 

65 patient_spec_for_filename_is_valid, 

66) 

67 

68if TYPE_CHECKING: 

69 from camcops_server.cc_modules.cc_config import CamcopsConfig 

70 from camcops_server.cc_modules.cc_request import CamcopsRequest 

71 from camcops_server.cc_modules.cc_task import Task 

72 

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

74 

75 

76# ============================================================================= 

77# Constants 

78# ============================================================================= 

79 

80COMMA = "," 

81CONFIG_RECIPIENT_PREFIX = "recipient:" 

82RIO_MAX_USER_LEN = 10 

83 

84 

85class ExportTransmissionMethod(object): 

86 """ 

87 Possible export transmission methods. 

88 """ 

89 

90 DATABASE = "database" 

91 EMAIL = "email" 

92 FHIR = "fhir" 

93 FILE = "file" 

94 HL7 = "hl7" 

95 REDCAP = "redcap" 

96 

97 

98NO_PUSH_METHODS = [ 

99 # Methods that do not support "push" exports (exports on receipt of a new 

100 # task). 

101 ExportTransmissionMethod.DATABASE, 

102 # ... because these are large and it would probably be silly to export a 

103 # whole database whenever a new task arrives. (Is there also a locking 

104 # problem? Can't remember right now, 2021-11-08.) 

105] 

106 

107 

108ALL_TRANSMISSION_METHODS = [ 

109 v 

110 for k, v in vars(ExportTransmissionMethod).items() 

111 if not k.startswith("_") 

112] # ... the values of all the relevant attributes 

113 

114ALL_TASK_FORMATS = [FileType.HTML, FileType.PDF, FileType.XML] 

115 

116 

117class InvalidExportRecipient(ValueError): 

118 """ 

119 Exception for invalid export recipients. 

120 """ 

121 

122 def __init__(self, recipient_name: str, msg: str) -> None: 

123 super().__init__(f"For export recipient [{recipient_name}]: {msg}") 

124 

125 

126# Internal shorthand: 

127_Invalid = InvalidExportRecipient 

128 

129 

130class _Missing(_Invalid): 

131 """ 

132 Exception for missing config parameters 

133 """ 

134 

135 def __init__(self, recipient_name: str, paramname: str) -> None: 

136 super().__init__(recipient_name, f"Missing parameter {paramname}") 

137 

138 

139# ============================================================================= 

140# ExportRecipientInfo class 

141# ============================================================================= 

142 

143 

144class ExportRecipientInfo(object): 

145 """ 

146 Class representing an export recipient, that is not an SQLAlchemy ORM 

147 object. 

148 

149 This has an unfortunate close relationship with 

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

151 (q.v.). 

152 

153 Full details of parameters are in the docs for the config file. 

154 """ 

155 

156 IGNORE_FOR_EQ_ATTRNAMES = [ 

157 # Attribute names to ignore for equality comparison 

158 "email_host_password", 

159 "fhir_app_secret", 

160 "fhir_launch_token", 

161 "redcap_api_key", 

162 ] 

163 

164 def __init__(self, other: "ExportRecipientInfo" = None) -> None: 

165 """ 

166 Initializes, optionally copying attributes from ``other``. 

167 """ 

168 cd = ConfigDefaults() 

169 

170 self.recipient_name = "" 

171 

172 # How to export 

173 

174 self.transmission_method = ExportTransmissionMethod.EMAIL 

175 self.push = cd.PUSH 

176 self.task_format = cd.TASK_FORMAT 

177 self.xml_field_comments = cd.XML_FIELD_COMMENTS 

178 

179 # What to export 

180 

181 self.all_groups = cd.ALL_GROUPS 

182 self.group_names = ( 

183 [] 

184 ) # type: List[str] # not in database; see group_ids 

185 self.group_ids = [] # type: List[int] 

186 self.tasks = [] # type: List[str] 

187 self.start_datetime_utc = None # type: Optional[datetime.datetime] 

188 self.end_datetime_utc = None # type: Optional[datetime.datetime] 

189 self.finalized_only = cd.FINALIZED_ONLY 

190 self.include_anonymous = cd.INCLUDE_ANONYMOUS 

191 self.primary_idnum = None # type: Optional[int] 

192 self.require_idnum_mandatory = ( 

193 cd.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY 

194 ) 

195 

196 # Database 

197 

198 self.db_url = "" 

199 self.db_echo = cd.DB_ECHO 

200 self.db_include_blobs = cd.DB_INCLUDE_BLOBS 

201 self.db_add_summaries = cd.DB_ADD_SUMMARIES 

202 self.db_patient_id_per_row = cd.DB_PATIENT_ID_PER_ROW 

203 

204 # Email 

205 

206 self.email_host = "" 

207 self.email_port = cd.EMAIL_PORT 

208 self.email_use_tls = cd.EMAIL_USE_TLS 

209 self.email_host_username = "" 

210 self.email_host_password = "" # not in database for security 

211 self.email_from = "" 

212 self.email_sender = "" 

213 self.email_reply_to = "" 

214 self.email_to = "" # CSV list 

215 self.email_cc = "" # CSV list 

216 self.email_bcc = "" # CSV list 

217 self.email_patient_spec = "" 

218 self.email_patient_spec_if_anonymous = cd.PATIENT_SPEC_IF_ANONYMOUS 

219 self.email_subject = "" 

220 self.email_body_as_html = cd.EMAIL_BODY_IS_HTML 

221 self.email_body = "" 

222 self.email_keep_message = cd.EMAIL_KEEP_MESSAGE 

223 

224 # HL7 

225 

226 self.hl7_host = "" 

227 self.hl7_port = cd.HL7_PORT 

228 self.hl7_ping_first = cd.HL7_PING_FIRST 

229 self.hl7_network_timeout_ms = cd.HL7_NETWORK_TIMEOUT_MS 

230 self.hl7_keep_message = cd.HL7_KEEP_MESSAGE 

231 self.hl7_keep_reply = cd.HL7_KEEP_REPLY 

232 self.hl7_debug_divert_to_file = cd.HL7_DEBUG_DIVERT_TO_FILE 

233 self.hl7_debug_treat_diverted_as_sent = ( 

234 cd.HL7_DEBUG_TREAT_DIVERTED_AS_SENT 

235 ) 

236 

237 # File 

238 

239 self.file_patient_spec = "" 

240 self.file_patient_spec_if_anonymous = cd.PATIENT_SPEC_IF_ANONYMOUS 

241 self.file_filename_spec = "" 

242 self.file_make_directory = cd.FILE_MAKE_DIRECTORY 

243 self.file_overwrite_files = cd.FILE_OVERWRITE_FILES 

244 self.file_export_rio_metadata = cd.FILE_EXPORT_RIO_METADATA 

245 self.file_script_after_export = "" 

246 

247 # File/RiO 

248 

249 self.rio_idnum = None # type: Optional[int] 

250 self.rio_uploading_user = "" 

251 self.rio_document_type = "" 

252 

253 # REDCap 

254 

255 self.redcap_api_key = "" # not in database for security 

256 self.redcap_api_url = "" 

257 self.redcap_fieldmap_filename = "" 

258 

259 # FHIR 

260 

261 self.fhir_app_id = "" 

262 self.fhir_api_url = "" 

263 self.fhir_app_secret = "" # not in database for security 

264 self.fhir_launch_token = "" # not in database for security 

265 self.fhir_concurrent = False 

266 

267 # Copy from other? 

268 if other is not None: 

269 assert isinstance(other, ExportRecipientInfo) 

270 for attrname in self.get_attrnames(): 

271 # Note that both "self" and "other" may be an ExportRecipient 

272 # rather than an ExportRecipientInfo. 

273 if hasattr(other, attrname): 

274 setattr(self, attrname, getattr(other, attrname)) 

275 

276 def get_attrnames(self) -> List[str]: 

277 """ 

278 Returns all relevant attribute names. 

279 """ 

280 return sorted( 

281 [key for key in self.__dict__ if not key.startswith("_")] 

282 ) 

283 

284 def get_eq_attrnames(self) -> List[str]: 

285 """ 

286 Returns attribute names to use for equality comparison. 

287 """ 

288 return [ 

289 x 

290 for x in self.get_attrnames() 

291 if x not in self.IGNORE_FOR_EQ_ATTRNAMES 

292 ] 

293 

294 def __repr__(self): 

295 return simple_repr(self, self.get_attrnames()) 

296 

297 def __str__(self) -> str: 

298 return repr(self.recipient_name) 

299 

300 def __eq__(self, other: "ExportRecipientInfo") -> bool: 

301 """ 

302 Does this object equal another -- meaning "sufficiently equal that we 

303 can use the same one, rather than making a new database copy"? 

304 """ 

305 for attrname in self.get_attrnames(): 

306 if attrname not in self.IGNORE_FOR_EQ_ATTRNAMES: 

307 selfattr = getattr(self, attrname) 

308 otherattr = getattr(other, attrname) 

309 # log.debug("{}.{}: {} {} {}", 

310 # self.__class__.__name__, 

311 # attrname, 

312 # selfattr, 

313 # "==" if selfattr == otherattr else "!=", 

314 # otherattr) 

315 if selfattr != otherattr: 

316 log.debug( 

317 "{}: For {!r}, new export recipient mismatches " 

318 "previous copy on {}: {!r} != {!r}", 

319 self.__class__.__name__, 

320 self.recipient_name, 

321 attrname, 

322 selfattr, 

323 otherattr, 

324 ) 

325 return False 

326 return True 

327 

328 @classmethod 

329 def create_dummy_recipient(cls) -> "ExportRecipientInfo": 

330 """ 

331 Creates and returns a dummy :class:`ExportRecipientInfo`. 

332 """ 

333 d = cls() 

334 

335 d.recipient_name = "_dummy_export_recipient_" 

336 d.current = True 

337 

338 d.transmission_method = ExportTransmissionMethod.FILE 

339 

340 d.all_groups = True 

341 d.primary_idnum = 1 

342 d.require_idnum_mandatory = False 

343 d.finalized_only = False 

344 d.task_format = FileType.XML 

345 

346 # File 

347 d.include_anonymous = True 

348 d.file_patient_spec_if_anonymous = "anonymous" 

349 d.file_patient_spec = "{surname}_{forename}_{idshortdesc1}{idnum1}" 

350 d.file_filename_spec = ( 

351 "/tmp/camcops_debug_testing/" 

352 "TestCamCOPS_{patient}_{created}_{tasktype}-{serverpk}" 

353 ".{filetype}" 

354 ) 

355 d.file_overwrite_files = False 

356 d.file_make_directory = True 

357 

358 return d 

359 

360 @classmethod 

361 def read_from_config( 

362 cls, 

363 cfg: "CamcopsConfig", 

364 parser: configparser.ConfigParser, 

365 recipient_name: str, 

366 ) -> "ExportRecipientInfo": 

367 """ 

368 Reads from the config file and writes this instance's attributes. 

369 

370 Args: 

371 cfg: a :class:`camcops_server.cc_modules.cc_config.CamcopsConfig` 

372 parser: configparser INI file object 

373 recipient_name: name of recipient and of INI file section 

374 

375 Returns: 

376 an :class:`ExportRecipient` object, which is **not** currently in 

377 a database session 

378 """ 

379 assert recipient_name 

380 log.debug("Loading export config for recipient {!r}", recipient_name) 

381 

382 section = CONFIG_RECIPIENT_PREFIX + recipient_name 

383 cps = ConfigParamSite 

384 cpr = ConfigParamExportRecipient 

385 cd = ConfigDefaults() 

386 r = cls() # type: ExportRecipientInfo 

387 

388 def _get_str(paramname: str, default: str = None) -> Optional[str]: 

389 return get_config_parameter( 

390 parser, section, paramname, str, default 

391 ) 

392 

393 def _get_bool(paramname: str, default: bool) -> bool: 

394 return get_config_parameter_boolean( 

395 parser, section, paramname, default 

396 ) 

397 

398 def _get_int(paramname: str, default: int = None) -> Optional[int]: 

399 return get_config_parameter( 

400 parser, section, paramname, int, default 

401 ) 

402 

403 def _get_multiline(paramname: str) -> List[str]: 

404 return get_config_parameter_multiline( 

405 parser, section, paramname, [] 

406 ) 

407 

408 def _get_site_str( 

409 paramname: str, default: str = None 

410 ) -> Optional[str]: 

411 return get_config_parameter( 

412 parser, CONFIG_FILE_SITE_SECTION, paramname, str, default 

413 ) 

414 

415 # noinspection PyUnusedLocal 

416 def _get_site_bool(paramname: str, default: bool) -> bool: 

417 return get_config_parameter_boolean( 

418 parser, CONFIG_FILE_SITE_SECTION, paramname, default 

419 ) 

420 

421 # noinspection PyUnusedLocal 

422 def _get_site_int( 

423 paramname: str, default: int = None 

424 ) -> Optional[int]: 

425 return get_config_parameter( 

426 parser, CONFIG_FILE_SITE_SECTION, paramname, int, default 

427 ) 

428 

429 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

430 # Identity 

431 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

432 r.recipient_name = recipient_name 

433 

434 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

435 # How to export 

436 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

437 r.transmission_method = _get_str(cpr.TRANSMISSION_METHOD) 

438 r.transmission_method = str(r.transmission_method).lower() 

439 # Check this one immediately, since we use it in conditions below 

440 if r.transmission_method not in ALL_TRANSMISSION_METHODS: 

441 raise _Invalid( 

442 r.recipient_name, 

443 f"Missing/invalid " 

444 f"{ConfigParamExportRecipient.TRANSMISSION_METHOD}: " 

445 f"{r.transmission_method}", 

446 ) 

447 r.push = _get_bool(cpr.PUSH, cd.PUSH) 

448 r.task_format = _get_str(cpr.TASK_FORMAT, cd.TASK_FORMAT) 

449 r.xml_field_comments = _get_bool( 

450 cpr.XML_FIELD_COMMENTS, cd.XML_FIELD_COMMENTS 

451 ) 

452 

453 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

454 # What to export 

455 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

456 r.all_groups = _get_bool(cpr.ALL_GROUPS, cd.ALL_GROUPS) 

457 r.group_names = _get_multiline(cpr.GROUPS) 

458 r.group_ids = [] 

459 # ... read later by validate_db_dependent() 

460 r.tasks = sorted([x.lower() for x in _get_multiline(cpr.TASKS)]) 

461 sd = _get_str(cpr.START_DATETIME_UTC) 

462 r.start_datetime_utc = ( 

463 pendulum_to_utc_datetime_without_tz( 

464 coerce_to_pendulum(sd, assume_local=False) 

465 ) 

466 if sd 

467 else None 

468 ) 

469 ed = _get_str(cpr.END_DATETIME_UTC) 

470 r.end_datetime_utc = ( 

471 pendulum_to_utc_datetime_without_tz( 

472 coerce_to_pendulum(ed, assume_local=False) 

473 ) 

474 if ed 

475 else None 

476 ) 

477 r.finalized_only = _get_bool(cpr.FINALIZED_ONLY, cd.FINALIZED_ONLY) 

478 r.include_anonymous = _get_bool( 

479 cpr.INCLUDE_ANONYMOUS, cd.INCLUDE_ANONYMOUS 

480 ) 

481 r.primary_idnum = _get_int(cpr.PRIMARY_IDNUM) 

482 r.require_idnum_mandatory = _get_bool( 

483 cpr.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY, 

484 cd.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY, 

485 ) 

486 

487 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

488 # Database 

489 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

490 if r.transmission_method == ExportTransmissionMethod.DATABASE: 

491 r.db_url = _get_str(cpr.DB_URL) 

492 r.db_echo = _get_bool(cpr.DB_ECHO, cd.DB_ECHO) 

493 r.db_include_blobs = _get_bool( 

494 cpr.DB_INCLUDE_BLOBS, cd.DB_INCLUDE_BLOBS 

495 ) 

496 r.db_add_summaries = _get_bool( 

497 cpr.DB_ADD_SUMMARIES, cd.DB_ADD_SUMMARIES 

498 ) 

499 r.db_patient_id_per_row = _get_bool( 

500 cpr.DB_PATIENT_ID_PER_ROW, cd.DB_PATIENT_ID_PER_ROW 

501 ) 

502 

503 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

504 # Email 

505 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

506 def _make_email_csv_list(paramname: str) -> str: 

507 return ", ".join(x for x in _get_multiline(paramname)) 

508 

509 if r.transmission_method == ExportTransmissionMethod.EMAIL: 

510 r.email_host = cfg.email_host 

511 r.email_port = cfg.email_port 

512 r.email_use_tls = cfg.email_use_tls 

513 r.email_host_username = cfg.email_host_username 

514 r.email_host_password = cfg.email_host_password 

515 

516 # Read from password safe using 'pass' 

517 # from subprocess import run, PIPE 

518 # output = run(["pass", "dept-of-psychiatry/Hermes"], stdout=PIPE) 

519 # r.email_host_password = output.stdout.decode("utf-8").split()[0] 

520 

521 r.email_from = _get_site_str(cps.EMAIL_FROM, "") 

522 r.email_sender = _get_site_str(cps.EMAIL_SENDER, "") 

523 r.email_reply_to = _get_site_str(cps.EMAIL_REPLY_TO, "") 

524 

525 r.email_to = _make_email_csv_list(cpr.EMAIL_TO) 

526 r.email_cc = _make_email_csv_list(cpr.EMAIL_CC) 

527 r.email_bcc = _make_email_csv_list(cpr.EMAIL_BCC) 

528 r.email_patient_spec_if_anonymous = _get_str( 

529 cpr.EMAIL_PATIENT_SPEC_IF_ANONYMOUS, "" 

530 ) 

531 r.email_patient_spec = _get_str(cpr.EMAIL_PATIENT_SPEC, "") 

532 r.email_subject = _get_str(cpr.EMAIL_SUBJECT, "") 

533 r.email_body_as_html = _get_bool( 

534 cpr.EMAIL_BODY_IS_HTML, cd.EMAIL_BODY_IS_HTML 

535 ) 

536 r.email_body = _get_str(cpr.EMAIL_BODY, "") 

537 r.email_keep_message = _get_bool( 

538 cpr.EMAIL_KEEP_MESSAGE, cd.EMAIL_KEEP_MESSAGE 

539 ) 

540 

541 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

542 # HL7 

543 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

544 if r.transmission_method == ExportTransmissionMethod.HL7: 

545 r.hl7_host = _get_str(cpr.HL7_HOST) 

546 r.hl7_port = _get_int(cpr.HL7_PORT, cd.HL7_PORT) 

547 r.hl7_ping_first = _get_bool(cpr.HL7_PING_FIRST, cd.HL7_PING_FIRST) 

548 r.hl7_network_timeout_ms = _get_int( 

549 cpr.HL7_NETWORK_TIMEOUT_MS, cd.HL7_NETWORK_TIMEOUT_MS 

550 ) 

551 r.hl7_keep_message = _get_bool( 

552 cpr.HL7_KEEP_MESSAGE, cd.HL7_KEEP_MESSAGE 

553 ) 

554 r.hl7_keep_reply = _get_bool(cpr.HL7_KEEP_REPLY, cd.HL7_KEEP_REPLY) 

555 r.hl7_debug_divert_to_file = _get_bool( 

556 cpr.HL7_DEBUG_DIVERT_TO_FILE, cd.HL7_DEBUG_DIVERT_TO_FILE 

557 ) 

558 r.hl7_debug_treat_diverted_as_sent = _get_bool( 

559 cpr.HL7_DEBUG_TREAT_DIVERTED_AS_SENT, 

560 cd.HL7_DEBUG_TREAT_DIVERTED_AS_SENT, 

561 ) 

562 

563 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

564 # File 

565 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

566 if r._need_file_name(): 

567 r.file_patient_spec = _get_str(cpr.FILE_PATIENT_SPEC) 

568 r.file_patient_spec_if_anonymous = _get_str( 

569 cpr.FILE_PATIENT_SPEC_IF_ANONYMOUS, 

570 cd.FILE_PATIENT_SPEC_IF_ANONYMOUS, 

571 ) 

572 r.file_filename_spec = _get_str(cpr.FILE_FILENAME_SPEC) 

573 

574 if r._need_file_disk_options(): 

575 r.file_make_directory = _get_bool( 

576 cpr.FILE_MAKE_DIRECTORY, cd.FILE_MAKE_DIRECTORY 

577 ) 

578 r.file_overwrite_files = _get_bool( 

579 cpr.FILE_OVERWRITE_FILES, cd.FILE_OVERWRITE_FILES 

580 ) 

581 

582 if r.transmission_method == ExportTransmissionMethod.FILE: 

583 r.file_export_rio_metadata = _get_bool( 

584 cpr.FILE_EXPORT_RIO_METADATA, cd.FILE_EXPORT_RIO_METADATA 

585 ) 

586 r.file_script_after_export = _get_str(cpr.FILE_SCRIPT_AFTER_EXPORT) 

587 

588 if r._need_rio_metadata_options(): 

589 # RiO metadata 

590 r.rio_idnum = _get_int(cpr.RIO_IDNUM) 

591 r.rio_uploading_user = _get_str(cpr.RIO_UPLOADING_USER) 

592 r.rio_document_type = _get_str(cpr.RIO_DOCUMENT_TYPE) 

593 

594 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

595 # REDCap 

596 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

597 if r.transmission_method == ExportTransmissionMethod.REDCAP: 

598 r.redcap_api_url = _get_str(cpr.REDCAP_API_URL) 

599 r.redcap_api_key = _get_str(cpr.REDCAP_API_KEY) 

600 r.redcap_fieldmap_filename = _get_str(cpr.REDCAP_FIELDMAP_FILENAME) 

601 

602 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

603 # FHIR 

604 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

605 if r.transmission_method == ExportTransmissionMethod.FHIR: 

606 r.fhir_api_url = _get_str(cpr.FHIR_API_URL) 

607 r.fhir_app_id = _get_str( 

608 cpr.FHIR_APP_ID, CAMCOPS_DEFAULT_FHIR_APP_ID 

609 ) 

610 r.fhir_app_secret = _get_str(cpr.FHIR_APP_SECRET) 

611 r.fhir_launch_token = _get_str(cpr.FHIR_LAUNCH_TOKEN) 

612 r.fhir_concurrent = _get_bool(cpr.FHIR_CONCURRENT, False) 

613 

614 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

615 # Validate the basics and return 

616 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

617 r.validate_db_independent() 

618 return r 

619 

620 @classmethod 

621 def report_error(cls, msg: str) -> None: 

622 """ 

623 Report an error to the log. 

624 """ 

625 log.error("{}: {}", cls.__name__, msg) 

626 

627 def valid(self, req: "CamcopsRequest") -> bool: 

628 """ 

629 Is this definition valid? 

630 

631 Args: 

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

633 """ 

634 try: 

635 self.validate(req) 

636 return True 

637 except InvalidExportRecipient as e: 

638 self.report_error(str(e)) 

639 return False 

640 

641 def validate(self, req: "CamcopsRequest") -> None: 

642 """ 

643 Validates all aspects. 

644 

645 Args: 

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

647 

648 Raises: 

649 :exc:`InvalidExportRecipient` if invalid 

650 """ 

651 self.validate_db_independent() 

652 self.validate_db_dependent(req) 

653 

654 def validate_db_independent(self) -> None: 

655 """ 

656 Validates the database-independent aspects of the 

657 :class:`ExportRecipient`, or raises :exc:`InvalidExportRecipient`. 

658 """ 

659 # noinspection PyUnresolvedReferences 

660 import camcops_server.cc_modules.cc_all_models # import side effects (ensure all models registered) # noqa 

661 from camcops_server.cc_modules.cc_task import ( 

662 all_task_tablenames, 

663 ) # delayed import 

664 

665 def fail_invalid(msg: str) -> NoReturn: 

666 raise _Invalid(self.recipient_name, msg) 

667 

668 def fail_missing(paramname: str) -> NoReturn: 

669 raise _Missing(self.recipient_name, paramname) 

670 

671 cpr = ConfigParamExportRecipient 

672 cps = ConfigParamSite 

673 

674 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

675 # Export type 

676 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

677 if self.transmission_method not in ALL_TRANSMISSION_METHODS: 

678 fail_invalid( 

679 f"Missing/invalid {cpr.TRANSMISSION_METHOD}: " 

680 f"{self.transmission_method}" 

681 ) 

682 if self.push and self.transmission_method in NO_PUSH_METHODS: 

683 fail_invalid( 

684 f"Push notifications not supported for these " 

685 f"transmission methods: {NO_PUSH_METHODS!r}" 

686 ) 

687 

688 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

689 # What to export 

690 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

691 if not self.all_groups and not self.group_names: 

692 fail_invalid(f"Missing group names (from {cpr.GROUPS})") 

693 

694 all_basetables = all_task_tablenames() 

695 for basetable in self.tasks: 

696 if basetable not in all_basetables: 

697 fail_invalid(f"Task {basetable!r} doesn't exist") 

698 

699 if ( 

700 self.transmission_method == ExportTransmissionMethod.HL7 

701 and not self.primary_idnum 

702 ): 

703 fail_invalid( 

704 f"Must specify {cpr.PRIMARY_IDNUM} with " 

705 f"{cpr.TRANSMISSION_METHOD} = {ExportTransmissionMethod.HL7}" 

706 ) 

707 

708 if not self.task_format or self.task_format not in ALL_TASK_FORMATS: 

709 fail_invalid( 

710 f"Missing/invalid {cpr.TASK_FORMAT}: {self.task_format}" 

711 ) 

712 

713 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

714 # Database 

715 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

716 if self.transmission_method == ExportTransmissionMethod.DATABASE: 

717 if not self.db_url: 

718 fail_missing(cpr.DB_URL) 

719 

720 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

721 # Email 

722 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

723 if self.transmission_method == ExportTransmissionMethod.EMAIL: 

724 if not self.email_host: 

725 # You can't send an e-mail without knowing which server to send 

726 # it to. 

727 fail_missing(cps.EMAIL_HOST) 

728 # Username is *not* required by all servers! 

729 if not self.email_from: 

730 # From is mandatory in all e-mails. 

731 # (Sender and Reply-To are optional.) 

732 fail_missing(cps.EMAIL_FROM) 

733 if COMMA in self.email_from: 

734 # RFC 5322 permits multiple addresses in From, but Python 

735 # sendmail doesn't. 

736 fail_invalid( 

737 f"Only a single 'From:' address permitted; was " 

738 f"{self.email_from!r}" 

739 ) 

740 if not any([self.email_to, self.email_cc, self.email_bcc]): 

741 # At least one destination is required (obviously). 

742 fail_invalid( 

743 f"Must specify some of: {cpr.EMAIL_TO}, {cpr.EMAIL_CC}, " 

744 f"{cpr.EMAIL_BCC}" 

745 ) 

746 if COMMA in self.email_sender: 

747 # RFC 5322 permits multiple addresses in From and Reply-To, 

748 # but only one in Sender. 

749 fail_invalid( 

750 f"Only a single 'Sender:' address permitted; was " 

751 f"{self.email_sender!r}" 

752 ) 

753 if not self.email_subject: 

754 # A subject is not obligatory for e-mails in general, but we 

755 # will require one for e-mails sent from CamCOPS. 

756 fail_missing(cpr.EMAIL_SUBJECT) 

757 

758 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

759 # HL7 

760 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

761 if self.transmission_method == ExportTransmissionMethod.HL7: 

762 if not self.hl7_debug_divert_to_file: 

763 if not self.hl7_host: 

764 fail_missing(cpr.HL7_HOST) 

765 if not self.hl7_port or self.hl7_port <= 0: 

766 fail_invalid( 

767 f"Missing/invalid {cpr.HL7_PORT}: {self.hl7_port}" 

768 ) 

769 if not self.primary_idnum: 

770 fail_missing(cpr.PRIMARY_IDNUM) 

771 if self.include_anonymous: 

772 fail_invalid("Can't include anonymous tasks for HL7") 

773 

774 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

775 # File 

776 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

777 if self._need_file_name(): 

778 # Filename options 

779 if not self.file_patient_spec_if_anonymous: 

780 fail_missing(cpr.FILE_PATIENT_SPEC_IF_ANONYMOUS) 

781 if not self.file_patient_spec: 

782 fail_missing(cpr.FILE_PATIENT_SPEC) 

783 if not self.file_filename_spec: 

784 fail_missing(cpr.FILE_FILENAME_SPEC) 

785 

786 if self._need_rio_metadata_options(): 

787 # RiO metadata 

788 if ( 

789 not self.rio_uploading_user 

790 or " " in self.rio_uploading_user 

791 or len(self.rio_uploading_user) > RIO_MAX_USER_LEN 

792 ): 

793 fail_invalid( 

794 f"Missing/invalid {cpr.RIO_UPLOADING_USER}: " 

795 f"{self.rio_uploading_user} (must be present, contain no " 

796 f"spaces, and max length {RIO_MAX_USER_LEN})" 

797 ) 

798 if not self.rio_document_type: 

799 fail_missing(cpr.RIO_DOCUMENT_TYPE) 

800 

801 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

802 # REDCap 

803 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

804 if self.transmission_method == ExportTransmissionMethod.HL7: 

805 if not self.primary_idnum: 

806 fail_missing(cpr.PRIMARY_IDNUM) 

807 if self.include_anonymous: 

808 fail_invalid("Can't include anonymous tasks for REDCap") 

809 

810 def validate_db_dependent(self, req: "CamcopsRequest") -> None: 

811 """ 

812 Validates the database-dependent aspects of the 

813 :class:`ExportRecipient`, or raises :exc:`InvalidExportRecipient`. 

814 

815 :meth:`validate_db_independent` should have been called first; this 

816 function presumes that those checks have been passed. 

817 

818 Args: 

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

820 """ 

821 from camcops_server.cc_modules.cc_group import Group # delayed import 

822 

823 def fail_invalid(msg: str) -> NoReturn: 

824 raise _Invalid(self.recipient_name, msg) 

825 

826 dbsession = req.dbsession 

827 valid_which_idnums = req.valid_which_idnums 

828 cpr = ConfigParamExportRecipient 

829 

830 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

831 # Set group IDs from group names 

832 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

833 self.group_ids = [] # type: List[int] 

834 for groupname in self.group_names: 

835 group = Group.get_group_by_name(dbsession, groupname) 

836 if not group: 

837 raise ValueError(f"No such group: {groupname!r}") 

838 self.group_ids.append(group.id) 

839 self.group_ids.sort() 

840 

841 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

842 # What to export 

843 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

844 if self.all_groups: 

845 groups = Group.get_all_groups(dbsession) 

846 else: 

847 groups = [] # type: List[Group] 

848 for gid in self.group_ids: 

849 group = Group.get_group_by_id(dbsession, gid) 

850 if not group: 

851 fail_invalid(f"Invalid group ID: {gid}") 

852 groups.append(group) 

853 

854 if self.primary_idnum: 

855 if self.primary_idnum not in valid_which_idnums: 

856 fail_invalid( 

857 f"Invalid {cpr.PRIMARY_IDNUM}: {self.primary_idnum}" 

858 ) 

859 

860 if self.require_idnum_mandatory: 

861 # (a) ID number must be mandatory in finalized records 

862 for group in groups: 

863 finalize_policy = group.tokenized_finalize_policy() 

864 if not finalize_policy.is_idnum_mandatory_in_policy( 

865 which_idnum=self.primary_idnum, 

866 valid_idnums=valid_which_idnums, 

867 ): 

868 fail_invalid( 

869 f"primary_idnum ({self.primary_idnum}) must be " 

870 f"mandatory in finalizing policy, but is not for " 

871 f"group {group}" 

872 ) 

873 if not self.finalized_only: 

874 # (b) ID number must also be mandatory in uploaded, 

875 # non-finalized records 

876 upload_policy = group.tokenized_upload_policy() 

877 if not upload_policy.is_idnum_mandatory_in_policy( 

878 which_idnum=self.primary_idnum, 

879 valid_idnums=valid_which_idnums, 

880 ): 

881 fail_invalid( 

882 f"primary_idnum ({self.primary_idnum}) must " 

883 f"be mandatory in upload policy (because " 

884 f"{cpr.FINALIZED_ONLY} is false), but is not " 

885 f"for group {group}" 

886 ) 

887 

888 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

889 # File 

890 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

891 if self._need_file_name(): 

892 # Filename options 

893 if not patient_spec_for_filename_is_valid( 

894 patient_spec=self.file_patient_spec, 

895 valid_which_idnums=valid_which_idnums, 

896 ): 

897 fail_invalid( 

898 f"Invalid {cpr.FILE_PATIENT_SPEC}: " 

899 f"{self.file_patient_spec}" 

900 ) 

901 if not filename_spec_is_valid( 

902 filename_spec=self.file_filename_spec, 

903 valid_which_idnums=valid_which_idnums, 

904 ): 

905 fail_invalid( 

906 f"Invalid {cpr.FILE_FILENAME_SPEC}: " 

907 f"{self.file_filename_spec}" 

908 ) 

909 

910 if self._need_rio_metadata_options(): 

911 # RiO metadata 

912 if self.rio_idnum not in valid_which_idnums: 

913 fail_invalid( 

914 f"Invalid ID number type for " 

915 f"{cpr.RIO_IDNUM}: {self.rio_idnum}" 

916 ) 

917 

918 def _need_file_name(self) -> bool: 

919 """ 

920 Do we need to know about filenames? 

921 """ 

922 return ( 

923 self.transmission_method == ExportTransmissionMethod.FILE 

924 or ( 

925 self.transmission_method == ExportTransmissionMethod.HL7 

926 and self.hl7_debug_divert_to_file 

927 ) 

928 or self.transmission_method == ExportTransmissionMethod.EMAIL 

929 ) 

930 

931 def _need_file_disk_options(self) -> bool: 

932 """ 

933 Do we need to know about how to write to disk (e.g. overwrite, make 

934 directories)? 

935 """ 

936 return self.transmission_method == ExportTransmissionMethod.FILE or ( 

937 self.transmission_method == ExportTransmissionMethod.HL7 

938 and self.hl7_debug_divert_to_file 

939 ) 

940 

941 def _need_rio_metadata_options(self) -> bool: 

942 """ 

943 Do we need to know about RiO metadata? 

944 """ 

945 return ( 

946 self.transmission_method == ExportTransmissionMethod.FILE 

947 and self.file_export_rio_metadata 

948 ) 

949 

950 def using_db(self) -> bool: 

951 """ 

952 Is the recipient a database? 

953 """ 

954 return self.transmission_method == ExportTransmissionMethod.DATABASE 

955 

956 def using_email(self) -> bool: 

957 """ 

958 Is the recipient an e-mail system? 

959 """ 

960 return self.transmission_method == ExportTransmissionMethod.EMAIL 

961 

962 def using_file(self) -> bool: 

963 """ 

964 Is the recipient a filestore? 

965 """ 

966 return self.transmission_method == ExportTransmissionMethod.FILE 

967 

968 def using_hl7(self) -> bool: 

969 """ 

970 Is the recipient an HL7 v2 recipient? 

971 """ 

972 return self.transmission_method == ExportTransmissionMethod.HL7 

973 

974 def using_fhir(self) -> bool: 

975 """ 

976 Is the recipient a FHIR recipient? 

977 """ 

978 return self.transmission_method == ExportTransmissionMethod.FHIR 

979 

980 def anonymous_ok(self) -> bool: 

981 """ 

982 Does this recipient permit/want anonymous tasks? 

983 """ 

984 return self.include_anonymous and not ( 

985 # Methods that require patient identification: 

986 self.using_hl7() 

987 or self.using_fhir() 

988 ) 

989 

990 def is_incremental(self) -> bool: 

991 """ 

992 Is this an incremental export? (That's the norm, except for database 

993 exports.) 

994 """ 

995 return not self.using_db() 

996 

997 @staticmethod 

998 def get_hl7_id_type(req: "CamcopsRequest", which_idnum: int) -> str: 

999 """ 

1000 Get the HL7 ID type for a specific CamCOPS ID number type. 

1001 """ 

1002 iddef = req.get_idnum_definition(which_idnum) 

1003 return (iddef.hl7_id_type or "") if iddef else "" 

1004 

1005 @staticmethod 

1006 def get_hl7_id_aa(req: "CamcopsRequest", which_idnum: int) -> str: 

1007 """ 

1008 Get the HL7 Assigning Authority for a specific CamCOPS ID number type. 

1009 """ 

1010 iddef = req.get_idnum_definition(which_idnum) 

1011 return (iddef.hl7_assigning_authority or "") if iddef else "" 

1012 

1013 def _get_processed_spec( 

1014 self, 

1015 req: "CamcopsRequest", 

1016 task: "Task", 

1017 patient_spec_if_anonymous: str, 

1018 patient_spec: str, 

1019 spec: str, 

1020 treat_as_filename: bool, 

1021 override_task_format: str = "", 

1022 ) -> str: 

1023 """ 

1024 Returns a 

1025 Args: 

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

1027 task: a :class:`camcops_server.cc_modules.cc_task.Task` 

1028 patient_spec_if_anonymous: 

1029 patient specification to be used for anonymous tasks 

1030 patient_spec: 

1031 patient specification to be used for patient-identifiable tasks 

1032 spec: 

1033 specification to use to create the string (may include 

1034 patient information from the patient specification) 

1035 treat_as_filename: 

1036 convert the resulting string to be a safe filename 

1037 override_task_format: 

1038 format to use to override the default (typically to force an 

1039 extension e.g. for HL7 debugging) 

1040 

1041 Returns: 

1042 a processed string specification (e.g. a filename; an e-mail 

1043 subject) 

1044 """ 

1045 return get_export_filename( 

1046 req=req, 

1047 patient_spec_if_anonymous=patient_spec_if_anonymous, 

1048 patient_spec=patient_spec, 

1049 filename_spec=spec, 

1050 filetype=( 

1051 override_task_format 

1052 if override_task_format 

1053 else self.task_format 

1054 ), 

1055 is_anonymous=task.is_anonymous, 

1056 surname=task.get_patient_surname(), 

1057 forename=task.get_patient_forename(), 

1058 dob=task.get_patient_dob(), 

1059 sex=task.get_patient_sex(), 

1060 idnum_objects=task.get_patient_idnum_objects(), 

1061 creation_datetime=task.get_creation_datetime(), 

1062 basetable=task.tablename, 

1063 serverpk=task.pk, 

1064 skip_conversion_to_safe_filename=not treat_as_filename, 

1065 ) 

1066 

1067 def get_filename( 

1068 self, 

1069 req: "CamcopsRequest", 

1070 task: "Task", 

1071 override_task_format: str = "", 

1072 ) -> str: 

1073 """ 

1074 Get the export filename, for file transfers. 

1075 """ 

1076 return self._get_processed_spec( 

1077 req=req, 

1078 task=task, 

1079 patient_spec_if_anonymous=self.file_patient_spec_if_anonymous, 

1080 patient_spec=self.file_patient_spec, 

1081 spec=self.file_filename_spec, 

1082 treat_as_filename=True, 

1083 override_task_format=override_task_format, 

1084 ) 

1085 

1086 def get_email_subject(self, req: "CamcopsRequest", task: "Task") -> str: 

1087 """ 

1088 Gets a substituted e-mail subject. 

1089 """ 

1090 return self._get_processed_spec( 

1091 req=req, 

1092 task=task, 

1093 patient_spec_if_anonymous=self.email_patient_spec_if_anonymous, 

1094 patient_spec=self.email_patient_spec, 

1095 spec=self.email_subject, 

1096 treat_as_filename=False, 

1097 ) 

1098 

1099 def get_email_body(self, req: "CamcopsRequest", task: "Task") -> str: 

1100 """ 

1101 Gets a substituted e-mail body. 

1102 """ 

1103 return self._get_processed_spec( 

1104 req=req, 

1105 task=task, 

1106 patient_spec_if_anonymous=self.email_patient_spec_if_anonymous, 

1107 patient_spec=self.email_patient_spec, 

1108 spec=self.email_body, 

1109 treat_as_filename=False, 

1110 )