diff --git a/README.md b/README.md index dc9b500..e562947 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,30 @@ -# AugMed-API +# The AugMed App (Backend API) + +AugMed is a web application, built for the UNC-Chapel Hill DHEP Lab, that allows the lab to collect data from participants in a user-friendly way. The app is designed to be used on a tablet, and it allows participants to answer questions about their health and well-being. The app is built using React, and it uses the DHEP Lab's API to store and retrieve data. + +![Python](https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white) +![Flask](https://img.shields.io/badge/Flask-000000?style=for-the-badge&logo=flask&logoColor=white) +![PostgreSQL](https://img.shields.io/badge/PostgreSQL-4169E1?style=for-the-badge&logo=postgresql&logoColor=white) +![Redis](https://img.shields.io/badge/Redis-DC382D?style=for-the-badge&logo=redis&logoColor=white) +![Celery](https://img.shields.io/badge/Celery-37814A?style=for-the-badge&logo=celery&logoColor=white) +![Alembic](https://img.shields.io/badge/Alembic-000000?style=for-the-badge&logo=alembic&logoColor=white) +![SQLAlchemy](https://img.shields.io/badge/SQLAlchemy-3E8EDE?style=for-the-badge&logo=sqlalchemy&logoColor=white) +![SQL](https://img.shields.io/badge/SQL-003B57?style=for-the-badge&logo=mysql&logoColor=white) +![pytest](https://img.shields.io/badge/pytest-0A9EDC?style=for-the-badge&logo=pytest&logoColor=white) +![Pipenv](https://img.shields.io/badge/Pipenv-343434?style=for-the-badge&logo=pipenv&logoColor=white) +![Flake8](https://img.shields.io/badge/Flake8-000000?style=for-the-badge&logo=flake8&logoColor=white) +![Pylint](https://img.shields.io/badge/Pylint-0D5BFF?style=for-the-badge&logo=pylint&logoColor=white) +![Shell](https://img.shields.io/badge/Shell-4EAA25?style=for-the-badge&logo=gnu-bash&logoColor=white) +![Docker](https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white) +![Docker Compose](https://img.shields.io/badge/Docker%20Compose-2496ED?style=for-the-badge&logo=docker&logoColor=white) +![Git](https://img.shields.io/badge/Git-F05032?style=for-the-badge&logo=git&logoColor=white) +![GitHub](https://img.shields.io/badge/GitHub-181717?style=for-the-badge&logo=github&logoColor=white) +![Postman](https://img.shields.io/badge/Postman-FF6C37?style=for-the-badge&logo=postman&logoColor=white) ## Local Environment Setup +Clone the repository if you haven't done so already, then follow these steps to set up your local environment: + 1. **Install Python** Use the following command to install Python: @@ -44,6 +67,19 @@ To run the application locally, follow these steps: ``` Adjust the command based on your application's entry point if different. +> **NOTE:** If your frontend is also running, ensure it is configured to communicate with this backend API. You may need to set the API URL in your frontend configuration. +> Also, you might need to use CORS (Cross-Origin Resource Sharing) if your frontend and backend are served from different origins. You can use the `flask-cors` package to handle this: +> ```shell +> pipenv install flask-cors +> ``` +> Then, in `src/__init__.py`, add: +> ```python +> from flask_cors import CORS +> # Then, after declaring `app` in the same file: +> CORS(app, origins=["http://localhost:3000"], supports_credentials=True, expose_headers=["Authorization"],) +> ``` +> This will allow your frontend to make requests to the backend without running into CORS issues. + ## Testing ### Pytest Naming @@ -102,5 +138,8 @@ Configure your local hooks as follows: git config core.hooksPath .githooks ``` +This will ensure that your local git hooks are used instead of the default ones. You can find the hooks in the `.githooks` directory. +## License +This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. diff --git a/src/cases/service/case_service.py b/src/cases/service/case_service.py index cbeccc5..955ab22 100644 --- a/src/cases/service/case_service.py +++ b/src/cases/service/case_service.py @@ -201,9 +201,7 @@ def get_value_of_observation(self, observation) -> str | None: if value and observation.unit_concept_id: value = value + " " + self.get_concept_name(observation.unit_concept_id) if value and observation.qualifier_concept_id: - value = ( - self.get_concept_name(observation.qualifier_concept_id) + " : " + value - ) + value = (self.get_concept_name(observation.qualifier_concept_id) + " : " + value) return value def get_nodes_of_background(self, case_id, title_config): @@ -270,142 +268,144 @@ def get_page_configuration(self): """ return self.system_config_repository.get_config_by_id("page_config").json_config - def get_case_review(self, case_config_id): # pragma: no cover + def get_case_review(self, case_config_id): # pragma: no cover """ - 1) Load the saved DisplayConfig (path_config list of { "path": "...", "style": {...} }) - for this case_config_id + user_email. If none or wrong user, error. - 2) Build the full “case_details” tree by calling `get_case_detail(case_id)`. - 3) Prune each parent (e.g. “Family History”, “Medical History”, etc.) down to exactly the - leaves specified in path_config. Attach collapse/highlight/top style at the parent. - 4) If the raw CSV contained exactly the path "RISK ASSESSMENT.CRC risk assessments", then - and only then fetch any “Adjusted CRC Risk: …” obs (concept_id = 45614722), rename it, - and append as “AI Colorectal Cancer Risk Score” under importantInfos. - 5) Return a Case(...) object containing the pruned tree plus any “important info” nodes. + 1) Load saved DisplayConfig for this case_config_id + user_email. + 2) Build full unpruned case_details tree. + 3) Prune under “BACKGROUND” according to CSV path_config. + 4) If CSV included a “Colorectal Cancer Score” entry, use that value + directly under “AI CRC Risk Score (<6: Low; 6-11: Medium; >11: High)”. + 5) Otherwise, if CSV toggled CRC risk assessments, look first for a + “Colorectal Cancer Score” observation in the DB; if none, fall + back to the old “Adjusted CRC Risk” observation logic. If still + nothing, emit “N/A”. """ + # load configuration & verify access configuration = self.configuration_repository.get_configuration_by_id(case_config_id) current_user = get_user_email_from_jwt() if not configuration or configuration.user_email != current_user: raise BusinessException(BusinessExceptionEnum.NoAccessToCaseReview) - # 1) Build the raw case_details (unpruned – contains all leaves) + # raw, unpruned case_details = self.get_case_detail(configuration.case_id) - # 2) Gather every single path_config entry into a map: parent_key → list of {leaf, style} + # index CSV path_config entries raw_path_cfg = configuration.path_config or [] parent_to_entries: dict[str, list[dict]] = defaultdict(list) - # Also: detect if the CSV explicitly toggled CRC risk assessments at all - has_crc_toggle = False + csv_crc_score_leaf: str | None = None + old_crc_toggle = False for entry in raw_path_cfg: - path_str = entry.get("path", "").strip() - style_dict = entry.get("style", {}) or {} + path_str = (entry.get("path") or "").strip() + style = entry.get("style") or {} if not path_str: - # skip lines with no path continue - # If exactly "RISK ASSESSMENT.CRC risk assessments" appears, note it: - if path_str == "RISK ASSESSMENT.CRC risk assessments": - has_crc_toggle = True - segments = path_str.split(".") if len(segments) < 2: - # malformed, skip continue - parent_key = ".".join(segments[:-1]) # e.g. "BACKGROUND.Family History" - leaf_text = segments[-1] # e.g. "Diabetes: Yes" - parent_to_entries[parent_key].append({ - "leaf": leaf_text, - "style": style_dict, - }) - - # 3) Build a list of important_infos for any node that has a “top” style - important_infos: list[dict] = [] - # 4) Walk down case_details to prune each parent. We only prune under “BACKGROUND” - for top_node in case_details: - if top_node.key != "BACKGROUND": + parent_key = ".".join(segments[:-1]) + leaf_text = segments[-1] + + # CSV‐provided score override? + if path_str.startswith("RISK ASSESSMENT.Colorectal Cancer Score"): + csv_crc_score_leaf = leaf_text + + # old‐style toggle + if path_str == "RISK ASSESSMENT.CRC risk assessments": + old_crc_toggle = True + + parent_to_entries[parent_key].append({"leaf": leaf_text, "style": style}) + + # prune under BACKGROUND + important_infos: list[dict] = [] + for top in case_details: + if top.key != "BACKGROUND": continue - # top_node.values is a list of TreeNode children: - # [ TreeNode("Patient Demographics"), TreeNode("Family History"), TreeNode("Social History"), - # TreeNode("Medical History"), ... ] - for child in top_node.values: - # ALWAYS keep "Patient Demographics" as-is (Age/Gender). Skip pruning for it: + for child in top.values: if child.key == "Patient Demographics": continue - parent_key = f"BACKGROUND.{child.key}" - if parent_key not in parent_to_entries: - # If CSV asked nothing about this parent, prune _all_ leaves under it. + pk = f"BACKGROUND.{child.key}" + if pk not in parent_to_entries: child.values = [] continue - # Otherwise, collect all leaf entries under this parent - entries = parent_to_entries[parent_key] - leaves_to_keep = {e["leaf"] for e in entries} + entries = parent_to_entries[pk] + keep = {e["leaf"] for e in entries} + child.values = [v for v in child.values if v in keep] - # child.values was originally a list of strings (all possible leaves). Prune: - child.values = [val for val in child.values if val in leaves_to_keep] - - # Merge all style dicts from entries for this parent into one combined style - combined_style: dict = {} + merged: dict = {} for e in entries: - style_dict = e.get("style") or {} - # CSV generator sets collapse=False to indicate “show this leaf” → invert it - if "collapse" in style_dict: - combined_style["collapse"] = not style_dict["collapse"] - if "highlight" in style_dict: - combined_style["highlight"] = style_dict["highlight"] - if "top" in style_dict: - # If multiple “top” values, pick the largest weight - if "top" not in combined_style or style_dict["top"] > combined_style["top"]: - combined_style["top"] = style_dict["top"] - - # Attach the merged style to this parent node - child.style = combined_style - - # If there is a “top” key, add to important_infos - if "top" in combined_style: + s = e["style"] + if "collapse" in s: + merged["collapse"] = not s["collapse"] + if "highlight" in s: + merged["highlight"] = s["highlight"] + if "top" in s: + merged["top"] = max(merged.get("top", -1), s["top"]) + + child.style = merged + if "top" in merged: important_infos.append({ "key": child.key, "values": child.values, - "weight": combined_style["top"], + "weight": merged["top"], }) - # 5) Sort important_infos by weight, then convert to TreeNode list + # sort & wrap important_infos.sort(key=itemgetter("weight")) - sorted_important_infos = list(map(lambda e: TreeNode(e["key"], e["values"]), important_infos)) + sorted_important = [TreeNode(e["key"], e["values"]) for e in important_infos] + + # label we use in the UI + ai_label = "AI CRC Risk Score (<6: Low; 6-11: Medium; >11: High)" + + if csv_crc_score_leaf: + sorted_important.append(TreeNode(ai_label, [csv_crc_score_leaf])) - # 6) ONLY if the CSV included "RISK ASSESSMENT.CRC risk assessments", - # fetch any “Adjusted CRC Risk: …” observation (concept_id = 45614722), - # rename it to “AI-Predicted CRC Risk Score”, and append as “AI Colorectal Cancer Risk Score”. - if has_crc_toggle: + # fallback branch + elif old_crc_toggle: crc_obs = self.observation_repository.get_observations_by_concept( configuration.case_id, [45614722] ) - for obs in crc_obs: - if obs.value_as_string and obs.value_as_string.startswith("Adjusted CRC Risk"): - # Rewrite the leaf text: replace "Adjusted CRC Risk" with "AI-Predicted CRC Risk Score" - raw_text = obs.value_as_string # e.g. "Adjusted CRC Risk: 0.546125" - new_leaf = raw_text.replace( - "Adjusted CRC Risk", "AI-Predicted CRC Risk Score" - ) - # Use "AI Colorectal Cancer Risk Score" as the parent key - sorted_important_infos.append( - TreeNode("AI Colorectal Cancer Risk Score", [new_leaf]) - ) - break - # 7) Return a Case object containing: - # • person_source_value (e.g. MRN or whatever) - # • case_id (converted to str) - # • case_details - # • importantInfos (only includes AI‐CRC node if CSV requested RISK ASSESSMENT toggles) + # (i) is there a literal Colorectal Cancer Score: X row? + csv_obs = next( + ( + o.value_as_string + for o in crc_obs + if o.value_as_string and o.value_as_string.startswith("Colorectal Cancer Score") + ), + None + ) + if csv_obs: + sorted_important.append(TreeNode(ai_label, [csv_obs])) + else: + # (ii) fall back to Adjusted CRC Risk + for obs in crc_obs: + txt = obs.value_as_string or "" + if txt.startswith("Adjusted CRC Risk"): + new_txt = txt.replace( + "Adjusted CRC Risk", + "AI-Predicted CRC Risk Score" + ) + sorted_important.append(TreeNode(ai_label, [new_txt])) + break + else: + # (iii) still nothing? emit N/A + sorted_important.append(TreeNode(ai_label, ["N/A"])) + + # if neither CSV nor old toggle, emit N/A + else: + sorted_important.append(TreeNode(ai_label, ["N/A"])) + return Case( self.person.person_source_value, str(configuration.case_id), case_details, - sorted_important_infos, + sorted_important, ) def __get_current_case_by_user(self, user_email) -> tuple[int, str] | tuple[None, None]: diff --git a/src/common/exception/BusinessException.py b/src/common/exception/BusinessException.py index bc7a75b..15b32e7 100644 --- a/src/common/exception/BusinessException.py +++ b/src/common/exception/BusinessException.py @@ -49,7 +49,8 @@ class BusinessExceptionEnum(Enum): "Failed to reset password due to expired token. Please resend reset password request.", ) RenderTemplateError = ("1030", "Template render error.") - SendEmailError = ("1040", "Email failed to send. Please try again.") + SendEmailError = ("1040", "Email failed to send. Please try again. If the problem persists, " + "contact dhep.lab@gmail.com for support.") def __init__(self, code: str, message: str): self._code = code diff --git a/tests/cases/controller/case_controller_test.py b/tests/cases/controller/case_controller_test.py index cc01a3f..6d4108c 100644 --- a/tests/cases/controller/case_controller_test.py +++ b/tests/cases/controller/case_controller_test.py @@ -54,6 +54,15 @@ def test_get_case_review(client, session, mocker): expected["details"][0]["values"][1]["values"] = [] expected["details"][0]["values"][2]["values"] = [] + # inject the AI CRC Risk Score node + expected["importantInfos"] = [ + { + "key": "AI CRC Risk Score (<6: Low; 6-11: Medium; >11: High)", + "style": None, + "values": ["N/A"], + } + ] + assert data == expected diff --git a/tests/cases/service/case_service_test.py b/tests/cases/service/case_service_test.py index 8aa63d1..3f6152d 100644 --- a/tests/cases/service/case_service_test.py +++ b/tests/cases/service/case_service_test.py @@ -905,6 +905,7 @@ def test_get_case_review_with_configuration_and_path_config(self, mocker): case_review = case_service.get_case_review(1) + # now expect the AI CRC Risk Score node assert case_review == Case( personName="sunwukong", caseNumber="1", @@ -919,7 +920,12 @@ def test_get_case_review_with_configuration_and_path_config(self, mocker): ], ) ], - importantInfos=[], + importantInfos=[ + TreeNode( + "AI CRC Risk Score (<6: Low; 6-11: Medium; >11: High)", + ["N/A"], + ) + ], ) def test_get_case_review_without_path_config(self, mocker): @@ -934,10 +940,12 @@ def test_get_case_review_without_path_config(self, mocker): system_config_repository, diagnosis_repository, ) = mock_repos(mocker) + # simulate no path_config on the DisplayConfig configuration_repository.get_configuration_by_id.return_value = DisplayConfig( user_email='goodbye@sunwukong.com', case_id=1 ) + case_service = CaseService( visit_occurrence_repository=visit_occurrence_repository, concept_repository=concept_repository, @@ -966,37 +974,14 @@ def test_get_case_review_without_path_config(self, mocker): ], ) ], - importantInfos=[] - ) - - def test_throw_error_when_configuration_not_found(self, mocker): - ( - concept_repository, - configuration_repository, - drug_exposure_repository, - measurement_repository, - observation_repository, - person_repository, - visit_occurrence_repository, - system_config_repository, - diagnosis_repository, - ) = mock_repos(mocker) - configuration_repository.get_configuration_by_id.return_value = None - case_service = CaseService( - visit_occurrence_repository=visit_occurrence_repository, - concept_repository=concept_repository, - measurement_repository=measurement_repository, - observation_repository=observation_repository, - person_repository=person_repository, - drug_exposure_repository=drug_exposure_repository, - configuration_repository=configuration_repository, - system_config_repository=system_config_repository, - diagnose_repository=diagnosis_repository + importantInfos=[ + TreeNode( + "AI CRC Risk Score (<6: Low; 6-11: Medium; >11: High)", + ["N/A"], + ) + ], ) - with pytest.raises(BusinessException, match=re.compile(BusinessExceptionEnum.NoAccessToCaseReview.name)): - case_service.get_case_review(1) - def test_get_case_review_when_path_config_top_area(self, mocker): ( concept_repository, @@ -1051,7 +1036,12 @@ def test_get_case_review_when_path_config_top_area(self, mocker): ], ) ], - importantInfos=[], + importantInfos=[ + TreeNode( + "AI CRC Risk Score (<6: Low; 6-11: Medium; >11: High)", + ["N/A"], + ) + ], ) diff --git a/tests/user/controller/auth_controller_test.py b/tests/user/controller/auth_controller_test.py index 867d0b9..24ac777 100644 --- a/tests/user/controller/auth_controller_test.py +++ b/tests/user/controller/auth_controller_test.py @@ -163,7 +163,7 @@ def test_reset_password_request_failed_with_no_user(client, mocker): assert response.status_code == 500 assert { "data": None, - "error": {'code': '1040', 'message': 'Email failed to send. Please try again.'} + "error": {'code': '1040', 'message': 'Email failed to send. Please try again. If the problem persists, contact dhep.lab@gmail.com for support.'} } == response.json