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_exportrecipient.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**ExportRecipientInfo class.** 

28 

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

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

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

32(q.v.). 

33 

34""" 

35 

36import configparser 

37import datetime 

38import logging 

39from typing import List, NoReturn, Optional, TYPE_CHECKING 

40 

41from cardinal_pythonlib.configfiles import ( 

42 get_config_parameter, 

43 get_config_parameter_boolean, 

44 get_config_parameter_multiline, 

45) 

46from cardinal_pythonlib.datetimefunc import ( 

47 coerce_to_pendulum, 

48 pendulum_to_utc_datetime_without_tz, 

49) 

50from cardinal_pythonlib.logs import BraceStyleAdapter 

51from cardinal_pythonlib.reprfunc import simple_repr 

52 

53from camcops_server.cc_modules.cc_constants import ( 

54 CONFIG_FILE_SITE_SECTION, 

55 ConfigDefaults, 

56 ConfigParamExportRecipient, 

57 ConfigParamSite, 

58 FileType, 

59) 

60from camcops_server.cc_modules.cc_filename import ( 

61 filename_spec_is_valid, 

62 get_export_filename, 

63 patient_spec_for_filename_is_valid, 

64) 

65 

66if TYPE_CHECKING: 

67 from camcops_server.cc_modules.cc_config import CamcopsConfig 

68 from camcops_server.cc_modules.cc_request import CamcopsRequest 

69 from camcops_server.cc_modules.cc_task import Task 

70 

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

72 

73 

74# ============================================================================= 

75# Constants 

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

77 

78COMMA = "," 

79CONFIG_RECIPIENT_PREFIX = "recipient:" 

80RIO_MAX_USER_LEN = 10 

81 

82 

83class ExportTransmissionMethod(object): 

84 """ 

85 Possible export transmission methods. 

86 """ 

87 DATABASE = "database" 

88 EMAIL = "email" 

89 FHIR = "fhir" 

90 FILE = "file" 

91 HL7 = "hl7" 

92 REDCAP = "redcap" 

93 

94 

95ALL_TRANSMISSION_METHODS = [ 

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

97 if not k.startswith("_") 

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

99 

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

101 

102 

103class InvalidExportRecipient(ValueError): 

104 """ 

105 Exception for invalid export recipients. 

106 """ 

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

108 super().__init__( 

109 f"For export recipient [{recipient_name}]: {msg}") 

110 

111 

112# Internal shorthand: 

113_Invalid = InvalidExportRecipient 

114 

115 

116class _Missing(_Invalid): 

117 """ 

118 Exception for missing config parameters 

119 """ 

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

121 super().__init__(recipient_name, 

122 f"Missing parameter {paramname}") 

123 

124 

125# ============================================================================= 

126# ExportRecipientInfo class 

127# ============================================================================= 

128 

129class ExportRecipientInfo(object): 

130 """ 

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

132 object. 

133 

134 This has an unfortunate close relationship with 

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

136 (q.v.). 

137 

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

139 """ 

140 IGNORE_FOR_EQ_ATTRNAMES = [ 

141 # Attribute names to ignore for equality comparison 

142 "email_host_password", 

143 "fhir_app_secret", 

144 "fhir_launch_token", 

145 "redcap_api_key", 

146 ] 

147 

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

149 """ 

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

151 """ 

152 cd = ConfigDefaults() 

153 

154 self.recipient_name = "" 

155 

156 # How to export 

157 

158 self.transmission_method = ExportTransmissionMethod.EMAIL 

159 self.push = cd.PUSH 

160 self.task_format = cd.TASK_FORMAT 

161 self.xml_field_comments = cd.XML_FIELD_COMMENTS 

162 

163 # What to export 

164 

165 self.all_groups = cd.ALL_GROUPS 

166 self.group_names = [] # type: List[str] # not in database; see group_ids # noqa 

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

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

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

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

171 self.finalized_only = cd.FINALIZED_ONLY 

172 self.include_anonymous = cd.INCLUDE_ANONYMOUS 

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

174 self.require_idnum_mandatory = cd.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY # noqa 

175 

176 # Database 

177 

178 self.db_url = "" 

179 self.db_echo = cd.DB_ECHO 

180 self.db_include_blobs = cd.DB_INCLUDE_BLOBS 

181 self.db_add_summaries = cd.DB_ADD_SUMMARIES 

182 self.db_patient_id_per_row = cd.DB_PATIENT_ID_PER_ROW 

183 

184 # Email 

185 

186 self.email_host = "" 

187 self.email_port = cd.EMAIL_PORT 

188 self.email_use_tls = cd.EMAIL_USE_TLS 

189 self.email_host_username = "" 

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

191 self.email_from = "" 

192 self.email_sender = "" 

193 self.email_reply_to = "" 

194 self.email_to = "" # CSV list 

195 self.email_cc = "" # CSV list 

196 self.email_bcc = "" # CSV list 

197 self.email_patient_spec = "" 

198 self.email_patient_spec_if_anonymous = cd.PATIENT_SPEC_IF_ANONYMOUS 

199 self.email_subject = "" 

200 self.email_body_as_html = cd.EMAIL_BODY_IS_HTML 

201 self.email_body = "" 

202 self.email_keep_message = cd.EMAIL_KEEP_MESSAGE 

203 

204 # HL7 

205 

206 self.hl7_host = "" 

207 self.hl7_port = cd.HL7_PORT 

208 self.hl7_ping_first = cd.HL7_PING_FIRST 

209 self.hl7_network_timeout_ms = cd.HL7_NETWORK_TIMEOUT_MS 

210 self.hl7_keep_message = cd.HL7_KEEP_MESSAGE 

211 self.hl7_keep_reply = cd.HL7_KEEP_REPLY 

212 self.hl7_debug_divert_to_file = cd.HL7_DEBUG_DIVERT_TO_FILE 

213 self.hl7_debug_treat_diverted_as_sent = cd.HL7_DEBUG_TREAT_DIVERTED_AS_SENT # noqa 

214 

215 # File 

216 

217 self.file_patient_spec = "" 

218 self.file_patient_spec_if_anonymous = cd.PATIENT_SPEC_IF_ANONYMOUS 

219 self.file_filename_spec = "" 

220 self.file_make_directory = cd.FILE_MAKE_DIRECTORY 

221 self.file_overwrite_files = cd.FILE_OVERWRITE_FILES 

222 self.file_export_rio_metadata = cd.FILE_EXPORT_RIO_METADATA 

223 self.file_script_after_export = "" 

224 

225 # File/RiO 

226 

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

228 self.rio_uploading_user = "" 

229 self.rio_document_type = "" 

230 

231 # REDCap 

232 

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

234 self.redcap_api_url = "" 

235 self.redcap_fieldmap_filename = "" 

236 

237 # FHIR 

238 

239 self.fhir_api_url = "" 

240 self.fhir_app_secret = "" 

241 self.fhir_launch_token = "" 

242 

243 # Copy from other? 

244 if other is not None: 

245 assert isinstance(other, ExportRecipientInfo) 

246 for attrname in self.get_attrnames(): 

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

248 # rather than an ExportRecipientInfo. 

249 if hasattr(other, attrname): 

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

251 

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

253 """ 

254 Returns all relevant attribute names. 

255 """ 

256 return sorted([key for key in self.__dict__ 

257 if not key.startswith('_')]) 

258 

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

260 """ 

261 Returns attribute names to use for equality comparison. 

262 """ 

263 return [x for x in self.get_attrnames() 

264 if x not in self.IGNORE_FOR_EQ_ATTRNAMES] 

265 

266 def __repr__(self): 

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

268 

269 def __str__(self) -> str: 

270 return repr(self.recipient_name) 

271 

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

273 """ 

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

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

276 """ 

277 for attrname in self.get_attrnames(): 

278 if attrname not in self.IGNORE_FOR_EQ_ATTRNAMES: 

279 selfattr = getattr(self, attrname) 

280 otherattr = getattr(other, attrname) 

281 # log.critical("{}.{}: {} {} {}", 

282 # self.__class__.__name__, 

283 # attrname, 

284 # selfattr, 

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

286 # otherattr) 

287 if selfattr != otherattr: 

288 log.debug( 

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

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

291 self.__class__.__name__, 

292 self.recipient_name, 

293 attrname, 

294 selfattr, 

295 otherattr) 

296 return False 

297 return True 

298 

299 @classmethod 

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

301 """ 

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

303 """ 

304 d = cls() 

305 

306 d.recipient_name = "_dummy_export_recipient_" 

307 d.current = True 

308 

309 d.transmission_method = ExportTransmissionMethod.FILE 

310 

311 d.all_groups = True 

312 d.primary_idnum = 1 

313 d.require_idnum_mandatory = False 

314 d.finalized_only = False 

315 d.task_format = FileType.XML 

316 

317 # File 

318 d.include_anonymous = True 

319 d.file_patient_spec_if_anonymous = "anonymous" 

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

321 d.file_filename_spec = ( 

322 "/tmp/camcops_debug_testing/" 

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

324 ".{filetype}" 

325 ) 

326 d.file_overwrite_files = False 

327 d.file_make_directory = True 

328 

329 return d 

330 

331 @classmethod 

332 def read_from_config(cls, 

333 cfg: "CamcopsConfig", 

334 parser: configparser.ConfigParser, 

335 recipient_name: str) -> "ExportRecipientInfo": 

336 """ 

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

338 

339 Args: 

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

341 parser: configparser INI file object 

342 recipient_name: name of recipient and of INI file section 

343 

344 Returns: 

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

346 a database session 

347 """ 

348 assert recipient_name 

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

350 

351 section = CONFIG_RECIPIENT_PREFIX + recipient_name 

352 cps = ConfigParamSite 

353 cpr = ConfigParamExportRecipient 

354 cd = ConfigDefaults() 

355 r = cls() # type: ExportRecipientInfo 

356 

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

358 return get_config_parameter( 

359 parser, section, paramname, str, default) 

360 

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

362 return get_config_parameter_boolean( 

363 parser, section, paramname, default) 

364 

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

366 return get_config_parameter( 

367 parser, section, paramname, int, default) 

368 

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

370 return get_config_parameter_multiline( 

371 parser, section, paramname, []) 

372 

373 def _get_site_str(paramname: str, 

374 default: str = None) -> Optional[str]: 

375 return get_config_parameter( 

376 parser, CONFIG_FILE_SITE_SECTION, paramname, str, default) 

377 

378 # noinspection PyUnusedLocal 

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

380 return get_config_parameter_boolean( 

381 parser, CONFIG_FILE_SITE_SECTION, paramname, default) 

382 

383 # noinspection PyUnusedLocal 

384 def _get_site_int(paramname: str, 

385 default: int = None) -> Optional[int]: 

386 return get_config_parameter( 

387 parser, CONFIG_FILE_SITE_SECTION, paramname, int, default) 

388 

389 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

390 # Identity 

391 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

392 r.recipient_name = recipient_name 

393 

394 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

395 # How to export 

396 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

397 r.transmission_method = _get_str(cpr.TRANSMISSION_METHOD) 

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

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

400 if r.transmission_method not in ALL_TRANSMISSION_METHODS: 

401 raise _Invalid( 

402 r.recipient_name, 

403 f"Missing/invalid " 

404 f"{ConfigParamExportRecipient.TRANSMISSION_METHOD}: " 

405 f"{r.transmission_method}" 

406 ) 

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

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

409 r.xml_field_comments = _get_bool(cpr.XML_FIELD_COMMENTS, 

410 cd.XML_FIELD_COMMENTS) 

411 

412 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

413 # What to export 

414 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

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

416 r.group_names = _get_multiline(cpr.GROUPS) 

417 r.group_ids = [] 

418 # ... read later by validate_db_dependent() 

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

420 sd = _get_str(cpr.START_DATETIME_UTC) 

421 r.start_datetime_utc = pendulum_to_utc_datetime_without_tz( 

422 coerce_to_pendulum(sd, assume_local=False)) if sd else None 

423 ed = _get_str(cpr.END_DATETIME_UTC) 

424 r.end_datetime_utc = pendulum_to_utc_datetime_without_tz( 

425 coerce_to_pendulum(ed, assume_local=False)) if ed else None 

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

427 r.include_anonymous = _get_bool(cpr.INCLUDE_ANONYMOUS, 

428 cd.INCLUDE_ANONYMOUS) 

429 r.primary_idnum = _get_int(cpr.PRIMARY_IDNUM) 

430 r.require_idnum_mandatory = _get_bool( 

431 cpr.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY, 

432 cd.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY) 

433 

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

435 # Database 

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

437 if r.transmission_method == ExportTransmissionMethod.DATABASE: 

438 r.db_url = _get_str(cpr.DB_URL) 

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

440 r.db_include_blobs = _get_bool(cpr.DB_INCLUDE_BLOBS, 

441 cd.DB_INCLUDE_BLOBS) 

442 r.db_add_summaries = _get_bool(cpr.DB_ADD_SUMMARIES, 

443 cd.DB_ADD_SUMMARIES) 

444 r.db_patient_id_per_row = _get_bool(cpr.DB_PATIENT_ID_PER_ROW, 

445 cd.DB_PATIENT_ID_PER_ROW) 

446 

447 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

448 # Email 

449 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

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

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

452 

453 if r.transmission_method == ExportTransmissionMethod.EMAIL: 

454 r.email_host = cfg.email_host 

455 r.email_port = cfg.email_port 

456 r.email_use_tls = cfg.email_use_tls 

457 r.email_host_username = cfg.email_host_username 

458 r.email_host_password = cfg.email_host_password 

459 

460 # Read from password safe using 'pass' 

461 # from subprocess import run, PIPE 

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

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

464 

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

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

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

468 

469 r.email_to = _make_email_csv_list(cpr.EMAIL_TO) 

470 r.email_cc = _make_email_csv_list(cpr.EMAIL_CC) 

471 r.email_bcc = _make_email_csv_list(cpr.EMAIL_BCC) 

472 r.email_patient_spec_if_anonymous = _get_str(cpr.EMAIL_PATIENT_SPEC_IF_ANONYMOUS, "") # noqa 

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

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

475 r.email_body_as_html = _get_bool(cpr.EMAIL_BODY_IS_HTML, 

476 cd.EMAIL_BODY_IS_HTML) 

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

478 r.email_keep_message = _get_bool(cpr.EMAIL_KEEP_MESSAGE, 

479 cd.EMAIL_KEEP_MESSAGE) 

480 

481 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

482 # HL7 

483 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

484 if r.transmission_method == ExportTransmissionMethod.HL7: 

485 r.hl7_host = _get_str(cpr.HL7_HOST) 

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

487 r.hl7_ping_first = _get_bool(cpr.HL7_PING_FIRST, 

488 cd.HL7_PING_FIRST) 

489 r.hl7_network_timeout_ms = _get_int(cpr.HL7_NETWORK_TIMEOUT_MS, 

490 cd.HL7_NETWORK_TIMEOUT_MS) 

491 r.hl7_keep_message = _get_bool(cpr.HL7_KEEP_MESSAGE, 

492 cd.HL7_KEEP_MESSAGE) 

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

494 r.hl7_debug_divert_to_file = _get_bool( 

495 cpr.HL7_DEBUG_DIVERT_TO_FILE, cd.HL7_DEBUG_DIVERT_TO_FILE) 

496 r.hl7_debug_treat_diverted_as_sent = _get_bool( 

497 cpr.HL7_DEBUG_TREAT_DIVERTED_AS_SENT, 

498 cd.HL7_DEBUG_TREAT_DIVERTED_AS_SENT) 

499 

500 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

501 # File 

502 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

503 if r._need_file_name(): 

504 r.file_patient_spec = _get_str(cpr.FILE_PATIENT_SPEC) 

505 r.file_patient_spec_if_anonymous = _get_str( 

506 cpr.FILE_PATIENT_SPEC_IF_ANONYMOUS, 

507 cd.FILE_PATIENT_SPEC_IF_ANONYMOUS) 

508 r.file_filename_spec = _get_str(cpr.FILE_FILENAME_SPEC) 

509 

510 if r._need_file_disk_options(): 

511 r.file_make_directory = _get_bool(cpr.FILE_MAKE_DIRECTORY, 

512 cd.FILE_MAKE_DIRECTORY) 

513 r.file_overwrite_files = _get_bool(cpr.FILE_OVERWRITE_FILES, 

514 cd.FILE_OVERWRITE_FILES) 

515 

516 if r.transmission_method == ExportTransmissionMethod.FILE: 

517 r.file_export_rio_metadata = _get_bool( 

518 cpr.FILE_EXPORT_RIO_METADATA, cd.FILE_EXPORT_RIO_METADATA) 

519 r.file_script_after_export = _get_str(cpr.FILE_SCRIPT_AFTER_EXPORT) 

520 

521 if r._need_rio_metadata_options(): 

522 # RiO metadata 

523 r.rio_idnum = _get_int(cpr.RIO_IDNUM) 

524 r.rio_uploading_user = _get_str(cpr.RIO_UPLOADING_USER) 

525 r.rio_document_type = _get_str(cpr.RIO_DOCUMENT_TYPE) 

526 

527 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

528 # REDCap 

529 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

530 if r.transmission_method == ExportTransmissionMethod.REDCAP: 

531 r.redcap_api_url = _get_str(cpr.REDCAP_API_URL) 

532 r.redcap_api_key = _get_str(cpr.REDCAP_API_KEY) 

533 r.redcap_fieldmap_filename = _get_str(cpr.REDCAP_FIELDMAP_FILENAME) 

534 

535 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

536 # FHIR 

537 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

538 if r.transmission_method == ExportTransmissionMethod.FHIR: 

539 r.fhir_api_url = _get_str(cpr.FHIR_API_URL) 

540 r.fhir_app_secret = _get_str(cpr.FHIR_APP_SECRET) 

541 r.fhir_launch_token = _get_str(cpr.FHIR_LAUNCH_TOKEN) 

542 

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

544 # Validate the basics and return 

545 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

546 r.validate_db_independent() 

547 return r 

548 

549 @classmethod 

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

551 """ 

552 Report an error to the log. 

553 """ 

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

555 

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

557 """ 

558 Is this definition valid? 

559 

560 Args: 

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

562 """ 

563 try: 

564 self.validate(req) 

565 return True 

566 except InvalidExportRecipient as e: 

567 self.report_error(str(e)) 

568 return False 

569 

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

571 """ 

572 Validates all aspects. 

573 

574 Args: 

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

576 

577 Raises: 

578 :exc:`InvalidExportRecipient` if invalid 

579 """ 

580 self.validate_db_independent() 

581 self.validate_db_dependent(req) 

582 

583 def validate_db_independent(self) -> None: 

584 """ 

585 Validates the database-independent aspects of the 

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

587 """ 

588 # noinspection PyUnresolvedReferences 

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

590 from camcops_server.cc_modules.cc_task import all_task_tablenames # delayed import # noqa 

591 

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

593 raise _Invalid(self.recipient_name, msg) 

594 

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

596 raise _Missing(self.recipient_name, paramname) 

597 

598 cpr = ConfigParamExportRecipient 

599 cps = ConfigParamSite 

600 

601 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

602 # Export type 

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

604 if self.transmission_method not in ALL_TRANSMISSION_METHODS: 

605 fail_invalid( 

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

607 f"{self.transmission_method}") 

608 no_push = [ExportTransmissionMethod.DATABASE] 

609 if self.push and self.transmission_method in no_push: 

610 fail_invalid(f"Push notifications not supported for these " 

611 f"transmission methods: {no_push!r}") 

612 

613 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

614 # What to export 

615 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

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

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

618 

619 all_basetables = all_task_tablenames() 

620 for basetable in self.tasks: 

621 if basetable not in all_basetables: 

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

623 

624 if (self.transmission_method == ExportTransmissionMethod.HL7 and 

625 not self.primary_idnum): 

626 fail_invalid( 

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

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

629 ) 

630 

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

632 fail_invalid( 

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

634 

635 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

636 # Database 

637 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

638 if self.transmission_method == ExportTransmissionMethod.DATABASE: 

639 if not self.db_url: 

640 fail_missing(cpr.DB_URL) 

641 

642 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

643 # Email 

644 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

645 if self.transmission_method == ExportTransmissionMethod.EMAIL: 

646 if not self.email_host: 

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

648 # it to. 

649 fail_missing(cps.EMAIL_HOST) 

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

651 if not self.email_from: 

652 # From is mandatory in all e-mails. 

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

654 fail_missing(cps.EMAIL_FROM) 

655 if COMMA in self.email_from: 

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

657 # sendmail doesn't. 

658 fail_invalid( 

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

660 f"{self.email_from!r}") 

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

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

663 fail_invalid( 

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

665 f"{cpr.EMAIL_BCC}") 

666 if COMMA in self.email_sender: 

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

668 # but only one in Sender. 

669 fail_invalid( 

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

671 f"{self.email_sender!r}") 

672 if not self.email_subject: 

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

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

675 fail_missing(cpr.EMAIL_SUBJECT) 

676 

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

678 # HL7 

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

680 if self.transmission_method == ExportTransmissionMethod.HL7: 

681 if not self.hl7_debug_divert_to_file: 

682 if not self.hl7_host: 

683 fail_missing(cpr.HL7_HOST) 

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

685 fail_invalid( 

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

687 if not self.primary_idnum: 

688 fail_missing(cpr.PRIMARY_IDNUM) 

689 if self.include_anonymous: 

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

691 

692 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

693 # File 

694 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

695 if self._need_file_name(): 

696 # Filename options 

697 if not self.file_patient_spec_if_anonymous: 

698 fail_missing(cpr.FILE_PATIENT_SPEC_IF_ANONYMOUS) 

699 if not self.file_patient_spec: 

700 fail_missing(cpr.FILE_PATIENT_SPEC) 

701 if not self.file_filename_spec: 

702 fail_missing(cpr.FILE_FILENAME_SPEC) 

703 

704 if self._need_rio_metadata_options(): 

705 # RiO metadata 

706 if (not self.rio_uploading_user or 

707 " " in self.rio_uploading_user or 

708 len(self.rio_uploading_user) > RIO_MAX_USER_LEN): 

709 fail_invalid( 

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

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

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

713 if not self.rio_document_type: 

714 fail_missing(cpr.RIO_DOCUMENT_TYPE) 

715 

716 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

717 # REDCap 

718 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

719 if self.transmission_method == ExportTransmissionMethod.HL7: 

720 if not self.primary_idnum: 

721 fail_missing(cpr.PRIMARY_IDNUM) 

722 if self.include_anonymous: 

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

724 

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

726 """ 

727 Validates the database-dependent aspects of the 

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

729 

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

731 function presumes that those checks have been passed. 

732 

733 Args: 

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

735 """ 

736 from camcops_server.cc_modules.cc_group import Group # delayed import # noqa 

737 

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

739 raise _Invalid(self.recipient_name, msg) 

740 

741 dbsession = req.dbsession 

742 valid_which_idnums = req.valid_which_idnums 

743 cpr = ConfigParamExportRecipient 

744 

745 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

746 # Set group IDs from group names 

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

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

749 for groupname in self.group_names: 

750 group = Group.get_group_by_name(dbsession, groupname) 

751 if not group: 

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

753 self.group_ids.append(group.id) 

754 self.group_ids.sort() 

755 

756 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

757 # What to export 

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

759 if self.all_groups: 

760 groups = Group.get_all_groups(dbsession) 

761 else: 

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

763 for gid in self.group_ids: 

764 group = Group.get_group_by_id(dbsession, gid) 

765 if not group: 

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

767 groups.append(group) 

768 

769 if self.primary_idnum: 

770 if self.primary_idnum not in valid_which_idnums: 

771 fail_invalid( 

772 f"Invalid {cpr.PRIMARY_IDNUM}: {self.primary_idnum}") 

773 

774 if self.require_idnum_mandatory: 

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

776 for group in groups: 

777 finalize_policy = group.tokenized_finalize_policy() 

778 if not finalize_policy.is_idnum_mandatory_in_policy( 

779 which_idnum=self.primary_idnum, 

780 valid_idnums=valid_which_idnums): 

781 fail_invalid( 

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

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

784 f"group {group}" 

785 ) 

786 if not self.finalized_only: 

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

788 # non-finalized records 

789 upload_policy = group.tokenized_upload_policy() 

790 if not upload_policy.is_idnum_mandatory_in_policy( 

791 which_idnum=self.primary_idnum, 

792 valid_idnums=valid_which_idnums): 

793 fail_invalid( 

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

795 f"be mandatory in upload policy (because " 

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

797 f"for group {group}") 

798 

799 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 

800 # File 

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

802 if self._need_file_name(): 

803 # Filename options 

804 if not patient_spec_for_filename_is_valid( 

805 patient_spec=self.file_patient_spec, 

806 valid_which_idnums=valid_which_idnums): 

807 fail_invalid(f"Invalid {cpr.FILE_PATIENT_SPEC}: " 

808 f"{self.file_patient_spec}") 

809 if not filename_spec_is_valid( 

810 filename_spec=self.file_filename_spec, 

811 valid_which_idnums=valid_which_idnums): 

812 fail_invalid(f"Invalid {cpr.FILE_FILENAME_SPEC}: " 

813 f"{self.file_filename_spec}") 

814 

815 if self._need_rio_metadata_options(): 

816 # RiO metadata 

817 if self.rio_idnum not in valid_which_idnums: 

818 fail_invalid(f"Invalid ID number type for " 

819 f"{cpr.RIO_IDNUM}: {self.rio_idnum}") 

820 

821 def _need_file_name(self) -> bool: 

822 """ 

823 Do we need to know about filenames? 

824 """ 

825 return ( 

826 self.transmission_method == ExportTransmissionMethod.FILE or 

827 (self.transmission_method == ExportTransmissionMethod.HL7 and 

828 self.hl7_debug_divert_to_file) or 

829 self.transmission_method == ExportTransmissionMethod.EMAIL 

830 ) 

831 

832 def _need_file_disk_options(self) -> bool: 

833 """ 

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

835 directories)? 

836 """ 

837 return ( 

838 self.transmission_method == ExportTransmissionMethod.FILE or 

839 (self.transmission_method == ExportTransmissionMethod.HL7 and 

840 self.hl7_debug_divert_to_file) 

841 ) 

842 

843 def _need_rio_metadata_options(self) -> bool: 

844 """ 

845 Do we need to know about RiO metadata? 

846 """ 

847 return ( 

848 self.transmission_method == ExportTransmissionMethod.FILE and 

849 self.file_export_rio_metadata 

850 ) 

851 

852 def using_db(self) -> bool: 

853 """ 

854 Is the recipient a database? 

855 """ 

856 return self.transmission_method == ExportTransmissionMethod.DATABASE 

857 

858 def using_email(self) -> bool: 

859 """ 

860 Is the recipient an e-mail system? 

861 """ 

862 return self.transmission_method == ExportTransmissionMethod.EMAIL 

863 

864 def using_file(self) -> bool: 

865 """ 

866 Is the recipient a filestore? 

867 """ 

868 return self.transmission_method == ExportTransmissionMethod.FILE 

869 

870 def using_hl7(self) -> bool: 

871 """ 

872 Is the recipient an HL7 recipient? 

873 """ 

874 return self.transmission_method == ExportTransmissionMethod.HL7 

875 

876 def anonymous_ok(self) -> bool: 

877 """ 

878 Does this recipient permit/want anonymous tasks? 

879 """ 

880 return self.include_anonymous and not ( 

881 # Methods that require patient identification: 

882 self.using_hl7() 

883 ) 

884 

885 def is_incremental(self) -> bool: 

886 """ 

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

888 exports.) 

889 """ 

890 return not self.using_db() 

891 

892 @staticmethod 

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

894 """ 

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

896 """ 

897 iddef = req.get_idnum_definition(which_idnum) 

898 return (iddef.hl7_id_type or '') if iddef else '' 

899 

900 @staticmethod 

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

902 """ 

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

904 """ 

905 iddef = req.get_idnum_definition(which_idnum) 

906 return (iddef.hl7_assigning_authority or '') if iddef else '' 

907 

908 def _get_processed_spec(self, 

909 req: "CamcopsRequest", 

910 task: "Task", 

911 patient_spec_if_anonymous: str, 

912 patient_spec: str, 

913 spec: str, 

914 treat_as_filename: bool, 

915 override_task_format: str = "") -> str: 

916 """ 

917 Returns a 

918 Args: 

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

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

921 patient_spec_if_anonymous: 

922 patient specification to be used for anonymous tasks 

923 patient_spec: 

924 patient specification to be used for patient-identifiable tasks 

925 spec: 

926 specification to use to create the string (may include 

927 patient information from the patient specification) 

928 treat_as_filename: 

929 convert the resulting string to be a safe filename 

930 override_task_format: 

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

932 extension e.g. for HL7 debugging) 

933 

934 Returns: 

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

936 subject) 

937 """ 

938 return get_export_filename( 

939 req=req, 

940 patient_spec_if_anonymous=patient_spec_if_anonymous, 

941 patient_spec=patient_spec, 

942 filename_spec=spec, 

943 filetype=(override_task_format if override_task_format 

944 else self.task_format), 

945 is_anonymous=task.is_anonymous, 

946 surname=task.get_patient_surname(), 

947 forename=task.get_patient_forename(), 

948 dob=task.get_patient_dob(), 

949 sex=task.get_patient_sex(), 

950 idnum_objects=task.get_patient_idnum_objects(), 

951 creation_datetime=task.get_creation_datetime(), 

952 basetable=task.tablename, 

953 serverpk=task.pk, 

954 skip_conversion_to_safe_filename=not treat_as_filename, 

955 ) 

956 

957 def get_filename(self, req: "CamcopsRequest", task: "Task", 

958 override_task_format: str = "") -> str: 

959 """ 

960 Get the export filename, for file transfers. 

961 """ 

962 return self._get_processed_spec( 

963 req=req, 

964 task=task, 

965 patient_spec_if_anonymous=self.file_patient_spec_if_anonymous, 

966 patient_spec=self.file_patient_spec, 

967 spec=self.file_filename_spec, 

968 treat_as_filename=True, 

969 override_task_format=override_task_format, 

970 ) 

971 

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

973 """ 

974 Gets a substituted e-mail subject. 

975 """ 

976 return self._get_processed_spec( 

977 req=req, 

978 task=task, 

979 patient_spec_if_anonymous=self.email_patient_spec_if_anonymous, 

980 patient_spec=self.email_patient_spec, 

981 spec=self.email_subject, 

982 treat_as_filename=False, 

983 ) 

984 

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

986 """ 

987 Gets a substituted e-mail body. 

988 """ 

989 return self._get_processed_spec( 

990 req=req, 

991 task=task, 

992 patient_spec_if_anonymous=self.email_patient_spec_if_anonymous, 

993 patient_spec=self.email_patient_spec, 

994 spec=self.email_body, 

995 treat_as_filename=False, 

996 )