Coverage for cc_modules/cc_exportrecipientinfo.py: 34%

389 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 14:23 +0100

1""" 

2camcops_server/cc_modules/cc_exportrecipient.py 

3 

4=============================================================================== 

5 

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

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

8 

9 This file is part of CamCOPS. 

10 

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

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

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

14 (at your option) any later version. 

15 

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

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

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

19 GNU General Public License for more details. 

20 

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

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

23 

24=============================================================================== 

25 

26**ExportRecipientInfo class.** 

27 

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

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

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

31(q.v.). 

32 

33""" 

34 

35import configparser 

36import datetime 

37import logging 

38from typing import Any, List, NoReturn, Optional, TYPE_CHECKING 

39 

40from cardinal_pythonlib.configfiles import ( 

41 get_config_parameter, 

42 get_config_parameter_boolean, 

43 get_config_parameter_multiline, 

44) 

45from cardinal_pythonlib.datetimefunc import ( 

46 coerce_to_pendulum, 

47 pendulum_to_utc_datetime_without_tz, 

48) 

49from cardinal_pythonlib.logs import BraceStyleAdapter 

50from cardinal_pythonlib.reprfunc import simple_repr 

51 

52from sqlalchemy.orm import Mapped 

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, *args: Any, **kwargs: Any) -> None: 

165 """ 

166 Initializes, optionally copying attributes from named (keyword) 

167 argument ``other``, which, if present, should be of type 

168 ExportRecipientInfo (or something that inherits from it, like 

169 ExportRecipient). 

170 """ 

171 

172 other = kwargs.pop("other", None) 

173 

174 cd = ConfigDefaults() 

175 

176 self.recipient_name: Mapped[str] = "" 

177 

178 # How to export 

179 

180 self.transmission_method: Mapped[str] = ExportTransmissionMethod.EMAIL 

181 self.push: Mapped[bool] = cd.PUSH 

182 self.task_format: Mapped[str] = cd.TASK_FORMAT 

183 self.xml_field_comments: Mapped[bool] = cd.XML_FIELD_COMMENTS 

184 

185 # What to export 

186 

187 self.all_groups: Mapped[bool] = cd.ALL_GROUPS 

188 self.group_names = ( 

189 [] 

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

191 self.group_ids: Mapped[list[int]] = [] 

192 self.tasks: Mapped[list[str]] = [] 

193 self.start_datetime_utc: Mapped[Optional[datetime.datetime]] = None 

194 self.end_datetime_utc: Mapped[Optional[datetime.datetime]] = None 

195 self.finalized_only: Mapped[bool] = cd.FINALIZED_ONLY 

196 self.include_anonymous: Mapped[bool] = cd.INCLUDE_ANONYMOUS 

197 self.primary_idnum: Mapped[Optional[int]] = None 

198 self.require_idnum_mandatory: Mapped[bool] = ( 

199 cd.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY 

200 ) 

201 

202 # Database 

203 

204 self.db_url: Mapped[Optional[str]] = "" 

205 self.db_echo: Mapped[bool] = cd.DB_ECHO 

206 self.db_include_blobs: Mapped[bool] = cd.DB_INCLUDE_BLOBS 

207 self.db_add_summaries: Mapped[bool] = cd.DB_ADD_SUMMARIES 

208 self.db_patient_id_per_row: Mapped[bool] = cd.DB_PATIENT_ID_PER_ROW 

209 

210 # Email 

211 

212 self.email_host: Mapped[str] = "" 

213 self.email_port: Mapped[int] = cd.EMAIL_PORT 

214 self.email_use_tls: Mapped[bool] = cd.EMAIL_USE_TLS 

215 self.email_host_username: Mapped[str] = "" 

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

217 self.email_from: Mapped[Optional[str]] = "" 

218 self.email_sender: Mapped[Optional[str]] = "" 

219 self.email_reply_to: Mapped[Optional[str]] = "" 

220 self.email_to: Mapped[Optional[str]] = "" # CSV list 

221 self.email_cc: Mapped[Optional[str]] = "" # CSV list 

222 self.email_bcc: Mapped[Optional[str]] = "" # CSV list 

223 self.email_patient_spec: Mapped[Optional[str]] = "" 

224 self.email_patient_spec_if_anonymous: Mapped[Optional[str]] = ( 

225 cd.PATIENT_SPEC_IF_ANONYMOUS 

226 ) 

227 self.email_subject: Mapped[Optional[str]] = "" 

228 self.email_body_as_html: Mapped[bool] = cd.EMAIL_BODY_IS_HTML 

229 self.email_body: Mapped[Optional[str]] = "" 

230 self.email_keep_message: Mapped[bool] = cd.EMAIL_KEEP_MESSAGE 

231 

232 # HL7 

233 

234 self.hl7_host: Mapped[Optional[str]] = "" 

235 self.hl7_port: Mapped[Optional[int]] = cd.HL7_PORT 

236 self.hl7_ping_first: Mapped[bool] = cd.HL7_PING_FIRST 

237 self.hl7_network_timeout_ms: Mapped[Optional[int]] = ( 

238 cd.HL7_NETWORK_TIMEOUT_MS 

239 ) 

240 self.hl7_keep_message: Mapped[bool] = cd.HL7_KEEP_MESSAGE 

241 self.hl7_keep_reply: Mapped[bool] = cd.HL7_KEEP_REPLY 

242 self.hl7_debug_divert_to_file: Mapped[bool] = ( 

243 cd.HL7_DEBUG_DIVERT_TO_FILE 

244 ) 

245 self.hl7_debug_treat_diverted_as_sent: Mapped[bool] = ( 

246 cd.HL7_DEBUG_TREAT_DIVERTED_AS_SENT 

247 ) 

248 

249 # File 

250 

251 self.file_patient_spec: Mapped[Optional[str]] = "" 

252 self.file_patient_spec_if_anonymous: Mapped[Optional[str]] = ( 

253 cd.PATIENT_SPEC_IF_ANONYMOUS 

254 ) 

255 self.file_filename_spec: Mapped[Optional[str]] = "" 

256 self.file_make_directory: Mapped[bool] = cd.FILE_MAKE_DIRECTORY 

257 self.file_overwrite_files: Mapped[bool] = cd.FILE_OVERWRITE_FILES 

258 self.file_export_rio_metadata: Mapped[bool] = ( 

259 cd.FILE_EXPORT_RIO_METADATA 

260 ) 

261 self.file_script_after_export: Mapped[str] = "" 

262 

263 # File/RiO 

264 

265 self.rio_idnum: Mapped[Optional[int]] = None 

266 self.rio_uploading_user: Mapped[Optional[str]] = "" 

267 self.rio_document_type: Mapped[Optional[str]] = "" 

268 

269 # REDCap 

270 

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

272 self.redcap_api_url: Mapped[Optional[str]] = "" 

273 self.redcap_fieldmap_filename: Mapped[Optional[str]] = "" 

274 

275 # FHIR 

276 

277 self.fhir_app_id: Mapped[Optional[str]] = "" 

278 self.fhir_api_url: Mapped[Optional[str]] = "" 

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

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

281 self.fhir_concurrent: Mapped[Optional[bool]] = False 

282 

283 # Copy from other? 

284 if other is not None: 

285 assert isinstance(other, ExportRecipientInfo) 

286 for attrname in self.get_attrnames(): 

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

288 # rather than an ExportRecipientInfo. 

289 if hasattr(other, attrname): 

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

291 

292 super().__init__(*args, **kwargs) 

293 

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

295 """ 

296 Returns all relevant attribute names. 

297 """ 

298 return sorted( 

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

300 ) 

301 

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

303 """ 

304 Returns attribute names to use for equality comparison. 

305 """ 

306 return [ 

307 x 

308 for x in self.get_attrnames() 

309 if x not in self.IGNORE_FOR_EQ_ATTRNAMES 

310 ] 

311 

312 def __repr__(self) -> str: 

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

314 

315 def __str__(self) -> str: 

316 return repr(self.recipient_name) 

317 

318 def __eq__(self, other: object) -> bool: 

319 """ 

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

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

322 """ 

323 if not isinstance(other, ExportRecipientInfo): 

324 return NotImplemented 

325 

326 for attrname in self.get_attrnames(): 

327 if attrname not in self.IGNORE_FOR_EQ_ATTRNAMES: 

328 selfattr = getattr(self, attrname) 

329 otherattr = getattr(other, attrname) 

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

331 # self.__class__.__name__, 

332 # attrname, 

333 # selfattr, 

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

335 # otherattr) 

336 if selfattr != otherattr: 

337 log.debug( 

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

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

340 self.__class__.__name__, 

341 self.recipient_name, 

342 attrname, 

343 selfattr, 

344 otherattr, 

345 ) 

346 return False 

347 return True 

348 

349 @classmethod 

350 def read_from_config( 

351 cls, 

352 cfg: "CamcopsConfig", 

353 parser: configparser.ConfigParser, 

354 recipient_name: str, 

355 ) -> "ExportRecipientInfo": 

356 """ 

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

358 

359 Args: 

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

361 parser: configparser INI file object 

362 recipient_name: name of recipient and of INI file section 

363 

364 Returns: 

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

366 a database session 

367 """ 

368 assert recipient_name 

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

370 

371 section = CONFIG_RECIPIENT_PREFIX + recipient_name 

372 cps = ConfigParamSite 

373 cpr = ConfigParamExportRecipient 

374 cd = ConfigDefaults() 

375 r = cls() # type: ExportRecipientInfo 

376 

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

378 return get_config_parameter( 

379 parser, section, paramname, str, default 

380 ) 

381 

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

383 return get_config_parameter_boolean( 

384 parser, section, paramname, default 

385 ) 

386 

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

388 return get_config_parameter( 

389 parser, section, paramname, int, default 

390 ) 

391 

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

393 return get_config_parameter_multiline( 

394 parser, section, paramname, [] 

395 ) 

396 

397 def _get_site_str( 

398 paramname: str, default: str = None 

399 ) -> Optional[str]: 

400 return get_config_parameter( 

401 parser, CONFIG_FILE_SITE_SECTION, paramname, str, default 

402 ) 

403 

404 # noinspection PyUnusedLocal 

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

406 return get_config_parameter_boolean( 

407 parser, CONFIG_FILE_SITE_SECTION, paramname, default 

408 ) 

409 

410 # noinspection PyUnusedLocal 

411 def _get_site_int( 

412 paramname: str, default: int = None 

413 ) -> Optional[int]: 

414 return get_config_parameter( 

415 parser, CONFIG_FILE_SITE_SECTION, paramname, int, default 

416 ) 

417 

418 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

419 # Identity 

420 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

421 r.recipient_name = recipient_name 

422 

423 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

424 # How to export 

425 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

426 r.transmission_method = _get_str(cpr.TRANSMISSION_METHOD) 

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

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

429 if r.transmission_method not in ALL_TRANSMISSION_METHODS: 

430 raise _Invalid( 

431 r.recipient_name, 

432 f"Missing/invalid " 

433 f"{ConfigParamExportRecipient.TRANSMISSION_METHOD}: " 

434 f"{r.transmission_method}", 

435 ) 

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

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

438 r.xml_field_comments = _get_bool( 

439 cpr.XML_FIELD_COMMENTS, cd.XML_FIELD_COMMENTS 

440 ) 

441 

442 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

443 # What to export 

444 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

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

446 r.group_names = _get_multiline(cpr.GROUPS) 

447 r.group_ids = [] 

448 # ... read later by validate_db_dependent() 

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

450 sd = _get_str(cpr.START_DATETIME_UTC) 

451 r.start_datetime_utc = ( 

452 pendulum_to_utc_datetime_without_tz( 

453 coerce_to_pendulum(sd, assume_local=False) 

454 ) 

455 if sd 

456 else None 

457 ) 

458 ed = _get_str(cpr.END_DATETIME_UTC) 

459 r.end_datetime_utc = ( 

460 pendulum_to_utc_datetime_without_tz( 

461 coerce_to_pendulum(ed, assume_local=False) 

462 ) 

463 if ed 

464 else None 

465 ) 

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

467 r.include_anonymous = _get_bool( 

468 cpr.INCLUDE_ANONYMOUS, cd.INCLUDE_ANONYMOUS 

469 ) 

470 r.primary_idnum = _get_int(cpr.PRIMARY_IDNUM) 

471 r.require_idnum_mandatory = _get_bool( 

472 cpr.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY, 

473 cd.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY, 

474 ) 

475 

476 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

477 # Database 

478 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

479 if r.transmission_method == ExportTransmissionMethod.DATABASE: 

480 r.db_url = _get_str(cpr.DB_URL) 

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

482 r.db_include_blobs = _get_bool( 

483 cpr.DB_INCLUDE_BLOBS, cd.DB_INCLUDE_BLOBS 

484 ) 

485 r.db_add_summaries = _get_bool( 

486 cpr.DB_ADD_SUMMARIES, cd.DB_ADD_SUMMARIES 

487 ) 

488 r.db_patient_id_per_row = _get_bool( 

489 cpr.DB_PATIENT_ID_PER_ROW, cd.DB_PATIENT_ID_PER_ROW 

490 ) 

491 

492 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

493 # Email 

494 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

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

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

497 

498 if r.transmission_method == ExportTransmissionMethod.EMAIL: 

499 r.email_host = cfg.email_host 

500 r.email_port = cfg.email_port 

501 r.email_use_tls = cfg.email_use_tls 

502 r.email_host_username = cfg.email_host_username 

503 r.email_host_password = cfg.email_host_password 

504 

505 # Read from password safe using 'pass' 

506 # from subprocess import run, PIPE 

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

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

509 

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

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

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

513 

514 r.email_to = _make_email_csv_list(cpr.EMAIL_TO) 

515 r.email_cc = _make_email_csv_list(cpr.EMAIL_CC) 

516 r.email_bcc = _make_email_csv_list(cpr.EMAIL_BCC) 

517 r.email_patient_spec_if_anonymous = _get_str( 

518 cpr.EMAIL_PATIENT_SPEC_IF_ANONYMOUS, "" 

519 ) 

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

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

522 r.email_body_as_html = _get_bool( 

523 cpr.EMAIL_BODY_IS_HTML, cd.EMAIL_BODY_IS_HTML 

524 ) 

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

526 r.email_keep_message = _get_bool( 

527 cpr.EMAIL_KEEP_MESSAGE, cd.EMAIL_KEEP_MESSAGE 

528 ) 

529 

530 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

531 # HL7 

532 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

533 if r.transmission_method == ExportTransmissionMethod.HL7: 

534 r.hl7_host = _get_str(cpr.HL7_HOST) 

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

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

537 r.hl7_network_timeout_ms = _get_int( 

538 cpr.HL7_NETWORK_TIMEOUT_MS, cd.HL7_NETWORK_TIMEOUT_MS 

539 ) 

540 r.hl7_keep_message = _get_bool( 

541 cpr.HL7_KEEP_MESSAGE, cd.HL7_KEEP_MESSAGE 

542 ) 

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

544 r.hl7_debug_divert_to_file = _get_bool( 

545 cpr.HL7_DEBUG_DIVERT_TO_FILE, cd.HL7_DEBUG_DIVERT_TO_FILE 

546 ) 

547 r.hl7_debug_treat_diverted_as_sent = _get_bool( 

548 cpr.HL7_DEBUG_TREAT_DIVERTED_AS_SENT, 

549 cd.HL7_DEBUG_TREAT_DIVERTED_AS_SENT, 

550 ) 

551 

552 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

553 # File 

554 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

555 if r._need_file_name(): 

556 r.file_patient_spec = _get_str(cpr.FILE_PATIENT_SPEC) 

557 r.file_patient_spec_if_anonymous = _get_str( 

558 cpr.FILE_PATIENT_SPEC_IF_ANONYMOUS, 

559 cd.FILE_PATIENT_SPEC_IF_ANONYMOUS, 

560 ) 

561 r.file_filename_spec = _get_str(cpr.FILE_FILENAME_SPEC) 

562 

563 if r._need_file_disk_options(): 

564 r.file_make_directory = _get_bool( 

565 cpr.FILE_MAKE_DIRECTORY, cd.FILE_MAKE_DIRECTORY 

566 ) 

567 r.file_overwrite_files = _get_bool( 

568 cpr.FILE_OVERWRITE_FILES, cd.FILE_OVERWRITE_FILES 

569 ) 

570 

571 if r.transmission_method == ExportTransmissionMethod.FILE: 

572 r.file_export_rio_metadata = _get_bool( 

573 cpr.FILE_EXPORT_RIO_METADATA, cd.FILE_EXPORT_RIO_METADATA 

574 ) 

575 r.file_script_after_export = _get_str(cpr.FILE_SCRIPT_AFTER_EXPORT) 

576 

577 if r._need_rio_metadata_options(): 

578 # RiO metadata 

579 r.rio_idnum = _get_int(cpr.RIO_IDNUM) 

580 r.rio_uploading_user = _get_str(cpr.RIO_UPLOADING_USER) 

581 r.rio_document_type = _get_str(cpr.RIO_DOCUMENT_TYPE) 

582 

583 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

584 # REDCap 

585 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

586 if r.transmission_method == ExportTransmissionMethod.REDCAP: 

587 r.redcap_api_url = _get_str(cpr.REDCAP_API_URL) 

588 r.redcap_api_key = _get_str(cpr.REDCAP_API_KEY) 

589 r.redcap_fieldmap_filename = _get_str(cpr.REDCAP_FIELDMAP_FILENAME) 

590 

591 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

592 # FHIR 

593 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

594 if r.transmission_method == ExportTransmissionMethod.FHIR: 

595 r.fhir_api_url = _get_str(cpr.FHIR_API_URL) 

596 r.fhir_app_id = _get_str( 

597 cpr.FHIR_APP_ID, CAMCOPS_DEFAULT_FHIR_APP_ID 

598 ) 

599 r.fhir_app_secret = _get_str(cpr.FHIR_APP_SECRET) 

600 r.fhir_launch_token = _get_str(cpr.FHIR_LAUNCH_TOKEN) 

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

602 

603 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

604 # Validate the basics and return 

605 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

606 r.validate_db_independent() 

607 return r 

608 

609 @classmethod 

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

611 """ 

612 Report an error to the log. 

613 """ 

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

615 

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

617 """ 

618 Is this definition valid? 

619 

620 Args: 

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

622 """ 

623 try: 

624 self.validate(req) 

625 return True 

626 except InvalidExportRecipient as e: 

627 self.report_error(str(e)) 

628 return False 

629 

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

631 """ 

632 Validates all aspects. 

633 

634 Args: 

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

636 

637 Raises: 

638 :exc:`InvalidExportRecipient` if invalid 

639 """ 

640 self.validate_db_independent() 

641 self.validate_db_dependent(req) 

642 

643 def validate_db_independent(self) -> None: 

644 """ 

645 Validates the database-independent aspects of the 

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

647 """ 

648 # noinspection PyUnresolvedReferences 

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

650 from camcops_server.cc_modules.cc_task import ( 

651 all_task_tablenames, 

652 ) # delayed import 

653 

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

655 raise _Invalid(self.recipient_name, msg) # type: ignore[arg-type] 

656 

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

658 raise _Missing(self.recipient_name, paramname) # type: ignore[arg-type] # noqa: E501 

659 

660 cpr = ConfigParamExportRecipient 

661 cps = ConfigParamSite 

662 

663 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

664 # Export type 

665 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

666 if self.transmission_method not in ALL_TRANSMISSION_METHODS: 

667 fail_invalid( 

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

669 f"{self.transmission_method}" 

670 ) 

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

672 fail_invalid( 

673 f"Push notifications not supported for these " 

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

675 ) 

676 

677 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

678 # What to export 

679 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

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

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

682 

683 all_basetables = all_task_tablenames() 

684 for basetable in self.tasks: # type: ignore[attr-defined] 

685 if basetable not in all_basetables: 

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

687 

688 if ( 

689 self.transmission_method == ExportTransmissionMethod.HL7 

690 and not self.primary_idnum 

691 ): 

692 fail_invalid( 

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

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

695 ) 

696 

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

698 fail_invalid( 

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

700 ) 

701 

702 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

703 # Database 

704 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

705 if self.transmission_method == ExportTransmissionMethod.DATABASE: 

706 if not self.db_url: 

707 fail_missing(cpr.DB_URL) 

708 

709 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

710 # Email 

711 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

712 if self.transmission_method == ExportTransmissionMethod.EMAIL: 

713 if not self.email_host: 

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

715 # it to. 

716 fail_missing(cps.EMAIL_HOST) 

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

718 if not self.email_from: 

719 # From is mandatory in all e-mails. 

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

721 fail_missing(cps.EMAIL_FROM) 

722 if COMMA in self.email_from: 

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

724 # sendmail doesn't. 

725 fail_invalid( 

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

727 f"{self.email_from!r}" 

728 ) 

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

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

731 fail_invalid( 

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

733 f"{cpr.EMAIL_BCC}" 

734 ) 

735 if COMMA in self.email_sender: 

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

737 # but only one in Sender. 

738 fail_invalid( 

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

740 f"{self.email_sender!r}" 

741 ) 

742 if not self.email_subject: 

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

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

745 fail_missing(cpr.EMAIL_SUBJECT) 

746 

747 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

748 # HL7 

749 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

750 if self.transmission_method == ExportTransmissionMethod.HL7: 

751 if not self.hl7_debug_divert_to_file: 

752 if not self.hl7_host: 

753 fail_missing(cpr.HL7_HOST) 

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

755 fail_invalid( 

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

757 ) 

758 if not self.primary_idnum: 

759 fail_missing(cpr.PRIMARY_IDNUM) 

760 if self.include_anonymous: 

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

762 

763 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

764 # File 

765 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

766 if self._need_file_name(): 

767 # Filename options 

768 if not self.file_patient_spec_if_anonymous: 

769 fail_missing(cpr.FILE_PATIENT_SPEC_IF_ANONYMOUS) 

770 if not self.file_patient_spec: 

771 fail_missing(cpr.FILE_PATIENT_SPEC) 

772 if not self.file_filename_spec: 

773 fail_missing(cpr.FILE_FILENAME_SPEC) 

774 

775 if self._need_rio_metadata_options(): 

776 # RiO metadata 

777 if ( 

778 not self.rio_uploading_user 

779 or " " in self.rio_uploading_user 

780 or len(self.rio_uploading_user) > RIO_MAX_USER_LEN # type: ignore[arg-type] # noqa: E501 

781 ): 

782 fail_invalid( 

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

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

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

786 ) 

787 if not self.rio_document_type: 

788 fail_missing(cpr.RIO_DOCUMENT_TYPE) 

789 

790 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

791 # REDCap 

792 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

793 if self.transmission_method == ExportTransmissionMethod.HL7: 

794 if not self.primary_idnum: 

795 fail_missing(cpr.PRIMARY_IDNUM) 

796 if self.include_anonymous: 

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

798 

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

800 """ 

801 Validates the database-dependent aspects of the 

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

803 

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

805 function presumes that those checks have been passed. 

806 

807 Args: 

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

809 """ 

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

811 

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

813 raise _Invalid(self.recipient_name, msg) # type: ignore[arg-type] 

814 

815 dbsession = req.dbsession 

816 valid_which_idnums = req.valid_which_idnums 

817 cpr = ConfigParamExportRecipient 

818 

819 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

820 # Set group IDs from group names 

821 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

822 for groupname in self.group_names: 

823 group = Group.get_group_by_name(dbsession, groupname) 

824 if not group: 

825 fail_invalid(f"No such group: {groupname!r}") 

826 self.group_ids.append(group.id) # type: ignore[attr-defined] 

827 self.group_ids.sort() # type: ignore[attr-defined] 

828 

829 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

830 # What to export 

831 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

832 if self.all_groups: 

833 groups = Group.get_all_groups(dbsession) 

834 else: 

835 groups = [] # type: ignore[no-redef] # type: List[Group] 

836 for gid in self.group_ids: # type: ignore[attr-defined] 

837 group = Group.get_group_by_id(dbsession, gid) 

838 if not group: 

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

840 groups.append(group) 

841 

842 if self.primary_idnum: 

843 if self.primary_idnum not in valid_which_idnums: 

844 fail_invalid( 

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

846 ) 

847 

848 if self.require_idnum_mandatory: 

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

850 for group in groups: 

851 finalize_policy = group.tokenized_finalize_policy() 

852 if not finalize_policy.is_idnum_mandatory_in_policy( 

853 which_idnum=self.primary_idnum, # type: ignore[arg-type] # noqa: E501 

854 valid_idnums=valid_which_idnums, 

855 ): 

856 fail_invalid( 

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

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

859 f"group {group}" 

860 ) 

861 if not self.finalized_only: 

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

863 # non-finalized records 

864 upload_policy = group.tokenized_upload_policy() 

865 if not upload_policy.is_idnum_mandatory_in_policy( 

866 which_idnum=self.primary_idnum, # type: ignore[arg-type] # noqa: E501 

867 valid_idnums=valid_which_idnums, 

868 ): 

869 fail_invalid( 

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

871 f"be mandatory in upload policy (because " 

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

873 f"for group {group}" 

874 ) 

875 

876 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

877 # File 

878 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

879 if self._need_file_name(): 

880 # Filename options 

881 if not patient_spec_for_filename_is_valid( 

882 patient_spec=self.file_patient_spec, # type: ignore[arg-type] 

883 valid_which_idnums=valid_which_idnums, 

884 ): 

885 fail_invalid( 

886 f"Invalid {cpr.FILE_PATIENT_SPEC}: " 

887 f"{self.file_patient_spec}" 

888 ) 

889 if not filename_spec_is_valid( 

890 filename_spec=self.file_filename_spec, # type: ignore[arg-type] # noqa: E501 

891 valid_which_idnums=valid_which_idnums, 

892 ): 

893 fail_invalid( 

894 f"Invalid {cpr.FILE_FILENAME_SPEC}: " 

895 f"{self.file_filename_spec}" 

896 ) 

897 

898 if self._need_rio_metadata_options(): 

899 # RiO metadata 

900 if self.rio_idnum not in valid_which_idnums: 

901 fail_invalid( 

902 f"Invalid ID number type for " 

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

904 ) 

905 

906 def _need_file_name(self) -> bool: 

907 """ 

908 Do we need to know about filenames? 

909 """ 

910 return ( 

911 self.transmission_method == ExportTransmissionMethod.FILE # type: ignore[return-value] # noqa: E501 

912 or ( 

913 self.transmission_method == ExportTransmissionMethod.HL7 

914 and self.hl7_debug_divert_to_file 

915 ) 

916 or self.transmission_method == ExportTransmissionMethod.EMAIL 

917 ) 

918 

919 def _need_file_disk_options(self) -> bool: 

920 """ 

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

922 directories)? 

923 """ 

924 return ( 

925 self.transmission_method == ExportTransmissionMethod.FILE 

926 or ( # type: ignore[return-value] 

927 self.transmission_method == ExportTransmissionMethod.HL7 

928 and self.hl7_debug_divert_to_file 

929 ) 

930 ) 

931 

932 def _need_rio_metadata_options(self) -> bool: 

933 """ 

934 Do we need to know about RiO metadata? 

935 """ 

936 return ( 

937 self.transmission_method == ExportTransmissionMethod.FILE # type: ignore[return-value] # noqa: E501 

938 and self.file_export_rio_metadata 

939 ) 

940 

941 def using_db(self) -> bool: 

942 """ 

943 Is the recipient a database? 

944 """ 

945 return self.transmission_method == ExportTransmissionMethod.DATABASE # type: ignore[return-value] # noqa: E501 

946 

947 def using_email(self) -> bool: 

948 """ 

949 Is the recipient an e-mail system? 

950 """ 

951 return self.transmission_method == ExportTransmissionMethod.EMAIL # type: ignore[return-value] # noqa: E501 

952 

953 def using_file(self) -> bool: 

954 """ 

955 Is the recipient a filestore? 

956 """ 

957 return self.transmission_method == ExportTransmissionMethod.FILE # type: ignore[return-value] # noqa: E501 

958 

959 def using_hl7(self) -> bool: 

960 """ 

961 Is the recipient an HL7 v2 recipient? 

962 """ 

963 return self.transmission_method == ExportTransmissionMethod.HL7 # type: ignore[return-value] # noqa: E501 

964 

965 def using_fhir(self) -> bool: 

966 """ 

967 Is the recipient a FHIR recipient? 

968 """ 

969 return self.transmission_method == ExportTransmissionMethod.FHIR # type: ignore[return-value] # noqa: E501 

970 

971 def anonymous_ok(self) -> bool: 

972 """ 

973 Does this recipient permit/want anonymous tasks? 

974 """ 

975 return self.include_anonymous and not ( # type: ignore[return-value] 

976 # Methods that require patient identification: 

977 self.using_hl7() 

978 or self.using_fhir() 

979 ) 

980 

981 def is_incremental(self) -> bool: 

982 """ 

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

984 exports.) 

985 """ 

986 return not self.using_db() 

987 

988 @staticmethod 

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

990 """ 

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

992 """ 

993 iddef = req.get_idnum_definition(which_idnum) 

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

995 

996 @staticmethod 

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

998 """ 

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

1000 """ 

1001 iddef = req.get_idnum_definition(which_idnum) 

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

1003 

1004 def _get_processed_spec( 

1005 self, 

1006 req: "CamcopsRequest", 

1007 task: "Task", 

1008 patient_spec_if_anonymous: str, 

1009 patient_spec: str, 

1010 spec: str, 

1011 treat_as_filename: bool, 

1012 override_task_format: str = "", 

1013 ) -> str: 

1014 """ 

1015 Returns a 

1016 Args: 

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

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

1019 patient_spec_if_anonymous: 

1020 patient specification to be used for anonymous tasks 

1021 patient_spec: 

1022 patient specification to be used for patient-identifiable tasks 

1023 spec: 

1024 specification to use to create the string (may include 

1025 patient information from the patient specification) 

1026 treat_as_filename: 

1027 convert the resulting string to be a safe filename 

1028 override_task_format: 

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

1030 extension e.g. for HL7 debugging) 

1031 

1032 Returns: 

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

1034 subject) 

1035 """ 

1036 return get_export_filename( 

1037 req=req, 

1038 patient_spec_if_anonymous=patient_spec_if_anonymous, 

1039 patient_spec=patient_spec, 

1040 filename_spec=spec, 

1041 filetype=( 

1042 override_task_format # type: ignore[arg-type] 

1043 if override_task_format 

1044 else self.task_format 

1045 ), 

1046 is_anonymous=task.is_anonymous, 

1047 surname=task.get_patient_surname(), 

1048 forename=task.get_patient_forename(), 

1049 dob=task.get_patient_dob(), 

1050 sex=task.get_patient_sex(), 

1051 idnum_objects=task.get_patient_idnum_objects(), 

1052 creation_datetime=task.get_creation_datetime(), 

1053 basetable=task.tablename, 

1054 serverpk=task.pk, 

1055 skip_conversion_to_safe_filename=not treat_as_filename, 

1056 ) 

1057 

1058 def get_filename( 

1059 self, 

1060 req: "CamcopsRequest", 

1061 task: "Task", 

1062 override_task_format: str = "", 

1063 ) -> str: 

1064 """ 

1065 Get the export filename, for file transfers. 

1066 """ 

1067 return self._get_processed_spec( 

1068 req=req, 

1069 task=task, 

1070 patient_spec_if_anonymous=self.file_patient_spec_if_anonymous, # type: ignore[arg-type] # noqa: E501 

1071 patient_spec=self.file_patient_spec, # type: ignore[arg-type] 

1072 spec=self.file_filename_spec, # type: ignore[arg-type] 

1073 treat_as_filename=True, 

1074 override_task_format=override_task_format, 

1075 ) 

1076 

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

1078 """ 

1079 Gets a substituted e-mail subject. 

1080 """ 

1081 return self._get_processed_spec( 

1082 req=req, 

1083 task=task, 

1084 patient_spec_if_anonymous=self.email_patient_spec_if_anonymous, # type: ignore[arg-type] # noqa: E501 

1085 patient_spec=self.email_patient_spec, # type: ignore[arg-type] 

1086 spec=self.email_subject, # type: ignore[arg-type] 

1087 treat_as_filename=False, 

1088 ) 

1089 

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

1091 """ 

1092 Gets a substituted e-mail body. 

1093 """ 

1094 return self._get_processed_spec( 

1095 req=req, 

1096 task=task, 

1097 patient_spec_if_anonymous=self.email_patient_spec_if_anonymous, # type: ignore[arg-type] # noqa: E501 

1098 patient_spec=self.email_patient_spec, # type: ignore[arg-type] 

1099 spec=self.email_body, # type: ignore[arg-type] 

1100 treat_as_filename=False, 

1101 )