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
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/cc_modules/cc_exportrecipient.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26**ExportRecipientInfo class.**
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.).
33"""
35import configparser
36import datetime
37import logging
38from typing import Any, List, NoReturn, Optional, TYPE_CHECKING
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
52from sqlalchemy.orm import Mapped
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)
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
73log = BraceStyleAdapter(logging.getLogger(__name__))
76# =============================================================================
77# Constants
78# =============================================================================
80COMMA = ","
81CONFIG_RECIPIENT_PREFIX = "recipient:"
82RIO_MAX_USER_LEN = 10
85class ExportTransmissionMethod(object):
86 """
87 Possible export transmission methods.
88 """
90 DATABASE = "database"
91 EMAIL = "email"
92 FHIR = "fhir"
93 FILE = "file"
94 HL7 = "hl7"
95 REDCAP = "redcap"
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]
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
114ALL_TASK_FORMATS = [FileType.HTML, FileType.PDF, FileType.XML]
117class InvalidExportRecipient(ValueError):
118 """
119 Exception for invalid export recipients.
120 """
122 def __init__(self, recipient_name: str, msg: str) -> None:
123 super().__init__(f"For export recipient [{recipient_name}]: {msg}")
126# Internal shorthand:
127_Invalid = InvalidExportRecipient
130class _Missing(_Invalid):
131 """
132 Exception for missing config parameters
133 """
135 def __init__(self, recipient_name: str, paramname: str) -> None:
136 super().__init__(recipient_name, f"Missing parameter {paramname}")
139# =============================================================================
140# ExportRecipientInfo class
141# =============================================================================
144class ExportRecipientInfo(object):
145 """
146 Class representing an export recipient, that is not an SQLAlchemy ORM
147 object.
149 This has an unfortunate close relationship with
150 :class:`camcops_server.cc_modules.cc_exportrecipient.ExportRecipient`
151 (q.v.).
153 Full details of parameters are in the docs for the config file.
154 """
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 ]
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 """
172 other = kwargs.pop("other", None)
174 cd = ConfigDefaults()
176 self.recipient_name: Mapped[str] = ""
178 # How to export
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
185 # What to export
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 )
202 # Database
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
210 # Email
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
232 # HL7
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 )
249 # File
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] = ""
263 # File/RiO
265 self.rio_idnum: Mapped[Optional[int]] = None
266 self.rio_uploading_user: Mapped[Optional[str]] = ""
267 self.rio_document_type: Mapped[Optional[str]] = ""
269 # REDCap
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]] = ""
275 # FHIR
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
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))
292 super().__init__(*args, **kwargs)
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 )
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 ]
312 def __repr__(self) -> str:
313 return simple_repr(self, self.get_attrnames())
315 def __str__(self) -> str:
316 return repr(self.recipient_name)
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
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
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.
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
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)
371 section = CONFIG_RECIPIENT_PREFIX + recipient_name
372 cps = ConfigParamSite
373 cpr = ConfigParamExportRecipient
374 cd = ConfigDefaults()
375 r = cls() # type: ExportRecipientInfo
377 def _get_str(paramname: str, default: str = None) -> Optional[str]:
378 return get_config_parameter(
379 parser, section, paramname, str, default
380 )
382 def _get_bool(paramname: str, default: bool) -> bool:
383 return get_config_parameter_boolean(
384 parser, section, paramname, default
385 )
387 def _get_int(paramname: str, default: int = None) -> Optional[int]:
388 return get_config_parameter(
389 parser, section, paramname, int, default
390 )
392 def _get_multiline(paramname: str) -> List[str]:
393 return get_config_parameter_multiline(
394 parser, section, paramname, []
395 )
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 )
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 )
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 )
418 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
419 # Identity
420 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
421 r.recipient_name = recipient_name
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 )
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 )
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 )
492 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
493 # Email
494 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
495 def _make_email_csv_list(paramname: str) -> str:
496 return ", ".join(x for x in _get_multiline(paramname))
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
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]
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, "")
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 )
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 )
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)
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 )
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)
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)
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)
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)
603 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
604 # Validate the basics and return
605 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
606 r.validate_db_independent()
607 return r
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)
616 def valid(self, req: "CamcopsRequest") -> bool:
617 """
618 Is this definition valid?
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
630 def validate(self, req: "CamcopsRequest") -> None:
631 """
632 Validates all aspects.
634 Args:
635 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
637 Raises:
638 :exc:`InvalidExportRecipient` if invalid
639 """
640 self.validate_db_independent()
641 self.validate_db_dependent(req)
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
654 def fail_invalid(msg: str) -> NoReturn:
655 raise _Invalid(self.recipient_name, msg) # type: ignore[arg-type]
657 def fail_missing(paramname: str) -> NoReturn:
658 raise _Missing(self.recipient_name, paramname) # type: ignore[arg-type] # noqa: E501
660 cpr = ConfigParamExportRecipient
661 cps = ConfigParamSite
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 )
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})")
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")
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 )
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 )
702 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
703 # Database
704 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
705 if self.transmission_method == ExportTransmissionMethod.DATABASE:
706 if not self.db_url:
707 fail_missing(cpr.DB_URL)
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)
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")
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)
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)
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")
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`.
804 :meth:`validate_db_independent` should have been called first; this
805 function presumes that those checks have been passed.
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
812 def fail_invalid(msg: str) -> NoReturn:
813 raise _Invalid(self.recipient_name, msg) # type: ignore[arg-type]
815 dbsession = req.dbsession
816 valid_which_idnums = req.valid_which_idnums
817 cpr = ConfigParamExportRecipient
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]
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)
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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
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
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
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
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
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 )
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()
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 ""
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 ""
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)
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 )
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 )
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 )
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 )