Hide keyboard shortcuts

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 

2 

3""" 

4camcops_server/cc_modules/cc_exportrecipient.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

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. 

16 

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. 

21 

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/>. 

24 

25=============================================================================== 

26 

27**ExportRecipient class.** 

28 

29""" 

30 

31import logging 

32from typing import List, Optional, TYPE_CHECKING 

33 

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) 

52 

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 

67 

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 

72 

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

74 

75 

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

77# ExportRecipient class 

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

79 

80class ExportRecipient(ExportRecipientInfo, Base): 

81 """ 

82 SQLAlchemy ORM class representing an export recipient. 

83 

84 This has a close relationship with (and inherits from) 

85 :class:`camcops_server.cc_modules.cc_exportrecipientinfo.ExportRecipientInfo` 

86 (q.v.). 

87 

88 Full details of parameters are in the docs for the config file. 

89 """ # noqa 

90 

91 __tablename__ = "_export_recipients" 

92 

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 ] 

103 

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 ) 

121 

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 ) 

142 

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 ) 

185 

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 ) 

209 

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 ) 

278 

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 ) 

315 

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 ) 

347 

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 ) 

363 

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 ) 

375 

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 ) 

383 

384 def __init__(self, *args, **kwargs) -> None: 

385 """ 

386 Creates a blank :class:`ExportRecipient` object. 

387 

388 NB not called when SQLAlchemy objects loaded from database; see 

389 :meth:`init_on_load` instead. 

390 """ 

391 super().__init__(*args, **kwargs) 

392 

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}") 

399 

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 = "" 

412 

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) 

420 

421 def __repr__(self) -> str: 

422 return simple_repr(self, self.get_attrnames()) 

423 

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"? 

428 

429 Called by 

430 :func:`camcops_server.cc_modules.cc_client_api_core.get_task_push_export_pks`. 

431 

432 Args: 

433 tablename: table name being uploaded 

434 uploading_group_id: group ID if the uploading user 

435 

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 

453 

454 def is_task_suitable(self, task: "Task") -> bool: 

455 """ 

456 Used as a double-check that a task remains suitable. 

457 

458 Args: 

459 task: a :class:`camcops_server.cc_modules.cc_task.Task` 

460 

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. 

469 

470 if self.tasks and task.tablename not in self.tasks: 

471 _warn(f"Task type {task.tablename!r} not included") 

472 return False 

473 

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 

479 

480 if not self.include_anonymous and task.is_anonymous: 

481 _warn("task is anonymous") 

482 return False 

483 

484 if self.finalized_only and not task.is_preserved(): 

485 _warn("task not finalized") 

486 return False 

487 

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 

496 

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 

507 

508 return True 

509 

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. 

518 

519 Args: 

520 dbsession: a :class:`sqlalchemy.orm.session.Session` 

521 recipient: an :class:`ExportRecipient` 

522 

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 

546 

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) 

555 

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 ) 

561 

562 

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``. 

572 

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 )