From 298bd0b96fc2386f9450420299210c547609bc9e Mon Sep 17 00:00:00 2001 From: Matt Jaquiery Date: Fri, 22 Nov 2024 12:14:08 +0000 Subject: [PATCH] feat: streamlined RC variables export --- .gitignore | 2 + README.md | 13 +- pyproject.toml | 14 -- tests/fixtures/tc_to_fixture.py | 27 --- tests/test_cli.py | 15 +- tests/test_conversions.py | 34 ++- tests/test_main_functions.py | 14 ++ trd_cli/__init__.py | 12 + trd_cli/conversions.py | 319 +------------------------ trd_cli/main.py | 20 +- trd_cli/main_functions.py | 27 ++- trd_cli/questionnaires.py | 404 ++++++++++++++++++++++++++++++++ 12 files changed, 525 insertions(+), 376 deletions(-) delete mode 100644 pyproject.toml create mode 100644 trd_cli/questionnaires.py diff --git a/.gitignore b/.gitignore index 581dfb2..605a1b6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ secrets.tfvars **/trd_cli.log tests/fixtures/rc_variables.txt + +tests/.redcap_structure.txt diff --git a/README.md b/README.md index bbf9356..2970bcd 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,12 @@ REDCap doesn't allow direct database access, however, so we need to create fake The quick way to set up the REDCap project is still quite slow, but it's faster than the long way. -1. Make sure you have all the questionnaires you need in your test dataset (`tests/fixtures/*.csv`). -2. Run `tests/fixtures/tc_to_fixture.py`. -3. Open the `tests/fixtures/rc_variables.txt` file created by the script. +#### Generate a list of variables + +Run `python trd_cli.py export_redcap_structure -o rc_variables.txt` to export the variables from the True Colours data. +This will output a file called `rc_variables.txt` in the current directory. +It will contain the names of the instruments and all the fields that will be exported for those instruments for +all the questionnaires the tool knows about. The `rc_variables.txt` file will look like this: @@ -49,7 +52,9 @@ field_3_name ... ``` -4. For each line with `######` at the start and end, create an instrument with the fields listed below it. +#### Create the instruments in REDCap + +For each line with `######` at the start and end, create an instrument with the fields listed below it. You should use `instrument_name` as the name of the instrument in REDCap, although this is not strictly necessary. The field names must be copied exactly as they appear in the file. diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 2ff8b7e..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,14 +0,0 @@ -[tool.poetry] -name = "trd-cli" -version = "0.1.0" -description = "Command line utility for syncing True Colours data to REDCap." -authors = ["Matt Jaquiery "] -readme = "README.md" - -[tool.poetry.dependencies] -python = "^3.10" - - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" diff --git a/tests/fixtures/tc_to_fixture.py b/tests/fixtures/tc_to_fixture.py index 2fa00c4..5a62bdd 100644 --- a/tests/fixtures/tc_to_fixture.py +++ b/tests/fixtures/tc_to_fixture.py @@ -1,4 +1,3 @@ -from trd_cli.main_functions import compare_tc_to_rc def patient_to_fixture(patient_csv_data: dict) -> dict: @@ -45,29 +44,3 @@ def tc_to_fixture(tc_data: dict) -> dict: json.dump(tc_to_fixture(parse_tc(".")), f, indent=4) with open("./tc_data_initial.json", "w+") as f: json.dump(parse_tc("./initial_tc_dump"), f, indent=4) - - # Dump a list of the variables REDCap needs to have available to import data. - # This aids in setting up the REDCap project. - pp, rr = compare_tc_to_rc(parse_tc("."), redcap_id_data=list()) - rc_variables_list = {} - for p in pp.values(): - rc_variables_list["private"] = set(p["private"].keys()) - rc_variables_list["info"] = set(p["info"].keys()) - break - for r in rr: - if r["redcap_repeat_instrument"] not in rc_variables_list: - rc_variables_list[r["redcap_repeat_instrument"]] = set(r.keys()) - else: - [rc_variables_list[r["redcap_repeat_instrument"]].add(k) for k in r.keys()] - - redcap_vars = ["study_id", "redcap_repeat_instrument", "redcap_repeat_instance"] - for k, v in rc_variables_list.items(): - rc_variables_list[k] = [x for x in v if x not in redcap_vars] - with open("./rc_variables.txt", "w+") as f: - f.flush() - for k in rc_variables_list.keys(): - f.write(f"###### {k} ######\n") - vv = sorted(rc_variables_list[k]) - for x in vv: - f.write(f"{x}\n") - f.write("\n") diff --git a/tests/test_cli.py b/tests/test_cli.py index 566628a..a604ca2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,11 +7,12 @@ import subprocess import requests -from trd_cli.conversions import QUESTIONNAIRES -from trd_cli.main import cli +from trd_cli.questionnaires import QUESTIONNAIRES +from trd_cli.main import cli, export_redcap_structure from trd_cli.main_functions import compare_tc_to_rc cli: Command # annotating to avoid linter warnings +export_redcap_structure: Command class CliTest(TestCase): @@ -198,6 +199,16 @@ def redcap_from_tc(tc_data: dict) -> List[dict]: self.assertEqual(result.exit_code, 0, result.output) + def test_export_redcap_structure(self): + """ + Test exporting the REDCap structure to a file + """ + runner = CliRunner() + result = runner.invoke(export_redcap_structure, "--output ./.redcap_structure.txt") + + self.assertEqual(result.exit_code, 0, result.output) + self.assertTrue(os.path.exists("./.redcap_structure.txt")) + if __name__ == "__main__": main() diff --git a/tests/test_conversions.py b/tests/test_conversions.py index d2134d4..b44f9c0 100644 --- a/tests/test_conversions.py +++ b/tests/test_conversions.py @@ -5,16 +5,14 @@ from trd_cli.conversions import ( extract_participant_info, - questionnaire_to_rc_record, get_code_by_name, ) +from trd_cli.questionnaires import questionnaire_to_rc_record, get_redcap_structure, get_code_by_name from trd_cli.parse_tc import parse_tc class ConversionsTest(TestCase): - def test_convert_scores(self): - responses = parse_tc("fixtures") - self.assertIn("questionnaireresponse.csv", responses) - expectations = { + def setUp(self): + self.expectations = { "phq9": { "phq9_response_id": "1589930675", "phq9_datetime": "2024-11-04T12:59:24.9477348+00:00", @@ -79,6 +77,10 @@ def test_convert_scores(self): "wsas_score_total_float": "21.0", }, } + + def test_convert_scores(self): + responses = parse_tc("fixtures") + self.assertIn("questionnaireresponse.csv", responses) done = [] for r in responses["questionnaireresponse.csv"]: interop = r.get("interoperability") @@ -88,7 +90,7 @@ def test_convert_scores(self): if q_code is not None and q_code not in done: with self.subTest(q_name=q_code): done.append(q_code) - if q_code not in expectations.keys(): + if q_code not in self.expectations.keys(): if q_code == "consent": continue else: @@ -96,9 +98,9 @@ def test_convert_scores(self): f"Unhandled questionnaire response of type {q_code}." ) result = questionnaire_to_rc_record(r) - self.assertEqual(expectations[q_code], result) + self.assertEqual(self.expectations[q_code], result) - for q_code in expectations.keys(): + for q_code in self.expectations.keys(): if q_code not in done: with self.subTest(q_name=q_code): self.fail(f"Did not find a {q_code} row to test in data.") @@ -142,6 +144,22 @@ def test_extract_info(self): public, ) + def test_redcap_dump(self): + dump = get_redcap_structure() + self.assertIn("private", dump) + self.assertIsInstance(dump["private"], list) + for f in ["id", "nhsnumber", "birthdate", "contactemail", "mobilenumber", "firstname", "lastname", "preferredcontact"]: + self.assertIn(f, dump["private"]) + self.assertIn("info", dump) + self.assertIsInstance(dump["info"], list) + for f in ["info_birthyear_int", "info_datetime", "info_deceased_datetime", "info_gender_int", "info_is_deceased_bool"]: + self.assertIn(f, dump["info"]) + for k, v in self.expectations.items(): + self.assertIn(k, dump) + self.assertIsInstance(dump[k], list) + for f in v.keys(): + self.assertIn(f, dump[k]) + if __name__ == "__main__": main() diff --git a/tests/test_main_functions.py b/tests/test_main_functions.py index 3c4831a..2d884d5 100644 --- a/tests/test_main_functions.py +++ b/tests/test_main_functions.py @@ -12,22 +12,36 @@ def test_parse_records(self): self.assertEqual( { "one-oh-one": { + 'aq10': [], + 'asrs': [], + 'audit': [], + 'brss': [], "consent": [], "demo": [], + 'dudit': [], "gad7": [("2222222", 1)], "mania": [], "phq9": [("1111111", 1), ("121212", 1)], + 'pvss': [], "reqol10": [], + 'sapas': [], "study_id": "101", "wsas": [], }, "one-oh-two": { + 'aq10': [], + 'asrs': [], + 'audit': [], + 'brss': [], "consent": [], "demo": [], + 'dudit': [], "gad7": [("1231241", 1)], "mania": [], "phq9": [], + 'pvss': [], "reqol10": [], + 'sapas': [], "study_id": "102", "wsas": [], }, diff --git a/trd_cli/__init__.py b/trd_cli/__init__.py index e69de29..c8bd6f3 100644 --- a/trd_cli/__init__.py +++ b/trd_cli/__init__.py @@ -0,0 +1,12 @@ +__version__ = "0.1.0" +__author__ = "Matt Jaquiery" +__email__ = "matt.jaquiery@dtc.ox.ac.uk" +__url__ = "https://github.com/OxfordRSE/trd-cli" +__description__ = "A command line interface for converting True Colours data for REDCap import." + +import sys + + +# Check for a minimum Python version +if sys.version_info < (3, 9): + raise RuntimeError("This package requires Python 3.8 or higher.") diff --git a/trd_cli/conversions.py b/trd_cli/conversions.py index 9e1d714..675846f 100644 --- a/trd_cli/conversions.py +++ b/trd_cli/conversions.py @@ -27,13 +27,6 @@ class QuestionnaireMetadata(TypedDict): conversion_fn: Callable[["QuestionnaireMetadata", dict], dict] -def get_code_by_name(name: str) -> str|None: - qs = [q["code"] for q in QUESTIONNAIRES if q["name"] == name] - if len(qs) == 0: - return None - return qs[0] - - def convert_consent(_q: QuestionnaireMetadata, questionnaire_data: dict) -> dict: question_scores = questionnaire_data["scores"]["QuestionScores"] out = { @@ -108,316 +101,6 @@ def convert_scores(questionnaire: QuestionnaireMetadata, questionnaire_response: return out -def to_rc_record(metadata: RCRecordMetadata, data: dict): - return {**metadata, **data} - - -QUESTIONNAIRES: List[QuestionnaireMetadata] = [ - { - "name": "Anxiety (GAD-7)", - "code": "gad7", - "items": [ - "anxious", - "uncontrollable_worrying", - "excessive_worrying", - "relaxing", - "restless", - "irritable", - "afraid", - "difficult", - ], - "scores": ["Total"], - "conversion_fn": convert_scores, - }, - { - "name": "AQ-10 Autistm Spectrum Quotient (AQ)", - "code": "aq10", - "items": [ - "sounds", - "holism", - "multitasking", - "task_switching", - "social_inference", - "detect_boredom", - "intentions_story", - "collections", - "facial_emotions", - "intentions_real" - ], - "scores": [ - "Scoring 1" - ], - "conversion_fn": convert_scores, - }, - { - "name": "Adult ADHD Self-Report Scale (ASRS-v1.1) Symptom Checklist Instructions", - "code": "asrs", - "items": [ - "completing", - "preparing", - "remembering", - "procrastination", - "fidgeting", - "overactivity", - "carelessness", - "attention", - "concentration", - "misplacing", - "distracted", - "standing", - "restlessness", - "not_relaxing", - "loquacity", - "finishing_sentences", - "queueing", - "interrupting", - ], - "scores": [], - "conversion_fn": convert_scores, - }, - { - "name": "Alcohol use disorders identification test (AUDIT)", - "code": "audit", - "items": [ - "frequency", - "units", - "binge_frequency", - "cant_stop", - "incapacitated", - "morning_drink", - "guilt", - "memory_loss", - "injuries", - "concern", - ], - "scores": [ - "Low Risk", - "Increased Risk", - "Higher Risk", - "Possible Dependence", - ], - "conversion_fn": convert_scores, - }, - { - "name": "Brooding subscale of Ruminative Response Scale", - "code": "brss", - "items": [ - "deserve", - "react", - "regret", - "why_me", - "handle" - ], - "scores": [ - "Scoring 1" - ], - "conversion_fn": convert_scores, - }, - { - "name": "Demographics", - "code": "demo", - "items": [ - "disabled", - "long_term_condition", - "national_identity", - "national_identity_other", - "ethnicity", - "ethnicity_asian_detail", - "ethnicity_asian_other", - "ethnicity_black_detail", - "ethnicity_black_other", - "ethnicity_mixed_detail", - "ethnicity_mixed_other", - "ethnicity_white_detail", - "ethnicity_white_other", - "ethnicity_other_detail", - "ethnicity_other_other", - "religion", - "religion_other", - "sex", - "gender", - "gender_other", - "trans", - "sexuality", - "sexuality_other", - "relationship", - "relationship_other", - "caring", - "employment", - "employment_other", - "education", - "education_other", - ], - "scores": [], - "conversion_fn": convert_display_values, - }, - { - "name": "Depression (PHQ-9)", - "code": "phq9", - "items": [ - "interest", - "depression", - "sleep", - "lack_of_energy", - "appetite", - "view_of_self", - "concentration", - "movements", - "self_harm", - ], - "scores": ["Total"], - "conversion_fn": convert_scores, - }, - { - "name": "Drug use disorders identification test (DUDIT)", - "code": "dudit", - "items": [ - "frequency", - "multiple", - "frequency_day", - "influence", - "longing", - "cant_stop", - "incapacitated", - "morning_drug", - "guilt", - "injury", - "concern" - ], - "scores": [ - "Scoring" - ], - "conversion_fn": convert_scores, - }, - { - "name": "Mania (Altman)", - "code": "mania", - "items": [ - "happiness", - "confidence", - "sleep", - "talking", - "activity", - ], - "scores": ["Total"], - "conversion_fn": convert_scores, - }, - { - "name": "MENTAL HEALTH MISSION MOOD DISORDER COHORT STUDY - Patient Information Sheet & Informed Consent Form", - "code": "consent", - "items": [], - "scores": [], - "conversion_fn": convert_consent, - }, - { - "name": "Positive Valence Systems Scale, 21 items (PVSS-21)", - "code": "pvss", - "items": [ - "savour", - "activities", - "fresh_air", - "social_time", - "weekend", - "touch", - "outdoors", - "feedback", - "meals", - "praise", - "social_time", - "goals", - "hug", - "fun", - "hard_work", - "meal", - "achievements", - "hug_afterglow", - "mastery", - "activities", - "beauty", - ], - "scores": [ - "Food", - "Physical Touch", - "Outdoors", - "Positive Feedback", - "Hobbies", - "Social Interactions", - "Goals", - "Reward Valuation", - "Reward Expectancy", - "Effort Valuation", - "Reward Aniticipation", - "Initial Responsiveness", - "Reward Satiation" - ], - "conversion_fn": convert_scores, - }, - { - "name": "ReQoL 10", - "code": "reqol10", - "items": [ - "everyday_tasks", - "trust_others", - "unable_to_cope", - "do_wanted_things", - "felt_happy", - "not_worth_living", - "enjoyed", - "felt_hopeful", - "felt_lonely", - "confident_in_self", - "pysical_health", # sic - ], - "scores": ["Total"], - "conversion_fn": convert_scores, - }, - { - "name": "Standardised Assessment of Personality - Abbreviated Scale (Moran)", - "code": "sapas", - "items": [ - "friends", - "loner", - "trust", - "temper", - "impulsive", - "worry", - "dependent", - "perfectionist", - ], - "scores": [ - "Scoring 1" - ], - "conversion_fn": convert_scores, - }, - { - "name": "Work and Social Adjustment Scale", - "code": "wsas", - "items": [ - "work", - "management", - "social_leisure", - "private_leisure", - "family", - ], - "scores": ["Total"], - "conversion_fn": convert_scores, - }, -] - -def questionnaire_to_rc_record(questionnaire_response: dict) -> dict: - """ - Convert a questionnaire response to a REDCap record. - """ - q_name = questionnaire_response["interoperability"]["title"] - q = list(filter(lambda x: x["name"] == q_name, QUESTIONNAIRES)) - if len(q) == 0: - # This should never happen because questionnaire_response will already have been vetted - raise ValueError(f"Unrecognised questionnaire name {q_name}") - else: - q = q[0] - return q["conversion_fn"](q, questionnaire_response) - - def extract_participant_info(patient_csv_data: dict) -> Tuple[dict, dict]: """ Extract participant info from CSV file. @@ -447,3 +130,5 @@ def extract_participant_info(patient_csv_data: dict) -> Tuple[dict, dict]: "info_is_deceased_bool": patient_csv_data.get("deceasedboolean"), "info_deceased_datetime": patient_csv_data.get("deceaseddatetime"), } + + diff --git a/trd_cli/main.py b/trd_cli/main.py index 6fe1c23..de8a9d0 100644 --- a/trd_cli/main.py +++ b/trd_cli/main.py @@ -5,9 +5,7 @@ import click import requests -from trd_cli.conversions import ( - QUESTIONNAIRES, -) +from trd_cli.questionnaires import QUESTIONNAIRES, dump_redcap_structure from trd_cli.main_functions import extract_redcap_ids, get_true_colours_data, compare_tc_to_rc # Construct a logger that saves logged events to a dictionary that we can attach to an email later @@ -219,5 +217,21 @@ def cli(): exit(1) +# Allow dumping the REDCap structure to a given file +@click.command() +@click.option( + "--output", + "-o", + default="redcap_structure.txt", + help="Output file to dump the REDCap structure to.", + type=click.Path(dir_okay=False, writable=True, resolve_path=True) +) +def export_redcap_structure(output): + """ + Export the required REDCap structure to a file so that REDCap can be correctly configured for the project. + """ + dump_redcap_structure(output) + + if __name__ == "__main__": cli() diff --git a/trd_cli/main_functions.py b/trd_cli/main_functions.py index ad18d0e..d4c859b 100644 --- a/trd_cli/main_functions.py +++ b/trd_cli/main_functions.py @@ -1,7 +1,10 @@ import subprocess from typing import Tuple, List -from trd_cli.conversions import QUESTIONNAIRES, extract_participant_info, questionnaire_to_rc_record, get_code_by_name +from redcap import Project + +from trd_cli.conversions import extract_participant_info +from trd_cli.questionnaires import QUESTIONNAIRES, questionnaire_to_rc_record, get_redcap_structure, get_code_by_name from trd_cli.parse_tc import parse_tc import logging @@ -129,3 +132,25 @@ def compare_tc_to_rc(tc_data: dict, redcap_id_data: List[dict]) -> Tuple[dict, l } ) return new_participants, new_responses + + +def is_redcap_structure_valid(redcap_project: Project, raise_error: bool = False) -> bool|None: + """ + Check if the REDCap structure is valid. + This can only check if all the required fields are present, not that they belong to the appropriate instruments. + It can also not check anything if there are no records in REDCap. + In that case, it returns None rather than False. + """ + required_structure = get_redcap_structure() + redcap_structure = redcap_project.export_records() + if len(redcap_structure) == 0: + if raise_error: + raise ValueError("No records found in REDCap") + return None + for r, v in required_structure.items(): + for var in v: + if var not in redcap_structure[0].keys(): + if raise_error: + raise ValueError(f"Missing field {var} in REDCap structure.") + return False + return True diff --git a/trd_cli/questionnaires.py b/trd_cli/questionnaires.py new file mode 100644 index 0000000..05a4777 --- /dev/null +++ b/trd_cli/questionnaires.py @@ -0,0 +1,404 @@ +from typing import List, Dict + +from trd_cli.conversions import QuestionnaireMetadata, convert_scores, convert_display_values, convert_consent, \ + RCRecordMetadata, extract_participant_info + +QUESTIONNAIRES: List[QuestionnaireMetadata] = [ + { + "name": "Anxiety (GAD-7)", + "code": "gad7", + "items": [ + "anxious", + "uncontrollable_worrying", + "excessive_worrying", + "relaxing", + "restless", + "irritable", + "afraid", + "difficult", + ], + "scores": ["Total"], + "conversion_fn": convert_scores, + }, + { + "name": "AQ-10 Autistm Spectrum Quotient (AQ)", + "code": "aq10", + "items": [ + "sounds", + "holism", + "multitasking", + "task_switching", + "social_inference", + "detect_boredom", + "intentions_story", + "collections", + "facial_emotions", + "intentions_real" + ], + "scores": [ + "Scoring 1" + ], + "conversion_fn": convert_scores, + }, + { + "name": "Adult ADHD Self-Report Scale (ASRS-v1.1) Symptom Checklist Instructions", + "code": "asrs", + "items": [ + "completing", + "preparing", + "remembering", + "procrastination", + "fidgeting", + "overactivity", + "carelessness", + "attention", + "concentration", + "misplacing", + "distracted", + "standing", + "restlessness", + "not_relaxing", + "loquacity", + "finishing_sentences", + "queueing", + "interrupting", + ], + "scores": [], + "conversion_fn": convert_scores, + }, + { + "name": "Alcohol use disorders identification test (AUDIT)", + "code": "audit", + "items": [ + "frequency", + "units", + "binge_frequency", + "cant_stop", + "incapacitated", + "morning_drink", + "guilt", + "memory_loss", + "injuries", + "concern", + ], + "scores": [ + "Low Risk", + "Increased Risk", + "Higher Risk", + "Possible Dependence", + ], + "conversion_fn": convert_scores, + }, + { + "name": "Brooding subscale of Ruminative Response Scale", + "code": "brss", + "items": [ + "deserve", + "react", + "regret", + "why_me", + "handle" + ], + "scores": [ + "Scoring 1" + ], + "conversion_fn": convert_scores, + }, + { + "name": "Demographics", + "code": "demo", + "items": [ + "disabled", + "long_term_condition", + "national_identity", + "national_identity_other", + "ethnicity", + "ethnicity_asian_detail", + "ethnicity_asian_other", + "ethnicity_black_detail", + "ethnicity_black_other", + "ethnicity_mixed_detail", + "ethnicity_mixed_other", + "ethnicity_white_detail", + "ethnicity_white_other", + "ethnicity_other_detail", + "ethnicity_other_other", + "religion", + "religion_other", + "sex", + "gender", + "gender_other", + "trans", + "sexuality", + "sexuality_other", + "relationship", + "relationship_other", + "caring", + "employment", + "employment_other", + "education", + "education_other", + ], + "scores": [], + "conversion_fn": convert_display_values, + }, + { + "name": "Depression (PHQ-9)", + "code": "phq9", + "items": [ + "interest", + "depression", + "sleep", + "lack_of_energy", + "appetite", + "view_of_self", + "concentration", + "movements", + "self_harm", + ], + "scores": ["Total"], + "conversion_fn": convert_scores, + }, + { + "name": "Drug use disorders identification test (DUDIT)", + "code": "dudit", + "items": [ + "frequency", + "multiple", + "frequency_day", + "influence", + "longing", + "cant_stop", + "incapacitated", + "morning_drug", + "guilt", + "injury", + "concern" + ], + "scores": [ + "Scoring" + ], + "conversion_fn": convert_scores, + }, + { + "name": "Mania (Altman)", + "code": "mania", + "items": [ + "happiness", + "confidence", + "sleep", + "talking", + "activity", + ], + "scores": ["Total"], + "conversion_fn": convert_scores, + }, + { + "name": "MENTAL HEALTH MISSION MOOD DISORDER COHORT STUDY - Patient Information Sheet & Informed Consent Form", + "code": "consent", + "items": [], + "scores": [], + "conversion_fn": convert_consent, + }, + { + "name": "Positive Valence Systems Scale, 21 items (PVSS-21)", + "code": "pvss", + "items": [ + "savour", + "activities", + "fresh_air", + "social_time", + "weekend", + "touch", + "outdoors", + "feedback", + "meals", + "praise", + "social_time", + "goals", + "hug", + "fun", + "hard_work", + "meal", + "achievements", + "hug_afterglow", + "mastery", + "activities", + "beauty", + ], + "scores": [ + "Food", + "Physical Touch", + "Outdoors", + "Positive Feedback", + "Hobbies", + "Social Interactions", + "Goals", + "Reward Valuation", + "Reward Expectancy", + "Effort Valuation", + "Reward Aniticipation", + "Initial Responsiveness", + "Reward Satiation" + ], + "conversion_fn": convert_scores, + }, + { + "name": "ReQoL 10", + "code": "reqol10", + "items": [ + "everyday_tasks", + "trust_others", + "unable_to_cope", + "do_wanted_things", + "felt_happy", + "not_worth_living", + "enjoyed", + "felt_hopeful", + "felt_lonely", + "confident_in_self", + "pysical_health", # sic + ], + "scores": ["Total"], + "conversion_fn": convert_scores, + }, + { + "name": "Standardised Assessment of Personality - Abbreviated Scale (Moran)", + "code": "sapas", + "items": [ + "friends", + "loner", + "trust", + "temper", + "impulsive", + "worry", + "dependent", + "perfectionist", + ], + "scores": [ + "Scoring 1" + ], + "conversion_fn": convert_scores, + }, + { + "name": "Work and Social Adjustment Scale", + "code": "wsas", + "items": [ + "work", + "management", + "social_leisure", + "private_leisure", + "family", + ], + "scores": ["Total"], + "conversion_fn": convert_scores, + }, +] + + +def to_rc_record(metadata: RCRecordMetadata, data: dict): + return {**metadata, **data} + + +def questionnaire_to_rc_record(questionnaire_response: dict) -> dict: + """ + Convert a questionnaire response to a REDCap record. + """ + q_name = questionnaire_response["interoperability"]["title"] + q = list(filter(lambda x: x["name"] == q_name, QUESTIONNAIRES)) + if len(q) == 0: + # This should never happen because questionnaire_response will already have been vetted + raise ValueError(f"Unrecognised questionnaire name {q_name}") + else: + q = q[0] + return q["conversion_fn"](q, questionnaire_response) + + +def get_redcap_structure() -> Dict[str, List[str]]: + """ + Map the required REDCap structure to a dict of {"instrument": [fields]}. + """ + dump = {} + # The participant data is a special case because it's not a questionnaire + dummy_participant = { + "id": "1234567890", + "nhsnumber": "1234567890", + "birthdate": "YYYY-MM-DD", + "contactemail": "", + "mobilenumber": "", + "firstname": "", + "lastname": "", + "preferredcontact": "", + "deceasedboolean": "", + "deceaseddatetime": "", + "gender": "", + } + dump["private"], dump["info"] = extract_participant_info(dummy_participant) + + consent_q = list(filter(lambda x: x["code"] == "consent", QUESTIONNAIRES))[0] + consent_data = { + "id": "1234567890", + "submitted": "YYYY-MM-DD HH:MM:SS.sss", + "interoperability": { + "submitted": "YYYY-MM-DD HH:MM:SS.sss", + "title": consent_q["name"], + }, + "scores": { + "QuestionScores": [ + {"QuestionNumber": i + 1, "Score": "0", "DisplayValue": "Answer"} for i in range(18) + ] + }, + } + # Consent's penultimate question is a signature that isn't exported + consent_data["scores"]["QuestionScores"][-1]["QuestionNumber"] += 1 + dump["consent"] = convert_consent(consent_q, consent_data) + + for q in QUESTIONNAIRES: + # Special case for 'consent' questionnaire where Q17 is a non-exported signature + if q["code"] == "consent": + continue + # Mock questionnaire data with reference to its declaration + dummy_questionnaire = { + "id": "1234567890", + "submitted": "YYYY-MM-DD HH:MM:SS.sss", + "interoperability": { + "submitted": "YYYY-MM-DD HH:MM:SS.sss", + "title": q["name"], + }, + "scores": { + "QuestionScores": [ + {"QuestionNumber": i + 1, "Score": "0", "DisplayValue": "Answer"} for i in range(len(q["items"])) + ], + "CategoryScores": [ + {"Name": c, "Score": 0} for c in q["scores"] + ], + }, + } + dump[q["code"]] = q["conversion_fn"](q, dummy_questionnaire) + + for k, v in dump.items(): + dump[k] = list(v.keys()) + + return dump + + +def dump_redcap_structure(filename: str = "redcap_structure.txt"): + """ + Dump the REDCap structure to a file. + This is useful for setting up REDCap to handle the incoming data. + """ + dump = get_redcap_structure() + with open(filename, "w+") as f: + for k in dump.keys(): + f.write(f"###### {k} ######\n") + vv = sorted(dump[k]) + for x in vv: + f.write(f"{x}\n") + f.write("\n") + + +def get_code_by_name(name: str) -> str|None: + qs = [q["code"] for q in QUESTIONNAIRES if q["name"] == name] + if len(qs) == 0: + return None + return qs[0]