Repository: communitiesuk/funding-service-pre-award
Pull request #329: Display percentage score in Assess
Author: login: ksp37-dluhc
Created: 2025-03-13T16:17:35Z | Merged: 2025-04-07T09:01:13Z

### Change description

This PR implements the display of the total percentage score in Assess. It updates an existing method `calculate_overall_score_percentage_for_application` and adds additional test cases to cover various new scenarios (e.g. zero weights, running scores etc).

- [ ] Unit tests and other appropriate tests added or updated
- [ ] README and other documentation has been updated / added (if needed)
- [ ] Commit messages are meaningful and follow good commit message guidelines (e.g. "FS-XXXX: Add margin to nav items preventing overlapping of logo")


### How to test
_If manual testing is needed, give suggested testing steps_


### Screenshots of UI changes (if applicable)
<img width="674" alt="Screenshot 2025-04-02 at 10 26 44" src="https://github.com/user-attachments/assets/619192f8-cbed-4ca4-bc38-cc972853aef6" />




Diff:
diff --git a/pre_award/assess/assessments/routes.py b/pre_award/assess/assessments/routes.py
index 9f507d997..76cfdb04d 100644
--- a/pre_award/assess/assessments/routes.py
+++ b/pre_award/assess/assessments/routes.py
@@ -139,6 +139,7 @@
     map_application_with_sub_criterias_and_themes,
     order_entire_application_by_themes,
 )
+from pre_award.assessment_store.api.routes.assessment_routes import calculate_overall_score_percentage_for_application
 from pre_award.assessment_store.db.models.assessment_record.enums import Status as WorkflowStatus
 from pre_award.assessment_store.db.queries.flags.queries import (
     get_change_requests_for_application,
@@ -1697,6 +1698,10 @@ def application(application_id):
             )
         )
 
+    percentage_score = calculate_overall_score_percentage_for_application(
+        application_id=application_id, fund_id=fund.id, round_id=fund_round.id
+    )
+
     return render_template(
         "assessments/assessor_tasklist.html",
         sub_criteria_status_completed=sub_criteria_status_completed,
@@ -1723,6 +1728,7 @@ def application(application_id):
         comment_form=comment_form,
         comments=theme_matched_comments,
         fund=fund,
+        percentage_score=percentage_score,
     )
 
 
diff --git a/pre_award/assess/assessments/templates/assessments/assessor_tasklist.html b/pre_award/assess/assessments/templates/assessments/assessor_tasklist.html
index f68cc02b0..335a25784 100644
--- a/pre_award/assess/assessments/templates/assessments/assessor_tasklist.html
+++ b/pre_award/assess/assessments/templates/assessments/assessor_tasklist.html
@@ -194,8 +194,12 @@ <h2 class="govuk-heading-l govuk-!-margin-top-8">Scored</h2>
                 {{ criteria_element(criteria, name_classes, application_id, max_possible_sub_criteria_score) }}
             {% endfor %}
         {% endif %}
+        {% if not is_uncompeted_flow(fund) and percentage_score %}
+        <p class="govuk-body-l"><strong>Overall score:</strong> {{percentage_score}}%</p>
+        {% endif %}
     </div>
 
+
 </div>
 {% if g.access_controller.is_assessor %}
    </div>
diff --git a/pre_award/assessment_store/api/routes/assessment_routes.py b/pre_award/assessment_store/api/routes/assessment_routes.py
index 00ee02cbe..90f8b8b5a 100644
--- a/pre_award/assessment_store/api/routes/assessment_routes.py
+++ b/pre_award/assessment_store/api/routes/assessment_routes.py
@@ -1,4 +1,5 @@
 import copy
+from decimal import ROUND_HALF_UP, Decimal
 from typing import Dict, List
 
 from flask import Response, current_app, request
@@ -37,18 +38,18 @@
 assessment_assessment_bp = Blueprint("assessment_assessment_bp", __name__)
 
 
-def calculate_overall_score_percentage_for_application(application):
-    scoring_system = get_scoring_system_for_round_id(application["round_id"])
+def calculate_overall_score_percentage_for_application(application_id, fund_id, round_id):
+    scoring_system = get_scoring_system_for_round_id(round_id)
 
     # Deep copy the assessment mapping configuration for the specific fund and round
-    mapping = copy.deepcopy(Config.ASSESSMENT_MAPPING_CONFIG[f"{application['fund_id']}:{application['round_id']}"])
+    mapping = copy.deepcopy(Config.ASSESSMENT_MAPPING_CONFIG[f"{fund_id}:{round_id}"])
     sub_criteria_to_criteria_weighting_map = {}
     highest_possible_weighted_score_for_round = 0
     if mapping["scored_criteria"] == []:
         # We have no scoring config for this round (possibly an EOI)
         current_app.logger.info(
             "No scoring config found for %(fund_id)s:%(round_id)s",
-            dict(fund_id=application["fund_id"], round_id=application["round_id"]),
+            dict(fund_id=fund_id, round_id=round_id),
         )
         return None
 
@@ -66,12 +67,11 @@ def calculate_overall_score_percentage_for_application(application):
 
     application_weighted_score = sum(
         sub_criteria_score * sub_criteria_to_criteria_weighting_map[sub_criteria]
-        for sub_criteria, sub_criteria_score in get_sub_criteria_to_latest_score_map(
-            application["application_id"]
-        ).items()
+        for sub_criteria, sub_criteria_score in get_sub_criteria_to_latest_score_map(application_id).items()
     )
-
-    return (application_weighted_score / highest_possible_weighted_score_for_round) * 100
+    score = (application_weighted_score / highest_possible_weighted_score_for_round) * 100
+    rounded_score = Decimal(score).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
+    return float(rounded_score)
 
 
 @assessment_assessment_bp.get("/application/<application_id>/metadata")
@@ -137,7 +137,9 @@ def all_assessments_for_fund_round_id(
 
     # Calculate and assign score percentages for each application
     for app in app_list:
-        app["overall_score_percentage"] = calculate_overall_score_percentage_for_application(app)
+        app["overall_score_percentage"] = calculate_overall_score_percentage_for_application(
+            application_id=app["application_id"], fund_id=app["fund_id"], round_id=app["round_id"]
+        )
 
     return compress_response(app_list)
 
diff --git a/tests/pre_award/assess_tests/conftest.py b/tests/pre_award/assess_tests/conftest.py
index c2186e316..f2771092e 100644
--- a/tests/pre_award/assess_tests/conftest.py
+++ b/tests/pre_award/assess_tests/conftest.py
@@ -848,6 +848,16 @@ def mock_get_tag_map_and_tag_options(mocker):
     yield
 
 
+@pytest.fixture(scope="function")
+def mock_get_calculate_overall_score_percentage(request, mocker):
+    mocker.patch(
+        "pre_award.assess.assessments.routes.calculate_overall_score_percentage_for_application",
+        return_value="0",
+    )
+
+    yield
+
+
 @pytest.fixture(scope="function")
 def mock_get_scoring_system(request, mocker):
     mocker.patch(
diff --git a/tests/pre_award/assess_tests/test_authorisation.py b/tests/pre_award/assess_tests/test_authorisation.py
index 15041745c..2cf682cf8 100644
--- a/tests/pre_award/assess_tests/test_authorisation.py
+++ b/tests/pre_award/assess_tests/test_authorisation.py
@@ -659,6 +659,7 @@ def test_resolve_flag_option_shows_for_correct_permissions(
         mock_get_associated_tags_for_application,
         mocker,
         mock_get_scoring_system,
+        mock_get_calculate_overall_score_percentage,
     ):
         token = create_valid_token(user_account)
         assess_test_client.set_cookie("fsd_user_token", token)
diff --git a/tests/pre_award/assess_tests/test_routes.py b/tests/pre_award/assess_tests/test_routes.py
index ede13ad8b..a7c4244ac 100644
--- a/tests/pre_award/assess_tests/test_routes.py
+++ b/tests/pre_award/assess_tests/test_routes.py
@@ -1033,6 +1033,7 @@ def test_application_route_should_show_stopped_flag(
         mock_get_associated_tags_for_application,
         mocker,
         mock_get_scoring_system,
+        mock_get_calculate_overall_score_percentage,
     ):
         marker = request.node.get_closest_marker("application_id")
         application_id = marker.args[0]
@@ -1066,6 +1067,7 @@ def test_application_route_should_show_resolved_flag(
         mock_get_associated_tags_for_application,
         mocker,
         mock_get_scoring_system,
+        mock_get_calculate_overall_score_percentage,
     ):
         marker = request.node.get_closest_marker("application_id")
         application_id = marker.args[0]
@@ -1300,6 +1302,7 @@ def test_qa_complete_flag_displayed(
         mock_get_associated_tags_for_application,
         mocker,
         mock_get_scoring_system,
+        mock_get_calculate_overall_score_percentage,
     ):
         token = create_valid_token(test_lead_assessor_claims)
         assess_test_client.set_cookie("fsd_user_token", token)
@@ -1328,6 +1331,7 @@ def test_qa_completed_flagged_application(
         mock_get_associated_tags_for_application,
         mocker,
         mock_get_scoring_system,
+        mock_get_calculate_overall_score_percentage,
     ):
         token = create_valid_token(test_lead_assessor_claims)
         assess_test_client.set_cookie("fsd_user_token", token)
@@ -1504,6 +1508,7 @@ def test_change_received_notification_banner(
         mock_get_application_metadata,
         mock_get_assessor_tasklist_state,
         mock_get_scoring_system,
+        mock_get_calculate_overall_score_percentage,
     ):
         response = assess_test_client.get("/assess/application/uncompeted_app")
         assert response.status_code == 200
diff --git a/tests/pre_award/assessment_store_tests/test_routes.py b/tests/pre_award/assessment_store_tests/test_routes.py
index 8852275e4..ec49fcebb 100644
--- a/tests/pre_award/assessment_store_tests/test_routes.py
+++ b/tests/pre_award/assessment_store_tests/test_routes.py
@@ -6,6 +6,7 @@
 from uuid import uuid4
 
 import pytest
+from pytest import approx
 from sqlalchemy import select
 
 from pre_award.assessment_store.api.routes.assessment_routes import (
@@ -817,16 +818,96 @@ def mock_get_scores(mocker):
 def test_calculate_overall_score_percentage_for_application(mocker, mock_get_scores, mock_get_scoring_system):
     mock_config = mocker.patch("pre_award.assessment_store.api.routes.assessment_routes.Config")
     mock_config.ASSESSMENT_MAPPING_CONFIG = mapping_config
-    result = calculate_overall_score_percentage_for_application(app)
+    result = calculate_overall_score_percentage_for_application(
+        application_id=app["application_id"], fund_id=app["fund_id"], round_id=app["round_id"]
+    )
     expected_score = ((3 * 2 + 4 * 2 + 5 * 1) / (5 * 2 * 2 + 5 * 1 * 1)) * 100
     assert result == expected_score, "The calculated score did not match the expected score"
 
 
+def test_calculate_running_score_percentage_for_application(mocker, mock_get_scores, mock_get_scoring_system):
+    mock_config = mocker.patch("pre_award.assessment_store.api.routes.assessment_routes.Config")
+    mock_config.ASSESSMENT_MAPPING_CONFIG = {
+        f"{COF_FUND_ID}:{COF_ROUND_2_ID}": {
+            "scored_criteria": [
+                {
+                    "id": "criteria1",
+                    "weighting": 0.3,
+                    "sub_criteria": [{"id": "sub1"}, {"id": "sub2"}],
+                },
+                {"id": "criteria2", "weighting": 0.6, "sub_criteria": [{"id": "sub3"}]},
+                {"id": "criteria3", "weighting": 0.1, "sub_criteria": [{"id": "sub4"}]},
+            ]
+        }
+    }
+    mock_get_scores.return_value = {"sub1": 3, "sub3": 5}
+    result = calculate_overall_score_percentage_for_application(
+        application_id=app["application_id"], fund_id=app["fund_id"], round_id=app["round_id"]
+    )
+    # Note this is a fractional percentage
+    expected_score = ((3 * 0.3 + 5 * 0.6) / (5 * 2 * 0.3 + 5 * 1 * 0.6 + 5 * 1 * 0.1)) * 100
+    rounded_expected_score = round(expected_score, 2)
+    assert result == approx(rounded_expected_score), "The calculated score did not match the expected score"
+
+
+def test_calculate_score_percentage_with_zero_weights(mocker, mock_get_scores, mock_get_scoring_system):
+    mock_config = mocker.patch("pre_award.assessment_store.api.routes.assessment_routes.Config")
+    mock_config.ASSESSMENT_MAPPING_CONFIG = {
+        f"{COF_FUND_ID}:{COF_ROUND_2_ID}": {
+            "scored_criteria": [
+                {
+                    "id": "criteria1",
+                    "weighting": 0.3,
+                    "sub_criteria": [{"id": "sub1"}, {"id": "sub2"}],
+                },
+                {"id": "criteria2", "weighting": 0.7, "sub_criteria": [{"id": "sub3"}]},
+                {"id": "criteria3", "weighting": 0, "sub_criteria": [{"id": "sub4"}]},
+            ]
+        }
+    }
+    mock_get_scores.return_value = {"sub1": 3, "sub2": 0, "sub3": 5, "sub4": 5}
+    result = calculate_overall_score_percentage_for_application(
+        application_id=app["application_id"], fund_id=app["fund_id"], round_id=app["round_id"]
+    )
+
+    expected_score = ((3 * 0.3 + 5 * 0.7) / (5 * 2 * 0.3 + 5 * 1 * 0.7)) * 100
+    rounded_expected_score = round(expected_score, 2)
+    assert result == approx(rounded_expected_score), "The calculated score did not match the expected score"
+
+
+def test_calculate_score_percentage_round_half_up(mocker, mock_get_scores):
+    mock_config = mocker.patch("pre_award.assessment_store.api.routes.assessment_routes.Config")
+    mocker.patch(
+        "pre_award.assessment_store.api.routes.assessment_routes.get_scoring_system_for_round_id",
+        return_value={"maximum_score": 4},
+    )
+    mock_config.ASSESSMENT_MAPPING_CONFIG = {
+        f"{COF_FUND_ID}:{COF_ROUND_2_ID}": {
+            "scored_criteria": [
+                {
+                    "id": "criteria1",
+                    "weighting": 1,
+                    "sub_criteria": [{"id": "sub1"}],
+                },
+            ]
+        }
+    }
+    mock_get_scores.return_value = {"sub1": 0.405}
+    result = calculate_overall_score_percentage_for_application(
+        application_id=app["application_id"], fund_id=app["fund_id"], round_id=app["round_id"]
+    )
+    # We should get a raw score of 10.125 . In banker's rounding (default) this will be rounded to 10.12,
+    # in half-up rounding this should be 10.13.
+    assert result == approx(10.13), "The calculated score did not round the expected way"
+
+
 def test_with_no_sub_criteria_scores(mocker, mock_get_scores, mock_get_scoring_system):
     mock_config = mocker.patch("pre_award.assessment_store.api.routes.assessment_routes.Config")
     mock_config.ASSESSMENT_MAPPING_CONFIG = mapping_config
     mock_get_scores.return_value = {}
-    result = calculate_overall_score_percentage_for_application(app)
+    result = calculate_overall_score_percentage_for_application(
+        application_id=app["application_id"], fund_id=app["fund_id"], round_id=app["round_id"]
+    )
     assert result == 0, "The result should be 0 when there are no sub-criteria scores"
 
 
@@ -835,11 +916,15 @@ def test_with_invalid_application_id(mocker, mock_get_scores, mock_get_scoring_s
     mock_config.ASSESSMENT_MAPPING_CONFIG = mapping_config
     mock_get_scores.side_effect = KeyError("Invalid application ID")
     with pytest.raises(KeyError):
-        calculate_overall_score_percentage_for_application(app)
+        calculate_overall_score_percentage_for_application(
+            application_id=app["application_id"], fund_id=app["fund_id"], round_id=app["round_id"]
+        )
 
 
 def test_no_scored_criteria_exists(mocker, mock_get_scores, mock_get_scoring_system):
     mock_config = mocker.patch("pre_award.assessment_store.api.routes.assessment_routes.Config")
     mock_config.ASSESSMENT_MAPPING_CONFIG = {f"{COF_FUND_ID}:{COF_ROUND_2_ID}": {"scored_criteria": []}}
-    result = calculate_overall_score_percentage_for_application(app)
+    result = calculate_overall_score_percentage_for_application(
+        application_id=app["application_id"], fund_id=app["fund_id"], round_id=app["round_id"]
+    )
     assert result is None, "The result should be 0 when there are no scored criteria"


Reviews:
user: login: israr-ulhaq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T13:39:08Z
user: login: israr-ulhaq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T13:39:13Z
user: login: israr-ulhaq
body: Hi @ksp37-dluhc  — perfect math, I tested it with CFA and it worked fine. It calculated all the scored criteria correctly for both running and overall score. Great work! Just two quick and small changes that I think would be nice to do.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T13:39:27Z
user: login: ksp37-dluhc
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-04T14:29:14Z
user: login: israr-ulhaq
body: Looks great to me!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T09:01:12Z

Review comments:
diff_hunk: @@ -194,8 +194,14 @@ <h2 class="govuk-heading-l govuk-!-margin-top-8">Scored</h2>
                 {{ criteria_element(criteria, name_classes, application_id, max_possible_sub_criteria_score) }}
             {% endfor %}
         {% endif %}
+        {% if sub_criteria_status_completed %}
+        <p class="govuk-body-l"><strong>Overall score:</strong> {{percentage_score}}%</p>
+        {% else %}
+        <p class="govuk-body-l"><strong>Running score:</strong> {{percentage_score}}%</p>
+        {% endif %}
     </div>
 
+
path: pre_award/assess/assessments/templates/assessments/assessor_tasklist.html
user: login: israr-ulhaq
body: Could we please disable it for the un-competed flow?
created_at: 2025-04-04T13:39:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,18 +38,18 @@
 assessment_assessment_bp = Blueprint("assessment_assessment_bp", __name__)
 
 
-def calculate_overall_score_percentage_for_application(application):
-    scoring_system = get_scoring_system_for_round_id(application["round_id"])
+def calculate_overall_score_percentage_for_application(application_id, fund_id, round_id):
+    scoring_system = get_scoring_system_for_round_id(round_id)
 
     # Deep copy the assessment mapping configuration for the specific fund and round
-    mapping = copy.deepcopy(Config.ASSESSMENT_MAPPING_CONFIG[f"{application['fund_id']}:{application['round_id']}"])
+    mapping = copy.deepcopy(Config.ASSESSMENT_MAPPING_CONFIG[f"{fund_id}:{round_id}"])
     sub_criteria_to_criteria_weighting_map = {}
     highest_possible_weighted_score_for_round = 0
     if mapping["scored_criteria"] == []:
         # We have no scoring config for this round (possibly an EOI)
         current_app.logger.info(
             "No scoring config found for %(fund_id)s:%(round_id)s",
-            dict(fund_id=application["fund_id"], round_id=application["round_id"]),
+            dict(fund_id=fund_id, round_id=round_id),
         )
         return None
 
path: pre_award/assessment_store/api/routes/assessment_routes.py
user: login: israr-ulhaq
body: If scored_criteria is empty, we return None, which shows as Overall score: None%. Maybe we should hide the score in this case for a cleaner display?
created_at: 2025-04-04T13:39:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,18 +38,18 @@
 assessment_assessment_bp = Blueprint("assessment_assessment_bp", __name__)
 
 
-def calculate_overall_score_percentage_for_application(application):
-    scoring_system = get_scoring_system_for_round_id(application["round_id"])
+def calculate_overall_score_percentage_for_application(application_id, fund_id, round_id):
+    scoring_system = get_scoring_system_for_round_id(round_id)
 
     # Deep copy the assessment mapping configuration for the specific fund and round
-    mapping = copy.deepcopy(Config.ASSESSMENT_MAPPING_CONFIG[f"{application['fund_id']}:{application['round_id']}"])
+    mapping = copy.deepcopy(Config.ASSESSMENT_MAPPING_CONFIG[f"{fund_id}:{round_id}"])
     sub_criteria_to_criteria_weighting_map = {}
     highest_possible_weighted_score_for_round = 0
     if mapping["scored_criteria"] == []:
         # We have no scoring config for this round (possibly an EOI)
         current_app.logger.info(
             "No scoring config found for %(fund_id)s:%(round_id)s",
-            dict(fund_id=application["fund_id"], round_id=application["round_id"]),
+            dict(fund_id=fund_id, round_id=round_id),
         )
         return None
 
path: pre_award/assessment_store/api/routes/assessment_routes.py
user: login: ksp37-dluhc
body: Ooo good spot. I've updated it, thanks!
created_at: 2025-04-04T14:28:24Z
author_association: NONE
diff_hunk: @@ -194,8 +194,14 @@ <h2 class="govuk-heading-l govuk-!-margin-top-8">Scored</h2>
                 {{ criteria_element(criteria, name_classes, application_id, max_possible_sub_criteria_score) }}
             {% endfor %}
         {% endif %}
+        {% if sub_criteria_status_completed %}
+        <p class="govuk-body-l"><strong>Overall score:</strong> {{percentage_score}}%</p>
+        {% else %}
+        <p class="govuk-body-l"><strong>Running score:</strong> {{percentage_score}}%</p>
+        {% endif %}
     </div>
 
+
path: pre_award/assess/assessments/templates/assessments/assessor_tasklist.html
user: login: ksp37-dluhc
body: Yep, I missed that, thank you! I've also removed the conditional for "Running score" - that was an old design, but in the latest design we use "Overall score" everywhere.
created_at: 2025-04-04T14:29:10Z
author_association: NONE

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-07T08:13:35Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=communitiesuk_funding-service-pre-award-stores&pullRequest=329) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [3 New issues](https://sonarcloud.io/project/issues?id=communitiesuk_funding-service-pre-award-stores&pullRequest=329&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=communitiesuk_funding-service-pre-award-stores&pullRequest=329&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=communitiesuk_funding-service-pre-award-stores&pullRequest=329&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=communitiesuk_funding-service-pre-award-stores&pullRequest=329&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=communitiesuk_funding-service-pre-award-stores&pullRequest=329&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=communitiesuk_funding-service-pre-award-stores&pullRequest=329)


author_association: NONE