Coverage for cc_modules/cc_exportrecipientinfo.py: 33%
404 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_exportrecipient.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
13 CamCOPS is free software: you can redistribute it and/or modify
14 it under the terms of the GNU General Public License as published by
15 the Free Software Foundation, either version 3 of the License, or
16 (at your option) any later version.
18 CamCOPS is distributed in the hope that it will be useful,
19 but WITHOUT ANY WARRANTY; without even the implied warranty of
20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 GNU General Public License for more details.
23 You should have received a copy of the GNU General Public License
24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
26===============================================================================
28**ExportRecipientInfo class.**
30The purpose of this is to capture information without using an SQLAlchemy
31class. The :class:`camcops_server.cc_modules.cc_config.CamcopsConfig` class
32uses this, as it needs to be readable in the absence of a database connection
33(q.v.).
35"""
37import configparser
38import datetime
39import logging
40from typing import List, NoReturn, Optional, TYPE_CHECKING
42from cardinal_pythonlib.configfiles import (
43 get_config_parameter,
44 get_config_parameter_boolean,
45 get_config_parameter_multiline,
46)
47from cardinal_pythonlib.datetimefunc import (
48 coerce_to_pendulum,
49 pendulum_to_utc_datetime_without_tz,
50)
51from cardinal_pythonlib.logs import BraceStyleAdapter
52from cardinal_pythonlib.reprfunc import simple_repr
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, other: "ExportRecipientInfo" = None) -> None:
165 """
166 Initializes, optionally copying attributes from ``other``.
167 """
168 cd = ConfigDefaults()
170 self.recipient_name = ""
172 # How to export
174 self.transmission_method = ExportTransmissionMethod.EMAIL
175 self.push = cd.PUSH
176 self.task_format = cd.TASK_FORMAT
177 self.xml_field_comments = cd.XML_FIELD_COMMENTS
179 # What to export
181 self.all_groups = cd.ALL_GROUPS
182 self.group_names = (
183 []
184 ) # type: List[str] # not in database; see group_ids
185 self.group_ids = [] # type: List[int]
186 self.tasks = [] # type: List[str]
187 self.start_datetime_utc = None # type: Optional[datetime.datetime]
188 self.end_datetime_utc = None # type: Optional[datetime.datetime]
189 self.finalized_only = cd.FINALIZED_ONLY
190 self.include_anonymous = cd.INCLUDE_ANONYMOUS
191 self.primary_idnum = None # type: Optional[int]
192 self.require_idnum_mandatory = (
193 cd.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY
194 )
196 # Database
198 self.db_url = ""
199 self.db_echo = cd.DB_ECHO
200 self.db_include_blobs = cd.DB_INCLUDE_BLOBS
201 self.db_add_summaries = cd.DB_ADD_SUMMARIES
202 self.db_patient_id_per_row = cd.DB_PATIENT_ID_PER_ROW
204 # Email
206 self.email_host = ""
207 self.email_port = cd.EMAIL_PORT
208 self.email_use_tls = cd.EMAIL_USE_TLS
209 self.email_host_username = ""
210 self.email_host_password = "" # not in database for security
211 self.email_from = ""
212 self.email_sender = ""
213 self.email_reply_to = ""
214 self.email_to = "" # CSV list
215 self.email_cc = "" # CSV list
216 self.email_bcc = "" # CSV list
217 self.email_patient_spec = ""
218 self.email_patient_spec_if_anonymous = cd.PATIENT_SPEC_IF_ANONYMOUS
219 self.email_subject = ""
220 self.email_body_as_html = cd.EMAIL_BODY_IS_HTML
221 self.email_body = ""
222 self.email_keep_message = cd.EMAIL_KEEP_MESSAGE
224 # HL7
226 self.hl7_host = ""
227 self.hl7_port = cd.HL7_PORT
228 self.hl7_ping_first = cd.HL7_PING_FIRST
229 self.hl7_network_timeout_ms = cd.HL7_NETWORK_TIMEOUT_MS
230 self.hl7_keep_message = cd.HL7_KEEP_MESSAGE
231 self.hl7_keep_reply = cd.HL7_KEEP_REPLY
232 self.hl7_debug_divert_to_file = cd.HL7_DEBUG_DIVERT_TO_FILE
233 self.hl7_debug_treat_diverted_as_sent = (
234 cd.HL7_DEBUG_TREAT_DIVERTED_AS_SENT
235 )
237 # File
239 self.file_patient_spec = ""
240 self.file_patient_spec_if_anonymous = cd.PATIENT_SPEC_IF_ANONYMOUS
241 self.file_filename_spec = ""
242 self.file_make_directory = cd.FILE_MAKE_DIRECTORY
243 self.file_overwrite_files = cd.FILE_OVERWRITE_FILES
244 self.file_export_rio_metadata = cd.FILE_EXPORT_RIO_METADATA
245 self.file_script_after_export = ""
247 # File/RiO
249 self.rio_idnum = None # type: Optional[int]
250 self.rio_uploading_user = ""
251 self.rio_document_type = ""
253 # REDCap
255 self.redcap_api_key = "" # not in database for security
256 self.redcap_api_url = ""
257 self.redcap_fieldmap_filename = ""
259 # FHIR
261 self.fhir_app_id = ""
262 self.fhir_api_url = ""
263 self.fhir_app_secret = "" # not in database for security
264 self.fhir_launch_token = "" # not in database for security
265 self.fhir_concurrent = False
267 # Copy from other?
268 if other is not None:
269 assert isinstance(other, ExportRecipientInfo)
270 for attrname in self.get_attrnames():
271 # Note that both "self" and "other" may be an ExportRecipient
272 # rather than an ExportRecipientInfo.
273 if hasattr(other, attrname):
274 setattr(self, attrname, getattr(other, attrname))
276 def get_attrnames(self) -> List[str]:
277 """
278 Returns all relevant attribute names.
279 """
280 return sorted(
281 [key for key in self.__dict__ if not key.startswith("_")]
282 )
284 def get_eq_attrnames(self) -> List[str]:
285 """
286 Returns attribute names to use for equality comparison.
287 """
288 return [
289 x
290 for x in self.get_attrnames()
291 if x not in self.IGNORE_FOR_EQ_ATTRNAMES
292 ]
294 def __repr__(self):
295 return simple_repr(self, self.get_attrnames())
297 def __str__(self) -> str:
298 return repr(self.recipient_name)
300 def __eq__(self, other: "ExportRecipientInfo") -> bool:
301 """
302 Does this object equal another -- meaning "sufficiently equal that we
303 can use the same one, rather than making a new database copy"?
304 """
305 for attrname in self.get_attrnames():
306 if attrname not in self.IGNORE_FOR_EQ_ATTRNAMES:
307 selfattr = getattr(self, attrname)
308 otherattr = getattr(other, attrname)
309 # log.debug("{}.{}: {} {} {}",
310 # self.__class__.__name__,
311 # attrname,
312 # selfattr,
313 # "==" if selfattr == otherattr else "!=",
314 # otherattr)
315 if selfattr != otherattr:
316 log.debug(
317 "{}: For {!r}, new export recipient mismatches "
318 "previous copy on {}: {!r} != {!r}",
319 self.__class__.__name__,
320 self.recipient_name,
321 attrname,
322 selfattr,
323 otherattr,
324 )
325 return False
326 return True
328 @classmethod
329 def create_dummy_recipient(cls) -> "ExportRecipientInfo":
330 """
331 Creates and returns a dummy :class:`ExportRecipientInfo`.
332 """
333 d = cls()
335 d.recipient_name = "_dummy_export_recipient_"
336 d.current = True
338 d.transmission_method = ExportTransmissionMethod.FILE
340 d.all_groups = True
341 d.primary_idnum = 1
342 d.require_idnum_mandatory = False
343 d.finalized_only = False
344 d.task_format = FileType.XML
346 # File
347 d.include_anonymous = True
348 d.file_patient_spec_if_anonymous = "anonymous"
349 d.file_patient_spec = "{surname}_{forename}_{idshortdesc1}{idnum1}"
350 d.file_filename_spec = (
351 "/tmp/camcops_debug_testing/"
352 "TestCamCOPS_{patient}_{created}_{tasktype}-{serverpk}"
353 ".{filetype}"
354 )
355 d.file_overwrite_files = False
356 d.file_make_directory = True
358 return d
360 @classmethod
361 def read_from_config(
362 cls,
363 cfg: "CamcopsConfig",
364 parser: configparser.ConfigParser,
365 recipient_name: str,
366 ) -> "ExportRecipientInfo":
367 """
368 Reads from the config file and writes this instance's attributes.
370 Args:
371 cfg: a :class:`camcops_server.cc_modules.cc_config.CamcopsConfig`
372 parser: configparser INI file object
373 recipient_name: name of recipient and of INI file section
375 Returns:
376 an :class:`ExportRecipient` object, which is **not** currently in
377 a database session
378 """
379 assert recipient_name
380 log.debug("Loading export config for recipient {!r}", recipient_name)
382 section = CONFIG_RECIPIENT_PREFIX + recipient_name
383 cps = ConfigParamSite
384 cpr = ConfigParamExportRecipient
385 cd = ConfigDefaults()
386 r = cls() # type: ExportRecipientInfo
388 def _get_str(paramname: str, default: str = None) -> Optional[str]:
389 return get_config_parameter(
390 parser, section, paramname, str, default
391 )
393 def _get_bool(paramname: str, default: bool) -> bool:
394 return get_config_parameter_boolean(
395 parser, section, paramname, default
396 )
398 def _get_int(paramname: str, default: int = None) -> Optional[int]:
399 return get_config_parameter(
400 parser, section, paramname, int, default
401 )
403 def _get_multiline(paramname: str) -> List[str]:
404 return get_config_parameter_multiline(
405 parser, section, paramname, []
406 )
408 def _get_site_str(
409 paramname: str, default: str = None
410 ) -> Optional[str]:
411 return get_config_parameter(
412 parser, CONFIG_FILE_SITE_SECTION, paramname, str, default
413 )
415 # noinspection PyUnusedLocal
416 def _get_site_bool(paramname: str, default: bool) -> bool:
417 return get_config_parameter_boolean(
418 parser, CONFIG_FILE_SITE_SECTION, paramname, default
419 )
421 # noinspection PyUnusedLocal
422 def _get_site_int(
423 paramname: str, default: int = None
424 ) -> Optional[int]:
425 return get_config_parameter(
426 parser, CONFIG_FILE_SITE_SECTION, paramname, int, default
427 )
429 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
430 # Identity
431 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
432 r.recipient_name = recipient_name
434 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
435 # How to export
436 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
437 r.transmission_method = _get_str(cpr.TRANSMISSION_METHOD)
438 r.transmission_method = str(r.transmission_method).lower()
439 # Check this one immediately, since we use it in conditions below
440 if r.transmission_method not in ALL_TRANSMISSION_METHODS:
441 raise _Invalid(
442 r.recipient_name,
443 f"Missing/invalid "
444 f"{ConfigParamExportRecipient.TRANSMISSION_METHOD}: "
445 f"{r.transmission_method}",
446 )
447 r.push = _get_bool(cpr.PUSH, cd.PUSH)
448 r.task_format = _get_str(cpr.TASK_FORMAT, cd.TASK_FORMAT)
449 r.xml_field_comments = _get_bool(
450 cpr.XML_FIELD_COMMENTS, cd.XML_FIELD_COMMENTS
451 )
453 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
454 # What to export
455 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
456 r.all_groups = _get_bool(cpr.ALL_GROUPS, cd.ALL_GROUPS)
457 r.group_names = _get_multiline(cpr.GROUPS)
458 r.group_ids = []
459 # ... read later by validate_db_dependent()
460 r.tasks = sorted([x.lower() for x in _get_multiline(cpr.TASKS)])
461 sd = _get_str(cpr.START_DATETIME_UTC)
462 r.start_datetime_utc = (
463 pendulum_to_utc_datetime_without_tz(
464 coerce_to_pendulum(sd, assume_local=False)
465 )
466 if sd
467 else None
468 )
469 ed = _get_str(cpr.END_DATETIME_UTC)
470 r.end_datetime_utc = (
471 pendulum_to_utc_datetime_without_tz(
472 coerce_to_pendulum(ed, assume_local=False)
473 )
474 if ed
475 else None
476 )
477 r.finalized_only = _get_bool(cpr.FINALIZED_ONLY, cd.FINALIZED_ONLY)
478 r.include_anonymous = _get_bool(
479 cpr.INCLUDE_ANONYMOUS, cd.INCLUDE_ANONYMOUS
480 )
481 r.primary_idnum = _get_int(cpr.PRIMARY_IDNUM)
482 r.require_idnum_mandatory = _get_bool(
483 cpr.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY,
484 cd.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY,
485 )
487 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
488 # Database
489 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
490 if r.transmission_method == ExportTransmissionMethod.DATABASE:
491 r.db_url = _get_str(cpr.DB_URL)
492 r.db_echo = _get_bool(cpr.DB_ECHO, cd.DB_ECHO)
493 r.db_include_blobs = _get_bool(
494 cpr.DB_INCLUDE_BLOBS, cd.DB_INCLUDE_BLOBS
495 )
496 r.db_add_summaries = _get_bool(
497 cpr.DB_ADD_SUMMARIES, cd.DB_ADD_SUMMARIES
498 )
499 r.db_patient_id_per_row = _get_bool(
500 cpr.DB_PATIENT_ID_PER_ROW, cd.DB_PATIENT_ID_PER_ROW
501 )
503 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
504 # Email
505 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
506 def _make_email_csv_list(paramname: str) -> str:
507 return ", ".join(x for x in _get_multiline(paramname))
509 if r.transmission_method == ExportTransmissionMethod.EMAIL:
510 r.email_host = cfg.email_host
511 r.email_port = cfg.email_port
512 r.email_use_tls = cfg.email_use_tls
513 r.email_host_username = cfg.email_host_username
514 r.email_host_password = cfg.email_host_password
516 # Read from password safe using 'pass'
517 # from subprocess import run, PIPE
518 # output = run(["pass", "dept-of-psychiatry/Hermes"], stdout=PIPE)
519 # r.email_host_password = output.stdout.decode("utf-8").split()[0]
521 r.email_from = _get_site_str(cps.EMAIL_FROM, "")
522 r.email_sender = _get_site_str(cps.EMAIL_SENDER, "")
523 r.email_reply_to = _get_site_str(cps.EMAIL_REPLY_TO, "")
525 r.email_to = _make_email_csv_list(cpr.EMAIL_TO)
526 r.email_cc = _make_email_csv_list(cpr.EMAIL_CC)
527 r.email_bcc = _make_email_csv_list(cpr.EMAIL_BCC)
528 r.email_patient_spec_if_anonymous = _get_str(
529 cpr.EMAIL_PATIENT_SPEC_IF_ANONYMOUS, ""
530 )
531 r.email_patient_spec = _get_str(cpr.EMAIL_PATIENT_SPEC, "")
532 r.email_subject = _get_str(cpr.EMAIL_SUBJECT, "")
533 r.email_body_as_html = _get_bool(
534 cpr.EMAIL_BODY_IS_HTML, cd.EMAIL_BODY_IS_HTML
535 )
536 r.email_body = _get_str(cpr.EMAIL_BODY, "")
537 r.email_keep_message = _get_bool(
538 cpr.EMAIL_KEEP_MESSAGE, cd.EMAIL_KEEP_MESSAGE
539 )
541 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
542 # HL7
543 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
544 if r.transmission_method == ExportTransmissionMethod.HL7:
545 r.hl7_host = _get_str(cpr.HL7_HOST)
546 r.hl7_port = _get_int(cpr.HL7_PORT, cd.HL7_PORT)
547 r.hl7_ping_first = _get_bool(cpr.HL7_PING_FIRST, cd.HL7_PING_FIRST)
548 r.hl7_network_timeout_ms = _get_int(
549 cpr.HL7_NETWORK_TIMEOUT_MS, cd.HL7_NETWORK_TIMEOUT_MS
550 )
551 r.hl7_keep_message = _get_bool(
552 cpr.HL7_KEEP_MESSAGE, cd.HL7_KEEP_MESSAGE
553 )
554 r.hl7_keep_reply = _get_bool(cpr.HL7_KEEP_REPLY, cd.HL7_KEEP_REPLY)
555 r.hl7_debug_divert_to_file = _get_bool(
556 cpr.HL7_DEBUG_DIVERT_TO_FILE, cd.HL7_DEBUG_DIVERT_TO_FILE
557 )
558 r.hl7_debug_treat_diverted_as_sent = _get_bool(
559 cpr.HL7_DEBUG_TREAT_DIVERTED_AS_SENT,
560 cd.HL7_DEBUG_TREAT_DIVERTED_AS_SENT,
561 )
563 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
564 # File
565 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
566 if r._need_file_name():
567 r.file_patient_spec = _get_str(cpr.FILE_PATIENT_SPEC)
568 r.file_patient_spec_if_anonymous = _get_str(
569 cpr.FILE_PATIENT_SPEC_IF_ANONYMOUS,
570 cd.FILE_PATIENT_SPEC_IF_ANONYMOUS,
571 )
572 r.file_filename_spec = _get_str(cpr.FILE_FILENAME_SPEC)
574 if r._need_file_disk_options():
575 r.file_make_directory = _get_bool(
576 cpr.FILE_MAKE_DIRECTORY, cd.FILE_MAKE_DIRECTORY
577 )
578 r.file_overwrite_files = _get_bool(
579 cpr.FILE_OVERWRITE_FILES, cd.FILE_OVERWRITE_FILES
580 )
582 if r.transmission_method == ExportTransmissionMethod.FILE:
583 r.file_export_rio_metadata = _get_bool(
584 cpr.FILE_EXPORT_RIO_METADATA, cd.FILE_EXPORT_RIO_METADATA
585 )
586 r.file_script_after_export = _get_str(cpr.FILE_SCRIPT_AFTER_EXPORT)
588 if r._need_rio_metadata_options():
589 # RiO metadata
590 r.rio_idnum = _get_int(cpr.RIO_IDNUM)
591 r.rio_uploading_user = _get_str(cpr.RIO_UPLOADING_USER)
592 r.rio_document_type = _get_str(cpr.RIO_DOCUMENT_TYPE)
594 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
595 # REDCap
596 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
597 if r.transmission_method == ExportTransmissionMethod.REDCAP:
598 r.redcap_api_url = _get_str(cpr.REDCAP_API_URL)
599 r.redcap_api_key = _get_str(cpr.REDCAP_API_KEY)
600 r.redcap_fieldmap_filename = _get_str(cpr.REDCAP_FIELDMAP_FILENAME)
602 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
603 # FHIR
604 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
605 if r.transmission_method == ExportTransmissionMethod.FHIR:
606 r.fhir_api_url = _get_str(cpr.FHIR_API_URL)
607 r.fhir_app_id = _get_str(
608 cpr.FHIR_APP_ID, CAMCOPS_DEFAULT_FHIR_APP_ID
609 )
610 r.fhir_app_secret = _get_str(cpr.FHIR_APP_SECRET)
611 r.fhir_launch_token = _get_str(cpr.FHIR_LAUNCH_TOKEN)
612 r.fhir_concurrent = _get_bool(cpr.FHIR_CONCURRENT, False)
614 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
615 # Validate the basics and return
616 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
617 r.validate_db_independent()
618 return r
620 @classmethod
621 def report_error(cls, msg: str) -> None:
622 """
623 Report an error to the log.
624 """
625 log.error("{}: {}", cls.__name__, msg)
627 def valid(self, req: "CamcopsRequest") -> bool:
628 """
629 Is this definition valid?
631 Args:
632 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
633 """
634 try:
635 self.validate(req)
636 return True
637 except InvalidExportRecipient as e:
638 self.report_error(str(e))
639 return False
641 def validate(self, req: "CamcopsRequest") -> None:
642 """
643 Validates all aspects.
645 Args:
646 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
648 Raises:
649 :exc:`InvalidExportRecipient` if invalid
650 """
651 self.validate_db_independent()
652 self.validate_db_dependent(req)
654 def validate_db_independent(self) -> None:
655 """
656 Validates the database-independent aspects of the
657 :class:`ExportRecipient`, or raises :exc:`InvalidExportRecipient`.
658 """
659 # noinspection PyUnresolvedReferences
660 import camcops_server.cc_modules.cc_all_models # import side effects (ensure all models registered) # noqa
661 from camcops_server.cc_modules.cc_task import (
662 all_task_tablenames,
663 ) # delayed import
665 def fail_invalid(msg: str) -> NoReturn:
666 raise _Invalid(self.recipient_name, msg)
668 def fail_missing(paramname: str) -> NoReturn:
669 raise _Missing(self.recipient_name, paramname)
671 cpr = ConfigParamExportRecipient
672 cps = ConfigParamSite
674 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
675 # Export type
676 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
677 if self.transmission_method not in ALL_TRANSMISSION_METHODS:
678 fail_invalid(
679 f"Missing/invalid {cpr.TRANSMISSION_METHOD}: "
680 f"{self.transmission_method}"
681 )
682 if self.push and self.transmission_method in NO_PUSH_METHODS:
683 fail_invalid(
684 f"Push notifications not supported for these "
685 f"transmission methods: {NO_PUSH_METHODS!r}"
686 )
688 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
689 # What to export
690 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
691 if not self.all_groups and not self.group_names:
692 fail_invalid(f"Missing group names (from {cpr.GROUPS})")
694 all_basetables = all_task_tablenames()
695 for basetable in self.tasks:
696 if basetable not in all_basetables:
697 fail_invalid(f"Task {basetable!r} doesn't exist")
699 if (
700 self.transmission_method == ExportTransmissionMethod.HL7
701 and not self.primary_idnum
702 ):
703 fail_invalid(
704 f"Must specify {cpr.PRIMARY_IDNUM} with "
705 f"{cpr.TRANSMISSION_METHOD} = {ExportTransmissionMethod.HL7}"
706 )
708 if not self.task_format or self.task_format not in ALL_TASK_FORMATS:
709 fail_invalid(
710 f"Missing/invalid {cpr.TASK_FORMAT}: {self.task_format}"
711 )
713 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
714 # Database
715 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
716 if self.transmission_method == ExportTransmissionMethod.DATABASE:
717 if not self.db_url:
718 fail_missing(cpr.DB_URL)
720 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
721 # Email
722 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
723 if self.transmission_method == ExportTransmissionMethod.EMAIL:
724 if not self.email_host:
725 # You can't send an e-mail without knowing which server to send
726 # it to.
727 fail_missing(cps.EMAIL_HOST)
728 # Username is *not* required by all servers!
729 if not self.email_from:
730 # From is mandatory in all e-mails.
731 # (Sender and Reply-To are optional.)
732 fail_missing(cps.EMAIL_FROM)
733 if COMMA in self.email_from:
734 # RFC 5322 permits multiple addresses in From, but Python
735 # sendmail doesn't.
736 fail_invalid(
737 f"Only a single 'From:' address permitted; was "
738 f"{self.email_from!r}"
739 )
740 if not any([self.email_to, self.email_cc, self.email_bcc]):
741 # At least one destination is required (obviously).
742 fail_invalid(
743 f"Must specify some of: {cpr.EMAIL_TO}, {cpr.EMAIL_CC}, "
744 f"{cpr.EMAIL_BCC}"
745 )
746 if COMMA in self.email_sender:
747 # RFC 5322 permits multiple addresses in From and Reply-To,
748 # but only one in Sender.
749 fail_invalid(
750 f"Only a single 'Sender:' address permitted; was "
751 f"{self.email_sender!r}"
752 )
753 if not self.email_subject:
754 # A subject is not obligatory for e-mails in general, but we
755 # will require one for e-mails sent from CamCOPS.
756 fail_missing(cpr.EMAIL_SUBJECT)
758 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
759 # HL7
760 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
761 if self.transmission_method == ExportTransmissionMethod.HL7:
762 if not self.hl7_debug_divert_to_file:
763 if not self.hl7_host:
764 fail_missing(cpr.HL7_HOST)
765 if not self.hl7_port or self.hl7_port <= 0:
766 fail_invalid(
767 f"Missing/invalid {cpr.HL7_PORT}: {self.hl7_port}"
768 )
769 if not self.primary_idnum:
770 fail_missing(cpr.PRIMARY_IDNUM)
771 if self.include_anonymous:
772 fail_invalid("Can't include anonymous tasks for HL7")
774 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
775 # File
776 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
777 if self._need_file_name():
778 # Filename options
779 if not self.file_patient_spec_if_anonymous:
780 fail_missing(cpr.FILE_PATIENT_SPEC_IF_ANONYMOUS)
781 if not self.file_patient_spec:
782 fail_missing(cpr.FILE_PATIENT_SPEC)
783 if not self.file_filename_spec:
784 fail_missing(cpr.FILE_FILENAME_SPEC)
786 if self._need_rio_metadata_options():
787 # RiO metadata
788 if (
789 not self.rio_uploading_user
790 or " " in self.rio_uploading_user
791 or len(self.rio_uploading_user) > RIO_MAX_USER_LEN
792 ):
793 fail_invalid(
794 f"Missing/invalid {cpr.RIO_UPLOADING_USER}: "
795 f"{self.rio_uploading_user} (must be present, contain no "
796 f"spaces, and max length {RIO_MAX_USER_LEN})"
797 )
798 if not self.rio_document_type:
799 fail_missing(cpr.RIO_DOCUMENT_TYPE)
801 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
802 # REDCap
803 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
804 if self.transmission_method == ExportTransmissionMethod.HL7:
805 if not self.primary_idnum:
806 fail_missing(cpr.PRIMARY_IDNUM)
807 if self.include_anonymous:
808 fail_invalid("Can't include anonymous tasks for REDCap")
810 def validate_db_dependent(self, req: "CamcopsRequest") -> None:
811 """
812 Validates the database-dependent aspects of the
813 :class:`ExportRecipient`, or raises :exc:`InvalidExportRecipient`.
815 :meth:`validate_db_independent` should have been called first; this
816 function presumes that those checks have been passed.
818 Args:
819 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
820 """
821 from camcops_server.cc_modules.cc_group import Group # delayed import
823 def fail_invalid(msg: str) -> NoReturn:
824 raise _Invalid(self.recipient_name, msg)
826 dbsession = req.dbsession
827 valid_which_idnums = req.valid_which_idnums
828 cpr = ConfigParamExportRecipient
830 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
831 # Set group IDs from group names
832 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
833 self.group_ids = [] # type: List[int]
834 for groupname in self.group_names:
835 group = Group.get_group_by_name(dbsession, groupname)
836 if not group:
837 raise ValueError(f"No such group: {groupname!r}")
838 self.group_ids.append(group.id)
839 self.group_ids.sort()
841 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
842 # What to export
843 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
844 if self.all_groups:
845 groups = Group.get_all_groups(dbsession)
846 else:
847 groups = [] # type: List[Group]
848 for gid in self.group_ids:
849 group = Group.get_group_by_id(dbsession, gid)
850 if not group:
851 fail_invalid(f"Invalid group ID: {gid}")
852 groups.append(group)
854 if self.primary_idnum:
855 if self.primary_idnum not in valid_which_idnums:
856 fail_invalid(
857 f"Invalid {cpr.PRIMARY_IDNUM}: {self.primary_idnum}"
858 )
860 if self.require_idnum_mandatory:
861 # (a) ID number must be mandatory in finalized records
862 for group in groups:
863 finalize_policy = group.tokenized_finalize_policy()
864 if not finalize_policy.is_idnum_mandatory_in_policy(
865 which_idnum=self.primary_idnum,
866 valid_idnums=valid_which_idnums,
867 ):
868 fail_invalid(
869 f"primary_idnum ({self.primary_idnum}) must be "
870 f"mandatory in finalizing policy, but is not for "
871 f"group {group}"
872 )
873 if not self.finalized_only:
874 # (b) ID number must also be mandatory in uploaded,
875 # non-finalized records
876 upload_policy = group.tokenized_upload_policy()
877 if not upload_policy.is_idnum_mandatory_in_policy(
878 which_idnum=self.primary_idnum,
879 valid_idnums=valid_which_idnums,
880 ):
881 fail_invalid(
882 f"primary_idnum ({self.primary_idnum}) must "
883 f"be mandatory in upload policy (because "
884 f"{cpr.FINALIZED_ONLY} is false), but is not "
885 f"for group {group}"
886 )
888 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
889 # File
890 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
891 if self._need_file_name():
892 # Filename options
893 if not patient_spec_for_filename_is_valid(
894 patient_spec=self.file_patient_spec,
895 valid_which_idnums=valid_which_idnums,
896 ):
897 fail_invalid(
898 f"Invalid {cpr.FILE_PATIENT_SPEC}: "
899 f"{self.file_patient_spec}"
900 )
901 if not filename_spec_is_valid(
902 filename_spec=self.file_filename_spec,
903 valid_which_idnums=valid_which_idnums,
904 ):
905 fail_invalid(
906 f"Invalid {cpr.FILE_FILENAME_SPEC}: "
907 f"{self.file_filename_spec}"
908 )
910 if self._need_rio_metadata_options():
911 # RiO metadata
912 if self.rio_idnum not in valid_which_idnums:
913 fail_invalid(
914 f"Invalid ID number type for "
915 f"{cpr.RIO_IDNUM}: {self.rio_idnum}"
916 )
918 def _need_file_name(self) -> bool:
919 """
920 Do we need to know about filenames?
921 """
922 return (
923 self.transmission_method == ExportTransmissionMethod.FILE
924 or (
925 self.transmission_method == ExportTransmissionMethod.HL7
926 and self.hl7_debug_divert_to_file
927 )
928 or self.transmission_method == ExportTransmissionMethod.EMAIL
929 )
931 def _need_file_disk_options(self) -> bool:
932 """
933 Do we need to know about how to write to disk (e.g. overwrite, make
934 directories)?
935 """
936 return self.transmission_method == ExportTransmissionMethod.FILE or (
937 self.transmission_method == ExportTransmissionMethod.HL7
938 and self.hl7_debug_divert_to_file
939 )
941 def _need_rio_metadata_options(self) -> bool:
942 """
943 Do we need to know about RiO metadata?
944 """
945 return (
946 self.transmission_method == ExportTransmissionMethod.FILE
947 and self.file_export_rio_metadata
948 )
950 def using_db(self) -> bool:
951 """
952 Is the recipient a database?
953 """
954 return self.transmission_method == ExportTransmissionMethod.DATABASE
956 def using_email(self) -> bool:
957 """
958 Is the recipient an e-mail system?
959 """
960 return self.transmission_method == ExportTransmissionMethod.EMAIL
962 def using_file(self) -> bool:
963 """
964 Is the recipient a filestore?
965 """
966 return self.transmission_method == ExportTransmissionMethod.FILE
968 def using_hl7(self) -> bool:
969 """
970 Is the recipient an HL7 v2 recipient?
971 """
972 return self.transmission_method == ExportTransmissionMethod.HL7
974 def using_fhir(self) -> bool:
975 """
976 Is the recipient a FHIR recipient?
977 """
978 return self.transmission_method == ExportTransmissionMethod.FHIR
980 def anonymous_ok(self) -> bool:
981 """
982 Does this recipient permit/want anonymous tasks?
983 """
984 return self.include_anonymous and not (
985 # Methods that require patient identification:
986 self.using_hl7()
987 or self.using_fhir()
988 )
990 def is_incremental(self) -> bool:
991 """
992 Is this an incremental export? (That's the norm, except for database
993 exports.)
994 """
995 return not self.using_db()
997 @staticmethod
998 def get_hl7_id_type(req: "CamcopsRequest", which_idnum: int) -> str:
999 """
1000 Get the HL7 ID type for a specific CamCOPS ID number type.
1001 """
1002 iddef = req.get_idnum_definition(which_idnum)
1003 return (iddef.hl7_id_type or "") if iddef else ""
1005 @staticmethod
1006 def get_hl7_id_aa(req: "CamcopsRequest", which_idnum: int) -> str:
1007 """
1008 Get the HL7 Assigning Authority for a specific CamCOPS ID number type.
1009 """
1010 iddef = req.get_idnum_definition(which_idnum)
1011 return (iddef.hl7_assigning_authority or "") if iddef else ""
1013 def _get_processed_spec(
1014 self,
1015 req: "CamcopsRequest",
1016 task: "Task",
1017 patient_spec_if_anonymous: str,
1018 patient_spec: str,
1019 spec: str,
1020 treat_as_filename: bool,
1021 override_task_format: str = "",
1022 ) -> str:
1023 """
1024 Returns a
1025 Args:
1026 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1027 task: a :class:`camcops_server.cc_modules.cc_task.Task`
1028 patient_spec_if_anonymous:
1029 patient specification to be used for anonymous tasks
1030 patient_spec:
1031 patient specification to be used for patient-identifiable tasks
1032 spec:
1033 specification to use to create the string (may include
1034 patient information from the patient specification)
1035 treat_as_filename:
1036 convert the resulting string to be a safe filename
1037 override_task_format:
1038 format to use to override the default (typically to force an
1039 extension e.g. for HL7 debugging)
1041 Returns:
1042 a processed string specification (e.g. a filename; an e-mail
1043 subject)
1044 """
1045 return get_export_filename(
1046 req=req,
1047 patient_spec_if_anonymous=patient_spec_if_anonymous,
1048 patient_spec=patient_spec,
1049 filename_spec=spec,
1050 filetype=(
1051 override_task_format
1052 if override_task_format
1053 else self.task_format
1054 ),
1055 is_anonymous=task.is_anonymous,
1056 surname=task.get_patient_surname(),
1057 forename=task.get_patient_forename(),
1058 dob=task.get_patient_dob(),
1059 sex=task.get_patient_sex(),
1060 idnum_objects=task.get_patient_idnum_objects(),
1061 creation_datetime=task.get_creation_datetime(),
1062 basetable=task.tablename,
1063 serverpk=task.pk,
1064 skip_conversion_to_safe_filename=not treat_as_filename,
1065 )
1067 def get_filename(
1068 self,
1069 req: "CamcopsRequest",
1070 task: "Task",
1071 override_task_format: str = "",
1072 ) -> str:
1073 """
1074 Get the export filename, for file transfers.
1075 """
1076 return self._get_processed_spec(
1077 req=req,
1078 task=task,
1079 patient_spec_if_anonymous=self.file_patient_spec_if_anonymous,
1080 patient_spec=self.file_patient_spec,
1081 spec=self.file_filename_spec,
1082 treat_as_filename=True,
1083 override_task_format=override_task_format,
1084 )
1086 def get_email_subject(self, req: "CamcopsRequest", task: "Task") -> str:
1087 """
1088 Gets a substituted e-mail subject.
1089 """
1090 return self._get_processed_spec(
1091 req=req,
1092 task=task,
1093 patient_spec_if_anonymous=self.email_patient_spec_if_anonymous,
1094 patient_spec=self.email_patient_spec,
1095 spec=self.email_subject,
1096 treat_as_filename=False,
1097 )
1099 def get_email_body(self, req: "CamcopsRequest", task: "Task") -> str:
1100 """
1101 Gets a substituted e-mail body.
1102 """
1103 return self._get_processed_spec(
1104 req=req,
1105 task=task,
1106 patient_spec_if_anonymous=self.email_patient_spec_if_anonymous,
1107 patient_spec=self.email_patient_spec,
1108 spec=self.email_body,
1109 treat_as_filename=False,
1110 )