Coverage for cc_modules/cc_exportrecipientinfo.py: 34%

1"""

2 camcops_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

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

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

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

31(q.v.).

32

33"""

34

35 import configparser

36 import datetime

37 import logging

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

39

40 from cardinal_pythonlib.configfiles import (

41 get_config_parameter,

42 get_config_parameter_boolean,

43 get_config_parameter_multiline,

44)

45 from cardinal_pythonlib.datetimefunc import (

46 coerce_to_pendulum,

47 pendulum_to_utc_datetime_without_tz,

48)

49 from cardinal_pythonlib.logs import BraceStyleAdapter

50 from cardinal_pythonlib.reprfunc import simple_repr

51

52 from sqlalchemy.orm import Mapped

53

54 from 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)

62 from 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

68 if 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

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

74

75

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

77# Constants

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

79

80 COMMA = ","

81 CONFIG_RECIPIENT_PREFIX = "recipient:"

82 RIO_MAX_USER_LEN = 10

83

84

85 class 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

98 NO_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

108 ALL_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

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

115

116

117 class 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

130 class _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

144 class 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 )