Coverage for cc_modules/cc_redcap.py: 28%

265 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3"""camcops_server/cc_modules/cc_redcap.py 

4 

5=============================================================================== 

6 

7 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

8 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

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**Implements communication with REDCap.** 

28 

29- For general information about REDCap, see https://www.project-redcap.org/. 

30 

31- The API documentation is not provided there, but is available from 

32 your local REDCap server. Pick a project. Choose "API" from the left-hand 

33 menu. Follow the "REDCap API documentation" link. 

34 

35- We use PyCap (https://pycap.readthedocs.io/ or 

36 https://github.com/redcap-tools/PyCap). See also 

37 https://redcap-tools.github.io/projects/. PyCap is no longer being actively 

38 developed though the author is still responding to issues and pull requests. 

39 

40We use an XML fieldmap to describe how the rows in CamCOPS task tables are 

41translated into REDCap records. See :ref:`REDCap export <redcap>`. 

42 

43REDCap does not assign instance IDs for repeating instruments so we need to 

44query the database in order to determine the next instance ID. It is possible 

45to create a race condition if more than one client is trying to update the same 

46record at the same time. 

47 

48""" 

49 

50from enum import Enum 

51import io 

52import logging 

53from typing import Any, Dict, Iterable, List, Optional, TYPE_CHECKING, Union 

54import xml.etree.cElementTree as ElementTree 

55 

56from asteval import Interpreter, make_symbol_table 

57from cardinal_pythonlib.datetimefunc import format_datetime 

58from cardinal_pythonlib.logs import BraceStyleAdapter 

59from pandas import DataFrame 

60from pandas.errors import EmptyDataError 

61import redcap 

62 

63from camcops_server.cc_modules.cc_constants import ( 

64 ConfigParamExportRecipient, 

65 DateFormat, 

66) 

67from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient 

68 

69if TYPE_CHECKING: 

70 from camcops_server.cc_modules.cc_exportmodels import ExportedTaskRedcap 

71 from camcops_server.cc_modules.cc_request import CamcopsRequest 

72 from camcops_server.cc_modules.cc_task import Task 

73 

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

75 

76MISSING_EVENT_TAG_OR_ATTRIBUTE = ( 

77 "The REDCap project has events but there is no 'event' tag " 

78 "in the fieldmap or an instrument is missing an 'event' " 

79 "attribute" 

80) 

81 

82 

83class RedcapExportException(Exception): 

84 pass 

85 

86 

87class RedcapFieldmap(object): 

88 """ 

89 Internal representation of the fieldmap XML file. 

90 This describes how the task fields should be translated to 

91 the REDCap record. 

92 """ 

93 

94 def __init__(self, filename: str) -> None: 

95 """ 

96 Args: 

97 filename: 

98 Name of an XML file telling CamCOPS how to map task fields 

99 to REDCap. See :ref:`REDCap export <redcap>`. 

100 """ 

101 self.filename = filename 

102 self.fields = {} # type: Dict[str, Dict[str, str]] 

103 # ... {task: {name: formula}} 

104 self.files = {} # type: Dict[str, Dict[str, str]] 

105 # ... {task: {name: formula}} 

106 self.instruments = {} # type: Dict[str, str] 

107 # ... {task: instrument_name} 

108 self.events = {} # type: Dict[str, str] 

109 # ... {task: event_name} 

110 

111 parser = ElementTree.XMLParser(encoding="UTF-8") 

112 try: 

113 tree = ElementTree.parse(filename, parser=parser) 

114 except FileNotFoundError: 

115 raise RedcapExportException( 

116 f"Unable to open fieldmap file '{filename}'" 

117 ) 

118 except ElementTree.ParseError as e: 

119 raise RedcapExportException( 

120 f"There was a problem parsing {filename}: {str(e)}" 

121 ) from e 

122 

123 root = tree.getroot() 

124 if root.tag != "fieldmap": 

125 raise RedcapExportException( 

126 ( 

127 f"Expected the root tag to be 'fieldmap' instead of " 

128 f"'{root.tag}' in {filename}" 

129 ) 

130 ) 

131 

132 patient_element = root.find("patient") 

133 if patient_element is None: 

134 raise RedcapExportException( 

135 f"'patient' is missing from {filename}" 

136 ) 

137 

138 self.patient = self._validate_and_return_attributes( 

139 patient_element, ("instrument", "redcap_field") 

140 ) 

141 

142 record_element = root.find("record") 

143 if record_element is None: 

144 raise RedcapExportException(f"'record' is missing from {filename}") 

145 

146 self.record = self._validate_and_return_attributes( 

147 record_element, ("instrument", "redcap_field") 

148 ) 

149 

150 default_event = None 

151 event_element = root.find("event") 

152 if event_element is not None: 

153 event_attributes = self._validate_and_return_attributes( 

154 event_element, ("name",) 

155 ) 

156 default_event = event_attributes["name"] 

157 

158 instrument_elements = root.find("instruments") 

159 if instrument_elements is None: 

160 raise RedcapExportException( 

161 f"'instruments' tag is missing from {filename}" 

162 ) 

163 

164 for instrument_element in instrument_elements: 

165 instrument_attributes = self._validate_and_return_attributes( 

166 instrument_element, ("name", "task") 

167 ) 

168 

169 task = instrument_attributes["task"] 

170 instrument_name = instrument_attributes["name"] 

171 self.fields[task] = {} 

172 self.files[task] = {} 

173 self.events[task] = instrument_attributes.get( 

174 "event", default_event 

175 ) 

176 self.instruments[task] = instrument_name 

177 

178 field_elements = instrument_element.find("fields") or [] 

179 

180 for field_element in field_elements: 

181 field_attributes = self._validate_and_return_attributes( 

182 field_element, ("name", "formula") 

183 ) 

184 name = field_attributes["name"] 

185 formula = field_attributes["formula"] 

186 

187 self.fields[task][name] = formula 

188 

189 file_elements = instrument_element.find("files") or [] 

190 for file_element in file_elements: 

191 file_attributes = self._validate_and_return_attributes( 

192 file_element, ("name", "formula") 

193 ) 

194 

195 name = file_attributes["name"] 

196 formula = file_attributes["formula"] 

197 self.files[task][name] = formula 

198 

199 def _validate_and_return_attributes( 

200 self, element: ElementTree.Element, expected_attributes: Iterable[str] 

201 ) -> Dict[str, str]: 

202 """ 

203 Checks that all the expected attributes are present in the XML element 

204 (from the fieldmap XML file), or raises :exc:`RedcapExportException`. 

205 """ 

206 attributes = element.attrib 

207 

208 if not all(a in attributes.keys() for a in expected_attributes): 

209 raise RedcapExportException( 

210 ( 

211 f"'{element.tag}' must have attributes: " 

212 f"{', '.join(expected_attributes)} in {self.filename}" 

213 ) 

214 ) 

215 

216 return attributes 

217 

218 def instrument_names(self) -> List[str]: 

219 """ 

220 Returns the names of all REDCap instruments. 

221 """ 

222 return list(self.instruments.values()) 

223 

224 

225class RedcapTaskExporter(object): 

226 """ 

227 Main entry point for task export to REDCap. Works out which record needs 

228 updating or creating. Creates the fieldmap and initiates upload. 

229 """ 

230 

231 def export_task( 

232 self, req: "CamcopsRequest", exported_task_redcap: "ExportedTaskRedcap" 

233 ) -> None: 

234 """ 

235 Exports a specific task. 

236 

237 Args: 

238 req: 

239 a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

240 exported_task_redcap: 

241 a :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskRedcap` 

242 """ # noqa 

243 exported_task = exported_task_redcap.exported_task 

244 recipient = exported_task.recipient 

245 task = exported_task.task 

246 

247 if task.is_anonymous: 

248 raise RedcapExportException( 

249 f"Skipping anonymous task '{task.tablename}'" 

250 ) 

251 

252 which_idnum = recipient.primary_idnum 

253 idnum_object = task.patient.get_idnum_object(which_idnum) 

254 

255 project = self.get_project(recipient) 

256 fieldmap = self.get_fieldmap(recipient) 

257 

258 if project.is_longitudinal(): 

259 if not all(fieldmap.events.values()): 

260 raise RedcapExportException(MISSING_EVENT_TAG_OR_ATTRIBUTE) 

261 

262 existing_records = self._get_existing_records(project, fieldmap) 

263 existing_record_id = self._get_existing_record_id( 

264 existing_records, fieldmap, idnum_object.idnum_value 

265 ) 

266 

267 if existing_record_id is None: 

268 uploader_class = RedcapNewRecordUploader 

269 else: 

270 uploader_class = RedcapUpdatedRecordUploader 

271 

272 try: 

273 instrument_name = fieldmap.instruments[task.tablename] 

274 except KeyError: 

275 raise RedcapExportException( 

276 ( 

277 f"Instrument for task '{task.tablename}' is missing from " 

278 f"the fieldmap" 

279 ) 

280 ) 

281 

282 record_id_fieldname = fieldmap.record["redcap_field"] 

283 

284 next_instance_id = self._get_next_instance_id( 

285 existing_records, 

286 instrument_name, 

287 record_id_fieldname, 

288 existing_record_id, 

289 ) 

290 

291 uploader = uploader_class(req, project) 

292 

293 new_record_id = uploader.upload( 

294 task, 

295 existing_record_id, 

296 next_instance_id, 

297 fieldmap, 

298 idnum_object.idnum_value, 

299 ) 

300 

301 exported_task_redcap.redcap_record_id = new_record_id 

302 exported_task_redcap.redcap_instrument_name = instrument_name 

303 exported_task_redcap.redcap_instance_id = next_instance_id 

304 

305 @staticmethod 

306 def _get_existing_records( 

307 project: redcap.project.Project, fieldmap: RedcapFieldmap 

308 ) -> "DataFrame": 

309 """ 

310 Returns a Pandas data frame containing existing REDCap records for this 

311 project, for instruments we are interested in. 

312 

313 Args: 

314 project: 

315 a :class:`redcap.project.Project` 

316 fieldmap: 

317 a :class:`RedcapFieldmap` 

318 """ 

319 # Arguments to pandas read_csv() 

320 

321 type_dict = { 

322 # otherwise pandas may infer as int or str 

323 fieldmap.record["redcap_field"]: str 

324 } 

325 

326 df_kwargs = { 

327 "index_col": None, # don't index by record_id 

328 "dtype": type_dict, 

329 } 

330 

331 forms = ( 

332 fieldmap.instrument_names() 

333 + [fieldmap.patient["instrument"]] 

334 + [fieldmap.record["instrument"]] 

335 ) 

336 

337 try: 

338 records = project.export_records( 

339 format="df", forms=forms, df_kwargs=df_kwargs 

340 ) 

341 except EmptyDataError: 

342 # Should not happen, but in case of PyCap failing to catch this... 

343 return DataFrame() 

344 except redcap.RedcapError as e: 

345 raise RedcapExportException(str(e)) 

346 

347 return records 

348 

349 @staticmethod 

350 def _get_existing_record_id( 

351 records: "DataFrame", fieldmap: RedcapFieldmap, idnum_value: int 

352 ) -> Optional[str]: 

353 """ 

354 Returns the ID of an existing record that matches a specific 

355 patient, if one can be found. 

356 

357 Args: 

358 records: 

359 records retrieved from REDCap; Pandas data frame from 

360 :meth:`_get_existing_records` 

361 fieldmap: 

362 :class:`RedcapFieldmap` 

363 idnum_value: 

364 CamCOPS patient ID number 

365 

366 Returns: 

367 REDCap record ID or ``None`` 

368 """ 

369 

370 if records.empty: 

371 return None 

372 

373 patient_id_fieldname = fieldmap.patient["redcap_field"] 

374 

375 if patient_id_fieldname not in records: 

376 raise RedcapExportException( 

377 ( 

378 f"Field '{patient_id_fieldname}' does not exist in " 

379 f"REDCap. Is the 'patient' tag in the fieldmap correct?" 

380 ) 

381 ) 

382 

383 with_identifier = records[patient_id_fieldname] == idnum_value 

384 

385 if len(records[with_identifier]) == 0: 

386 return None 

387 

388 return records[with_identifier].iat[0, 0] 

389 

390 @staticmethod 

391 def _get_next_instance_id( 

392 records: "DataFrame", 

393 instrument: str, 

394 record_id_fieldname: str, 

395 existing_record_id: Optional[str], 

396 ) -> int: 

397 """ 

398 Returns the next REDCap record ID to use for a particular instrument, 

399 including for a repeating instrument (the previous highest ID plus 1, 

400 or 1 if none can be found). 

401 

402 Args: 

403 records: 

404 records retrieved from REDCap; Pandas data frame from 

405 :meth:`_get_existing_records` 

406 instrument: 

407 instrument name 

408 existing_record_id: 

409 ID of existing record 

410 """ 

411 if existing_record_id is None: 

412 return 1 

413 

414 if record_id_fieldname not in records: 

415 raise RedcapExportException( 

416 ( 

417 f"Field '{record_id_fieldname}' does not exist in REDCap. " 

418 f"Is the 'record' tag in the fieldmap correct?" 

419 ) 

420 ) 

421 

422 previous_instances = records[ 

423 (records["redcap_repeat_instrument"] == instrument) 

424 & (records[record_id_fieldname] == existing_record_id) 

425 ] 

426 

427 if len(previous_instances) == 0: 

428 return 1 

429 

430 return int(previous_instances.max()["redcap_repeat_instance"] + 1) 

431 

432 def get_fieldmap(self, recipient: ExportRecipient) -> RedcapFieldmap: 

433 """ 

434 Returns the relevant :class:`RedcapFieldmap`. 

435 

436 Args: 

437 recipient: 

438 an 

439 :class:`camcops_server.cc_modules.cc_exportmodels.ExportRecipient` 

440 """ # noqa 

441 fieldmap = RedcapFieldmap(self.get_fieldmap_filename(recipient)) 

442 

443 return fieldmap 

444 

445 @staticmethod 

446 def get_fieldmap_filename(recipient: ExportRecipient) -> str: 

447 """ 

448 Returns the name of the XML file containing our fieldmap details, or 

449 raises :exc:`RedcapExportException`. 

450 

451 Args: 

452 recipient: 

453 an 

454 :class:`camcops_server.cc_modules.cc_exportmodels.ExportRecipient` 

455 """ # noqa 

456 filename = recipient.redcap_fieldmap_filename 

457 if filename is None: 

458 raise RedcapExportException( 

459 f"{ConfigParamExportRecipient.REDCAP_FIELDMAP_FILENAME} " 

460 f"is not set in the config file" 

461 ) 

462 

463 if filename == "": 

464 raise RedcapExportException( 

465 f"{ConfigParamExportRecipient.REDCAP_FIELDMAP_FILENAME} " 

466 f"is empty in the config file" 

467 ) 

468 

469 return filename 

470 

471 @staticmethod 

472 def get_project(recipient: ExportRecipient) -> redcap.project.Project: 

473 """ 

474 Returns the :class:`redcap.project.Project`. 

475 

476 Args: 

477 recipient: 

478 an 

479 :class:`camcops_server.cc_modules.cc_exportmodels.ExportRecipient` 

480 """ 

481 try: 

482 project = redcap.project.Project( 

483 recipient.redcap_api_url, recipient.redcap_api_key 

484 ) 

485 except redcap.RedcapError as e: 

486 raise RedcapExportException(str(e)) 

487 

488 return project 

489 

490 

491class RedcapRecordStatus(Enum): 

492 """ 

493 Corresponds to valid values of Form Status -> Complete? field in REDCap 

494 """ 

495 

496 INCOMPLETE = 0 

497 UNVERIFIED = 1 

498 COMPLETE = 2 

499 

500 

501class RedcapUploader(object): 

502 """ 

503 Uploads records and files into REDCap, transforming the fields via the 

504 fieldmap. 

505 

506 Abstract base class. 

507 

508 Knows nothing about ExportedTaskRedcap, ExportedTask, ExportRecipient 

509 """ 

510 

511 def __init__( 

512 self, req: "CamcopsRequest", project: "redcap.project.Project" 

513 ) -> None: 

514 """ 

515 

516 Args: 

517 req: 

518 a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

519 project: 

520 a :class:`redcap.project.Project` 

521 """ 

522 self.req = req 

523 self.project = project 

524 self.project_info = project.export_project_info() 

525 

526 def get_record_id(self, existing_record_id: Optional[str]) -> str: 

527 """ 

528 Returns the REDCap record ID to use. 

529 

530 Args: 

531 existing_record_id: highest existing record ID, if known 

532 """ 

533 raise NotImplementedError("implement in subclass") 

534 

535 @property 

536 def return_content(self) -> str: 

537 """ 

538 The ``return_content`` argument to be passed to 

539 :meth:`redcap.project.Project.import_records`. Can be: 

540 

541 - ``count`` [default] - the number of records imported 

542 - ``ids`` - a list of all record IDs that were imported 

543 - ``auto_ids`` = (used only when ``forceAutoNumber=true``) a list of 

544 pairs of all record IDs that were imported, includes the new ID 

545 created and the ID value that was sent in the API request 

546 (e.g., 323,10). 

547 

548 Note (2020-01-27) that it can return e.g. ``15-30,0``, i.e. the ID 

549 values can be non-integer. 

550 """ 

551 raise NotImplementedError("implement in subclass") 

552 

553 @property 

554 def force_auto_number(self) -> bool: 

555 """ 

556 Should we force auto-numbering of records in REDCap? 

557 """ 

558 raise NotImplementedError("implement in subclass") 

559 

560 def get_new_record_id(self, record_id: str, response: List[str]) -> str: 

561 """ 

562 Returns the ID of the new (or updated) REDCap record. 

563 

564 Args: 

565 record_id: 

566 existing record ID 

567 response: 

568 response from :meth:`redcap.project.Project.import_records` 

569 """ 

570 raise NotImplementedError("implement in subclass") 

571 

572 @staticmethod 

573 def log_success(record_id: str) -> None: 

574 """ 

575 Report upload success to the Python log. 

576 

577 Args: 

578 record_id: REDCap record ID 

579 """ 

580 raise NotImplementedError("implement in subclass") 

581 

582 @property 

583 def autonumbering_enabled(self) -> bool: 

584 """ 

585 Does this REDCap project have record autonumbering enabled? 

586 """ 

587 return self.project_info["record_autonumbering_enabled"] 

588 

589 def upload( 

590 self, 

591 task: "Task", 

592 existing_record_id: Optional[str], 

593 next_instance_id: int, 

594 fieldmap: RedcapFieldmap, 

595 idnum_value: int, 

596 ) -> str: 

597 """ 

598 Uploads a CamCOPS task to REDCap. 

599 

600 Args: 

601 task: 

602 :class:`camcops_server.cc_modules.cc_task.Task` to be uploaded 

603 existing_record_id: 

604 REDCap ID of the existing record, if there is one 

605 next_instance_id: 

606 REDCap instance ID to be used for a repeating instrument 

607 fieldmap: 

608 :class:`RedcapFieldmap` 

609 idnum_value: 

610 CamCOPS patient ID number 

611 

612 Returns: 

613 str: REDCap record ID of the record that was created or updated 

614 

615 """ 

616 complete_status = RedcapRecordStatus.INCOMPLETE 

617 

618 if task.is_complete(): 

619 complete_status = RedcapRecordStatus.COMPLETE 

620 instrument_name = fieldmap.instruments[task.tablename] 

621 record_id_fieldname = fieldmap.record["redcap_field"] 

622 

623 record_id = self.get_record_id(existing_record_id) 

624 

625 record = { 

626 record_id_fieldname: record_id, 

627 "redcap_repeat_instrument": instrument_name, 

628 # https://community.projectredcap.org/questions/74561/unexpected-behaviour-with-import-records-repeat-in.html # noqa 

629 # REDCap won't create instance IDs automatically so we have to 

630 # assume no one else is writing to this record 

631 "redcap_repeat_instance": next_instance_id, 

632 f"{instrument_name}_complete": complete_status.value, 

633 "redcap_event_name": fieldmap.events[task.tablename], 

634 } 

635 

636 self.transform_fields(record, task, fieldmap.fields[task.tablename]) 

637 

638 import_kwargs = { 

639 "return_content": self.return_content, 

640 "force_auto_number": self.force_auto_number, 

641 } 

642 

643 response = self.upload_record(record, **import_kwargs) 

644 

645 new_record_id = self.get_new_record_id(record_id, response) 

646 

647 # We don't mark the patient record as complete - it could be part of 

648 # a larger form. We don't require it to be complete. 

649 patient_record = { 

650 record_id_fieldname: new_record_id, 

651 fieldmap.patient["redcap_field"]: idnum_value, 

652 } 

653 self.upload_record(patient_record) 

654 

655 file_dict = {} 

656 self.transform_fields(file_dict, task, fieldmap.files[task.tablename]) 

657 

658 self.upload_files( 

659 task, 

660 new_record_id, 

661 next_instance_id, 

662 file_dict, 

663 event=fieldmap.events[task.tablename], 

664 ) 

665 

666 self.log_success(new_record_id) 

667 

668 return new_record_id 

669 

670 def upload_record( 

671 self, record: Dict[str, Any], **kwargs 

672 ) -> Union[Dict, List, str]: 

673 """ 

674 Uploads a REDCap record via the pycap 

675 :func:`redcap.project.Project.import_record` function. Returns its 

676 response. 

677 """ 

678 try: 

679 response = self.project.import_records([record], **kwargs) 

680 except redcap.RedcapError as e: 

681 raise RedcapExportException(str(e)) 

682 

683 return response 

684 

685 def upload_files( 

686 self, 

687 task: "Task", 

688 record_id: Union[int, str], 

689 repeat_instance: int, 

690 file_dict: Dict[str, bytes], 

691 event: Optional[str] = None, 

692 ) -> None: 

693 """ 

694 Uploads files attached to a task (e.g. a PDF of the CamCOPS task). 

695 

696 Args: 

697 task: 

698 the :class:`camcops_server.cc_modules.cc_task.Task` 

699 record_id: 

700 the REDCap record ID 

701 repeat_instance: 

702 instance number for repeating instruments 

703 file_dict: 

704 dictionary mapping filename to file contents 

705 event: 

706 for longitudinal projects, specify the unique event here 

707 

708 Raises: 

709 :exc:`RedcapExportException` 

710 """ 

711 for fieldname, value in file_dict.items(): 

712 with io.BytesIO(value) as file_obj: 

713 filename = f"{task.tablename}_{record_id}_{fieldname}" 

714 

715 try: 

716 self.project.import_file( 

717 record_id, 

718 fieldname, 

719 filename, 

720 file_obj, 

721 event=event, 

722 repeat_instance=repeat_instance, 

723 ) 

724 # ValueError if the field does not exist or is not 

725 # a file field 

726 except (redcap.RedcapError, ValueError) as e: 

727 raise RedcapExportException(str(e)) 

728 

729 def transform_fields( 

730 self, 

731 field_dict: Dict[str, Any], 

732 task: "Task", 

733 formula_dict: Dict[str, str], 

734 ) -> None: 

735 """ 

736 Uses the definitions from the fieldmap XML to set up field values to be 

737 exported to REDCap. 

738 

739 Args: 

740 field_dict: 

741 Exported field values go here (the dictionary is modified). 

742 task: 

743 the :class:`camcops_server.cc_modules.cc_task.Task` 

744 formula_dict: 

745 dictionary (from the XML information) mapping REDCap field 

746 name to a "formula". The formula is applied to extract data 

747 from the task in a flexible way. 

748 """ 

749 extra_symbols = self.get_extra_symbols() 

750 

751 symbol_table = make_symbol_table(task=task, **extra_symbols) 

752 interpreter = Interpreter(symtable=symbol_table) 

753 

754 for redcap_field, formula in formula_dict.items(): 

755 v = interpreter(f"{formula}", show_errors=True) 

756 if interpreter.error: 

757 message = "\n".join([e.msg for e in interpreter.error]) 

758 raise RedcapExportException( 

759 ( 

760 f"Fieldmap:\n" 

761 f"Error in formula '{formula}': {message}\n" 

762 f"Task: '{task.tablename}'\n" 

763 f"REDCap field: '{redcap_field}'\n" 

764 ) 

765 ) 

766 field_dict[redcap_field] = v 

767 

768 def get_extra_symbols(self) -> Dict[str, Any]: 

769 """ 

770 Returns a dictionary made available to the ``asteval`` interpreter. 

771 These become variables that the system administrator can refer to in 

772 their fieldmap XML; see :ref:`REDCap export <redcap>`. 

773 """ 

774 return dict( 

775 format_datetime=format_datetime, 

776 DateFormat=DateFormat, 

777 request=self.req, 

778 ) 

779 

780 

781class RedcapNewRecordUploader(RedcapUploader): 

782 """ 

783 Creates a new REDCap record. 

784 """ 

785 

786 @property 

787 def force_auto_number(self) -> bool: 

788 return self.autonumbering_enabled 

789 

790 @property 

791 def return_content(self) -> str: 

792 if self.autonumbering_enabled: 

793 # import_records returns ["<redcap record id>, 0"] 

794 return "auto_ids" 

795 

796 # import_records returns {'count': 1} 

797 return "count" 

798 

799 # noinspection PyUnusedLocal 

800 def get_record_id(self, existing_record_id: str) -> str: 

801 """ 

802 Get the record ID to send to REDCap when importing records 

803 """ 

804 if self.autonumbering_enabled: 

805 # Is ignored but we still need to set this to something 

806 return "0" 

807 

808 return self.project.generate_next_record_name() 

809 

810 def get_new_record_id(self, record_id: str, response: List[str]) -> str: 

811 """ 

812 For autonumbering, read the generated record ID from the 

813 response. Otherwise we already have it. 

814 """ 

815 if not self.autonumbering_enabled: 

816 return record_id 

817 

818 id_pair = response[0] 

819 

820 record_id = id_pair.rsplit(",")[0] 

821 

822 return record_id 

823 

824 @staticmethod 

825 def log_success(record_id: str) -> None: 

826 log.info(f"Created new REDCap record {record_id}") 

827 

828 

829class RedcapUpdatedRecordUploader(RedcapUploader): 

830 """ 

831 Updates an existing REDCap record. 

832 """ 

833 

834 force_auto_number = False 

835 # import_records returns {'count': 1} 

836 return_content = "count" 

837 

838 # noinspection PyMethodMayBeStatic 

839 def get_record_id(self, existing_record_id: str) -> str: 

840 return existing_record_id 

841 

842 # noinspection PyMethodMayBeStatic,PyUnusedLocal 

843 def get_new_record_id(self, old_record_id: str, response: Any) -> str: 

844 return old_record_id 

845 

846 @staticmethod 

847 def log_success(record_id: str) -> None: 

848 log.info(f"Updated REDCap record {record_id}")