Coverage for cc_modules/cc_exportrecipient.py : 61%

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**ExportRecipient class.**
29"""
31import logging
32from typing import List, Optional, TYPE_CHECKING
34from cardinal_pythonlib.logs import BraceStyleAdapter
35from cardinal_pythonlib.reprfunc import simple_repr
36from cardinal_pythonlib.sqlalchemy.list_types import (
37 IntListType,
38 StringListType,
39)
40from cardinal_pythonlib.sqlalchemy.orm_inspect import gen_columns
41from cardinal_pythonlib.sqlalchemy.session import get_safe_url_from_url
42from sqlalchemy.event.api import listens_for
43from sqlalchemy.orm import reconstructor, Session as SqlASession
44from sqlalchemy.sql.schema import Column
45from sqlalchemy.sql.sqltypes import (
46 BigInteger,
47 Boolean,
48 DateTime,
49 Integer,
50 Text,
51)
53from camcops_server.cc_modules.cc_exportrecipientinfo import (
54 ExportRecipientInfo,
55)
56from camcops_server.cc_modules.cc_simpleobjects import TaskExportOptions
57from camcops_server.cc_modules.cc_sqla_coltypes import (
58 EmailAddressColType,
59 ExportRecipientNameColType,
60 ExportTransmissionMethodColType,
61 FileSpecColType,
62 HostnameColType,
63 UrlColType,
64 UserNameExternalColType,
65)
66from camcops_server.cc_modules.cc_sqlalchemy import Base
68if TYPE_CHECKING:
69 from sqlalchemy.engine.base import Connection
70 from sqlalchemy.orm.mapper import Mapper
71 from camcops_server.cc_modules.cc_task import Task
73log = BraceStyleAdapter(logging.getLogger(__name__))
76# =============================================================================
77# ExportRecipient class
78# =============================================================================
80class ExportRecipient(ExportRecipientInfo, Base):
81 """
82 SQLAlchemy ORM class representing an export recipient.
84 This has a close relationship with (and inherits from)
85 :class:`camcops_server.cc_modules.cc_exportrecipientinfo.ExportRecipientInfo`
86 (q.v.).
88 Full details of parameters are in the docs for the config file.
89 """ # noqa
91 __tablename__ = "_export_recipients"
93 IGNORE_FOR_EQ_ATTRNAMES = ExportRecipientInfo.IGNORE_FOR_EQ_ATTRNAMES + [
94 # Attribute names to ignore for equality comparison
95 "id",
96 "current",
97 "group_names", # Python only
98 ]
99 NEEDS_RECOPYING_EACH_TIME_FROM_CONFIG_ATTRNAMES = [
100 "email_host_password",
101 "redcap_api_key",
102 ]
104 # -------------------------------------------------------------------------
105 # Identifying this object, and whether it's the "live" version
106 # -------------------------------------------------------------------------
107 id = Column(
108 "id", BigInteger,
109 primary_key=True, autoincrement=True, index=True,
110 comment="Export recipient ID (arbitrary primary key)"
111 )
112 recipient_name = Column(
113 "recipient_name", ExportRecipientNameColType, nullable=False,
114 comment="Name of export recipient"
115 )
116 current = Column(
117 "current", Boolean, default=False, nullable=False,
118 comment="Is this the current record for this recipient? (If not, it's "
119 "a historical record for audit purposes.)"
120 )
122 # -------------------------------------------------------------------------
123 # How to export
124 # -------------------------------------------------------------------------
125 transmission_method = Column(
126 "transmission_method", ExportTransmissionMethodColType, nullable=False,
127 comment="Export transmission method (e.g. hl7, file)"
128 )
129 push = Column(
130 "push", Boolean, default=False, nullable=False,
131 comment="Push (support auto-export on upload)?"
132 )
133 task_format = Column(
134 "task_format", ExportTransmissionMethodColType,
135 comment="Format that task information should be sent in (e.g. PDF), "
136 "if not predetermined by the transmission method"
137 )
138 xml_field_comments = Column(
139 "xml_field_comments", Boolean, default=True, nullable=False,
140 comment="Whether to include field comments in XML output"
141 )
143 # -------------------------------------------------------------------------
144 # What to export
145 # -------------------------------------------------------------------------
146 all_groups = Column(
147 "all_groups", Boolean, default=False, nullable=False,
148 comment="Export all groups? (If not, see group_ids.)"
149 )
150 group_ids = Column(
151 "group_ids", IntListType,
152 comment="Integer IDs of CamCOPS group to export data from (as CSV)"
153 )
154 tasks = Column(
155 "tasks", StringListType,
156 comment="Base table names of CamCOPS tasks to export data from "
157 "(as CSV)"
158 )
159 start_datetime_utc = Column(
160 "start_datetime_utc", DateTime,
161 comment="Start date/time for tasks (UTC)"
162 )
163 end_datetime_utc = Column(
164 "end_datetime_utc", DateTime,
165 comment="End date/time for tasks (UTC)"
166 )
167 finalized_only = Column(
168 "finalized_only", Boolean, default=True, nullable=False,
169 comment="Send only finalized tasks"
170 )
171 include_anonymous = Column(
172 "include_anonymous", Boolean, default=False, nullable=False,
173 comment="Include anonymous tasks? "
174 "Not applicable to some methods (e.g. HL7)"
175 )
176 primary_idnum = Column(
177 "primary_idnum", Integer, nullable=False,
178 comment="Which ID number is used as the primary ID?"
179 )
180 require_idnum_mandatory = Column(
181 "require_idnum_mandatory", Boolean,
182 comment="Must the primary ID number be mandatory in the relevant "
183 "policy?"
184 )
186 # -------------------------------------------------------------------------
187 # Database
188 # -------------------------------------------------------------------------
189 db_url = Column(
190 "db_url", UrlColType,
191 comment="(DATABASE) SQLAlchemy database URL for export"
192 )
193 db_echo = Column(
194 "db_echo", Boolean, default=False, nullable=False,
195 comment="(DATABASE) Echo SQL applied to destination database?"
196 )
197 db_include_blobs = Column(
198 "db_include_blobs", Boolean, default=True, nullable=False,
199 comment="(DATABASE) Include BLOBs?"
200 )
201 db_add_summaries = Column(
202 "db_add_summaries", Boolean, default=True, nullable=False,
203 comment="(DATABASE) Add summary information?"
204 )
205 db_patient_id_per_row = Column(
206 "db_patient_id_per_row", Boolean, default=True, nullable=False,
207 comment="(DATABASE) Add patient ID information per row?"
208 )
210 # -------------------------------------------------------------------------
211 # Email
212 # -------------------------------------------------------------------------
213 email_host = Column(
214 "email_host", HostnameColType,
215 comment="(EMAIL) E-mail (SMTP) server host name/IP address"
216 )
217 email_port = Column(
218 "email_port", Integer,
219 comment="(EMAIL) E-mail (SMTP) server port number"
220 )
221 email_use_tls = Column(
222 "email_use_tls", Boolean, default=True, nullable=False,
223 comment="(EMAIL) Use explicit TLS connection?"
224 )
225 email_host_username = Column(
226 "email_host_username", UserNameExternalColType,
227 comment="(EMAIL) Username on e-mail server"
228 )
229 # email_host_password: not stored in database
230 email_from = Column(
231 "email_from", EmailAddressColType,
232 comment='(EMAIL) "From:" address(es)'
233 )
234 email_sender = Column(
235 "email_sender", EmailAddressColType,
236 comment='(EMAIL) "Sender:" address(es)'
237 )
238 email_reply_to = Column(
239 "email_reply_to", EmailAddressColType,
240 comment='(EMAIL) "Reply-To:" address(es)'
241 )
242 email_to = Column(
243 "email_to", Text,
244 comment='(EMAIL) "To:" recipient(s), as a CSV list'
245 )
246 email_cc = Column(
247 "email_cc", Text,
248 comment='(EMAIL) "CC:" recipient(s), as a CSV list'
249 )
250 email_bcc = Column(
251 "email_bcc", Text,
252 comment='(EMAIL) "BCC:" recipient(s), as a CSV list'
253 )
254 email_patient_spec = Column(
255 "email_patient", FileSpecColType,
256 comment="(EMAIL) Patient specification"
257 )
258 email_patient_spec_if_anonymous = Column(
259 "email_patient_spec_if_anonymous", FileSpecColType,
260 comment="(EMAIL) Patient specification for anonymous tasks"
261 )
262 email_subject = Column(
263 "email_subject", FileSpecColType,
264 comment="(EMAIL) Subject specification"
265 )
266 email_body_as_html = Column(
267 "email_body_as_html", Boolean, default=False, nullable=False,
268 comment="(EMAIL) Is the body HTML, rather than plain text?"
269 )
270 email_body = Column(
271 "email_body", Text,
272 comment="(EMAIL) Body contents"
273 )
274 email_keep_message = Column(
275 "email_keep_message", Boolean, default=False, nullable=False,
276 comment="(EMAIL) Keep entire message?"
277 )
279 # -------------------------------------------------------------------------
280 # HL7
281 # -------------------------------------------------------------------------
282 hl7_host = Column(
283 "hl7_host", HostnameColType,
284 comment="(HL7) Destination host name/IP address"
285 )
286 hl7_port = Column(
287 "hl7_port", Integer,
288 comment="(HL7) Destination port number"
289 )
290 hl7_ping_first = Column(
291 "hl7_ping_first", Boolean, default=False, nullable=False,
292 comment="(HL7) Ping via TCP/IP before sending HL7 messages?"
293 )
294 hl7_network_timeout_ms = Column(
295 "hl7_network_timeout_ms", Integer,
296 comment="(HL7) Network timeout (ms)."
297 )
298 hl7_keep_message = Column(
299 "hl7_keep_message", Boolean, default=False, nullable=False,
300 comment="(HL7) Keep copy of message in database? (May be large!)"
301 )
302 hl7_keep_reply = Column(
303 "hl7_keep_reply", Boolean, default=False, nullable=False,
304 comment="(HL7) Keep copy of server's reply in database?"
305 )
306 hl7_debug_divert_to_file = Column(
307 "hl7_debug_divert_to_file", Boolean, default=False, nullable=False,
308 comment="(HL7 debugging option) Divert messages to files?"
309 )
310 hl7_debug_treat_diverted_as_sent = Column(
311 "hl7_debug_treat_diverted_as_sent", Boolean,
312 default=False, nullable=False,
313 comment="(HL7 debugging option) Treat messages diverted to file as sent" # noqa
314 )
316 # -------------------------------------------------------------------------
317 # File
318 # -------------------------------------------------------------------------
319 file_patient_spec = Column(
320 "file_patient_spec", FileSpecColType,
321 comment="(FILE) Patient part of filename specification"
322 )
323 file_patient_spec_if_anonymous = Column(
324 "file_patient_spec_if_anonymous", FileSpecColType,
325 comment="(FILE) Patient part of filename specification for anonymous tasks" # noqa
326 )
327 file_filename_spec = Column(
328 "file_filename_spec", FileSpecColType,
329 comment="(FILE) Filename specification"
330 )
331 file_make_directory = Column(
332 "file_make_directory", Boolean, default=True, nullable=False,
333 comment="(FILE) Make destination directory if it doesn't already exist"
334 )
335 file_overwrite_files = Column(
336 "file_overwrite_files", Boolean, default=False, nullable=False,
337 comment="(FILE) Overwrite existing files"
338 )
339 file_export_rio_metadata = Column(
340 "file_export_rio_metadata", Boolean, default=False, nullable=False,
341 comment="(FILE) Export RiO metadata file along with main file?"
342 )
343 file_script_after_export = Column(
344 "file_script_after_export", Text,
345 comment="(FILE) Command/script to run after file export"
346 )
348 # -------------------------------------------------------------------------
349 # File/RiO
350 # -------------------------------------------------------------------------
351 rio_idnum = Column(
352 "rio_idnum", Integer,
353 comment="(FILE / RiO) RiO metadata: which ID number is the RiO ID?"
354 )
355 rio_uploading_user = Column(
356 "rio_uploading_user", Text,
357 comment="(FILE / RiO) RiO metadata: name of automatic upload user"
358 )
359 rio_document_type = Column(
360 "rio_document_type", Text,
361 comment="(FILE / RiO) RiO metadata: document type for RiO"
362 )
364 # -------------------------------------------------------------------------
365 # REDCap export
366 # -------------------------------------------------------------------------
367 redcap_api_url = Column(
368 "redcap_api_url", Text,
369 comment="(REDCap) REDCap API URL, pointing to the REDCap server"
370 )
371 redcap_fieldmap_filename = Column(
372 "redcap_fieldmap_filename", Text,
373 comment="(REDCap) File defining CamCOPS-to-REDCap field mapping"
374 )
376 # -------------------------------------------------------------------------
377 # FHIR export
378 # -------------------------------------------------------------------------
379 fhir_api_url = Column(
380 "fhir_api_url", Text,
381 comment="(FHIR) FHIR API URL, pointing to the FHIR server"
382 )
384 def __init__(self, *args, **kwargs) -> None:
385 """
386 Creates a blank :class:`ExportRecipient` object.
388 NB not called when SQLAlchemy objects loaded from database; see
389 :meth:`init_on_load` instead.
390 """
391 super().__init__(*args, **kwargs)
393 def __hash__(self) -> int:
394 """
395 Used by the ``merge_db`` function, and specifically the old-to-new map
396 maintained by :func:`cardinal_pythonlib.sqlalchemy.merge_db.merge_db`.
397 """
398 return hash(f"{self.id}_{self.recipient_name}")
400 @reconstructor
401 def init_on_load(self) -> None:
402 """
403 Called when SQLAlchemy recreates an object; see
404 https://docs.sqlalchemy.org/en/latest/orm/constructors.html.
405 """
406 # Python only:
407 self.group_names = [] # type: List[str]
408 self.email_host_password = ""
409 self.fhir_app_secret = ""
410 self.fhir_launch_token = ""
411 self.redcap_api_key = ""
413 def get_attrnames(self) -> List[str]:
414 """
415 Returns all relevant attribute names.
416 """
417 attrnames = set([attrname for attrname, _ in gen_columns(self)])
418 attrnames.update(key for key in self.__dict__ if not key.startswith('_')) # noqa
419 return sorted(attrnames)
421 def __repr__(self) -> str:
422 return simple_repr(self, self.get_attrnames())
424 def is_upload_suitable_for_push(self, tablename: str,
425 uploading_group_id: int) -> bool:
426 """
427 Might an upload potentially give tasks to be "pushed"?
429 Called by
430 :func:`camcops_server.cc_modules.cc_client_api_core.get_task_push_export_pks`.
432 Args:
433 tablename: table name being uploaded
434 uploading_group_id: group ID if the uploading user
436 Returns:
437 whether this upload should be considered further
438 """
439 if not self.push:
440 # Not a push export recipient
441 return False
442 if self.tasks and tablename not in self.tasks:
443 # Recipient is restricted to tasks that don't include the table
444 # being uploaded (or, the table is a subtable that we don't care
445 # about)
446 return False
447 if not self.all_groups:
448 # Recipient is restricted to specific groups
449 if uploading_group_id not in self.group_ids:
450 # Wrong group!
451 return False
452 return True
454 def is_task_suitable(self, task: "Task") -> bool:
455 """
456 Used as a double-check that a task remains suitable.
458 Args:
459 task: a :class:`camcops_server.cc_modules.cc_task.Task`
461 Returns:
462 bool: is the task suitable for this recipient?
463 """
464 def _warn(reason: str) -> None:
465 log.info("For recipient {}, task {!r} is unsuitable: {}",
466 self, task, reason)
467 # Not a warning, actually; it's normal to see these because it
468 # allows the client API to skip some checks for speed.
470 if self.tasks and task.tablename not in self.tasks:
471 _warn(f"Task type {task.tablename!r} not included")
472 return False
474 if not self.all_groups:
475 task_group_id = task.group_id
476 if task_group_id not in self.group_ids:
477 _warn(f"group_id {task_group_id} not permitted")
478 return False
480 if not self.include_anonymous and task.is_anonymous:
481 _warn("task is anonymous")
482 return False
484 if self.finalized_only and not task.is_preserved():
485 _warn("task not finalized")
486 return False
488 if self.start_datetime_utc or self.end_datetime_utc:
489 task_dt = task.get_creation_datetime_utc_tz_unaware()
490 if self.start_datetime_utc and task_dt < self.start_datetime_utc:
491 _warn("task created before recipient start_datetime_utc")
492 return False
493 if self.end_datetime_utc and task_dt >= self.end_datetime_utc:
494 _warn("task created at/after recipient end_datetime_utc")
495 return False
497 if (not task.is_anonymous and
498 self.primary_idnum is not None):
499 patient = task.patient
500 if not patient:
501 _warn("missing patient")
502 return False
503 if not patient.has_idnum_type(self.primary_idnum):
504 _warn(f"task's patient is missing ID number type "
505 f"{self.primary_idnum}")
506 return False
508 return True
510 @classmethod
511 def get_existing_matching_recipient(cls,
512 dbsession: SqlASession,
513 recipient: "ExportRecipient") \
514 -> Optional["ExportRecipient"]:
515 """
516 Retrieves an active instance from the database that matches ``other``,
517 if there is one.
519 Args:
520 dbsession: a :class:`sqlalchemy.orm.session.Session`
521 recipient: an :class:`ExportRecipient`
523 Returns:
524 a database instance of :class:`ExportRecipient` that matches, or
525 ``None``.
526 """
527 # noinspection PyPep8
528 q = dbsession.query(cls).filter(
529 cls.recipient_name == recipient.recipient_name,
530 cls.current == True) # noqa: E712
531 results = q.all()
532 if len(results) > 1:
533 raise ValueError(
534 "Database has gone wrong: more than one active record for "
535 "{t}.{c} = {r}".format(
536 t=cls.__tablename__,
537 c=cls.recipient_name.name, # column name from Column
538 r=recipient.recipient_name,
539 )
540 )
541 if results:
542 r = results[0]
543 if recipient == r:
544 return r
545 return None
547 @property
548 def db_url_obscuring_password(self) -> Optional[str]:
549 """
550 Returns the database URL (if present), but with its password obscured.
551 """
552 if not self.db_url:
553 return self.db_url
554 return get_safe_url_from_url(self.db_url)
556 def get_task_export_options(self) -> TaskExportOptions:
557 return TaskExportOptions(
558 xml_include_comments=self.xml_field_comments,
559 xml_with_header_comments=self.xml_field_comments,
560 )
563# noinspection PyUnusedLocal
564@listens_for(ExportRecipient, "after_insert")
565@listens_for(ExportRecipient, "after_update")
566def _check_current(mapper: "Mapper",
567 connection: "Connection",
568 target: ExportRecipient) -> None:
569 """
570 Ensures that only one :class:`ExportRecipient` is marked as ``current``
571 per ``recipient_name``.
573 As per
574 https://stackoverflow.com/questions/6269469/mark-a-single-row-in-a-table-in-sqlalchemy.
575 """ # noqa
576 if target.current:
577 # noinspection PyUnresolvedReferences
578 connection.execute(
579 ExportRecipient.__table__.update()
580 .values(current=False)
581 .where(ExportRecipient.recipient_name == target.recipient_name)
582 .where(ExportRecipient.id != target.id)
583 )