Coverage for cc_modules/cc_exportrecipientinfo.py : 52%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_exportrecipient.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
12 CamCOPS is free software: you can redistribute it and/or modify
13 it under the terms of the GNU General Public License as published by
14 the Free Software Foundation, either version 3 of the License, or
15 (at your option) any later version.
17 CamCOPS is distributed in the hope that it will be useful,
18 but WITHOUT ANY WARRANTY; without even the implied warranty of
19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 GNU General Public License for more details.
22 You should have received a copy of the GNU General Public License
23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
25===============================================================================
27**ExportRecipientInfo class.**
29The purpose of this is to capture information without using an SQLAlchemy
30class. The :class:`camcops_server.cc_modules.cc_config.CamcopsConfig` class
31uses this, as it needs to be readable in the absence of a database connection
32(q.v.).
34"""
36import configparser
37import datetime
38import logging
39from typing import List, NoReturn, Optional, TYPE_CHECKING
41from cardinal_pythonlib.configfiles import (
42 get_config_parameter,
43 get_config_parameter_boolean,
44 get_config_parameter_multiline,
45)
46from cardinal_pythonlib.datetimefunc import (
47 coerce_to_pendulum,
48 pendulum_to_utc_datetime_without_tz,
49)
50from cardinal_pythonlib.logs import BraceStyleAdapter
51from cardinal_pythonlib.reprfunc import simple_repr
53from camcops_server.cc_modules.cc_constants import (
54 CONFIG_FILE_SITE_SECTION,
55 ConfigDefaults,
56 ConfigParamExportRecipient,
57 ConfigParamSite,
58 FileType,
59)
60from camcops_server.cc_modules.cc_filename import (
61 filename_spec_is_valid,
62 get_export_filename,
63 patient_spec_for_filename_is_valid,
64)
66if TYPE_CHECKING:
67 from camcops_server.cc_modules.cc_config import CamcopsConfig
68 from camcops_server.cc_modules.cc_request import CamcopsRequest
69 from camcops_server.cc_modules.cc_task import Task
71log = BraceStyleAdapter(logging.getLogger(__name__))
74# =============================================================================
75# Constants
76# =============================================================================
78COMMA = ","
79CONFIG_RECIPIENT_PREFIX = "recipient:"
80RIO_MAX_USER_LEN = 10
83class ExportTransmissionMethod(object):
84 """
85 Possible export transmission methods.
86 """
87 DATABASE = "database"
88 EMAIL = "email"
89 FHIR = "fhir"
90 FILE = "file"
91 HL7 = "hl7"
92 REDCAP = "redcap"
95ALL_TRANSMISSION_METHODS = [
96 v for k, v in vars(ExportTransmissionMethod).items()
97 if not k.startswith("_")
98] # ... the values of all the relevant attributes
100ALL_TASK_FORMATS = [FileType.HTML, FileType.PDF, FileType.XML]
103class InvalidExportRecipient(ValueError):
104 """
105 Exception for invalid export recipients.
106 """
107 def __init__(self, recipient_name: str, msg: str) -> None:
108 super().__init__(
109 f"For export recipient [{recipient_name}]: {msg}")
112# Internal shorthand:
113_Invalid = InvalidExportRecipient
116class _Missing(_Invalid):
117 """
118 Exception for missing config parameters
119 """
120 def __init__(self, recipient_name: str, paramname: str) -> None:
121 super().__init__(recipient_name,
122 f"Missing parameter {paramname}")
125# =============================================================================
126# ExportRecipientInfo class
127# =============================================================================
129class ExportRecipientInfo(object):
130 """
131 Class representing an export recipient, that is not an SQLAlchemy ORM
132 object.
134 This has an unfortunate close relationship with
135 :class:`camcops_server.cc_modules.cc_exportrecipient.ExportRecipient`
136 (q.v.).
138 Full details of parameters are in the docs for the config file.
139 """
140 IGNORE_FOR_EQ_ATTRNAMES = [
141 # Attribute names to ignore for equality comparison
142 "email_host_password",
143 "fhir_app_secret",
144 "fhir_launch_token",
145 "redcap_api_key",
146 ]
148 def __init__(self, other: "ExportRecipientInfo" = None) -> None:
149 """
150 Initializes, optionally copying attributes from ``other``.
151 """
152 cd = ConfigDefaults()
154 self.recipient_name = ""
156 # How to export
158 self.transmission_method = ExportTransmissionMethod.EMAIL
159 self.push = cd.PUSH
160 self.task_format = cd.TASK_FORMAT
161 self.xml_field_comments = cd.XML_FIELD_COMMENTS
163 # What to export
165 self.all_groups = cd.ALL_GROUPS
166 self.group_names = [] # type: List[str] # not in database; see group_ids # noqa
167 self.group_ids = [] # type: List[int]
168 self.tasks = [] # type: List[str]
169 self.start_datetime_utc = None # type: Optional[datetime.datetime]
170 self.end_datetime_utc = None # type: Optional[datetime.datetime]
171 self.finalized_only = cd.FINALIZED_ONLY
172 self.include_anonymous = cd.INCLUDE_ANONYMOUS
173 self.primary_idnum = None # type: Optional[int]
174 self.require_idnum_mandatory = cd.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY # noqa
176 # Database
178 self.db_url = ""
179 self.db_echo = cd.DB_ECHO
180 self.db_include_blobs = cd.DB_INCLUDE_BLOBS
181 self.db_add_summaries = cd.DB_ADD_SUMMARIES
182 self.db_patient_id_per_row = cd.DB_PATIENT_ID_PER_ROW
184 # Email
186 self.email_host = ""
187 self.email_port = cd.EMAIL_PORT
188 self.email_use_tls = cd.EMAIL_USE_TLS
189 self.email_host_username = ""
190 self.email_host_password = "" # not in database for security
191 self.email_from = ""
192 self.email_sender = ""
193 self.email_reply_to = ""
194 self.email_to = "" # CSV list
195 self.email_cc = "" # CSV list
196 self.email_bcc = "" # CSV list
197 self.email_patient_spec = ""
198 self.email_patient_spec_if_anonymous = cd.PATIENT_SPEC_IF_ANONYMOUS
199 self.email_subject = ""
200 self.email_body_as_html = cd.EMAIL_BODY_IS_HTML
201 self.email_body = ""
202 self.email_keep_message = cd.EMAIL_KEEP_MESSAGE
204 # HL7
206 self.hl7_host = ""
207 self.hl7_port = cd.HL7_PORT
208 self.hl7_ping_first = cd.HL7_PING_FIRST
209 self.hl7_network_timeout_ms = cd.HL7_NETWORK_TIMEOUT_MS
210 self.hl7_keep_message = cd.HL7_KEEP_MESSAGE
211 self.hl7_keep_reply = cd.HL7_KEEP_REPLY
212 self.hl7_debug_divert_to_file = cd.HL7_DEBUG_DIVERT_TO_FILE
213 self.hl7_debug_treat_diverted_as_sent = cd.HL7_DEBUG_TREAT_DIVERTED_AS_SENT # noqa
215 # File
217 self.file_patient_spec = ""
218 self.file_patient_spec_if_anonymous = cd.PATIENT_SPEC_IF_ANONYMOUS
219 self.file_filename_spec = ""
220 self.file_make_directory = cd.FILE_MAKE_DIRECTORY
221 self.file_overwrite_files = cd.FILE_OVERWRITE_FILES
222 self.file_export_rio_metadata = cd.FILE_EXPORT_RIO_METADATA
223 self.file_script_after_export = ""
225 # File/RiO
227 self.rio_idnum = None # type: Optional[int]
228 self.rio_uploading_user = ""
229 self.rio_document_type = ""
231 # REDCap
233 self.redcap_api_key = "" # not in database for security
234 self.redcap_api_url = ""
235 self.redcap_fieldmap_filename = ""
237 # FHIR
239 self.fhir_api_url = ""
240 self.fhir_app_secret = ""
241 self.fhir_launch_token = ""
243 # Copy from other?
244 if other is not None:
245 assert isinstance(other, ExportRecipientInfo)
246 for attrname in self.get_attrnames():
247 # Note that both "self" and "other" may be an ExportRecipient
248 # rather than an ExportRecipientInfo.
249 if hasattr(other, attrname):
250 setattr(self, attrname, getattr(other, attrname))
252 def get_attrnames(self) -> List[str]:
253 """
254 Returns all relevant attribute names.
255 """
256 return sorted([key for key in self.__dict__
257 if not key.startswith('_')])
259 def get_eq_attrnames(self) -> List[str]:
260 """
261 Returns attribute names to use for equality comparison.
262 """
263 return [x for x in self.get_attrnames()
264 if x not in self.IGNORE_FOR_EQ_ATTRNAMES]
266 def __repr__(self):
267 return simple_repr(self, self.get_attrnames())
269 def __str__(self) -> str:
270 return repr(self.recipient_name)
272 def __eq__(self, other: "ExportRecipientInfo") -> bool:
273 """
274 Does this object equal another -- meaning "sufficiently equal that we
275 can use the same one, rather than making a new database copy"?
276 """
277 for attrname in self.get_attrnames():
278 if attrname not in self.IGNORE_FOR_EQ_ATTRNAMES:
279 selfattr = getattr(self, attrname)
280 otherattr = getattr(other, attrname)
281 # log.critical("{}.{}: {} {} {}",
282 # self.__class__.__name__,
283 # attrname,
284 # selfattr,
285 # "==" if selfattr == otherattr else "!=",
286 # otherattr)
287 if selfattr != otherattr:
288 log.debug(
289 "{}: For {!r}, new export recipient mismatches "
290 "previous copy on {}: {!r} != {!r}",
291 self.__class__.__name__,
292 self.recipient_name,
293 attrname,
294 selfattr,
295 otherattr)
296 return False
297 return True
299 @classmethod
300 def create_dummy_recipient(cls) -> "ExportRecipientInfo":
301 """
302 Creates and returns a dummy :class:`ExportRecipientInfo`.
303 """
304 d = cls()
306 d.recipient_name = "_dummy_export_recipient_"
307 d.current = True
309 d.transmission_method = ExportTransmissionMethod.FILE
311 d.all_groups = True
312 d.primary_idnum = 1
313 d.require_idnum_mandatory = False
314 d.finalized_only = False
315 d.task_format = FileType.XML
317 # File
318 d.include_anonymous = True
319 d.file_patient_spec_if_anonymous = "anonymous"
320 d.file_patient_spec = "{surname}_{forename}_{idshortdesc1}{idnum1}"
321 d.file_filename_spec = (
322 "/tmp/camcops_debug_testing/"
323 "TestCamCOPS_{patient}_{created}_{tasktype}-{serverpk}"
324 ".{filetype}"
325 )
326 d.file_overwrite_files = False
327 d.file_make_directory = True
329 return d
331 @classmethod
332 def read_from_config(cls,
333 cfg: "CamcopsConfig",
334 parser: configparser.ConfigParser,
335 recipient_name: str) -> "ExportRecipientInfo":
336 """
337 Reads from the config file and writes this instance's attributes.
339 Args:
340 cfg: a :class:`camcops_server.cc_modules.cc_config.CamcopsConfig`
341 parser: configparser INI file object
342 recipient_name: name of recipient and of INI file section
344 Returns:
345 an :class:`ExportRecipient` object, which is **not** currently in
346 a database session
347 """
348 assert recipient_name
349 log.debug("Loading export config for recipient {!r}", recipient_name)
351 section = CONFIG_RECIPIENT_PREFIX + recipient_name
352 cps = ConfigParamSite
353 cpr = ConfigParamExportRecipient
354 cd = ConfigDefaults()
355 r = cls() # type: ExportRecipientInfo
357 def _get_str(paramname: str, default: str = None) -> Optional[str]:
358 return get_config_parameter(
359 parser, section, paramname, str, default)
361 def _get_bool(paramname: str, default: bool) -> bool:
362 return get_config_parameter_boolean(
363 parser, section, paramname, default)
365 def _get_int(paramname: str, default: int = None) -> Optional[int]:
366 return get_config_parameter(
367 parser, section, paramname, int, default)
369 def _get_multiline(paramname: str) -> List[str]:
370 return get_config_parameter_multiline(
371 parser, section, paramname, [])
373 def _get_site_str(paramname: str,
374 default: str = None) -> Optional[str]:
375 return get_config_parameter(
376 parser, CONFIG_FILE_SITE_SECTION, paramname, str, default)
378 # noinspection PyUnusedLocal
379 def _get_site_bool(paramname: str, default: bool) -> bool:
380 return get_config_parameter_boolean(
381 parser, CONFIG_FILE_SITE_SECTION, paramname, default)
383 # noinspection PyUnusedLocal
384 def _get_site_int(paramname: str,
385 default: int = None) -> Optional[int]:
386 return get_config_parameter(
387 parser, CONFIG_FILE_SITE_SECTION, paramname, int, default)
389 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
390 # Identity
391 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
392 r.recipient_name = recipient_name
394 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
395 # How to export
396 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
397 r.transmission_method = _get_str(cpr.TRANSMISSION_METHOD)
398 r.transmission_method = str(r.transmission_method).lower()
399 # Check this one immediately, since we use it in conditions below
400 if r.transmission_method not in ALL_TRANSMISSION_METHODS:
401 raise _Invalid(
402 r.recipient_name,
403 f"Missing/invalid "
404 f"{ConfigParamExportRecipient.TRANSMISSION_METHOD}: "
405 f"{r.transmission_method}"
406 )
407 r.push = _get_bool(cpr.PUSH, cd.PUSH)
408 r.task_format = _get_str(cpr.TASK_FORMAT, cd.TASK_FORMAT)
409 r.xml_field_comments = _get_bool(cpr.XML_FIELD_COMMENTS,
410 cd.XML_FIELD_COMMENTS)
412 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
413 # What to export
414 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
415 r.all_groups = _get_bool(cpr.ALL_GROUPS, cd.ALL_GROUPS)
416 r.group_names = _get_multiline(cpr.GROUPS)
417 r.group_ids = []
418 # ... read later by validate_db_dependent()
419 r.tasks = sorted([x.lower() for x in _get_multiline(cpr.TASKS)])
420 sd = _get_str(cpr.START_DATETIME_UTC)
421 r.start_datetime_utc = pendulum_to_utc_datetime_without_tz(
422 coerce_to_pendulum(sd, assume_local=False)) if sd else None
423 ed = _get_str(cpr.END_DATETIME_UTC)
424 r.end_datetime_utc = pendulum_to_utc_datetime_without_tz(
425 coerce_to_pendulum(ed, assume_local=False)) if ed else None
426 r.finalized_only = _get_bool(cpr.FINALIZED_ONLY, cd.FINALIZED_ONLY)
427 r.include_anonymous = _get_bool(cpr.INCLUDE_ANONYMOUS,
428 cd.INCLUDE_ANONYMOUS)
429 r.primary_idnum = _get_int(cpr.PRIMARY_IDNUM)
430 r.require_idnum_mandatory = _get_bool(
431 cpr.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY,
432 cd.REQUIRE_PRIMARY_IDNUM_MANDATORY_IN_POLICY)
434 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
435 # Database
436 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
437 if r.transmission_method == ExportTransmissionMethod.DATABASE:
438 r.db_url = _get_str(cpr.DB_URL)
439 r.db_echo = _get_bool(cpr.DB_ECHO, cd.DB_ECHO)
440 r.db_include_blobs = _get_bool(cpr.DB_INCLUDE_BLOBS,
441 cd.DB_INCLUDE_BLOBS)
442 r.db_add_summaries = _get_bool(cpr.DB_ADD_SUMMARIES,
443 cd.DB_ADD_SUMMARIES)
444 r.db_patient_id_per_row = _get_bool(cpr.DB_PATIENT_ID_PER_ROW,
445 cd.DB_PATIENT_ID_PER_ROW)
447 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
448 # Email
449 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
450 def _make_email_csv_list(paramname: str) -> str:
451 return ", ".join(x for x in _get_multiline(paramname))
453 if r.transmission_method == ExportTransmissionMethod.EMAIL:
454 r.email_host = cfg.email_host
455 r.email_port = cfg.email_port
456 r.email_use_tls = cfg.email_use_tls
457 r.email_host_username = cfg.email_host_username
458 r.email_host_password = cfg.email_host_password
460 # Read from password safe using 'pass'
461 # from subprocess import run, PIPE
462 # output = run(["pass", "dept-of-psychiatry/Hermes"], stdout=PIPE)
463 # r.email_host_password = output.stdout.decode("utf-8").split()[0]
465 r.email_from = _get_site_str(cps.EMAIL_FROM, "")
466 r.email_sender = _get_site_str(cps.EMAIL_SENDER, "")
467 r.email_reply_to = _get_site_str(cps.EMAIL_REPLY_TO, "")
469 r.email_to = _make_email_csv_list(cpr.EMAIL_TO)
470 r.email_cc = _make_email_csv_list(cpr.EMAIL_CC)
471 r.email_bcc = _make_email_csv_list(cpr.EMAIL_BCC)
472 r.email_patient_spec_if_anonymous = _get_str(cpr.EMAIL_PATIENT_SPEC_IF_ANONYMOUS, "") # noqa
473 r.email_patient_spec = _get_str(cpr.EMAIL_PATIENT_SPEC, "")
474 r.email_subject = _get_str(cpr.EMAIL_SUBJECT, "")
475 r.email_body_as_html = _get_bool(cpr.EMAIL_BODY_IS_HTML,
476 cd.EMAIL_BODY_IS_HTML)
477 r.email_body = _get_str(cpr.EMAIL_BODY, "")
478 r.email_keep_message = _get_bool(cpr.EMAIL_KEEP_MESSAGE,
479 cd.EMAIL_KEEP_MESSAGE)
481 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
482 # HL7
483 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
484 if r.transmission_method == ExportTransmissionMethod.HL7:
485 r.hl7_host = _get_str(cpr.HL7_HOST)
486 r.hl7_port = _get_int(cpr.HL7_PORT, cd.HL7_PORT)
487 r.hl7_ping_first = _get_bool(cpr.HL7_PING_FIRST,
488 cd.HL7_PING_FIRST)
489 r.hl7_network_timeout_ms = _get_int(cpr.HL7_NETWORK_TIMEOUT_MS,
490 cd.HL7_NETWORK_TIMEOUT_MS)
491 r.hl7_keep_message = _get_bool(cpr.HL7_KEEP_MESSAGE,
492 cd.HL7_KEEP_MESSAGE)
493 r.hl7_keep_reply = _get_bool(cpr.HL7_KEEP_REPLY, cd.HL7_KEEP_REPLY)
494 r.hl7_debug_divert_to_file = _get_bool(
495 cpr.HL7_DEBUG_DIVERT_TO_FILE, cd.HL7_DEBUG_DIVERT_TO_FILE)
496 r.hl7_debug_treat_diverted_as_sent = _get_bool(
497 cpr.HL7_DEBUG_TREAT_DIVERTED_AS_SENT,
498 cd.HL7_DEBUG_TREAT_DIVERTED_AS_SENT)
500 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
501 # File
502 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
503 if r._need_file_name():
504 r.file_patient_spec = _get_str(cpr.FILE_PATIENT_SPEC)
505 r.file_patient_spec_if_anonymous = _get_str(
506 cpr.FILE_PATIENT_SPEC_IF_ANONYMOUS,
507 cd.FILE_PATIENT_SPEC_IF_ANONYMOUS)
508 r.file_filename_spec = _get_str(cpr.FILE_FILENAME_SPEC)
510 if r._need_file_disk_options():
511 r.file_make_directory = _get_bool(cpr.FILE_MAKE_DIRECTORY,
512 cd.FILE_MAKE_DIRECTORY)
513 r.file_overwrite_files = _get_bool(cpr.FILE_OVERWRITE_FILES,
514 cd.FILE_OVERWRITE_FILES)
516 if r.transmission_method == ExportTransmissionMethod.FILE:
517 r.file_export_rio_metadata = _get_bool(
518 cpr.FILE_EXPORT_RIO_METADATA, cd.FILE_EXPORT_RIO_METADATA)
519 r.file_script_after_export = _get_str(cpr.FILE_SCRIPT_AFTER_EXPORT)
521 if r._need_rio_metadata_options():
522 # RiO metadata
523 r.rio_idnum = _get_int(cpr.RIO_IDNUM)
524 r.rio_uploading_user = _get_str(cpr.RIO_UPLOADING_USER)
525 r.rio_document_type = _get_str(cpr.RIO_DOCUMENT_TYPE)
527 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
528 # REDCap
529 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
530 if r.transmission_method == ExportTransmissionMethod.REDCAP:
531 r.redcap_api_url = _get_str(cpr.REDCAP_API_URL)
532 r.redcap_api_key = _get_str(cpr.REDCAP_API_KEY)
533 r.redcap_fieldmap_filename = _get_str(cpr.REDCAP_FIELDMAP_FILENAME)
535 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
536 # FHIR
537 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
538 if r.transmission_method == ExportTransmissionMethod.FHIR:
539 r.fhir_api_url = _get_str(cpr.FHIR_API_URL)
540 r.fhir_app_secret = _get_str(cpr.FHIR_APP_SECRET)
541 r.fhir_launch_token = _get_str(cpr.FHIR_LAUNCH_TOKEN)
543 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
544 # Validate the basics and return
545 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
546 r.validate_db_independent()
547 return r
549 @classmethod
550 def report_error(cls, msg: str) -> None:
551 """
552 Report an error to the log.
553 """
554 log.error("{}: {}", cls.__name__, msg)
556 def valid(self, req: "CamcopsRequest") -> bool:
557 """
558 Is this definition valid?
560 Args:
561 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
562 """
563 try:
564 self.validate(req)
565 return True
566 except InvalidExportRecipient as e:
567 self.report_error(str(e))
568 return False
570 def validate(self, req: "CamcopsRequest") -> None:
571 """
572 Validates all aspects.
574 Args:
575 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
577 Raises:
578 :exc:`InvalidExportRecipient` if invalid
579 """
580 self.validate_db_independent()
581 self.validate_db_dependent(req)
583 def validate_db_independent(self) -> None:
584 """
585 Validates the database-independent aspects of the
586 :class:`ExportRecipient`, or raises :exc:`InvalidExportRecipient`.
587 """
588 # noinspection PyUnresolvedReferences
589 import camcops_server.cc_modules.cc_all_models # import side effects (ensure all models registered) # noqa
590 from camcops_server.cc_modules.cc_task import all_task_tablenames # delayed import # noqa
592 def fail_invalid(msg: str) -> NoReturn:
593 raise _Invalid(self.recipient_name, msg)
595 def fail_missing(paramname: str) -> NoReturn:
596 raise _Missing(self.recipient_name, paramname)
598 cpr = ConfigParamExportRecipient
599 cps = ConfigParamSite
601 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
602 # Export type
603 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
604 if self.transmission_method not in ALL_TRANSMISSION_METHODS:
605 fail_invalid(
606 f"Missing/invalid {cpr.TRANSMISSION_METHOD}: "
607 f"{self.transmission_method}")
608 no_push = [ExportTransmissionMethod.DATABASE]
609 if self.push and self.transmission_method in no_push:
610 fail_invalid(f"Push notifications not supported for these "
611 f"transmission methods: {no_push!r}")
613 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
614 # What to export
615 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
616 if not self.all_groups and not self.group_names:
617 fail_invalid(f"Missing group names (from {cpr.GROUPS})")
619 all_basetables = all_task_tablenames()
620 for basetable in self.tasks:
621 if basetable not in all_basetables:
622 fail_invalid(f"Task {basetable!r} doesn't exist")
624 if (self.transmission_method == ExportTransmissionMethod.HL7 and
625 not self.primary_idnum):
626 fail_invalid(
627 f"Must specify {cpr.PRIMARY_IDNUM} with "
628 f"{cpr.TRANSMISSION_METHOD} = {ExportTransmissionMethod.HL7}"
629 )
631 if not self.task_format or self.task_format not in ALL_TASK_FORMATS:
632 fail_invalid(
633 f"Missing/invalid {cpr.TASK_FORMAT}: {self.task_format}")
635 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
636 # Database
637 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
638 if self.transmission_method == ExportTransmissionMethod.DATABASE:
639 if not self.db_url:
640 fail_missing(cpr.DB_URL)
642 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
643 # Email
644 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
645 if self.transmission_method == ExportTransmissionMethod.EMAIL:
646 if not self.email_host:
647 # You can't send an e-mail without knowing which server to send
648 # it to.
649 fail_missing(cps.EMAIL_HOST)
650 # Username is *not* required by all servers!
651 if not self.email_from:
652 # From is mandatory in all e-mails.
653 # (Sender and Reply-To are optional.)
654 fail_missing(cps.EMAIL_FROM)
655 if COMMA in self.email_from:
656 # RFC 5322 permits multiple addresses in From, but Python
657 # sendmail doesn't.
658 fail_invalid(
659 f"Only a single 'From:' address permitted; was "
660 f"{self.email_from!r}")
661 if not any([self.email_to, self.email_cc, self.email_bcc]):
662 # At least one destination is required (obviously).
663 fail_invalid(
664 f"Must specify some of: {cpr.EMAIL_TO}, {cpr.EMAIL_CC}, "
665 f"{cpr.EMAIL_BCC}")
666 if COMMA in self.email_sender:
667 # RFC 5322 permits multiple addresses in From and Reply-To,
668 # but only one in Sender.
669 fail_invalid(
670 f"Only a single 'Sender:' address permitted; was "
671 f"{self.email_sender!r}")
672 if not self.email_subject:
673 # A subject is not obligatory for e-mails in general, but we
674 # will require one for e-mails sent from CamCOPS.
675 fail_missing(cpr.EMAIL_SUBJECT)
677 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
678 # HL7
679 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
680 if self.transmission_method == ExportTransmissionMethod.HL7:
681 if not self.hl7_debug_divert_to_file:
682 if not self.hl7_host:
683 fail_missing(cpr.HL7_HOST)
684 if not self.hl7_port or self.hl7_port <= 0:
685 fail_invalid(
686 f"Missing/invalid {cpr.HL7_PORT}: {self.hl7_port}")
687 if not self.primary_idnum:
688 fail_missing(cpr.PRIMARY_IDNUM)
689 if self.include_anonymous:
690 fail_invalid("Can't include anonymous tasks for HL7")
692 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
693 # File
694 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
695 if self._need_file_name():
696 # Filename options
697 if not self.file_patient_spec_if_anonymous:
698 fail_missing(cpr.FILE_PATIENT_SPEC_IF_ANONYMOUS)
699 if not self.file_patient_spec:
700 fail_missing(cpr.FILE_PATIENT_SPEC)
701 if not self.file_filename_spec:
702 fail_missing(cpr.FILE_FILENAME_SPEC)
704 if self._need_rio_metadata_options():
705 # RiO metadata
706 if (not self.rio_uploading_user or
707 " " in self.rio_uploading_user or
708 len(self.rio_uploading_user) > RIO_MAX_USER_LEN):
709 fail_invalid(
710 f"Missing/invalid {cpr.RIO_UPLOADING_USER}: "
711 f"{self.rio_uploading_user} (must be present, contain no "
712 f"spaces, and max length {RIO_MAX_USER_LEN})")
713 if not self.rio_document_type:
714 fail_missing(cpr.RIO_DOCUMENT_TYPE)
716 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
717 # REDCap
718 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
719 if self.transmission_method == ExportTransmissionMethod.HL7:
720 if not self.primary_idnum:
721 fail_missing(cpr.PRIMARY_IDNUM)
722 if self.include_anonymous:
723 fail_invalid("Can't include anonymous tasks for REDCap")
725 def validate_db_dependent(self, req: "CamcopsRequest") -> None:
726 """
727 Validates the database-dependent aspects of the
728 :class:`ExportRecipient`, or raises :exc:`InvalidExportRecipient`.
730 :meth:`validate_db_independent` should have been called first; this
731 function presumes that those checks have been passed.
733 Args:
734 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
735 """
736 from camcops_server.cc_modules.cc_group import Group # delayed import # noqa
738 def fail_invalid(msg: str) -> NoReturn:
739 raise _Invalid(self.recipient_name, msg)
741 dbsession = req.dbsession
742 valid_which_idnums = req.valid_which_idnums
743 cpr = ConfigParamExportRecipient
745 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
746 # Set group IDs from group names
747 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
748 self.group_ids = [] # type: List[int]
749 for groupname in self.group_names:
750 group = Group.get_group_by_name(dbsession, groupname)
751 if not group:
752 raise ValueError(f"No such group: {groupname!r}")
753 self.group_ids.append(group.id)
754 self.group_ids.sort()
756 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
757 # What to export
758 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
759 if self.all_groups:
760 groups = Group.get_all_groups(dbsession)
761 else:
762 groups = [] # type: List[Group]
763 for gid in self.group_ids:
764 group = Group.get_group_by_id(dbsession, gid)
765 if not group:
766 fail_invalid(f"Invalid group ID: {gid}")
767 groups.append(group)
769 if self.primary_idnum:
770 if self.primary_idnum not in valid_which_idnums:
771 fail_invalid(
772 f"Invalid {cpr.PRIMARY_IDNUM}: {self.primary_idnum}")
774 if self.require_idnum_mandatory:
775 # (a) ID number must be mandatory in finalized records
776 for group in groups:
777 finalize_policy = group.tokenized_finalize_policy()
778 if not finalize_policy.is_idnum_mandatory_in_policy(
779 which_idnum=self.primary_idnum,
780 valid_idnums=valid_which_idnums):
781 fail_invalid(
782 f"primary_idnum ({self.primary_idnum}) must be "
783 f"mandatory in finalizing policy, but is not for "
784 f"group {group}"
785 )
786 if not self.finalized_only:
787 # (b) ID number must also be mandatory in uploaded,
788 # non-finalized records
789 upload_policy = group.tokenized_upload_policy()
790 if not upload_policy.is_idnum_mandatory_in_policy(
791 which_idnum=self.primary_idnum,
792 valid_idnums=valid_which_idnums):
793 fail_invalid(
794 f"primary_idnum ({self.primary_idnum}) must "
795 f"be mandatory in upload policy (because "
796 f"{cpr.FINALIZED_ONLY} is false), but is not "
797 f"for group {group}")
799 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
800 # File
801 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
802 if self._need_file_name():
803 # Filename options
804 if not patient_spec_for_filename_is_valid(
805 patient_spec=self.file_patient_spec,
806 valid_which_idnums=valid_which_idnums):
807 fail_invalid(f"Invalid {cpr.FILE_PATIENT_SPEC}: "
808 f"{self.file_patient_spec}")
809 if not filename_spec_is_valid(
810 filename_spec=self.file_filename_spec,
811 valid_which_idnums=valid_which_idnums):
812 fail_invalid(f"Invalid {cpr.FILE_FILENAME_SPEC}: "
813 f"{self.file_filename_spec}")
815 if self._need_rio_metadata_options():
816 # RiO metadata
817 if self.rio_idnum not in valid_which_idnums:
818 fail_invalid(f"Invalid ID number type for "
819 f"{cpr.RIO_IDNUM}: {self.rio_idnum}")
821 def _need_file_name(self) -> bool:
822 """
823 Do we need to know about filenames?
824 """
825 return (
826 self.transmission_method == ExportTransmissionMethod.FILE or
827 (self.transmission_method == ExportTransmissionMethod.HL7 and
828 self.hl7_debug_divert_to_file) or
829 self.transmission_method == ExportTransmissionMethod.EMAIL
830 )
832 def _need_file_disk_options(self) -> bool:
833 """
834 Do we need to know about how to write to disk (e.g. overwrite, make
835 directories)?
836 """
837 return (
838 self.transmission_method == ExportTransmissionMethod.FILE or
839 (self.transmission_method == ExportTransmissionMethod.HL7 and
840 self.hl7_debug_divert_to_file)
841 )
843 def _need_rio_metadata_options(self) -> bool:
844 """
845 Do we need to know about RiO metadata?
846 """
847 return (
848 self.transmission_method == ExportTransmissionMethod.FILE and
849 self.file_export_rio_metadata
850 )
852 def using_db(self) -> bool:
853 """
854 Is the recipient a database?
855 """
856 return self.transmission_method == ExportTransmissionMethod.DATABASE
858 def using_email(self) -> bool:
859 """
860 Is the recipient an e-mail system?
861 """
862 return self.transmission_method == ExportTransmissionMethod.EMAIL
864 def using_file(self) -> bool:
865 """
866 Is the recipient a filestore?
867 """
868 return self.transmission_method == ExportTransmissionMethod.FILE
870 def using_hl7(self) -> bool:
871 """
872 Is the recipient an HL7 recipient?
873 """
874 return self.transmission_method == ExportTransmissionMethod.HL7
876 def anonymous_ok(self) -> bool:
877 """
878 Does this recipient permit/want anonymous tasks?
879 """
880 return self.include_anonymous and not (
881 # Methods that require patient identification:
882 self.using_hl7()
883 )
885 def is_incremental(self) -> bool:
886 """
887 Is this an incremental export? (That's the norm, except for database
888 exports.)
889 """
890 return not self.using_db()
892 @staticmethod
893 def get_hl7_id_type(req: "CamcopsRequest", which_idnum: int) -> str:
894 """
895 Get the HL7 ID type for a specific CamCOPS ID number type.
896 """
897 iddef = req.get_idnum_definition(which_idnum)
898 return (iddef.hl7_id_type or '') if iddef else ''
900 @staticmethod
901 def get_hl7_id_aa(req: "CamcopsRequest", which_idnum: int) -> str:
902 """
903 Get the HL7 Assigning Authority for a specific CamCOPS ID number type.
904 """
905 iddef = req.get_idnum_definition(which_idnum)
906 return (iddef.hl7_assigning_authority or '') if iddef else ''
908 def _get_processed_spec(self,
909 req: "CamcopsRequest",
910 task: "Task",
911 patient_spec_if_anonymous: str,
912 patient_spec: str,
913 spec: str,
914 treat_as_filename: bool,
915 override_task_format: str = "") -> str:
916 """
917 Returns a
918 Args:
919 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
920 task: a :class:`camcops_server.cc_modules.cc_task.Task`
921 patient_spec_if_anonymous:
922 patient specification to be used for anonymous tasks
923 patient_spec:
924 patient specification to be used for patient-identifiable tasks
925 spec:
926 specification to use to create the string (may include
927 patient information from the patient specification)
928 treat_as_filename:
929 convert the resulting string to be a safe filename
930 override_task_format:
931 format to use to override the default (typically to force an
932 extension e.g. for HL7 debugging)
934 Returns:
935 a processed string specification (e.g. a filename; an e-mail
936 subject)
937 """
938 return get_export_filename(
939 req=req,
940 patient_spec_if_anonymous=patient_spec_if_anonymous,
941 patient_spec=patient_spec,
942 filename_spec=spec,
943 filetype=(override_task_format if override_task_format
944 else self.task_format),
945 is_anonymous=task.is_anonymous,
946 surname=task.get_patient_surname(),
947 forename=task.get_patient_forename(),
948 dob=task.get_patient_dob(),
949 sex=task.get_patient_sex(),
950 idnum_objects=task.get_patient_idnum_objects(),
951 creation_datetime=task.get_creation_datetime(),
952 basetable=task.tablename,
953 serverpk=task.pk,
954 skip_conversion_to_safe_filename=not treat_as_filename,
955 )
957 def get_filename(self, req: "CamcopsRequest", task: "Task",
958 override_task_format: str = "") -> str:
959 """
960 Get the export filename, for file transfers.
961 """
962 return self._get_processed_spec(
963 req=req,
964 task=task,
965 patient_spec_if_anonymous=self.file_patient_spec_if_anonymous,
966 patient_spec=self.file_patient_spec,
967 spec=self.file_filename_spec,
968 treat_as_filename=True,
969 override_task_format=override_task_format,
970 )
972 def get_email_subject(self, req: "CamcopsRequest", task: "Task") -> str:
973 """
974 Gets a substituted e-mail subject.
975 """
976 return self._get_processed_spec(
977 req=req,
978 task=task,
979 patient_spec_if_anonymous=self.email_patient_spec_if_anonymous,
980 patient_spec=self.email_patient_spec,
981 spec=self.email_subject,
982 treat_as_filename=False,
983 )
985 def get_email_body(self, req: "CamcopsRequest", task: "Task") -> str:
986 """
987 Gets a substituted e-mail body.
988 """
989 return self._get_processed_spec(
990 req=req,
991 task=task,
992 patient_spec_if_anonymous=self.email_patient_spec_if_anonymous,
993 patient_spec=self.email_patient_spec,
994 spec=self.email_body,
995 treat_as_filename=False,
996 )