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
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""camcops_server/cc_modules/cc_redcap.py
5===============================================================================
7 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
8 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
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**Implements communication with REDCap.**
29- For general information about REDCap, see https://www.project-redcap.org/.
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.
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.
40We use an XML fieldmap to describe how the rows in CamCOPS task tables are
41translated into REDCap records. See :ref:`REDCap export <redcap>`.
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.
48"""
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
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
63from camcops_server.cc_modules.cc_constants import (
64 ConfigParamExportRecipient,
65 DateFormat,
66)
67from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient
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
74log = BraceStyleAdapter(logging.getLogger(__name__))
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)
83class RedcapExportException(Exception):
84 pass
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 """
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}
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
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 )
132 patient_element = root.find("patient")
133 if patient_element is None:
134 raise RedcapExportException(
135 f"'patient' is missing from {filename}"
136 )
138 self.patient = self._validate_and_return_attributes(
139 patient_element, ("instrument", "redcap_field")
140 )
142 record_element = root.find("record")
143 if record_element is None:
144 raise RedcapExportException(f"'record' is missing from {filename}")
146 self.record = self._validate_and_return_attributes(
147 record_element, ("instrument", "redcap_field")
148 )
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"]
158 instrument_elements = root.find("instruments")
159 if instrument_elements is None:
160 raise RedcapExportException(
161 f"'instruments' tag is missing from {filename}"
162 )
164 for instrument_element in instrument_elements:
165 instrument_attributes = self._validate_and_return_attributes(
166 instrument_element, ("name", "task")
167 )
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
178 field_elements = instrument_element.find("fields") or []
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"]
187 self.fields[task][name] = formula
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 )
195 name = file_attributes["name"]
196 formula = file_attributes["formula"]
197 self.files[task][name] = formula
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
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 )
216 return attributes
218 def instrument_names(self) -> List[str]:
219 """
220 Returns the names of all REDCap instruments.
221 """
222 return list(self.instruments.values())
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 """
231 def export_task(
232 self, req: "CamcopsRequest", exported_task_redcap: "ExportedTaskRedcap"
233 ) -> None:
234 """
235 Exports a specific task.
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
247 if task.is_anonymous:
248 raise RedcapExportException(
249 f"Skipping anonymous task '{task.tablename}'"
250 )
252 which_idnum = recipient.primary_idnum
253 idnum_object = task.patient.get_idnum_object(which_idnum)
255 project = self.get_project(recipient)
256 fieldmap = self.get_fieldmap(recipient)
258 if project.is_longitudinal():
259 if not all(fieldmap.events.values()):
260 raise RedcapExportException(MISSING_EVENT_TAG_OR_ATTRIBUTE)
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 )
267 if existing_record_id is None:
268 uploader_class = RedcapNewRecordUploader
269 else:
270 uploader_class = RedcapUpdatedRecordUploader
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 )
282 record_id_fieldname = fieldmap.record["redcap_field"]
284 next_instance_id = self._get_next_instance_id(
285 existing_records,
286 instrument_name,
287 record_id_fieldname,
288 existing_record_id,
289 )
291 uploader = uploader_class(req, project)
293 new_record_id = uploader.upload(
294 task,
295 existing_record_id,
296 next_instance_id,
297 fieldmap,
298 idnum_object.idnum_value,
299 )
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
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.
313 Args:
314 project:
315 a :class:`redcap.project.Project`
316 fieldmap:
317 a :class:`RedcapFieldmap`
318 """
319 # Arguments to pandas read_csv()
321 type_dict = {
322 # otherwise pandas may infer as int or str
323 fieldmap.record["redcap_field"]: str
324 }
326 df_kwargs = {
327 "index_col": None, # don't index by record_id
328 "dtype": type_dict,
329 }
331 forms = (
332 fieldmap.instrument_names()
333 + [fieldmap.patient["instrument"]]
334 + [fieldmap.record["instrument"]]
335 )
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))
347 return records
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.
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
366 Returns:
367 REDCap record ID or ``None``
368 """
370 if records.empty:
371 return None
373 patient_id_fieldname = fieldmap.patient["redcap_field"]
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 )
383 with_identifier = records[patient_id_fieldname] == idnum_value
385 if len(records[with_identifier]) == 0:
386 return None
388 return records[with_identifier].iat[0, 0]
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).
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
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 )
422 previous_instances = records[
423 (records["redcap_repeat_instrument"] == instrument)
424 & (records[record_id_fieldname] == existing_record_id)
425 ]
427 if len(previous_instances) == 0:
428 return 1
430 return int(previous_instances.max()["redcap_repeat_instance"] + 1)
432 def get_fieldmap(self, recipient: ExportRecipient) -> RedcapFieldmap:
433 """
434 Returns the relevant :class:`RedcapFieldmap`.
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))
443 return fieldmap
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`.
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 )
463 if filename == "":
464 raise RedcapExportException(
465 f"{ConfigParamExportRecipient.REDCAP_FIELDMAP_FILENAME} "
466 f"is empty in the config file"
467 )
469 return filename
471 @staticmethod
472 def get_project(recipient: ExportRecipient) -> redcap.project.Project:
473 """
474 Returns the :class:`redcap.project.Project`.
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))
488 return project
491class RedcapRecordStatus(Enum):
492 """
493 Corresponds to valid values of Form Status -> Complete? field in REDCap
494 """
496 INCOMPLETE = 0
497 UNVERIFIED = 1
498 COMPLETE = 2
501class RedcapUploader(object):
502 """
503 Uploads records and files into REDCap, transforming the fields via the
504 fieldmap.
506 Abstract base class.
508 Knows nothing about ExportedTaskRedcap, ExportedTask, ExportRecipient
509 """
511 def __init__(
512 self, req: "CamcopsRequest", project: "redcap.project.Project"
513 ) -> None:
514 """
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()
526 def get_record_id(self, existing_record_id: Optional[str]) -> str:
527 """
528 Returns the REDCap record ID to use.
530 Args:
531 existing_record_id: highest existing record ID, if known
532 """
533 raise NotImplementedError("implement in subclass")
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:
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).
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")
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")
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.
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")
572 @staticmethod
573 def log_success(record_id: str) -> None:
574 """
575 Report upload success to the Python log.
577 Args:
578 record_id: REDCap record ID
579 """
580 raise NotImplementedError("implement in subclass")
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"]
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.
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
612 Returns:
613 str: REDCap record ID of the record that was created or updated
615 """
616 complete_status = RedcapRecordStatus.INCOMPLETE
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"]
623 record_id = self.get_record_id(existing_record_id)
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 }
636 self.transform_fields(record, task, fieldmap.fields[task.tablename])
638 import_kwargs = {
639 "return_content": self.return_content,
640 "force_auto_number": self.force_auto_number,
641 }
643 response = self.upload_record(record, **import_kwargs)
645 new_record_id = self.get_new_record_id(record_id, response)
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)
655 file_dict = {}
656 self.transform_fields(file_dict, task, fieldmap.files[task.tablename])
658 self.upload_files(
659 task,
660 new_record_id,
661 next_instance_id,
662 file_dict,
663 event=fieldmap.events[task.tablename],
664 )
666 self.log_success(new_record_id)
668 return new_record_id
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))
683 return response
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).
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
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}"
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))
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.
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()
751 symbol_table = make_symbol_table(task=task, **extra_symbols)
752 interpreter = Interpreter(symtable=symbol_table)
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
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 )
781class RedcapNewRecordUploader(RedcapUploader):
782 """
783 Creates a new REDCap record.
784 """
786 @property
787 def force_auto_number(self) -> bool:
788 return self.autonumbering_enabled
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"
796 # import_records returns {'count': 1}
797 return "count"
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"
808 return self.project.generate_next_record_name()
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
818 id_pair = response[0]
820 record_id = id_pair.rsplit(",")[0]
822 return record_id
824 @staticmethod
825 def log_success(record_id: str) -> None:
826 log.info(f"Created new REDCap record {record_id}")
829class RedcapUpdatedRecordUploader(RedcapUploader):
830 """
831 Updates an existing REDCap record.
832 """
834 force_auto_number = False
835 # import_records returns {'count': 1}
836 return_content = "count"
838 # noinspection PyMethodMayBeStatic
839 def get_record_id(self, existing_record_id: str) -> str:
840 return existing_record_id
842 # noinspection PyMethodMayBeStatic,PyUnusedLocal
843 def get_new_record_id(self, old_record_id: str, response: Any) -> str:
844 return old_record_id
846 @staticmethod
847 def log_success(record_id: str) -> None:
848 log.info(f"Updated REDCap record {record_id}")