Coverage for cc_modules/cc_redcap.py: 27%
264 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""camcops_server/cc_modules/cc_redcap.py
3===============================================================================
5 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
6 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
8 This file is part of CamCOPS.
10 CamCOPS is free software: you can redistribute it and/or modify
11 it under the terms of the GNU General Public License as published by
12 the Free Software Foundation, either version 3 of the License, or
13 (at your option) any later version.
15 CamCOPS is distributed in the hope that it will be useful,
16 but WITHOUT ANY WARRANTY; without even the implied warranty of
17 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18 GNU General Public License for more details.
20 You should have received a copy of the GNU General Public License
21 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
23===============================================================================
25**Implements communication with REDCap.**
27- For general information about REDCap, see https://www.project-redcap.org/.
29- The API documentation is not provided there, but is available from
30 your local REDCap server. Pick a project. Choose "API" from the left-hand
31 menu. Follow the "REDCap API documentation" link.
33- We use PyCap (https://pycap.readthedocs.io/ or
34 https://github.com/redcap-tools/PyCap). See also
35 https://redcap-tools.github.io/projects/. PyCap is no longer being actively
36 developed though the author is still responding to issues and pull requests.
38We use an XML fieldmap to describe how the rows in CamCOPS task tables are
39translated into REDCap records. See :ref:`REDCap export <redcap>`.
41REDCap does not assign instance IDs for repeating instruments so we need to
42query the database in order to determine the next instance ID. It is possible
43to create a race condition if more than one client is trying to update the same
44record at the same time.
46"""
48from enum import Enum
49import io
50import logging
51from typing import Any, Dict, Iterable, List, Optional, TYPE_CHECKING, Union
52import xml.etree.cElementTree as ElementTree
54from asteval import Interpreter, make_symbol_table
55from cardinal_pythonlib.datetimefunc import format_datetime
56from cardinal_pythonlib.logs import BraceStyleAdapter
57from pandas import DataFrame
58from pandas.errors import EmptyDataError
59import redcap
61from camcops_server.cc_modules.cc_constants import (
62 ConfigParamExportRecipient,
63 DateFormat,
64)
65from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient
67if TYPE_CHECKING:
68 from camcops_server.cc_modules.cc_exportmodels import ExportedTaskRedcap
69 from camcops_server.cc_modules.cc_request import CamcopsRequest
70 from camcops_server.cc_modules.cc_task import Task
72log = BraceStyleAdapter(logging.getLogger(__name__))
74MISSING_EVENT_TAG_OR_ATTRIBUTE = (
75 "The REDCap project has events but there is no 'event' tag "
76 "in the fieldmap or an instrument is missing an 'event' "
77 "attribute"
78)
81class RedcapExportException(Exception):
82 pass
85class RedcapFieldmap(object):
86 """
87 Internal representation of the fieldmap XML file.
88 This describes how the task fields should be translated to
89 the REDCap record.
90 """
92 def __init__(self, filename: str) -> None:
93 """
94 Args:
95 filename:
96 Name of an XML file telling CamCOPS how to map task fields
97 to REDCap. See :ref:`REDCap export <redcap>`.
98 """
99 self.filename = filename
100 self.fields = {} # type: Dict[str, Dict[str, str]]
101 # ... {task: {name: formula}}
102 self.files = {} # type: Dict[str, Dict[str, str]]
103 # ... {task: {name: formula}}
104 self.instruments = {} # type: Dict[str, str]
105 # ... {task: instrument_name}
106 self.events = {} # type: Dict[str, str]
107 # ... {task: event_name}
109 parser = ElementTree.XMLParser(encoding="UTF-8")
110 try:
111 tree = ElementTree.parse(filename, parser=parser)
112 except FileNotFoundError:
113 raise RedcapExportException(
114 f"Unable to open fieldmap file '{filename}'"
115 )
116 except ElementTree.ParseError as e:
117 raise RedcapExportException(
118 f"There was a problem parsing {filename}: {str(e)}"
119 ) from e
121 root = tree.getroot()
122 if root.tag != "fieldmap":
123 raise RedcapExportException(
124 (
125 f"Expected the root tag to be 'fieldmap' instead of "
126 f"'{root.tag}' in {filename}"
127 )
128 )
130 patient_element = root.find("patient")
131 if patient_element is None:
132 raise RedcapExportException(
133 f"'patient' is missing from {filename}"
134 )
136 self.patient = self._validate_and_return_attributes(
137 patient_element, ("instrument", "redcap_field")
138 )
140 record_element = root.find("record")
141 if record_element is None:
142 raise RedcapExportException(f"'record' is missing from {filename}")
144 self.record = self._validate_and_return_attributes(
145 record_element, ("instrument", "redcap_field")
146 )
148 default_event = None
149 event_element = root.find("event")
150 if event_element is not None:
151 event_attributes = self._validate_and_return_attributes(
152 event_element, ("name",)
153 )
154 default_event = event_attributes["name"]
156 instrument_elements = root.find("instruments")
157 if instrument_elements is None:
158 raise RedcapExportException(
159 f"'instruments' tag is missing from {filename}"
160 )
162 for instrument_element in instrument_elements:
163 instrument_attributes = self._validate_and_return_attributes(
164 instrument_element, ("name", "task")
165 )
167 task = instrument_attributes["task"]
168 instrument_name = instrument_attributes["name"]
169 self.fields[task] = {}
170 self.files[task] = {}
171 self.events[task] = instrument_attributes.get(
172 "event", default_event
173 )
174 self.instruments[task] = instrument_name
176 field_elements = instrument_element.find("fields") or []
178 for field_element in field_elements:
179 field_attributes = self._validate_and_return_attributes(
180 field_element, ("name", "formula")
181 )
182 name = field_attributes["name"]
183 formula = field_attributes["formula"]
185 self.fields[task][name] = formula
187 file_elements = instrument_element.find("files") or []
188 for file_element in file_elements:
189 file_attributes = self._validate_and_return_attributes(
190 file_element, ("name", "formula")
191 )
193 name = file_attributes["name"]
194 formula = file_attributes["formula"]
195 self.files[task][name] = formula
197 def _validate_and_return_attributes(
198 self, element: ElementTree.Element, expected_attributes: Iterable[str]
199 ) -> Dict[str, str]:
200 """
201 Checks that all the expected attributes are present in the XML element
202 (from the fieldmap XML file), or raises :exc:`RedcapExportException`.
203 """
204 attributes = element.attrib
206 if not all(a in attributes.keys() for a in expected_attributes):
207 raise RedcapExportException(
208 (
209 f"'{element.tag}' must have attributes: "
210 f"{', '.join(expected_attributes)} in {self.filename}"
211 )
212 )
214 return attributes
216 def instrument_names(self) -> List[str]:
217 """
218 Returns the names of all REDCap instruments.
219 """
220 return list(self.instruments.values())
223class RedcapTaskExporter(object):
224 """
225 Main entry point for task export to REDCap. Works out which record needs
226 updating or creating. Creates the fieldmap and initiates upload.
227 """
229 def export_task(
230 self, req: "CamcopsRequest", exported_task_redcap: "ExportedTaskRedcap"
231 ) -> None:
232 """
233 Exports a specific task.
235 Args:
236 req:
237 a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
238 exported_task_redcap:
239 a :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskRedcap`
240 """ # noqa
241 exported_task = exported_task_redcap.exported_task
242 recipient = exported_task.recipient
243 task = exported_task.task
245 if task.is_anonymous:
246 raise RedcapExportException(
247 f"Skipping anonymous task '{task.tablename}'"
248 )
250 which_idnum = recipient.primary_idnum
251 idnum_object = task.patient.get_idnum_object(which_idnum)
253 project = self.get_project(recipient)
254 fieldmap = self.get_fieldmap(recipient)
256 if project.is_longitudinal():
257 if not all(fieldmap.events.values()):
258 raise RedcapExportException(MISSING_EVENT_TAG_OR_ATTRIBUTE)
260 existing_records = self._get_existing_records(project, fieldmap)
261 existing_record_id = self._get_existing_record_id(
262 existing_records, fieldmap, idnum_object.idnum_value
263 )
265 if existing_record_id is None:
266 uploader_class = RedcapNewRecordUploader
267 else:
268 uploader_class = RedcapUpdatedRecordUploader # type: ignore[assignment] # noqa: E501
270 try:
271 instrument_name = fieldmap.instruments[task.tablename]
272 except KeyError:
273 raise RedcapExportException(
274 (
275 f"Instrument for task '{task.tablename}' is missing from "
276 f"the fieldmap"
277 )
278 )
280 record_id_fieldname = fieldmap.record["redcap_field"]
282 next_instance_id = self._get_next_instance_id(
283 existing_records,
284 instrument_name,
285 record_id_fieldname,
286 existing_record_id,
287 )
289 uploader = uploader_class(req, project)
291 new_record_id = uploader.upload(
292 task,
293 existing_record_id,
294 next_instance_id,
295 fieldmap,
296 idnum_object.idnum_value,
297 )
299 exported_task_redcap.redcap_record_id = new_record_id # type: ignore[assignment] # noqa: E501
300 exported_task_redcap.redcap_instrument_name = instrument_name
301 exported_task_redcap.redcap_instance_id = next_instance_id
303 @staticmethod
304 def _get_existing_records(
305 project: redcap.project.Project, fieldmap: RedcapFieldmap
306 ) -> "DataFrame":
307 """
308 Returns a Pandas data frame containing existing REDCap records for this
309 project, for instruments we are interested in.
311 Args:
312 project:
313 a :class:`redcap.project.Project`
314 fieldmap:
315 a :class:`RedcapFieldmap`
316 """
317 # Arguments to pandas read_csv()
319 type_dict = {
320 # otherwise pandas may infer as int or str
321 fieldmap.record["redcap_field"]: str
322 }
324 df_kwargs = {
325 "index_col": None, # don't index by record_id
326 "dtype": type_dict,
327 }
329 forms = (
330 fieldmap.instrument_names()
331 + [fieldmap.patient["instrument"]]
332 + [fieldmap.record["instrument"]]
333 )
335 try:
336 records = project.export_records(
337 format="df", forms=forms, df_kwargs=df_kwargs
338 )
339 except EmptyDataError:
340 # Should not happen, but in case of PyCap failing to catch this...
341 return DataFrame()
342 except redcap.RedcapError as e:
343 raise RedcapExportException(str(e))
345 return records
347 @staticmethod
348 def _get_existing_record_id(
349 records: "DataFrame", fieldmap: RedcapFieldmap, idnum_value: int
350 ) -> Optional[str]:
351 """
352 Returns the ID of an existing record that matches a specific
353 patient, if one can be found.
355 Args:
356 records:
357 records retrieved from REDCap; Pandas data frame from
358 :meth:`_get_existing_records`
359 fieldmap:
360 :class:`RedcapFieldmap`
361 idnum_value:
362 CamCOPS patient ID number
364 Returns:
365 REDCap record ID or ``None``
366 """
368 if records.empty:
369 return None
371 patient_id_fieldname = fieldmap.patient["redcap_field"]
373 if patient_id_fieldname not in records:
374 raise RedcapExportException(
375 (
376 f"Field '{patient_id_fieldname}' does not exist in "
377 f"REDCap. Is the 'patient' tag in the fieldmap correct?"
378 )
379 )
381 with_identifier = records[patient_id_fieldname] == idnum_value
383 if len(records[with_identifier]) == 0:
384 return None
386 return records[with_identifier].iat[0, 0]
388 @staticmethod
389 def _get_next_instance_id(
390 records: "DataFrame",
391 instrument: str,
392 record_id_fieldname: str,
393 existing_record_id: Optional[str],
394 ) -> int:
395 """
396 Returns the next REDCap record ID to use for a particular instrument,
397 including for a repeating instrument (the previous highest ID plus 1,
398 or 1 if none can be found).
400 Args:
401 records:
402 records retrieved from REDCap; Pandas data frame from
403 :meth:`_get_existing_records`
404 instrument:
405 instrument name
406 existing_record_id:
407 ID of existing record
408 """
409 if existing_record_id is None:
410 return 1
412 if record_id_fieldname not in records:
413 raise RedcapExportException(
414 (
415 f"Field '{record_id_fieldname}' does not exist in REDCap. "
416 f"Is the 'record' tag in the fieldmap correct?"
417 )
418 )
420 previous_instances = records[
421 (records["redcap_repeat_instrument"] == instrument)
422 & (records[record_id_fieldname] == existing_record_id)
423 ]
425 if len(previous_instances) == 0:
426 return 1
428 return int(previous_instances.max()["redcap_repeat_instance"] + 1)
430 def get_fieldmap(self, recipient: ExportRecipient) -> RedcapFieldmap:
431 """
432 Returns the relevant :class:`RedcapFieldmap`.
434 Args:
435 recipient:
436 an
437 :class:`camcops_server.cc_modules.cc_exportmodels.ExportRecipient`
438 """
439 fieldmap = RedcapFieldmap(self.get_fieldmap_filename(recipient))
441 return fieldmap
443 @staticmethod
444 def get_fieldmap_filename(recipient: ExportRecipient) -> str:
445 """
446 Returns the name of the XML file containing our fieldmap details, or
447 raises :exc:`RedcapExportException`.
449 Args:
450 recipient:
451 an
452 :class:`camcops_server.cc_modules.cc_exportmodels.ExportRecipient`
453 """
454 filename = recipient.redcap_fieldmap_filename
455 if filename is None:
456 raise RedcapExportException(
457 f"{ConfigParamExportRecipient.REDCAP_FIELDMAP_FILENAME} "
458 f"is not set in the config file"
459 )
461 if filename == "":
462 raise RedcapExportException(
463 f"{ConfigParamExportRecipient.REDCAP_FIELDMAP_FILENAME} "
464 f"is empty in the config file"
465 )
467 return filename
469 @staticmethod
470 def get_project(recipient: ExportRecipient) -> redcap.project.Project:
471 """
472 Returns the :class:`redcap.project.Project`.
474 Args:
475 recipient:
476 an
477 :class:`camcops_server.cc_modules.cc_exportmodels.ExportRecipient`
478 """
479 try:
480 project = redcap.project.Project(
481 recipient.redcap_api_url, recipient.redcap_api_key
482 )
483 except redcap.RedcapError as e:
484 raise RedcapExportException(str(e))
486 return project
489class RedcapRecordStatus(Enum):
490 """
491 Corresponds to valid values of Form Status -> Complete? field in REDCap
492 """
494 INCOMPLETE = 0
495 UNVERIFIED = 1
496 COMPLETE = 2
499class RedcapUploader(object):
500 """
501 Uploads records and files into REDCap, transforming the fields via the
502 fieldmap.
504 Abstract base class.
506 Knows nothing about ExportedTaskRedcap, ExportedTask, ExportRecipient
507 """
509 def __init__(
510 self, req: "CamcopsRequest", project: "redcap.project.Project"
511 ) -> None:
512 """
514 Args:
515 req:
516 a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
517 project:
518 a :class:`redcap.project.Project`
519 """
520 self.req = req
521 self.project = project
522 self.project_info = project.export_project_info()
524 def get_record_id(self, existing_record_id: Optional[str]) -> str:
525 """
526 Returns the REDCap record ID to use.
528 Args:
529 existing_record_id: highest existing record ID, if known
530 """
531 raise NotImplementedError("implement in subclass")
533 @property
534 def return_content(self) -> str:
535 """
536 The ``return_content`` argument to be passed to
537 :meth:`redcap.project.Project.import_records`. Can be:
539 - ``count`` [default] - the number of records imported
540 - ``ids`` - a list of all record IDs that were imported
541 - ``auto_ids`` = (used only when ``forceAutoNumber=true``) a list of
542 pairs of all record IDs that were imported, includes the new ID
543 created and the ID value that was sent in the API request
544 (e.g., 323,10).
546 Note (2020-01-27) that it can return e.g. ``15-30,0``, i.e. the ID
547 values can be non-integer.
548 """
549 raise NotImplementedError("implement in subclass")
551 @property
552 def force_auto_number(self) -> bool:
553 """
554 Should we force auto-numbering of records in REDCap?
555 """
556 raise NotImplementedError("implement in subclass")
558 def get_new_record_id(self, record_id: str, response: List[str]) -> str:
559 """
560 Returns the ID of the new (or updated) REDCap record.
562 Args:
563 record_id:
564 existing record ID
565 response:
566 response from :meth:`redcap.project.Project.import_records`
567 """
568 raise NotImplementedError("implement in subclass")
570 @staticmethod
571 def log_success(record_id: str) -> None:
572 """
573 Report upload success to the Python log.
575 Args:
576 record_id: REDCap record ID
577 """
578 raise NotImplementedError("implement in subclass")
580 @property
581 def autonumbering_enabled(self) -> bool:
582 """
583 Does this REDCap project have record autonumbering enabled?
584 """
585 return self.project_info["record_autonumbering_enabled"]
587 def upload(
588 self,
589 task: "Task",
590 existing_record_id: Optional[str],
591 next_instance_id: int,
592 fieldmap: RedcapFieldmap,
593 idnum_value: int,
594 ) -> str:
595 """
596 Uploads a CamCOPS task to REDCap.
598 Args:
599 task:
600 :class:`camcops_server.cc_modules.cc_task.Task` to be uploaded
601 existing_record_id:
602 REDCap ID of the existing record, if there is one
603 next_instance_id:
604 REDCap instance ID to be used for a repeating instrument
605 fieldmap:
606 :class:`RedcapFieldmap`
607 idnum_value:
608 CamCOPS patient ID number
610 Returns:
611 str: REDCap record ID of the record that was created or updated
613 """
614 complete_status = RedcapRecordStatus.INCOMPLETE
616 if task.is_complete():
617 complete_status = RedcapRecordStatus.COMPLETE
618 instrument_name = fieldmap.instruments[task.tablename]
619 record_id_fieldname = fieldmap.record["redcap_field"]
621 record_id = self.get_record_id(existing_record_id)
623 record = {
624 record_id_fieldname: record_id,
625 "redcap_repeat_instrument": instrument_name,
626 # https://community.projectredcap.org/questions/74561/unexpected-behaviour-with-import-records-repeat-in.html # noqa
627 # REDCap won't create instance IDs automatically so we have to
628 # assume no one else is writing to this record
629 "redcap_repeat_instance": next_instance_id,
630 f"{instrument_name}_complete": complete_status.value,
631 "redcap_event_name": fieldmap.events[task.tablename],
632 }
634 self.transform_fields(record, task, fieldmap.fields[task.tablename])
636 import_kwargs = {
637 "return_content": self.return_content,
638 "force_auto_number": self.force_auto_number,
639 }
641 response = self.upload_record(record, **import_kwargs)
643 new_record_id = self.get_new_record_id(record_id, response) # type: ignore[arg-type] # noqa: E501
645 # We don't mark the patient record as complete - it could be part of
646 # a larger form. We don't require it to be complete.
647 patient_record = {
648 record_id_fieldname: new_record_id,
649 fieldmap.patient["redcap_field"]: idnum_value,
650 }
651 self.upload_record(patient_record)
653 file_dict: dict[str, Any] = {}
654 self.transform_fields(file_dict, task, fieldmap.files[task.tablename])
656 self.upload_files(
657 task,
658 new_record_id,
659 next_instance_id,
660 file_dict,
661 event=fieldmap.events[task.tablename],
662 )
664 self.log_success(new_record_id)
666 return new_record_id
668 def upload_record(
669 self, record: Dict[str, Any], **kwargs: Any
670 ) -> Union[Dict, List, str]:
671 """
672 Uploads a REDCap record via the pycap
673 :func:`redcap.project.Project.import_record` function. Returns its
674 response.
675 """
676 try:
677 response = self.project.import_records([record], **kwargs)
678 except redcap.RedcapError as e:
679 raise RedcapExportException(str(e))
681 return response
683 def upload_files(
684 self,
685 task: "Task",
686 record_id: Union[int, str],
687 repeat_instance: int,
688 file_dict: Dict[str, bytes],
689 event: Optional[str] = None,
690 ) -> None:
691 """
692 Uploads files attached to a task (e.g. a PDF of the CamCOPS task).
694 Args:
695 task:
696 the :class:`camcops_server.cc_modules.cc_task.Task`
697 record_id:
698 the REDCap record ID
699 repeat_instance:
700 instance number for repeating instruments
701 file_dict:
702 dictionary mapping filename to file contents
703 event:
704 for longitudinal projects, specify the unique event here
706 Raises:
707 :exc:`RedcapExportException`
708 """
709 for fieldname, value in file_dict.items():
710 with io.BytesIO(value) as file_obj:
711 filename = f"{task.tablename}_{record_id}_{fieldname}"
713 try:
714 self.project.import_file(
715 record_id,
716 fieldname,
717 filename,
718 file_obj,
719 event=event,
720 repeat_instance=repeat_instance,
721 )
722 # ValueError if the field does not exist or is not
723 # a file field
724 except (redcap.RedcapError, ValueError) as e:
725 raise RedcapExportException(str(e))
727 def transform_fields(
728 self,
729 field_dict: Dict[str, Any],
730 task: "Task",
731 formula_dict: Dict[str, str],
732 ) -> None:
733 """
734 Uses the definitions from the fieldmap XML to set up field values to be
735 exported to REDCap.
737 Args:
738 field_dict:
739 Exported field values go here (the dictionary is modified).
740 task:
741 the :class:`camcops_server.cc_modules.cc_task.Task`
742 formula_dict:
743 dictionary (from the XML information) mapping REDCap field
744 name to a "formula". The formula is applied to extract data
745 from the task in a flexible way.
746 """
747 extra_symbols = self.get_extra_symbols()
749 symbol_table = make_symbol_table(task=task, **extra_symbols)
750 interpreter = Interpreter(symtable=symbol_table)
752 for redcap_field, formula in formula_dict.items():
753 v = interpreter(f"{formula}", show_errors=True)
754 if interpreter.error:
755 message = "\n".join([e.msg for e in interpreter.error])
756 raise RedcapExportException(
757 (
758 f"Fieldmap:\n"
759 f"Error in formula '{formula}': {message}\n"
760 f"Task: '{task.tablename}'\n"
761 f"REDCap field: '{redcap_field}'\n"
762 )
763 )
764 field_dict[redcap_field] = v
766 def get_extra_symbols(self) -> Dict[str, Any]:
767 """
768 Returns a dictionary made available to the ``asteval`` interpreter.
769 These become variables that the system administrator can refer to in
770 their fieldmap XML; see :ref:`REDCap export <redcap>`.
771 """
772 return dict(
773 format_datetime=format_datetime,
774 DateFormat=DateFormat,
775 request=self.req,
776 )
779class RedcapNewRecordUploader(RedcapUploader):
780 """
781 Creates a new REDCap record.
782 """
784 @property
785 def force_auto_number(self) -> bool:
786 return self.autonumbering_enabled
788 @property
789 def return_content(self) -> str:
790 if self.autonumbering_enabled:
791 # import_records returns ["<redcap record id>, 0"]
792 return "auto_ids"
794 # import_records returns {'count': 1}
795 return "count"
797 # noinspection PyUnusedLocal
798 def get_record_id(self, existing_record_id: str) -> str:
799 """
800 Get the record ID to send to REDCap when importing records
801 """
802 if self.autonumbering_enabled:
803 # Is ignored but we still need to set this to something
804 return "0"
806 return self.project.generate_next_record_name()
808 def get_new_record_id(self, record_id: str, response: List[str]) -> str:
809 """
810 For autonumbering, read the generated record ID from the
811 response. Otherwise we already have it.
812 """
813 if not self.autonumbering_enabled:
814 return record_id
816 id_pair = response[0]
818 record_id = id_pair.rsplit(",")[0]
820 return record_id
822 @staticmethod
823 def log_success(record_id: str) -> None:
824 log.info(f"Created new REDCap record {record_id}")
827class RedcapUpdatedRecordUploader(RedcapUploader):
828 """
829 Updates an existing REDCap record.
830 """
832 force_auto_number = False
833 # import_records returns {'count': 1}
834 return_content = "count"
836 # noinspection PyMethodMayBeStatic
837 def get_record_id(self, existing_record_id: str) -> str:
838 return existing_record_id
840 # noinspection PyMethodMayBeStatic,PyUnusedLocal
841 def get_new_record_id(self, old_record_id: str, response: Any) -> str:
842 return old_record_id
844 @staticmethod
845 def log_success(record_id: str) -> None:
846 log.info(f"Updated REDCap record {record_id}")