From ab062a5bf2ec2e3a2831e0bc51e146b81291395f Mon Sep 17 00:00:00 2001 From: Katie Gardner_ONS <114991656+katie-gardner@users.noreply.github.com> Date: Mon, 5 Jun 2023 11:06:02 +0100 Subject: [PATCH] Update Prepop branch with changes from main (#1126) * Make response expiry date mandatory (#1104) * Bind additional contexts to flush requests (#1108) * Schemas v3.56.0 (#1110) * Schemas v3.57.0 (#1113) * Schemas v3.57.1 (#1115) * Schemas v3.58.0 (#1116) * Implement "progress" value source (#1044) Co-authored-by: Rhys Berrow <47635349+berroar@users.noreply.github.c * Fix handling of invalid values in form numerical inputs (#1111) * Update Chromedriver to version 113 (#1118) * update chromedriver * temporarily comment out line, to be fixed separately * Feat/Grand calculated summary (#1107) * initial ideas * Implementation closer to calculated summaries * better test schemas and routing evaluation * get routing working * dont need section id * make routing better, start adding tests * start fixing test schemas * Two further schema fixes * Fix remaining schema issue * Add integration test * Add functional test * Functional tests for routing and answers not on the path * Changing an answer updates GCS progress * Add schema for overlapping answers * Functionally test skip-question in grand calculated summary * Add tests for GCS routing * Fix errors caused by merge * type hints, comments, remove some duplication * fix formatting * Update translation templates * remove accidental update * Fix some type hints * Use validator branch and update test description * refactor GCS answer format * Fix lint and test errors * Address PR comments * improve context and coverage of test schemas * Fix invalid schema and add invalid routing test * Fix line length in test * test schema linting * Fix routing bug * Make schema easier to follow * Linting * partial fix to routing * Add addtional routing test case * Remove arg from new test case * Routing to next incomplete block for summaries * linting error * remove arg from router tests * PR comments * PR Comment * Amend comment and improve dependencies function * Handle merge errors * fix gcs routing within same section * Add test for GCS with progress value source * Remove backtick * update chromedriver * Revert "update chromedriver" This reverts commit a2e3698b7a3f437b76e08472f973a4aef8d273d9. * Fix schema labels * Revert validator branch to latest --------- Co-authored-by: petechd <53475968+petechd@users.noreply.github.com> Co-authored-by: Mebin Abraham <35296336+MebinAbraham@users.noreply.github.com> Co-authored-by: Guilhem <122792081+ONS-Guilhem-Forey@users.noreply.github.com> --- app/jinja_filters.py | 18 +- app/questionnaire/questionnaire_schema.py | 95 +++- app/questionnaire/router.py | 170 ++++++- app/questionnaire/value_source_resolver.py | 14 +- app/translations/messages.pot | 56 +-- app/views/contexts/__init__.py | 2 + .../contexts/calculated_summary_context.py | 131 ++++-- .../grand_calculated_summary_context.py | 124 ++++++ app/views/contexts/section_summary_context.py | 4 +- .../summary/calculated_summary_block.py | 83 ++++ app/views/contexts/summary/group.py | 55 ++- .../contexts/summary/list_collector_block.py | 9 +- app/views/handlers/block.py | 8 + app/views/handlers/block_factory.py | 6 +- ...ated_summary.py => calculation_summary.py} | 21 +- .../en/test_grand_calculated_summary.json | 294 ++++++++++++ ...ed_summary_cross_section_dependencies.json | 341 ++++++++++++++ ..._calculated_summary_multiple_sections.json | 348 +++++++++++++++ ...alculated_summary_overlapping_answers.json | 329 ++++++++++++++ templates/calculatedsummary.html | 35 +- templates/grandcalculatedsummary.html | 5 + templates/layouts/_calculatedsummary.html | 35 ++ tests/app/questionnaire/conftest.py | 25 +- tests/app/questionnaire/test_router.py | 418 +++++++++++++++++- tests/app/views/contexts/__init__.py | 10 +- tests/app/views/contexts/conftest.py | 20 + .../test_calculated_summary_context.py | 89 +++- .../test_grand_calculated_summary_context.py | 111 +++++ .../grand-calculated-summary.page.js | 17 + tests/functional/generate_pages.py | 19 + ...summary_cross_section_dependencies.spec.js | 120 +++++ ...lculated_summary_multiple_sections.spec.js | 154 +++++++ ...ulated_summary_overlapping_answers.spec.js | 130 ++++++ ..._questionnaire_grand_calculated_summary.py | 163 +++++++ 34 files changed, 3276 insertions(+), 183 deletions(-) create mode 100644 app/views/contexts/grand_calculated_summary_context.py create mode 100644 app/views/contexts/summary/calculated_summary_block.py rename app/views/handlers/{calculated_summary.py => calculation_summary.py} (65%) create mode 100644 schemas/test/en/test_grand_calculated_summary.json create mode 100644 schemas/test/en/test_grand_calculated_summary_cross_section_dependencies.json create mode 100644 schemas/test/en/test_grand_calculated_summary_multiple_sections.json create mode 100644 schemas/test/en/test_grand_calculated_summary_overlapping_answers.json create mode 100644 templates/grandcalculatedsummary.html create mode 100644 templates/layouts/_calculatedsummary.html create mode 100644 tests/app/views/contexts/test_grand_calculated_summary_context.py create mode 100644 tests/functional/base_pages/grand-calculated-summary.page.js create mode 100644 tests/functional/spec/features/grand_calculated_summary/grand_calculated_summary_cross_section_dependencies.spec.js create mode 100644 tests/functional/spec/features/grand_calculated_summary/grand_calculated_summary_multiple_sections.spec.js create mode 100644 tests/functional/spec/features/grand_calculated_summary/grand_calculated_summary_overlapping_answers.spec.js create mode 100644 tests/integration/questionnaire/test_questionnaire_grand_calculated_summary.py diff --git a/app/jinja_filters.py b/app/jinja_filters.py index 8bfe24b0f1..834a036502 100644 --- a/app/jinja_filters.py +++ b/app/jinja_filters.py @@ -12,6 +12,7 @@ from markupsafe import Markup, escape from wtforms import SelectFieldBase +from app.questionnaire.questionnaire_schema import is_summary_with_calculation from app.questionnaire.rules.utils import parse_datetime from app.settings import MAX_NUMBER @@ -469,7 +470,7 @@ def __init__( # noqa: C901, R0912 pylint: disable=too-complex, too-many-branche ( multiple_answers or answer_type == "relationship" - or summary_type == "CalculatedSummary" + or is_summary_with_calculation(summary_type) ) and "label" in answer and answer["label"] @@ -553,7 +554,7 @@ def __init__( multiple_answers = len(question["answers"]) > 1 - if summary_type == "CalculatedSummary" and not answers_are_editable: + if is_summary_with_calculation(summary_type) and not answers_are_editable: self.total = True for answer in question["answers"]: @@ -598,6 +599,17 @@ def map_summary_item_config( edit_link_aria_label, ) ) + elif block.get("calculated_summary"): + rows.append( + SummaryRow( + block["calculated_summary"], + summary_type, + answers_are_editable, + no_answer_provided, + edit_link_text, + edit_link_aria_label, + ) + ) else: list_collector_rows = map_list_collector_config( list_items=block["list"]["list_items"], @@ -613,7 +625,7 @@ def map_summary_item_config( rows.extend(list_collector_rows) - if summary_type == "CalculatedSummary": + if is_summary_with_calculation(summary_type): rows.append(SummaryRow(calculated_question, summary_type, False, "", "", "")) return rows diff --git a/app/questionnaire/questionnaire_schema.py b/app/questionnaire/questionnaire_schema.py index 2f57e8874f..2eb751424d 100644 --- a/app/questionnaire/questionnaire_schema.py +++ b/app/questionnaire/questionnaire_schema.py @@ -326,11 +326,8 @@ def _get_answers_by_id(self) -> dict[str, list[ImmutableDict]]: def _populate_answer_dependencies(self) -> None: for block in self.get_blocks(): - if block["type"] == "CalculatedSummary": - answer_ids_for_block = get_calculated_summary_answer_ids(block) - self._update_answer_dependencies_for_calculated_summary( - answer_ids_for_block, block["id"] - ) + if block["type"] in {"CalculatedSummary", "GrandCalculatedSummary"}: + self._update_answer_dependencies_for_summary(block) continue for question in self.get_all_questions_for_block(block): @@ -363,14 +360,44 @@ def _populate_answer_dependencies(self) -> None: option["detail_answer"], block_id=block["id"] ) - def _update_answer_dependencies_for_calculated_summary( - self, calculated_summary_answer_ids: Iterable[str], block_id: str + def _update_answer_dependencies_for_summary(self, block: ImmutableDict) -> None: + if block["type"] == "CalculatedSummary": + self._update_answer_dependencies_for_calculated_summary_dependency( + calculated_summary_block=block, dependent_block=block + ) + elif block["type"] == "GrandCalculatedSummary": + self._update_answer_dependencies_for_grand_calculated_summary(block) + + def _update_answer_dependencies_for_calculated_summary_dependency( + self, *, calculated_summary_block: ImmutableDict, dependent_block: ImmutableDict ) -> None: + """ + update all calculated summary answers to be dependencies of the dependent block + """ + calculated_summary_answer_ids = get_calculated_summary_answer_ids( + calculated_summary_block + ) for answer_id in calculated_summary_answer_ids: self._answer_dependencies_map[answer_id] |= { - self._get_answer_dependent_for_block_id(block_id=block_id) + self._get_answer_dependent_for_block_id(block_id=dependent_block["id"]) } + def _update_answer_dependencies_for_grand_calculated_summary( + self, grand_calculated_summary_block: ImmutableDict + ) -> None: + grand_calculated_summary_calculated_summary_ids = ( + get_calculation_block_ids_for_grand_calculated_summary( + grand_calculated_summary_block + ) + ) + for calculated_summary_id in grand_calculated_summary_calculated_summary_ids: + # Type ignore: safe to assume block exists + calculated_summary_block: ImmutableDict = self.get_block(calculated_summary_id) # type: ignore + self._update_answer_dependencies_for_calculated_summary_dependency( + calculated_summary_block=calculated_summary_block, + dependent_block=grand_calculated_summary_block, + ) + def _update_answer_dependencies_for_calculations( self, calculations: tuple[ImmutableDict, ...], *, block_id: str ) -> None: @@ -408,7 +435,7 @@ def _update_answer_dependencies_for_answer( def _update_answer_dependencies_for_dynamic_options( self, - dynamic_options_values: Mapping[str, Mapping], + dynamic_options_values: Mapping, *, block_id: str, answer_id: str, @@ -806,6 +833,23 @@ def get_first_answer_id_for_block(self, block_id: str) -> str: answer_ids = self.get_answer_ids_for_block(block_id) return answer_ids[0] + def get_answer_format_for_calculated_summary( + self, calculated_summary_block_id: str + ) -> dict: + """ + Given a calculated summary block id, find the format of the total by using the first answer + """ + # Type ignore: the block will exist for any valid calculated summary id + calculated_summary_block: ImmutableDict = self.get_block(calculated_summary_block_id) # type: ignore + first_answer_id = get_calculated_summary_answer_ids(calculated_summary_block)[0] + first_answer = self.get_answers_by_answer_id(first_answer_id)[0] + return { + "type": first_answer["type"].lower(), + "unit": first_answer.get("unit"), + "unit_length": first_answer.get("unit_length"), + "currency": first_answer.get("currency"), + } + def get_answer_ids_for_block(self, block_id: str) -> list[str]: block = self.get_block(block_id) @@ -1205,23 +1249,44 @@ def get_item_anchor(self, section_id: str, list_name: str) -> str | None: return f"#{str(item['item_anchor_answer_id'])}" +def is_summary_with_calculation(summary_type: str) -> bool: + return summary_type in {"GrandCalculatedSummary", "CalculatedSummary"} + + def get_sources_for_type_from_data( *, source_type: str, data: MultiDict | Mapping | Sequence, - ignore_keys: list, -) -> list | None: + ignore_keys: list | None = None, +) -> list: sources = get_mappings_with_key("source", data, ignore_keys=ignore_keys) return [source for source in sources if source["source"] == source_type] +def get_identifiers_from_calculation_block( + *, calculation_block: Mapping, source_type: str +) -> list[str]: + values = get_sources_for_type_from_data( + source_type=source_type, data=calculation_block["calculation"]["operation"] + ) + + return [value["identifier"] for value in values] + + def get_calculated_summary_answer_ids(calculated_summary_block: Mapping) -> list[str]: if calculated_summary_block["calculation"].get("answers_to_calculate"): - return calculated_summary_block["calculation"]["answers_to_calculate"] # type: ignore + return list(calculated_summary_block["calculation"]["answers_to_calculate"]) - values = get_mappings_with_key( - "source", calculated_summary_block["calculation"]["operation"] + return get_identifiers_from_calculation_block( + calculation_block=calculated_summary_block, source_type="answers" ) - return [value["identifier"] for value in values if value["source"] == "answers"] + +def get_calculation_block_ids_for_grand_calculated_summary( + grand_calculated_summary_block: Mapping, +) -> list[str]: + return get_identifiers_from_calculation_block( + calculation_block=grand_calculated_summary_block, + source_type="calculated_summary", + ) diff --git a/app/questionnaire/router.py b/app/questionnaire/router.py index b5ab65e7d5..23a672c86d 100644 --- a/app/questionnaire/router.py +++ b/app/questionnaire/router.py @@ -133,6 +133,7 @@ def get_next_location_url( location, return_to, routing_path, + is_for_previous=False, is_section_complete=is_section_complete, return_to_answer_id=return_to_answer_id, return_to_block_id=return_to_block_id, @@ -179,6 +180,7 @@ def get_previous_location_url( location, return_to, routing_path, + is_for_previous=True, return_to_answer_id=return_to_answer_id, return_to_block_id=return_to_block_id, ): @@ -217,6 +219,7 @@ def _get_return_to_location_url( location: Location, return_to: str | None, routing_path: RoutingPath, + is_for_previous: bool, is_section_complete: bool | None = None, return_to_answer_id: str | None = None, return_to_block_id: str | None = None, @@ -224,22 +227,29 @@ def _get_return_to_location_url( if not return_to: return None - if return_to == "calculated-summary" and self.can_access_location( - Location( - block_id=return_to_block_id, - section_id=location.section_id, - list_item_id=location.list_item_id, - ), - routing_path, + if return_to == "grand-calculated-summary" and ( + url := self._get_return_to_for_grand_calculated_summary( + return_to=return_to, + return_to_block_id=return_to_block_id, + location=location, + routing_path=routing_path, + is_for_previous=is_for_previous, + return_to_answer_id=return_to_answer_id, + ) ): - return url_for( - "questionnaire.block", - block_id=return_to_block_id, - list_name=location.list_name, - list_item_id=location.list_item_id, + return url + + if return_to.startswith("calculated-summary") and ( + url := self._get_return_to_for_calculated_summary( return_to=return_to, - _anchor=return_to_answer_id, + return_to_block_id=return_to_block_id, + location=location, + routing_path=routing_path, + is_for_previous=is_for_previous, + return_to_answer_id=return_to_answer_id, ) + ): + return url if is_section_complete is None: is_section_complete = self._progress_store.is_section_complete( @@ -258,6 +268,140 @@ def _get_return_to_location_url( "questionnaire.submit_questionnaire", _anchor=return_to_answer_id ) + def _get_return_to_for_grand_calculated_summary( + self, + *, + return_to: str | None, + return_to_block_id: str | None, + location: Location, + routing_path: RoutingPath, + is_for_previous: bool, + return_to_answer_id: str | None = None, + ) -> str | None: + """ + Builds the return url for a grand calculated summary, + and accounts for it possibly being in a different section to the calculated summaries it references + """ + if not (return_to_block_id and self._schema.is_block_valid(return_to_block_id)): + return None + + # Type ignore: if the block is valid, then we'll be able to find a section for it + grand_calculated_summary_section: str = self._schema.get_section_id_for_block_id(return_to_block_id) # type: ignore + if grand_calculated_summary_section != location.section_id: + # the grand calculated summary is in a different section which will have a different routing path + # but don't go to it unless the current section is complete + if not self._progress_store.is_section_complete(location.section_id): + return self._get_return_url_for_inaccessible_location( + is_for_previous=is_for_previous, + return_to_block_id=return_to_block_id, + return_to=return_to, + routing_path=routing_path, + ) + + routing_path = self._path_finder.routing_path( + section_id=grand_calculated_summary_section + ) + if self.can_access_location( + # grand calculated summaries do not yet support repeating sections, when they do, this will need to make use of list item id as well + Location( + block_id=return_to_block_id, + section_id=grand_calculated_summary_section, + ), + routing_path, + ): + return url_for( + "questionnaire.block", + block_id=return_to_block_id, + return_to=return_to, + _anchor=return_to_answer_id, + ) + return self._get_return_url_for_inaccessible_location( + is_for_previous=is_for_previous, + return_to_block_id=return_to_block_id, + return_to=return_to, + routing_path=routing_path, + ) + + def _get_return_to_for_calculated_summary( + self, + *, + return_to: str, + return_to_block_id: str | None, + location: Location, + routing_path: RoutingPath, + is_for_previous: bool, + return_to_answer_id: str | None = None, + ) -> str | None: + """ + The return url for a calculated summary varies based on whether it's standalone or part of a grand calculated summary + + If the user goes from GrandCalculatedSummary -> CalculatedSummary -> Question, then return_to_block_ids needs to be a list + so that both the calculated summary id and the grand calculated summary ids are stored. + """ + block_id = None + remaining: list[str] = [] + # for a calculated summary this might have multiple items, e.g. a calculated summary to go to and then a grand calculated one + if return_to_block_id: + # the first item is the block id to route to (e.g. a calculated summary to go back to first) + # anything remaining forms where to go next (e.g. a grand calculated summary) + block_id, *remaining = return_to_block_id.split(",") + + if self.can_access_location( + Location( + block_id=block_id, + section_id=location.section_id, + list_item_id=location.list_item_id, + ), + routing_path, + ): + # if the next location is valid, the new url is that location, and the new 'return to block id' is just what remains + return_to_block_id = ",".join(remaining) if remaining else None + # if return_to is a list, return all but the first item, but if it's a single item then leave as is + return_to = return_to[return_to.find(",") + 1 :] + + return url_for( + "questionnaire.block", + block_id=block_id, + list_name=location.list_name, + list_item_id=location.list_item_id, + return_to=return_to, + return_to_block_id=return_to_block_id, + _anchor=return_to_answer_id, + ) + + return self._get_return_url_for_inaccessible_location( + is_for_previous=is_for_previous, + return_to_block_id=return_to_block_id, + return_to=return_to, + routing_path=routing_path, + ) + + def _get_return_url_for_inaccessible_location( + self, + *, + is_for_previous: bool, + return_to_block_id: str | None, + return_to: str | None, + routing_path: RoutingPath, + ) -> str | None: + """ + Routes to the next incomplete block in the section and preserves return to parameters + but only when routing forwards, returns None in the case of the previous link + """ + if ( + not is_for_previous + and return_to_block_id + and ( + next_incomplete_location := self._get_first_incomplete_location_in_section( + routing_path + ) + ) + ): + return next_incomplete_location.url( + return_to=return_to, + return_to_block_id=return_to_block_id, + ) + def get_next_location_url_for_end_of_section(self) -> str: if self._schema.is_flow_hub and self.can_access_hub(): return url_for("questionnaire.get_questionnaire") diff --git a/app/questionnaire/value_source_resolver.py b/app/questionnaire/value_source_resolver.py index ed2be5bfd8..adfc8f2384 100644 --- a/app/questionnaire/value_source_resolver.py +++ b/app/questionnaire/value_source_resolver.py @@ -3,6 +3,7 @@ from typing import Callable, Iterable, Mapping, MutableMapping from markupsafe import Markup +from werkzeug.datastructures import ImmutableDict from app.data_models import ProgressStore from app.data_models.answer import AnswerValueTypes, escape_answer_value @@ -163,12 +164,19 @@ def _resolve_list_value_source(self, value_source: Mapping) -> int | str | list: def _resolve_calculated_summary_value_source( self, value_source: Mapping, *, assess_routing_path: bool - ) -> IntOrDecimal: + ) -> IntOrDecimal | None: """Calculates the value for the 'calculation' used by the provided Calculated Summary. - The caller is responsible for ensuring the provided Calculated Summary and its answers are on the path. + The caller is responsible for ensuring the provided Calculated Summary and its answers are on the path, + or providing routing_path_block_ids when initialising the value source resolver. """ - calculated_summary_block: Mapping = self.schema.get_block(value_source["identifier"]) # type: ignore + calculated_summary_block: ImmutableDict = self.schema.get_block(value_source["identifier"]) # type: ignore + + if self.routing_path_block_ids and not self._is_block_on_path( + calculated_summary_block["id"] + ): + return None + calculation = calculated_summary_block["calculation"] if calculation.get("answers_to_calculate"): operator = self.get_calculation_operator(calculation["calculation_type"]) diff --git a/app/translations/messages.pot b/app/translations/messages.pot index e53387de23..85ac35f5e2 100644 --- a/app/translations/messages.pot +++ b/app/translations/messages.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-04-27 09:02+0100\n" +"POT-Creation-Date: 2023-05-18 10:40+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -41,9 +41,9 @@ msgstr "" msgid "%(from_date)s to %(to_date)s" msgstr "" -#: app/jinja_filters.py:701 -#: templates/partials/summary/collapsible-summary.html:26 -#: templates/partials/summary/summary.html:22 +#: app/jinja_filters.py:716 +#: templates/partials/summary/collapsible-summary.html:27 +#: templates/partials/summary/summary.html:25 msgid "No answer provided" msgstr "" @@ -456,7 +456,7 @@ msgstr "" #: templates/individual_response/confirmation-post.html:21 #: templates/individual_response/confirmation-text-message.html:29 #: templates/individual_response/question.html:5 templates/interstitial.html:8 -#: templates/sectionsummary.html:10 templates/sectionsummary.html:50 +#: templates/sectionsummary.html:10 templates/sectionsummary.html:48 msgid "Continue" msgstr "" @@ -546,7 +546,7 @@ msgstr "" #: app/views/handlers/confirm_email.py:129 #: app/views/handlers/confirmation_email.py:67 -#: app/views/handlers/feedback.py:84 app/views/handlers/question.py:170 +#: app/views/handlers/feedback.py:84 app/views/handlers/question.py:185 msgid "Error: {page_title}" msgstr "" @@ -735,14 +735,6 @@ msgstr "" msgid "View Submitted Response" msgstr "" -#: templates/calculatedsummary.html:13 -msgid "Please review your answers and confirm these are correct" -msgstr "" - -#: templates/calculatedsummary.html:24 -msgid "Yes, I confirm these are correct" -msgstr "" - #: templates/census-thank-you.html:9 templates/confirmation-email.html:8 msgid "There is a problem with this page" msgstr "" @@ -871,7 +863,7 @@ msgstr "" msgid "If you can’t answer someone else’s questions" msgstr "" -#: templates/interstitial.html:23 templates/partials/question.html:45 +#: templates/interstitial.html:23 templates/partials/question.html:49 msgid "If you can’t answer questions for this person" msgstr "" @@ -930,13 +922,13 @@ msgid "Save questions as PDF" msgstr "" #: templates/partials/introduction/preview.html:37 -#: templates/partials/summary/collapsible-summary.html:49 +#: templates/partials/summary/collapsible-summary.html:51 #: templates/preview.html:92 msgid "Show all" msgstr "" #: templates/partials/introduction/preview.html:38 -#: templates/partials/summary/collapsible-summary.html:50 +#: templates/partials/summary/collapsible-summary.html:52 #: templates/preview.html:93 msgid "Hide all" msgstr "" @@ -1323,6 +1315,14 @@ msgstr "" msgid "Continue survey" msgstr "" +#: templates/layouts/_calculatedsummary.html:14 +msgid "Please review your answers and confirm these are correct" +msgstr "" + +#: templates/layouts/_calculatedsummary.html:25 +msgid "Yes, I confirm these are correct" +msgstr "" + #: templates/layouts/_questionnaire.html:39 msgid "Choose another section and return to this later" msgstr "" @@ -1382,7 +1382,7 @@ msgid "" msgstr "" #: templates/partials/preview-question.html:34 -#: templates/partials/question.html:81 +#: templates/partials/question.html:85 msgid "Or" msgstr "" @@ -1390,19 +1390,19 @@ msgstr "" msgid "{max_characters} characters can be added." msgstr "" -#: templates/partials/question.html:72 +#: templates/partials/question.html:76 msgid "Selecting this will clear your answer" msgstr "" -#: templates/partials/question.html:73 +#: templates/partials/question.html:77 msgid "cleared" msgstr "" -#: templates/partials/question.html:76 +#: templates/partials/question.html:80 msgid "Selecting this will deselect any selected options" msgstr "" -#: templates/partials/question.html:77 templates/partials/question.html:85 +#: templates/partials/question.html:81 templates/partials/question.html:89 msgid "deselected" msgstr "" @@ -1568,14 +1568,14 @@ msgstr "" msgid "Start survey" msgstr "" -#: templates/partials/summary/collapsible-summary.html:27 +#: templates/partials/summary/collapsible-summary.html:28 #: templates/partials/summary/list-summary.html:7 -#: templates/partials/summary/summary.html:25 +#: templates/partials/summary/summary.html:28 msgid "Change" msgstr "" -#: templates/partials/summary/collapsible-summary.html:28 -#: templates/partials/summary/summary.html:26 +#: templates/partials/summary/collapsible-summary.html:29 +#: templates/partials/summary/summary.html:29 msgid "Change your answer for:" msgstr "" @@ -1584,7 +1584,7 @@ msgid "Change details for {item_name}" msgstr "" #: templates/partials/summary/list-summary.html:9 -#: templates/partials/summary/summary.html:23 +#: templates/partials/summary/summary.html:26 msgid "Remove" msgstr "" @@ -1592,7 +1592,7 @@ msgstr "" msgid "Remove {item_name}" msgstr "" -#: templates/partials/summary/summary.html:24 +#: templates/partials/summary/summary.html:27 msgid "Remove your answer for:" msgstr "" diff --git a/app/views/contexts/__init__.py b/app/views/contexts/__init__.py index 6477a330bc..1acb951d53 100644 --- a/app/views/contexts/__init__.py +++ b/app/views/contexts/__init__.py @@ -1,5 +1,6 @@ from .calculated_summary_context import CalculatedSummaryContext from .context import Context +from .grand_calculated_summary_context import GrandCalculatedSummaryContext from .hub_context import HubContext from .list_context import ListContext from .section_summary_context import SectionSummaryContext @@ -7,6 +8,7 @@ __all__ = [ "CalculatedSummaryContext", + "GrandCalculatedSummaryContext", "Context", "SubmitQuestionnaireContext", "HubContext", diff --git a/app/views/contexts/calculated_summary_context.py b/app/views/contexts/calculated_summary_context.py index bbe379ed59..b82822a919 100644 --- a/app/views/contexts/calculated_summary_context.py +++ b/app/views/contexts/calculated_summary_context.py @@ -1,16 +1,4 @@ -from copy import deepcopy -from decimal import Decimal -from typing import ( - Any, - Callable, - Iterable, - Literal, - Mapping, - MutableMapping, - Optional, - Tuple, - Union, -) +from typing import Callable, Iterable, Literal, Mapping, MutableMapping, Tuple from werkzeug.datastructures import ImmutableDict @@ -33,6 +21,7 @@ from app.questionnaire.value_source_resolver import ValueSourceResolver from app.questionnaire.variants import choose_question_to_display, transform_variants from app.views.contexts.context import Context +from app.views.contexts.summary.calculated_summary_block import NumericType from app.views.contexts.summary.group import Group @@ -44,10 +33,12 @@ def __init__( answer_store: AnswerStore, list_store: ListStore, progress_store: ProgressStore, - metadata: Optional[MetadataProxy], + metadata: MetadataProxy | None, response_metadata: MutableMapping, routing_path: RoutingPath, current_location: Location, + return_to: str | None = None, + return_to_block_id: str | None = None, ) -> None: super().__init__( language, @@ -58,18 +49,30 @@ def __init__( metadata, response_metadata, ) - self.routing_path = routing_path + self.routing_path_block_ids = routing_path.block_ids self.current_location = current_location + self.return_to = return_to + self.return_to_block_id = return_to_block_id def build_groups_for_section( self, - section: Mapping[str, Any], + *, + section: Mapping, return_to_block_id: str, - ) -> list[Mapping[str, Group]]: + routing_path_block_ids: Iterable[str], + ) -> list[Mapping]: + """ + If the calculated summary is being edited from a grand calculated summary + the details of the grand calculated summary to return to needs to be passed down to the calculated summary answer links + """ + return_to = "calculated-summary" + if self.return_to == "grand-calculated-summary": + return_to_block_id += f",{self.return_to_block_id}" + return_to += ",grand-calculated-summary" return [ Group( group_schema=group, - routing_path=self.routing_path, + routing_path_block_ids=routing_path_block_ids, answer_store=self._answer_store, list_store=self._list_store, metadata=self._metadata, @@ -78,25 +81,25 @@ def build_groups_for_section( location=self.current_location, language=self._language, progress_store=self._progress_store, - return_to="calculated-summary", + return_to=return_to, return_to_block_id=return_to_block_id, + summary_type="CalculatedSummary", ).serialize() for group in section["groups"] ] - def build_view_context_for_calculated_summary(self) -> dict[str, dict[str, Any]]: + def build_view_context(self) -> dict[str, dict]: # type ignores added as block will exist at this point block_id: str = self.current_location.block_id # type: ignore block: ImmutableDict = self._schema.get_block(block_id) # type: ignore - calculated_section: dict[str, Any] = self._build_calculated_summary_section( - block - ) + calculated_section: dict = self._build_calculated_summary_section(block) calculation = block["calculation"] groups = self.build_groups_for_section( - calculated_section, - block_id, + section=calculated_section, + return_to_block_id=block_id, + routing_path_block_ids=self.routing_path_block_ids, ) formatted_total = self._get_formatted_total( @@ -108,6 +111,23 @@ def build_view_context_for_calculated_summary(self) -> dict[str, dict[str, Any]] else calculation["operation"], ) + return self._build_formatted_summary( + block=block, + groups=groups, + calculation=calculation, + formatted_total=formatted_total, + summary_type="CalculatedSummary", + ) + + def _build_formatted_summary( + self, + *, + block: ImmutableDict, + groups: Iterable[Mapping], + calculation: ImmutableDict, + formatted_total: str, + summary_type: str, + ) -> dict[str, dict]: collapsible = block.get("collapsible") or False block_title = block["title"] @@ -122,13 +142,11 @@ def build_view_context_for_calculated_summary(self) -> dict[str, dict[str, Any]] ), "title": block_title % {"total": formatted_total}, "collapsible": collapsible, - "summary_type": "CalculatedSummary", + "summary_type": summary_type, } } - def _build_calculated_summary_section( - self, rendered_block: ImmutableDict[str, Any] - ) -> dict[str, Any]: + def _build_calculated_summary_section(self, rendered_block: ImmutableDict) -> dict: """Build up the list of blocks only including blocks / questions / answers which are relevant to the summary""" # type ignores added as block will exist at this point block_id: str = self.current_location.block_id # type: ignore @@ -170,7 +188,7 @@ def _remove_unwanted_questions_answers( """ Evaluates questions in a block and removes any questions not containing a relevant answer """ - transformed_block: ImmutableDict = transform_variants( + block_to_transform: ImmutableDict = transform_variants( block, self._schema, self._metadata, @@ -180,8 +198,9 @@ def _remove_unwanted_questions_answers( self.current_location, self._progress_store, ) - transformed_block = deepcopy(transformed_block) - transformed_block = QuestionnaireSchema.get_mutable_deepcopy(transformed_block) + transformed_block: dict = QuestionnaireSchema.get_mutable_deepcopy( + block_to_transform + ) block_question = transformed_block["question"] matching_answers = [] @@ -202,30 +221,45 @@ def _remove_unwanted_questions_answers( return transformed_block + def _get_evaluated_total( + self, + *, + calculation: ImmutableDict, + routing_path_block_ids: Iterable[str], + ) -> NumericType: + """ + For a calculation in the new style and the list of involved block ids (possibly across sections) evaluate the total + """ + evaluate_calculated_summary = RuleEvaluator( + self._schema, + self._answer_store, + self._list_store, + self._metadata, + self._response_metadata, + routing_path_block_ids=routing_path_block_ids, + location=self.current_location, + progress_store=self._progress_store, + ) + # Type ignore: in the case of a calculated summation it will always be a numeric type + calculated_total: NumericType = evaluate_calculated_summary.evaluate(calculation) # type: ignore + return calculated_total + def _get_formatted_total( self, groups: list, calculation: Callable | ImmutableDict ) -> str: answer_format, values_to_calculate = self._get_answer_format(groups) if isinstance(calculation, ImmutableDict): - evaluate_calculated_summary = RuleEvaluator( - self._schema, - self._answer_store, - self._list_store, - self._metadata, - self._response_metadata, - routing_path_block_ids=self.routing_path.block_ids, - location=self.current_location, - progress_store=self._progress_store, + calculated_total = self._get_evaluated_total( + calculation=calculation, + routing_path_block_ids=self.routing_path_block_ids, ) - - calculated_total: Union[int, float, Decimal] = evaluate_calculated_summary.evaluate(calculation) # type: ignore else: calculated_total = calculation(values_to_calculate) - return self._format_total(answer_format, calculated_total) + return self._format_total(answer_format=answer_format, total=calculated_total) - def _get_answer_format(self, groups: list) -> Tuple[dict[str, Any], list]: + def _get_answer_format(self, groups: Iterable[Mapping]) -> Tuple[dict, list]: values_to_calculate: list = [] answer_format: dict = {"type": None} for group in groups: @@ -255,8 +289,9 @@ def _get_answer_format(self, groups: list) -> Tuple[dict[str, Any], list]: @staticmethod def _format_total( + *, answer_format: Mapping[str, Literal["short", "long", "narrow"]], - total: int | float | Decimal, + total: NumericType, ) -> str: if answer_format["type"] == "currency": return get_formatted_currency(total, answer_format["currency"]) @@ -275,9 +310,9 @@ def _format_total( @staticmethod def _get_calculated_question( - calculation_question: ImmutableDict[str, Any], + calculation_question: ImmutableDict, formatted_total: str, - ) -> dict[str, Any]: + ) -> dict: calculation_title = calculation_question["title"] return { diff --git a/app/views/contexts/grand_calculated_summary_context.py b/app/views/contexts/grand_calculated_summary_context.py new file mode 100644 index 0000000000..467ba9f040 --- /dev/null +++ b/app/views/contexts/grand_calculated_summary_context.py @@ -0,0 +1,124 @@ +from typing import Iterable, Mapping + +from werkzeug.datastructures import ImmutableDict + +from app.questionnaire.questionnaire_schema import ( + get_calculation_block_ids_for_grand_calculated_summary, +) +from app.views.contexts.calculated_summary_context import CalculatedSummaryContext +from app.views.contexts.summary.group import Group + + +class GrandCalculatedSummaryContext(CalculatedSummaryContext): + def _build_grand_calculated_summary_section( + self, rendered_block: ImmutableDict + ) -> dict[str, str | list]: + """ + Build list of calculated summary blocks that the grand calculated summary will be adding up + """ + # Type ignore: the block, group and section will all exist at this point + calculated_summary_group: ImmutableDict = self._schema.get_group_for_block_id(self.current_location.block_id) # type: ignore + + calculated_summary_ids = get_calculation_block_ids_for_grand_calculated_summary( + rendered_block + ) + blocks_to_calculate = [ + self._schema.get_block(block_id) for block_id in calculated_summary_ids + ] + + return { + "id": self.current_location.section_id, + "groups": [ + {"id": calculated_summary_group["id"], "blocks": blocks_to_calculate} + ], + } + + def _blocks_on_routing_path( + self, calculated_summary_ids: Iterable[str] + ) -> list[str]: + """ + Find all blocks on the routing path for each of the calculated summaries + """ + # Type ignore: each block must have a section id + section_ids: set[str] = {self._schema.get_section_id_for_block_id(block_id) for block_id in calculated_summary_ids} # type: ignore + # find any sections involved in the grand calculated summary (but only if they have started, to avoid evaluating the path if not necessary) + started_sections = [ + key for key, _ in self._progress_store.started_section_keys(section_ids) + ] + routing_path_block_ids: list[str] = [] + + for section_id in started_sections: + if section_id == self.current_location.section_id: + routing_path_block_ids.extend(self.routing_path_block_ids) + else: + routing_path_block_ids.extend( + # repeating calculated summaries are not supported at the moment, so no list item is needed + self._router.routing_path(section_id).block_ids + ) + + return routing_path_block_ids + + def build_groups_for_section( + self, + *, + section: Mapping, + return_to_block_id: str, + routing_path_block_ids: Iterable[str], + ) -> list[Mapping]: + return [ + Group( + group_schema=group, + routing_path_block_ids=routing_path_block_ids, + answer_store=self._answer_store, + list_store=self._list_store, + metadata=self._metadata, + response_metadata=self._response_metadata, + schema=self._schema, + location=self.current_location, + language=self._language, + progress_store=self._progress_store, + return_to="grand-calculated-summary", + return_to_block_id=return_to_block_id, + summary_type="GrandCalculatedSummary", + ).serialize() + for group in section["groups"] + ] + + def build_view_context(self) -> dict[str, dict]: + """ + Build summary section with formatted total and change links for each calculated summary + """ + # Type ignore: Block will exist at this point + block: ImmutableDict = self._schema.get_block(self.current_location.block_id) # type: ignore + + calculation = block["calculation"] + calculated_summary_ids = get_calculation_block_ids_for_grand_calculated_summary( + block + ) + routing_path_block_ids = self._blocks_on_routing_path(calculated_summary_ids) + + calculated_section = self._build_grand_calculated_summary_section(block) + + groups = self.build_groups_for_section( + section=calculated_section, + return_to_block_id=block["id"], + routing_path_block_ids=routing_path_block_ids, + ) + total = self._get_evaluated_total( + calculation=calculation["operation"], + routing_path_block_ids=routing_path_block_ids, + ) + + # validator ensures all calculated summaries are of the same type, so the first can be used for the format + answer_format = self._schema.get_answer_format_for_calculated_summary( + calculated_summary_ids[0] + ) + formatted_total = self._format_total(answer_format=answer_format, total=total) + + return self._build_formatted_summary( + block=block, + groups=groups, + calculation=calculation, + formatted_total=formatted_total, + summary_type="GrandCalculatedSummary", + ) diff --git a/app/views/contexts/section_summary_context.py b/app/views/contexts/section_summary_context.py index 30bebc684d..06c4fc392e 100644 --- a/app/views/contexts/section_summary_context.py +++ b/app/views/contexts/section_summary_context.py @@ -138,7 +138,7 @@ def build_summary( "groups": [ Group( group_schema=group, - routing_path=self.routing_path, + routing_path_block_ids=self.routing_path.block_ids, answer_store=self._answer_store, list_store=self._list_store, metadata=self._metadata, @@ -172,7 +172,7 @@ def _custom_summary_elements( for summary_element in section_summary: if summary_element["type"] == "List": list_collector_block = ListCollectorBlock( - routing_path=self.routing_path, + routing_path_block_ids=self.routing_path.block_ids, answer_store=self._answer_store, list_store=self._list_store, progress_store=self._progress_store, diff --git a/app/views/contexts/summary/calculated_summary_block.py b/app/views/contexts/summary/calculated_summary_block.py new file mode 100644 index 0000000000..8ba998fe74 --- /dev/null +++ b/app/views/contexts/summary/calculated_summary_block.py @@ -0,0 +1,83 @@ +from decimal import Decimal +from typing import Iterable, Mapping, MutableMapping, TypeAlias + +from flask import url_for + +from app.data_models import AnswerStore, ListStore, ProgressStore +from app.data_models.metadata_proxy import MetadataProxy +from app.questionnaire import Location, QuestionnaireSchema +from app.questionnaire.rules.rule_evaluator import RuleEvaluator + +NumericType: TypeAlias = int | float | Decimal + + +class CalculatedSummaryBlock: + def __init__( + self, + block_schema: Mapping, + *, + answer_store: AnswerStore, + list_store: ListStore, + metadata: MetadataProxy | None, + response_metadata: MutableMapping, + schema: QuestionnaireSchema, + location: Location, + return_to: str | None, + return_to_block_id: str | None = None, + progress_store: ProgressStore, + routing_path_block_ids: Iterable[str], + ) -> None: + """ + A Calculated summary block that is rendered as part of a grand calculated summary + """ + + self.id = block_schema["id"] + self.title = block_schema["calculation"]["title"] + self._return_to = return_to + self._return_to_block_id = return_to_block_id + self._block_schema = block_schema + self._schema = schema + + self._rule_evaluator = RuleEvaluator( + schema=schema, + answer_store=answer_store, + list_store=list_store, + metadata=metadata, + response_metadata=response_metadata, + location=location, + progress_store=progress_store, + routing_path_block_ids=routing_path_block_ids, + ) + + # Type ignore: for a calculated summary the resolved answer would only ever be one of these 3 + calculated_total: NumericType = self._rule_evaluator.evaluate(block_schema["calculation"]["operation"]) # type: ignore + answer_format = self._schema.get_answer_format_for_calculated_summary(self.id) + self.answers = [ + { + "id": self.id, + "label": self.title, + "value": calculated_total, + "link": self._build_link(), + **answer_format, + } + ] + + def _build_link(self) -> str: + return url_for( + "questionnaire.block", + block_id=self.id, + return_to=self._return_to, + return_to_answer_id=self.id, + return_to_block_id=self._return_to_block_id, + _anchor=self.id, + ) + + def _calculated_summary(self) -> dict: + return {"id": self.id, "title": self.title, "answers": self.answers} + + def serialize(self) -> dict: + return { + "id": self.id, + "title": self.title, + "calculated_summary": self._calculated_summary(), + } diff --git a/app/views/contexts/summary/group.py b/app/views/contexts/summary/group.py index 9b4a15fae5..01be23b42c 100644 --- a/app/views/contexts/summary/group.py +++ b/app/views/contexts/summary/group.py @@ -1,4 +1,4 @@ -from typing import Any, Mapping, MutableMapping, Optional +from typing import Iterable, Mapping, MutableMapping from werkzeug.datastructures import ImmutableDict @@ -6,9 +6,9 @@ from app.data_models.metadata_proxy import MetadataProxy from app.questionnaire import Location, QuestionnaireSchema from app.questionnaire.placeholder_renderer import PlaceholderRenderer -from app.questionnaire.routing_path import RoutingPath from app.survey_config.link import Link from app.views.contexts.summary.block import Block +from app.views.contexts.summary.calculated_summary_block import CalculatedSummaryBlock from app.views.contexts.summary.list_collector_block import ListCollectorBlock @@ -16,8 +16,8 @@ class Group: def __init__( self, *, - group_schema: Mapping[str, Any], - routing_path: RoutingPath, + group_schema: Mapping, + routing_path_block_ids: Iterable[str], answer_store: AnswerStore, list_store: ListStore, metadata: MetadataProxy | None, @@ -28,6 +28,7 @@ def __init__( progress_store: ProgressStore, return_to: str | None, return_to_block_id: str | None = None, + summary_type: str | None = None, view_submitted_response: bool | None = False, ) -> None: self.id = group_schema["id"] @@ -39,7 +40,7 @@ def __init__( self.blocks = self._build_blocks_and_links( group_schema=group_schema, - routing_path=routing_path, + routing_path_block_ids=routing_path_block_ids, answer_store=answer_store, list_store=list_store, metadata=metadata, @@ -51,6 +52,7 @@ def __init__( language=language, return_to_block_id=return_to_block_id, view_submitted_response=view_submitted_response, + summary_type=summary_type, ) self.placeholder_renderer = PlaceholderRenderer( @@ -68,24 +70,25 @@ def __init__( def _build_blocks_and_links( self, *, - group_schema: Mapping[str, Any], - routing_path: RoutingPath, + group_schema: Mapping, + routing_path_block_ids: Iterable[str], answer_store: AnswerStore, list_store: ListStore, - metadata: Optional[MetadataProxy], + metadata: MetadataProxy | None, response_metadata: MutableMapping, schema: QuestionnaireSchema, location: Location, - return_to: Optional[str], + return_to: str | None, progress_store: ProgressStore, language: str, - return_to_block_id: Optional[str], + return_to_block_id: str | None, view_submitted_response: bool | None = False, + summary_type: str | None = None, ) -> list[dict[str, Block]]: blocks = [] for block in group_schema["blocks"]: - if block["id"] not in routing_path: + if block["id"] not in routing_path_block_ids: continue if block["type"] in [ "Question", @@ -108,20 +111,38 @@ def _build_blocks_and_links( ).serialize() ] ) + # check the summary_type as opposed to the block type + # otherwise this gets called on section summaries as well + elif summary_type == "GrandCalculatedSummary": + blocks.extend( + [ + CalculatedSummaryBlock( + block, + answer_store=answer_store, + list_store=list_store, + metadata=metadata, + response_metadata=response_metadata, + schema=schema, + location=location, + return_to=return_to, + return_to_block_id=return_to_block_id, + progress_store=progress_store, + routing_path_block_ids=routing_path_block_ids, + ).serialize() + ] + ) elif block["type"] == "ListCollector": - section: Optional[ImmutableDict] = schema.get_section( - location.section_id - ) + section: ImmutableDict | None = schema.get_section(location.section_id) - summary_item: Optional[ImmutableDict] + summary_item: ImmutableDict | None if summary_item := schema.get_summary_item_for_list_for_section( # Type ignore: section id will not be optional at this point section_id=section["id"], # type: ignore list_name=block["for_list"], ): list_collector_block = ListCollectorBlock( - routing_path=routing_path, + routing_path_block_ids=routing_path_block_ids, answer_store=answer_store, list_store=list_store, progress_store=progress_store, @@ -149,7 +170,7 @@ def _build_blocks_and_links( return blocks - def serialize(self) -> Mapping[str, Any]: + def serialize(self) -> Mapping: return self.placeholder_renderer.render( data_to_render={ "id": self.id, diff --git a/app/views/contexts/summary/list_collector_block.py b/app/views/contexts/summary/list_collector_block.py index 93a4737f68..2a6a980757 100644 --- a/app/views/contexts/summary/list_collector_block.py +++ b/app/views/contexts/summary/list_collector_block.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import Any, Mapping, MutableMapping, Optional +from typing import Any, Iterable, Mapping, MutableMapping, Optional from flask import url_for from werkzeug.datastructures import ImmutableDict @@ -9,7 +9,6 @@ from app.data_models.metadata_proxy import MetadataProxy from app.questionnaire import Location, QuestionnaireSchema from app.questionnaire.placeholder_renderer import PlaceholderRenderer -from app.questionnaire.routing_path import RoutingPath from app.views.contexts.list_context import ListContext from app.views.contexts.summary.block import Block @@ -17,7 +16,7 @@ class ListCollectorBlock: def __init__( self, - routing_path: RoutingPath, + routing_path_block_ids: Iterable[str], answer_store: AnswerStore, list_store: ListStore, progress_store: ProgressStore, @@ -47,7 +46,7 @@ def __init__( self._answer_store = answer_store self._metadata = metadata self._response_metadata = response_metadata - self._routing_path = routing_path + self._routing_path_block_ids = routing_path_block_ids self._progress_store = progress_store # pylint: disable=too-many-locals @@ -74,7 +73,7 @@ def list_summary_element(self, summary: Mapping[str, Any]) -> dict[str, Any]: list_collector_blocks_on_path = [ list_collector_block for list_collector_block in list_collector_blocks - if list_collector_block["id"] in self._routing_path.block_ids + if list_collector_block["id"] in self._routing_path_block_ids ] list_collector_block = ( diff --git a/app/views/handlers/block.py b/app/views/handlers/block.py index 6019bc762e..1495831744 100644 --- a/app/views/handlers/block.py +++ b/app/views/handlers/block.py @@ -53,6 +53,14 @@ def __init__( def current_location(self): return self._current_location + @property + def return_to(self): + return self._return_to + + @property + def return_to_block_id(self): + return self._return_to_block_id + @cached_property def questionnaire_store_updater(self): return QuestionnaireStoreUpdater( diff --git a/app/views/handlers/block_factory.py b/app/views/handlers/block_factory.py index 91c0a3d6c3..7961432d15 100644 --- a/app/views/handlers/block_factory.py +++ b/app/views/handlers/block_factory.py @@ -4,7 +4,10 @@ from app.questionnaire import QuestionnaireSchema from app.questionnaire.location import InvalidLocationException, Location from app.questionnaire.relationship_location import RelationshipLocation -from app.views.handlers.calculated_summary import CalculatedSummary +from app.views.handlers.calculation_summary import ( + CalculatedSummary, + GrandCalculatedSummary, +) from app.views.handlers.content import Content from app.views.handlers.list_add_question import ListAddQuestion from app.views.handlers.list_collector import ListCollector @@ -30,6 +33,7 @@ "Introduction": Content, "Interstitial": Content, "CalculatedSummary": CalculatedSummary, + "GrandCalculatedSummary": GrandCalculatedSummary, } diff --git a/app/views/handlers/calculated_summary.py b/app/views/handlers/calculation_summary.py similarity index 65% rename from app/views/handlers/calculated_summary.py rename to app/views/handlers/calculation_summary.py index 325ada18e1..852ce7a3b2 100644 --- a/app/views/handlers/calculated_summary.py +++ b/app/views/handlers/calculation_summary.py @@ -1,10 +1,15 @@ +from typing import Type + +from app.views.contexts import GrandCalculatedSummaryContext from app.views.contexts.calculated_summary_context import CalculatedSummaryContext from app.views.handlers.content import Content -class CalculatedSummary(Content): +class _SummaryWithCalculation(Content): + summary_class: Type[CalculatedSummaryContext] | Type[GrandCalculatedSummaryContext] + def get_context(self): - calculated_summary_context = CalculatedSummaryContext( + summary_context = self.summary_class( language=self._language, schema=self._schema, answer_store=self._questionnaire_store.answer_store, @@ -14,8 +19,10 @@ def get_context(self): response_metadata=self._questionnaire_store.response_metadata, current_location=self._current_location, routing_path=self._routing_path, + return_to=self.return_to, + return_to_block_id=self.return_to_block_id, ) - context = calculated_summary_context.build_view_context_for_calculated_summary() + context = summary_context.build_view_context() if not self.page_title: self.page_title = context["summary"]["calculated_question"]["title"] @@ -28,3 +35,11 @@ def handle_post(self): # Then we update dependent sections self.questionnaire_store_updater.capture_progress_section_dependencies() return super().handle_post() + + +class CalculatedSummary(_SummaryWithCalculation): + summary_class = CalculatedSummaryContext + + +class GrandCalculatedSummary(_SummaryWithCalculation): + summary_class = GrandCalculatedSummaryContext diff --git a/schemas/test/en/test_grand_calculated_summary.json b/schemas/test/en/test_grand_calculated_summary.json new file mode 100644 index 0000000000..e2271d03b9 --- /dev/null +++ b/schemas/test/en/test_grand_calculated_summary.json @@ -0,0 +1,294 @@ +{ + "mime_type": "application/json/ons/eq", + "language": "en", + "schema_version": "0.0.1", + "data_version": "0.0.3", + "survey_id": "0", + "title": "Simple Grand Calculated Summary demo", + "theme": "default", + "description": "A schema to showcase Grand Calculated Summary.", + "metadata": [ + { + "name": "user_id", + "type": "string" + }, + { + "name": "period_id", + "type": "string" + }, + { + "name": "ru_name", + "type": "string" + } + ], + "questionnaire_flow": { + "type": "Linear", + "options": { + "summary": { + "collapsible": false + } + } + }, + "sections": [ + { + "id": "section-1", + "title": "Commuting", + "groups": [ + { + "id": "group", + "title": "Commuting", + "blocks": [ + { + "type": "Question", + "id": "first-number-block", + "question": { + "id": "first-number-question", + "title": "How much do you walk per week?", + "type": "General", + "answers": [ + { + "id": "q1-a1", + "label": "Weekly distance travelled on foot", + "mandatory": true, + "type": "Unit", + "unit_length": "short", + "unit": "length-mile", + "decimal_places": 2 + }, + { + "id": "q1-a2", + "label": "Number of walks per week", + "mandatory": true, + "type": "Number" + } + ] + } + }, + { + "type": "Question", + "id": "second-number-block", + "question": { + "id": "second-number-question", + "title": "How much do you drive per week?", + "type": "General", + "answers": [ + { + "id": "q2-a1", + "label": "Weekly distance travelled by car", + "mandatory": true, + "type": "Unit", + "unit_length": "short", + "unit": "length-mile", + "decimal_places": 2 + }, + { + "id": "q2-a2", + "label": "Number of car journeys per week", + "mandatory": true, + "type": "Number" + } + ] + } + }, + { + "type": "CalculatedSummary", + "id": "distance-calculated-summary-1", + "title": "We calculate the total of distance travelled by foot and car to be %(total)s. Is this correct?", + "calculation": { + "operation": { + "+": [ + { + "source": "answers", + "identifier": "q1-a1" + }, + { + "source": "answers", + "identifier": "q2-a1" + } + ] + }, + "title": "Calculated distance on foot and driving" + } + }, + { + "type": "CalculatedSummary", + "id": "number-calculated-summary-1", + "title": "We calculate the total number of journeys on foot and in a car to be %(total)s. Is this correct?", + "calculation": { + "operation": { + "+": [ + { + "source": "answers", + "identifier": "q1-a2" + }, + { + "source": "answers", + "identifier": "q2-a2" + } + ] + }, + "title": "Calculated journeys on foot and driving" + } + } + ] + } + ] + }, + { + "id": "section-2", + "title": "Alternative Transport", + "groups": [ + { + "id": "transport-group", + "title": "Alternative Transport", + "blocks": [ + { + "type": "Question", + "id": "third-number-block", + "question": { + "id": "third-number-question", + "title": "How much do you cycle per week?", + "type": "General", + "answers": [ + { + "id": "q3-a1", + "label": "Weekly distance travelled by bike", + "mandatory": true, + "type": "Unit", + "unit_length": "short", + "unit": "length-mile", + "decimal_places": 2 + }, + { + "id": "q3-a2", + "label": "Number of bicycle journeys per week", + "mandatory": true, + "type": "Number" + } + ] + } + }, + { + "type": "Question", + "id": "fourth-number-block", + "question": { + "id": "fourth-number-question", + "title": "How much do you voi per week?", + "type": "General", + "answers": [ + { + "id": "q4-a1", + "label": "Weekly distance travelled on a Voi", + "mandatory": true, + "type": "Unit", + "unit_length": "short", + "unit": "length-mile", + "decimal_places": 2 + }, + { + "id": "q4-a2", + "label": "Number of scooter trips per week", + "mandatory": true, + "type": "Number" + } + ] + } + }, + { + "type": "CalculatedSummary", + "id": "distance-calculated-summary-2", + "title": "We calculate the total of distance travelled by bike and voi to be %(total)s. Is this correct?", + "calculation": { + "operation": { + "+": [ + { + "source": "answers", + "identifier": "q3-a1" + }, + { + "source": "answers", + "identifier": "q4-a1" + } + ] + }, + "title": "Calculated weekly distance on bike and scooter" + } + }, + { + "type": "CalculatedSummary", + "id": "number-calculated-summary-2", + "title": "We calculate the total number of journeys on bike and on a voi to be %(total)s. Is this correct?", + "calculation": { + "operation": { + "+": [ + { + "source": "answers", + "identifier": "q3-a2" + }, + { + "source": "answers", + "identifier": "q4-a2" + } + ] + }, + "title": "Calculated journeys on bike and scooter" + } + } + ] + } + ] + }, + { + "id": "section-3", + "title": "Grand calculated summaries", + "groups": [ + { + "id": "summary-group", + "title": "Grand calculated summary group", + "blocks": [ + { + "type": "GrandCalculatedSummary", + "id": "distance-grand-calculated-summary", + "title": "We calculate the grand total weekly distance travelled to be %(total)s. Is this correct?", + "calculation": { + "operation": { + "+": [ + { + "source": "calculated_summary", + "identifier": "distance-calculated-summary-1" + }, + { + "source": "calculated_summary", + "identifier": "distance-calculated-summary-2" + } + ] + }, + "title": "Grand calculated summary of distance travelled" + } + }, + { + "type": "GrandCalculatedSummary", + "id": "number-grand-calculated-summary", + "title": "We calculate the grand total journeys per week to be %(total)s. Is this correct?", + "calculation": { + "operation": { + "+": [ + { + "source": "calculated_summary", + "identifier": "number-calculated-summary-1" + }, + { + "source": "calculated_summary", + "identifier": "number-calculated-summary-2" + } + ] + }, + "title": "Grand calculated summary of journeys" + } + } + ] + } + ] + } + ] +} diff --git a/schemas/test/en/test_grand_calculated_summary_cross_section_dependencies.json b/schemas/test/en/test_grand_calculated_summary_cross_section_dependencies.json new file mode 100644 index 0000000000..b0754d04b6 --- /dev/null +++ b/schemas/test/en/test_grand_calculated_summary_cross_section_dependencies.json @@ -0,0 +1,341 @@ +{ + "mime_type": "application/json/ons/eq", + "language": "en", + "schema_version": "0.0.1", + "data_version": "0.0.3", + "survey_id": "0", + "title": "Grand Calculated Summary Cross Section Dependencies", + "theme": "default", + "description": "A questionnaire to demo resolution of grand calculated summary values across sections", + "metadata": [ + { + "name": "user_id", + "type": "string" + }, + { + "name": "period_id", + "type": "string" + }, + { + "name": "ru_name", + "type": "string" + } + ], + "questionnaire_flow": { + "type": "Hub", + "options": {} + }, + "sections": [ + { + "id": "questions-section", + "title": "Household bills", + "summary": { + "show_on_completion": true + }, + "groups": [ + { + "id": "radio", + "title": "Questions", + "blocks": [ + { + "type": "Question", + "id": "skip-first-block", + "question": { + "type": "General", + "id": "skip-question-1", + "title": "Are you a student?", + "guidance": { + "contents": [ + { + "description": "If you answer yes, then the question about council tax will be skipped and not included in total monthly expenditure." + } + ] + }, + "answers": [ + { + "type": "Radio", + "id": "skip-answer-1", + "mandatory": false, + "options": [ + { + "label": "Yes", + "value": "Yes" + }, + { + "label": "No", + "value": "No" + } + ] + } + ] + } + }, + { + "skip_conditions": { + "when": { + "==": [ + { + "identifier": "skip-answer-1", + "source": "answers" + }, + "Yes" + ] + } + }, + "type": "Question", + "id": "first-number-block-part-a", + "question": { + "id": "question-1-a", + "title": "How much do you pay monthly for council tax?", + "type": "General", + "answers": [ + { + "id": "first-number-answer-a", + "label": "Council tax (optional)", + "mandatory": false, + "type": "Currency", + "currency": "GBP", + "decimal_places": 2 + } + ] + } + }, + { + "type": "Question", + "id": "second-number-block", + "question": { + "id": "question-2", + "title": "How much are your monthly gas, water and electricity bills?", + "type": "General", + "answers": [ + { + "id": "second-number-answer-a", + "label": "Electricity Bill", + "mandatory": false, + "type": "Currency", + "currency": "GBP", + "decimal_places": 2 + }, + { + "id": "second-number-answer-b", + "label": "Gas Bill", + "mandatory": false, + "type": "Currency", + "currency": "GBP", + "decimal_places": 2 + }, + { + "id": "second-number-answer-c", + "label": "Water Bill", + "mandatory": false, + "type": "Currency", + "currency": "GBP", + "decimal_places": 2 + } + ] + } + }, + { + "type": "CalculatedSummary", + "id": "currency-section-1", + "title": "We calculate your total monthly expenditure on household bills to be %(total)s. Is this correct?", + "calculation": { + "operation": { + "+": [ + { + "source": "answers", + "identifier": "second-number-answer-a" + }, + { + "source": "answers", + "identifier": "second-number-answer-b" + }, + { + "source": "answers", + "identifier": "second-number-answer-c" + }, + { + "source": "answers", + "identifier": "first-number-answer-a" + } + ] + }, + "title": "Monthly expenditure on household bills" + } + } + ] + } + ] + }, + { + "id": "calculated-summary-section", + "title": "Other outgoing costs", + "summary": { + "show_on_completion": true + }, + "groups": [ + { + "id": "calculated-summary", + "blocks": [ + { + "type": "Question", + "id": "third-number-block", + "question": { + "id": "third-number-question", + "title": "How much do you spend on internet and television?", + "type": "General", + "guidance": { + "contents": [ + { + "description": "If you enter a value for the TV licence, it will unlock an additional question about premium channels." + } + ] + }, + "answers": [ + { + "id": "third-number-answer-part-a", + "label": "Internet bill", + "mandatory": true, + "type": "Currency", + "currency": "GBP", + "decimal_places": 2 + }, + { + "id": "third-number-answer-part-b", + "label": "TV licence (optional)", + "mandatory": false, + "type": "Currency", + "currency": "GBP", + "decimal_places": 2 + } + ] + } + }, + { + "skip_conditions": { + "when": { + "==": [ + { + "identifier": "third-number-answer-part-b", + "source": "answers" + }, + null + ] + } + }, + "type": "Question", + "id": "fourth-number-block", + "question": { + "id": "fourth-number-question", + "title": "How much do you spend per month on premium television channels?", + "type": "General", + "answers": [ + { + "id": "fourth-number-answer", + "label": "TV channel subscription fees", + "mandatory": true, + "type": "Currency", + "currency": "GBP", + "decimal_places": 2 + } + ] + } + }, + { + "type": "Question", + "id": "skip-calculated-summary", + "question": { + "type": "General", + "id": "skip-question-2", + "title": "Skip the calculated summary of other outgoing costs so it isn’t included in the grand calculated summary?", + "answers": [ + { + "type": "Radio", + "id": "skip-answer-2", + "mandatory": false, + "options": [ + { + "label": "Yes", + "value": "Yes" + }, + { + "label": "No", + "value": "No" + } + ] + } + ] + } + }, + { + "skip_conditions": { + "when": { + "==": [ + { + "identifier": "skip-answer-2", + "source": "answers" + }, + "Yes" + ] + } + }, + "type": "CalculatedSummary", + "id": "currency-question-3", + "title": "We calculate the total monthly spending on internet and TV to be %(total)s. Is this correct?", + "calculation": { + "operation": { + "+": [ + { + "source": "answers", + "identifier": "third-number-answer-part-a" + }, + { + "source": "answers", + "identifier": "third-number-answer-part-b" + }, + { + "source": "answers", + "identifier": "fourth-number-answer" + } + ] + }, + "title": "Total monthly spending on internet and TV" + } + } + ] + } + ] + }, + { + "id": "grand-calculated-summary-section", + "title": "Grand Calculated Summary", + "groups": [ + { + "id": "grand-calculated-summary", + "blocks": [ + { + "type": "GrandCalculatedSummary", + "id": "currency-all", + "title": "The grand calculated summary is calculated to be %(total)s. Is this correct?", + "calculation": { + "operation": { + "+": [ + { + "source": "calculated_summary", + "identifier": "currency-section-1" + }, + { + "source": "calculated_summary", + "identifier": "currency-question-3" + } + ] + }, + "title": "Grand total monthly expenditure" + } + } + ] + } + ] + } + ] +} diff --git a/schemas/test/en/test_grand_calculated_summary_multiple_sections.json b/schemas/test/en/test_grand_calculated_summary_multiple_sections.json new file mode 100644 index 0000000000..8b7700df1d --- /dev/null +++ b/schemas/test/en/test_grand_calculated_summary_multiple_sections.json @@ -0,0 +1,348 @@ +{ + "mime_type": "application/json/ons/eq", + "language": "en", + "schema_version": "0.0.1", + "data_version": "0.0.3", + "survey_id": "0", + "title": "Grand Calculated Summary cross section demo", + "theme": "default", + "description": "A schema to showcase grand calculated summaries across multiple sections and groups", + "metadata": [ + { + "name": "user_id", + "type": "string" + }, + { + "name": "period_id", + "type": "string" + }, + { + "name": "ru_name", + "type": "string" + } + ], + "questionnaire_flow": { + "type": "Hub", + "options": {} + }, + "sections": [ + { + "id": "section-1", + "title": "Section 1", + "summary": { + "show_on_completion": true + }, + "groups": [ + { + "id": "group-1", + "title": "Group title for questions about food", + "blocks": [ + { + "type": "Question", + "id": "block-1", + "question": { + "type": "General", + "id": "question-1", + "title": "How much do you spend per week on fruit and veg? (Question 1, Group 1, Section 1)", + "answers": [ + { + "id": "q1-a1", + "label": "Money spent on fruit", + "type": "Currency", + "currency": "GBP", + "mandatory": false + }, + { + "id": "q1-a2", + "label": "Money spent on veg", + "type": "Currency", + "currency": "GBP", + "mandatory": false + } + ] + } + }, + { + "type": "Question", + "id": "block-2", + "question": { + "type": "General", + "id": "question-2", + "title": "How much do you spend per week on other food? (Question 2, Group 1, Section 1)", + "answers": [ + { + "id": "q2-a1", + "label": "Money spent on bread", + "type": "Currency", + "currency": "GBP", + "mandatory": false + }, + { + "id": "q2-a2", + "label": "Money spent on not bread", + "type": "Currency", + "currency": "GBP", + "mandatory": false + } + ] + } + }, + { + "type": "CalculatedSummary", + "id": "calculated-summary-1", + "title": "Calculated Summary for Question 1 is calculated to be %(total)s. Is this correct?", + "calculation": { + "title": "Total food expenditure", + "operation": { + "+": [ + { + "source": "answers", + "identifier": "q1-a1" + }, + { + "source": "answers", + "identifier": "q1-a2" + }, + { + "source": "answers", + "identifier": "q2-a1" + }, + { + "source": "answers", + "identifier": "q2-a2" + } + ] + } + } + } + ] + }, + { + "id": "group-2", + "title": "Group title for clothing questions", + "blocks": [ + { + "type": "Question", + "id": "block-3", + "question": { + "type": "General", + "id": "question-3", + "title": "How much do you spend per week on clothes? (Question 3, Group 2, Section 1)", + "answers": [ + { + "id": "q3-a1", + "label": "Money spent on jumpers", + "type": "Currency", + "currency": "GBP", + "mandatory": false + }, + { + "id": "q3-a2", + "label": "Money spent on hats", + "type": "Currency", + "currency": "GBP", + "mandatory": false + } + ] + } + }, + { + "type": "CalculatedSummary", + "id": "calculated-summary-2", + "title": "Calculated summary for question 3 is calculated to be %(total)s. Is this correct?", + "calculation": { + "title": "Total clothes expenditure", + "operation": { + "+": [ + { + "source": "answers", + "identifier": "q3-a1" + }, + { + "source": "answers", + "identifier": "q3-a2" + } + ] + } + } + }, + { + "type": "CalculatedSummary", + "id": "calculated-summary-3", + "title": "Calculated summary for section 1 is calculated to be %(total)s. Is this correct?", + "calculation": { + "title": "Total food and clothes expenditure", + "operation": { + "+": [ + { + "source": "answers", + "identifier": "q1-a1" + }, + { + "source": "answers", + "identifier": "q1-a2" + }, + { + "source": "answers", + "identifier": "q2-a1" + }, + { + "source": "answers", + "identifier": "q2-a2" + }, + { + "source": "answers", + "identifier": "q3-a1" + }, + { + "source": "answers", + "identifier": "q3-a2" + } + ] + } + } + }, + { + "type": "GrandCalculatedSummary", + "id": "grand-calculated-summary-1", + "title": "Grand Calculated Summary which should match the previous calculated summary is calculated to be %(total)s. Is this correct?", + "calculation": { + "title": "Grand calculated summary", + "operation": { + "+": [ + { + "source": "calculated_summary", + "identifier": "calculated-summary-1" + }, + { + "source": "calculated_summary", + "identifier": "calculated-summary-2" + } + ] + } + } + } + ] + } + ] + }, + { + "id": "section-2", + "title": "Section 2", + "groups": [ + { + "id": "group-3", + "title": "Group title for questions about games", + "blocks": [ + { + "type": "Question", + "id": "block-4", + "question": { + "type": "General", + "id": "question-4", + "title": "How much do you spend per week on games? (Question 4, section 2, group 3)", + "guidance": { + "contents": [ + { + "description": "Note:" + }, + { + "list": [ + "The grand calculated summary section after this will only show if the total spending on games is not zero", + "You should test that if you use the change links on the grand calculated summary to come back here and set both answers to 0, that you are not routed to the grand calculated summary when you press continue on the calculated summary, but instead, taken to the Hub.", + "If you use the change links on the grand calculated summary to edit these answer values to a non-zero sum, pressing continue twice should take you back to the grand calculated summary" + ] + } + ] + }, + "answers": [ + { + "id": "q4-a1", + "label": "Video games", + "type": "Currency", + "currency": "GBP", + "mandatory": false + }, + { + "id": "q4-a2", + "label": "Board games", + "type": "Currency", + "currency": "GBP", + "mandatory": false + } + ] + } + }, + { + "type": "CalculatedSummary", + "id": "calculated-summary-4", + "title": "Calculated Summary for Question 4 is calculated to be %(total)s. Is this correct?", + "calculation": { + "title": "Total games expenditure", + "operation": { + "+": [ + { + "source": "answers", + "identifier": "q4-a1" + }, + { + "source": "answers", + "identifier": "q4-a2" + } + ] + } + } + } + ] + } + ] + }, + { + "id": "section-3", + "title": "Grand calculated summary section which only shows if the previous calculated summary is non zero", + "enabled": { + "when": { + "!=": [ + 0, + { + "source": "calculated_summary", + "identifier": "calculated-summary-4" + } + ] + } + }, + "groups": [ + { + "id": "group-4", + "title": "Group title for the grand calculated summary of both sections", + "blocks": [ + { + "type": "GrandCalculatedSummary", + "id": "grand-calculated-summary-2", + "title": "Grand Calculated Summary for section 1 and 2 is calculated to be %(total)s. Is this correct?", + "calculation": { + "title": "Total food clothes and games expenditure", + "operation": { + "+": [ + { + "source": "calculated_summary", + "identifier": "calculated-summary-1" + }, + { + "source": "calculated_summary", + "identifier": "calculated-summary-2" + }, + { + "source": "calculated_summary", + "identifier": "calculated-summary-4" + } + ] + } + } + } + ] + } + ] + } + ] +} diff --git a/schemas/test/en/test_grand_calculated_summary_overlapping_answers.json b/schemas/test/en/test_grand_calculated_summary_overlapping_answers.json new file mode 100644 index 0000000000..78bd81e4b0 --- /dev/null +++ b/schemas/test/en/test_grand_calculated_summary_overlapping_answers.json @@ -0,0 +1,329 @@ +{ + "mime_type": "application/json/ons/eq", + "language": "en", + "schema_version": "0.0.1", + "data_version": "0.0.3", + "survey_id": "0", + "title": "Grand Calculated Summary with overlapping answers", + "theme": "default", + "description": "A schema to showcase grand calculated summaries which include multiple calculated summaries using the same answers.", + "metadata": [ + { + "name": "user_id", + "type": "string" + }, + { + "name": "period_id", + "type": "string" + }, + { + "name": "ru_name", + "type": "string" + } + ], + "questionnaire_flow": { + "type": "Hub", + "options": { + "required_completed_sections": ["introduction-section"] + } + }, + "sections": [ + { + "id": "introduction-section", + "title": "Introduction", + "show_on_hub": false, + "groups": [ + { + "id": "introduction-group", + "title": "Introduction", + "blocks": [ + { + "type": "Introduction", + "id": "introduction-block", + "primary_content": [ + { + "id": "about", + "contents": [ + { + "title": "About", + "list": [ + "This survey tests that when you re-use answers between calculated summaries, the grand calculated summary still resolves to the correct value" + ] + }, + { + "title": "How to test this schema", + "list": [ + "Ensure that the grand calculated summary section does not show unless all dependent calculated summaries in section-1 have been confirmed.", + "Your answer to the third question, may unlock an additional calculated summary which re-use your answers to the first two questions", + "If you do not select to buy extra food, verify no additional calculated summary occurs, and that the grand calculated summary is correct", + "If you choose to buy any food items twice, verify that they are included twice in the grand calculated summary, one for each calculated summary", + "Verify that if you have the extra calculated summary, and change the cost of bread for example using either of the calculated summary change links which include it that you are routed to each calculated summary first, and only then the grand calculated summary" + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "section-1", + "title": "Weekly shop", + "summary": { + "show_on_completion": true + }, + "groups": [ + { + "id": "group-1", + "title": "Weekly shopping", + "blocks": [ + { + "type": "Question", + "id": "block-1", + "question": { + "type": "General", + "id": "question-1", + "title": "How much do you spend on the following in a typical weekly shop?", + "answers": [ + { + "id": "q1-a1", + "label": "Money on milk", + "type": "Currency", + "currency": "GBP", + "mandatory": false + }, + { + "id": "q1-a2", + "label": "Money on eggs", + "type": "Currency", + "currency": "GBP", + "mandatory": false + } + ] + } + }, + { + "type": "Question", + "id": "block-2", + "question": { + "type": "General", + "id": "question-2", + "title": "How much do you spend on these items in a typical week?", + "answers": [ + { + "id": "q2-a1", + "label": "Money on bread", + "type": "Currency", + "currency": "GBP", + "mandatory": false + }, + { + "id": "q2-a2", + "label": "Money on cheese", + "type": "Currency", + "currency": "GBP", + "mandatory": false + } + ] + } + }, + { + "type": "CalculatedSummary", + "id": "calculated-summary-1", + "title": "Total of milk and bread is calculated to be %(total)s. Is this correct?", + "calculation": { + "title": "milk + bread", + "operation": { + "+": [ + { + "source": "answers", + "identifier": "q1-a1" + }, + { + "source": "answers", + "identifier": "q2-a1" + } + ] + } + } + }, + { + "type": "CalculatedSummary", + "id": "calculated-summary-2", + "title": "Total of eggs and cheese is calculated to be %(total)s. Is this correct?", + "calculation": { + "title": "eggs + cheese", + "operation": { + "+": [ + { + "source": "answers", + "identifier": "q1-a2" + }, + { + "source": "answers", + "identifier": "q2-a2" + } + ] + } + } + }, + { + "type": "Question", + "id": "block-3", + "question": { + "type": "General", + "id": "question-3", + "title": "Do you want to buy extra of anything this week?", + "guidance": { + "contents": [ + { + "description": "If you select the first option, all your answers so far will be reused in a new calculated summary for extra shopping. If you select the second option, only your answers for bread and cheese will be reused." + } + ] + }, + "answers": [ + { + "type": "Radio", + "id": "radio-extra", + "mandatory": true, + "options": [ + { + "label": "Yes, I am going to buy two of everything", + "value": "Yes, I am going to buy two of everything" + }, + { + "label": "Yes, extra bread and cheese", + "value": "Yes, extra bread and cheese" + }, + { + "label": "No", + "value": "No" + } + ] + } + ] + } + }, + { + "skip_conditions": { + "when": { + "!=": [ + { + "source": "answers", + "identifier": "radio-extra" + }, + "Yes, I am going to buy two of everything" + ] + } + }, + "type": "CalculatedSummary", + "id": "calculated-summary-3", + "title": "Total extra items purchased is calculated to be %(total)s. Is this correct? This reuses your answers to question 1 and 2", + "calculation": { + "title": "(extra) milk + eggs + bread + cheese", + "operation": { + "+": [ + { + "source": "answers", + "identifier": "q1-a1" + }, + { + "source": "answers", + "identifier": "q1-a2" + }, + { + "source": "answers", + "identifier": "q2-a1" + }, + { + "source": "answers", + "identifier": "q2-a2" + } + ] + } + } + }, + { + "skip_conditions": { + "when": { + "!=": [ + { + "source": "answers", + "identifier": "radio-extra" + }, + "Yes, extra bread and cheese" + ] + } + }, + "type": "CalculatedSummary", + "id": "calculated-summary-4", + "title": "Total extra items cost is calculated to be %(total)s. Is this correct? This is reusing your bread and cheese answers", + "calculation": { + "title": "(extra) bread + cheese", + "operation": { + "+": [ + { + "source": "answers", + "identifier": "q2-a1" + }, + { + "source": "answers", + "identifier": "q2-a2" + } + ] + } + } + } + ] + } + ] + }, + { + "id": "section-3", + "title": "Grand calculated summary", + "enabled": { + "when": { + "==": [{ "source": "progress", "selector": "section", "identifier": "section-1" }, "COMPLETED"] + } + }, + "groups": [ + { + "id": "group-2", + "title": "Grand calculated summary", + "blocks": [ + { + "type": "GrandCalculatedSummary", + "id": "grand-calculated-summary-shopping", + "title": "Grand Calculated Summary of purchases this week comes to %(total)s. Is this correct?.", + "calculation": { + "title": "Weekly shopping cost", + "operation": { + "+": [ + { + "source": "calculated_summary", + "identifier": "calculated-summary-1" + }, + { + "source": "calculated_summary", + "identifier": "calculated-summary-2" + }, + { + "source": "calculated_summary", + "identifier": "calculated-summary-3" + }, + { + "source": "calculated_summary", + "identifier": "calculated-summary-4" + } + ] + } + } + } + ] + } + ] + } + ] +} diff --git a/templates/calculatedsummary.html b/templates/calculatedsummary.html index 674524fea9..bdb0f96511 100644 --- a/templates/calculatedsummary.html +++ b/templates/calculatedsummary.html @@ -1,34 +1,5 @@ -{% extends 'layouts/_questionnaire.html' %} -{% from "components/button/_macro.njk" import onsButton %} -{% from "components/panel/_macro.njk" import onsPanel %} +{% extends 'layouts/_calculatedsummary.html' %} -{% set save_on_signout = true %} - -{% block form_content %} -

{{content.summary.title}}

- - {% call onsPanel({ - "classes": "ons-u-mb-l" - }) %} -

{{ _("Please review your answers and confirm these are correct") }}

- {% endcall %} - -
- {% include 'partials/summary/summary.html' %} -
-{% endblock -%} - -{% block submit_button %} - {{ - onsButton({ - "text": _("Yes, I confirm these are correct"), - "variants": 'timer', - "attributes": { - "data-qa": "btn-submit", - "data-ga-category": "Submit button", - "data-ga-action": "Confirm", - "data-ga-label": "Confirm button click" - } - }) - }} +{% block form_title %} +

{{ content.summary.title }}

{% endblock %} diff --git a/templates/grandcalculatedsummary.html b/templates/grandcalculatedsummary.html new file mode 100644 index 0000000000..875870974a --- /dev/null +++ b/templates/grandcalculatedsummary.html @@ -0,0 +1,5 @@ +{% extends 'layouts/_calculatedsummary.html' %} + +{% block form_title %} +

{{ content.summary.title }}

+{% endblock %} diff --git a/templates/layouts/_calculatedsummary.html b/templates/layouts/_calculatedsummary.html new file mode 100644 index 0000000000..b618e817b3 --- /dev/null +++ b/templates/layouts/_calculatedsummary.html @@ -0,0 +1,35 @@ +{% extends 'layouts/_questionnaire.html' %} +{% from "components/button/_macro.njk" import onsButton %} +{% from "components/panel/_macro.njk" import onsPanel %} + +{% set save_on_signout = true %} + +{% block form_content %} + {% block form_title %} + {% endblock %} + + {% call onsPanel({ + "classes": "ons-u-mb-l" + }) %} +

{{ _("Please review your answers and confirm these are correct") }}

+ {% endcall %} + +
+ {% include 'partials/summary/summary.html' %} +
+{% endblock -%} + +{% block submit_button %} + {{ + onsButton({ + "text": _("Yes, I confirm these are correct"), + "variants": 'timer', + "attributes": { + "data-qa": "btn-submit", + "data-ga-category": "Submit button", + "data-ga-action": "Confirm", + "data-ga-label": "Confirm button click" + } + }) + }} +{% endblock %} diff --git a/tests/app/questionnaire/conftest.py b/tests/app/questionnaire/conftest.py index 876b236b79..5e92c71cd0 100644 --- a/tests/app/questionnaire/conftest.py +++ b/tests/app/questionnaire/conftest.py @@ -6,7 +6,7 @@ from app.data_models.answer_store import Answer, AnswerStore from app.data_models.list_store import ListStore from app.data_models.metadata_proxy import MetadataProxy -from app.data_models.progress_store import ProgressStore +from app.data_models.progress_store import CompletionStatus, ProgressStore from app.questionnaire import QuestionnaireSchema from app.questionnaire.location import Location from app.questionnaire.placeholder_parser import PlaceholderParser @@ -1311,6 +1311,29 @@ def progress_dependencies_schema(): ) +@pytest.fixture +def grand_calculated_summary_schema(): + return load_schema_from_name("test_grand_calculated_summary") + + +@pytest.fixture +def grand_calculated_summary_progress_store(): + return ProgressStore( + [ + { + "section_id": "section-1", + "block_ids": [ + "first-number-block", + "second-number-block", + "distance-calculated-summary-1", + "number-calculated-summary-1", + ], + "status": CompletionStatus.COMPLETED, + } + ] + ) + + @pytest.fixture @pytest.mark.usefixtures("app", "gb_locale") def placeholder_transform_question_dynamic_answers_json(): diff --git a/tests/app/questionnaire/test_router.py b/tests/app/questionnaire/test_router.py index 2772e5ad09..7c1ec52e6f 100644 --- a/tests/app/questionnaire/test_router.py +++ b/tests/app/questionnaire/test_router.py @@ -510,7 +510,25 @@ def test_section_summary_on_completion_false(self): ("test_calculated_summary",), ) def test_return_to_calculated_summary(self, schema): + """ + This tests that when you hit continue on an edited answer for a calculated summary + and all dependent answers for that calculated summary are complete, you are routed to the calculated summary + """ self.schema = load_schema_from_name(schema) + # for the purposes of this test, assume the routing path consists only of the first two blocks and the calculated summary + # and that those two blocks are complete - this will be a sufficient condition to return to the calculated summary + self.progress_store = ProgressStore( + [ + { + "section_id": "default-section", + "block_ids": [ + "first-number-block", + "second-number-block", + ], + "status": CompletionStatus.IN_PROGRESS, + } + ] + ) current_location = Location( section_id="default-section", block_id="second-number-block" @@ -518,8 +536,9 @@ def test_return_to_calculated_summary(self, schema): routing_path = RoutingPath( [ + "first-number-block", "second-number-block", - "currency-total-playback-skipped-fourth", + "currency-total-playback", ], section_id="default-section", ) @@ -529,11 +548,11 @@ def test_return_to_calculated_summary(self, schema): routing_path, return_to_answer_id="first-number-answer", return_to="calculated-summary", - return_to_block_id="currency-total-playback-skipped-fourth", + return_to_block_id="currency-total-playback", ) expected_location = Location( section_id="default-section", - block_id="currency-total-playback-skipped-fourth", + block_id="currency-total-playback", ) expected_location_url = url_for( @@ -541,8 +560,7 @@ def test_return_to_calculated_summary(self, schema): list_item_id=expected_location.list_item_id, block_id=expected_location.block_id, return_to="calculated-summary", - return_to_answer_id="first-number-answer", - return_to_block_id="currency-total-playback-skipped-fourth", + _anchor="first-number-answer", ) assert expected_location_url == next_location_url @@ -556,10 +574,24 @@ def test_return_to_calculated_summary(self, schema): ), ) def test_return_to_calculated_summary_not_on_allowable_path(self, schema): + """ + This tests that if you try to return to a calculated summary before all its dependencies have been answered + then you are instead routed to the first incomplete block of the section + """ self.schema = load_schema_from_name(schema) + self.progress_store = ProgressStore( + [ + { + "section_id": "default-section", + "block_ids": ["block-3"], + "status": CompletionStatus.IN_PROGRESS, + } + ] + ) current_location = Location(section_id="default-section", block_id="block-3") + # block 3 is complete, and block 4 is not, so block 4 should be routed to before the calculated summary routing_path = RoutingPath( [ "block-3", @@ -587,7 +619,6 @@ def test_return_to_calculated_summary_not_on_allowable_path(self, schema): list_item_id=expected_location.list_item_id, block_id=expected_location.block_id, return_to="calculated-summary", - return_to_answer_id="answer-3", return_to_block_id="calculated-summary-block", ) @@ -613,6 +644,15 @@ def test_return_to_calculated_summary_invalid_return_to_block_id( self, schema, return_to_block_id, expected_url ): self.schema = load_schema_from_name(schema) + self.progress_store = ProgressStore( + [ + { + "section_id": "default-section", + "block_ids": ["fifth-number-block"], + "status": CompletionStatus.IN_PROGRESS, + } + ] + ) current_location = Location( section_id="default-section", block_id="fifth-number-block" @@ -638,6 +678,15 @@ def test_return_to_calculated_summary_invalid_return_to_block_id( ) def test_return_to_calculated_summary_return_to_block_id_not_on_path(self, schema): self.schema = load_schema_from_name(schema) + self.progress_store = ProgressStore( + [ + { + "section_id": "default-section", + "block_ids": ["fifth-number-block"], + "status": CompletionStatus.IN_PROGRESS, + } + ] + ) current_location = Location( section_id="default-section", block_id="fifth-number-block" @@ -661,6 +710,196 @@ def test_return_to_calculated_summary_return_to_block_id_not_on_path(self, schem == next_location_url ) + @pytest.mark.usefixtures("app") + def test_return_to_grand_calculated_summary_from_answer( + self, grand_calculated_summary_progress_store, grand_calculated_summary_schema + ): + """ + If going from GCS -> CS -> answer -> CS -> GCS this tests going from CS -> GCS having just come from an answer + """ + self.schema = grand_calculated_summary_schema + self.progress_store = grand_calculated_summary_progress_store + + current_location = Location( + section_id="section-1", block_id="first-number-block" + ) + + routing_path = RoutingPath( + ["distance-calculated-summary-1"], + section_id="section-1", + ) + next_location_url = self.router.get_next_location_url( + current_location, + routing_path, + return_to="calculated-summary,grand-calculated-summary", + return_to_answer_id="distance-calculated-summary-1", + return_to_block_id="distance-calculated-summary-1,distance-grand-calculated-summary", + ) + + expected_previous_url = url_for( + "questionnaire.block", + return_to="grand-calculated-summary", + block_id="distance-calculated-summary-1", + return_to_block_id="distance-grand-calculated-summary", + _anchor="distance-calculated-summary-1", + ) + + assert expected_previous_url == next_location_url + + @pytest.mark.usefixtures("app") + def test_return_to_grand_calculated_summary_from_calculated_summary( + self, grand_calculated_summary_progress_store, grand_calculated_summary_schema + ): + """ + If going from GCS -> CS -> GCS this tests going from CS -> GCS having just come from the grand calculated summary + """ + self.schema = grand_calculated_summary_schema + self.progress_store = grand_calculated_summary_progress_store + + current_location = Location( + section_id="section-1", block_id="distance-calculated-summary-1" + ) + + routing_path = RoutingPath( + ["distance-calculated-summary-1"], + section_id="section-1", + ) + next_location_url = self.router.get_next_location_url( + current_location, + routing_path, + return_to="grand-calculated-summary", + return_to_answer_id="distance-calculated-summary-1", + return_to_block_id="distance-grand-calculated-summary", + ) + + expected_previous_url = url_for( + "questionnaire.block", + return_to="grand-calculated-summary", + block_id="distance-grand-calculated-summary", + _anchor="distance-calculated-summary-1", + ) + + assert expected_previous_url == next_location_url + + @pytest.mark.parametrize( + "return_to_block_id", + ("grand-calculated-summary-1", "grand-calculated-summary-2"), + ) + @pytest.mark.usefixtures("app") + def test_return_to_grand_calculated_summary_from_incomplete_section( + self, return_to_block_id + ): + """ + This tests that if you try to return to a grand calculated summary from an incomplete section + (or the same section but before the dependencies of the grand calculated summary are complete) + you are routed to the next block in the incomplete section rather than the grand calculated summary + """ + self.schema = load_schema_from_name( + "test_grand_calculated_summary_multiple_sections" + ) + # calculated summary 3 is not complete yet + self.progress_store = ProgressStore( + [ + { + "section_id": "section-1", + "block_ids": [ + "block-1", + "block-2", + "calculated-summary-1", + "block-3", + "calculated-summary-2", + ], + "status": "IN_PROGRESS", + } + ] + ) + + current_location = Location(section_id="section-1", block_id="block-2") + routing_path = RoutingPath( + [ + "block-1", + "block-2", + "calculated-summary-1", + "block-3", + "calculated-summary-2", + "calculated-summary-3", + "grand-calculated-summary-1", + ], + section_id="section-1", + ) + next_location_url = self.router.get_next_location_url( + current_location, + routing_path, + return_to="grand-calculated-summary", + return_to_answer_id="calculated-summary-1", + return_to_block_id=return_to_block_id, + ) + + # because calculated summary 3 isn't done, should go there before jumping to the grand calculated summary + # test from grand-calculated-summary-1 which is in the same section, and grand-calculated-summary-2 which is in another + expected_next_url = url_for( + "questionnaire.block", + return_to="grand-calculated-summary", + return_to_block_id=return_to_block_id, + block_id="calculated-summary-3", + ) + + assert expected_next_url == next_location_url + + @pytest.mark.usefixtures("app") + def test_return_to_calculated_summary_from_incomplete_section( + self, grand_calculated_summary_schema + ): + """ + This tests that if you try to return to a calculated summary section from an incomplete section + you are routed to the next block in the incomplete section rather than the calculated summary + """ + self.schema = grand_calculated_summary_schema + # second-number block not complete yet + self.progress_store = ProgressStore( + [ + { + "section_id": "section-1", + "block_ids": [ + "first-number-block", + "distance-calculated-summary-1", + ], + "status": CompletionStatus.IN_PROGRESS, + } + ] + ) + + current_location = Location( + section_id="section-1", block_id="first-number-block" + ) + routing_path = RoutingPath( + [ + "first-number-block", + "second-number-block", + "distance-calculated-summary-1", + "number-calculated-summary-1", + ], + section_id="section-1", + ) + # the test is being done as part of a two-step return to but its identical functionally + next_location_url = self.router.get_next_location_url( + current_location, + routing_path, + return_to="calculated-summary,grand-calculated-summary", + return_to_answer_id="first-number-block", + return_to_block_id="distance-calculated-summary-1,distance-grand-calculated-summary", + ) + + # should take you to the second-number-block before going back to the calculated summary + expected_next_url = url_for( + "questionnaire.block", + return_to="calculated-summary,grand-calculated-summary", + return_to_block_id="distance-calculated-summary-1,distance-grand-calculated-summary", + block_id="second-number-block", + ) + + assert expected_next_url == next_location_url + class TestRouterNextLocationLinearFlow(RouterTestCase): @pytest.mark.usefixtures("app") @@ -826,6 +1065,173 @@ def test_return_to_calculated_summary(self): assert expected_location_url == previous_location_url + @pytest.mark.usefixtures("app") + def test_return_to_grand_calculated_summary_from_answer_incomplete_section( + self, grand_calculated_summary_schema + ): + """ + This tests that if you are on a calculated summary, and your return_to_block_id is another calculated summary that you cannot reach yet + if you click previous, then you are taken to the previous block in the section + (rather than the first incomplete block of the section which is what next location would return) + """ + self.schema = grand_calculated_summary_schema + # trying to go to number-calculated-summary-1 but distance-calculated-summary-1 which comes before is not complete yet + self.progress_store = ProgressStore( + [ + { + "section_id": "section-1", + "block_ids": [ + "first-number-block", + "second-number-block", + "number-calculated-summary-1", + ], + "status": CompletionStatus.IN_PROGRESS, + } + ] + ) + + current_location = Location( + section_id="section-1", block_id="second-number-block" + ) + + routing_path = RoutingPath( + [ + "first-number-block", + "second-number-block", + "distance-calculated-summary-1", + "number-calculated-summary-1", + ], + section_id="section-1", + ) + previous_location_url = self.router.get_previous_location_url( + current_location, + routing_path, + return_to="calculated-summary,grand-calculated-summary", + return_to_answer_id="second-number-block", + return_to_block_id="number-calculated-summary-1,number-grand-calculated-summary", + ) + # return to can't go to the distance calculated summary, so go to previous block with return params preserved + expected_previous_url = url_for( + "questionnaire.block", + return_to="calculated-summary,grand-calculated-summary", + return_to_block_id="number-calculated-summary-1,number-grand-calculated-summary", + block_id="first-number-block", + _anchor="second-number-block", + ) + + assert expected_previous_url == previous_location_url + + @pytest.mark.usefixtures("app") + def test_return_to_grand_calculated_summary_from_calculated_summary_incomplete_section( + self, grand_calculated_summary_schema + ): + """ + This tests that if you are on a calculated summary, and your return_to_block_id is a grand calculated summary + if you click previous, then you are taken to the previous block in the section + (rather than the first incomplete block of the section which is what next location would return) + """ + self.schema = grand_calculated_summary_schema + # number calculated summary is not complete, so the section is not complete + self.progress_store = ProgressStore( + [ + { + "section_id": "section-1", + "block_ids": [ + "first-number-block", + "second-number-block", + "distance-calculated-summary-1", + ], + "status": CompletionStatus.IN_PROGRESS, + } + ] + ) + + current_location = Location( + section_id="section-1", block_id="distance-calculated-summary-1" + ) + + routing_path = RoutingPath( + [ + "first-number-block", + "second-number-block", + "distance-calculated-summary-1", + "number-calculated-summary-1", + ], + section_id="section-1", + ) + previous_location_url = self.router.get_previous_location_url( + current_location, + routing_path, + return_to="grand-calculated-summary", + return_to_answer_id="distance-calculated-summary-1", + return_to_block_id="distance-grand-calculated-summary", + ) + # return to can't go to the grand calculated summary, so routing is just to the previous block in the section with return params preserved + expected_previous_url = url_for( + "questionnaire.block", + return_to="grand-calculated-summary", + return_to_block_id="distance-grand-calculated-summary", + block_id="second-number-block", + _anchor="distance-calculated-summary-1", + ) + + assert expected_previous_url == previous_location_url + + @pytest.mark.parametrize( + "return_to, current_block, return_to_block_id, expected_url", + [ + ( + "grand-calculated-summary", + "distance-calculated-summary-1", + "invalid-block", + "/questionnaire/second-number-block/?return_to=grand-calculated-summary&return_to_block_id=invalid-block#distance-calculated-summary-1", + ), + ( + "calculated-summary,invalid", + "second-number-block", + "invalid-1,invalid-2", + "/questionnaire/first-number-block/?return_to=calculated-summary,invalid&return_to_block_id=invalid-1,invalid-2#distance-calculated-summary-1", + ), + ( + "invalid", + "distance-calculated-summary-1", + "first-number-block", + "/questionnaire/second-number-block/?return_to=invalid&return_to_block_id=first-number-block#distance-calculated-summary-1", + ), + ], + ) + @pytest.mark.usefixtures("app") + def test_return_to_grand_calculated_summary_invalid_url( + self, + return_to, + current_block, + return_to_block_id, + expected_url, + grand_calculated_summary_schema, + ): + self.schema = grand_calculated_summary_schema + + current_location = Location(section_id="section-1", block_id=current_block) + + routing_path = RoutingPath( + [ + "first-number-block", + "second-number-block", + "distance-calculated-summary-1", + "number-calculated-summary-1", + ], + section_id="section-1", + ) + previous_location_url = self.router.get_previous_location_url( + current_location, + routing_path, + return_to=return_to, + return_to_answer_id="distance-calculated-summary-1", + return_to_block_id=return_to_block_id, + ) + + assert expected_url == previous_location_url + @pytest.mark.usefixtures("app") def test_return_to_section_summary_section_is_complete(self): self.schema = load_schema_from_name("test_section_summary") diff --git a/tests/app/views/contexts/__init__.py b/tests/app/views/contexts/__init__.py index e929f99c7a..38e5cfe793 100644 --- a/tests/app/views/contexts/__init__.py +++ b/tests/app/views/contexts/__init__.py @@ -1,4 +1,4 @@ -def assert_summary_context(context): +def assert_summary_context(context, summary_item_type="question"): summary_context = context["summary"] for key_value in ("sections", "answers_are_editable", "summary_type"): assert ( @@ -10,10 +10,10 @@ def assert_summary_context(context): assert "id" in group assert "blocks" in group for block in group["blocks"]: - assert "question" in block - assert "title" in block["question"] - assert "answers" in block["question"] - for answer in block["question"]["answers"]: + assert summary_item_type in block + assert "title" in block[summary_item_type] + assert "answers" in block[summary_item_type] + for answer in block[summary_item_type]["answers"]: assert "id" in answer assert "value" in answer assert "type" in answer diff --git a/tests/app/views/contexts/conftest.py b/tests/app/views/contexts/conftest.py index 35bfffb073..d10dd27c78 100644 --- a/tests/app/views/contexts/conftest.py +++ b/tests/app/views/contexts/conftest.py @@ -329,6 +329,11 @@ def test_calculated_summary_schema(): return load_schema_from_name("test_calculated_summary") +@pytest.fixture +def test_grand_calculated_summary_schema(): + return load_schema_from_name("test_grand_calculated_summary") + + @pytest.fixture def test_calculated_summary_answers(): answers = [ @@ -349,6 +354,21 @@ def test_calculated_summary_answers(): return AnswerStore(answers) +@pytest.fixture +def test_grand_calculated_summary_answers(): + answers = [ + {"value": 10, "answer_id": "q1-a1"}, + {"value": 1, "answer_id": "q1-a2"}, + {"value": 20, "answer_id": "q2-a1"}, + {"value": 2, "answer_id": "q2-a2"}, + {"value": 30, "answer_id": "q3-a1"}, + {"value": 3, "answer_id": "q3-a2"}, + {"value": 40, "answer_id": "q4-a1"}, + {"value": 4, "answer_id": "q4-a2"}, + ] + return AnswerStore(answers) + + @pytest.fixture def test_calculated_summary_answers_skipped_fourth(): answers = [ diff --git a/tests/app/views/contexts/test_calculated_summary_context.py b/tests/app/views/contexts/test_calculated_summary_context.py index 9811e82762..8e9e4f4dab 100644 --- a/tests/app/views/contexts/test_calculated_summary_context.py +++ b/tests/app/views/contexts/test_calculated_summary_context.py @@ -122,7 +122,7 @@ def test_build_view_context_for_currency_calculated_summary( current_location=Location(section_id="default-section", block_id=block_id), ) - context = calculated_summary_context.build_view_context_for_calculated_summary() + context = calculated_summary_context.build_view_context() assert "summary" in context assert_summary_context(context) @@ -145,3 +145,90 @@ def test_build_view_context_for_currency_calculated_summary( assert "return_to=calculated-summary" in answer_change_link assert f"return_to_answer_id={return_to_answer_id}" in answer_change_link assert f"return_to_block_id={block_id}" in answer_change_link + + +@pytest.mark.usefixtures("app") +@pytest.mark.parametrize( + "block_id, return_to_answer_id, return_to, return_to_block_id", + ( + ( + "distance-calculated-summary-1", + "q1-a1", + "grand-calculated-summary", + "distance-grand-calculated-summary", + ), + ( + "number-calculated-summary-1", + "q1-a2", + "grand-calculated-summary", + "number-grand-calculated-summary", + ), + ( + "distance-calculated-summary-2", + "q3-a1", + "grand-calculated-summary", + "distance-grand-calculated-summary", + ), + ( + "number-calculated-summary-2", + "q3-a2", + "grand-calculated-summary", + "number-grand-calculated-summary", + ), + ), +) +def test_build_view_context_for_return_to_calculated_summary( + test_grand_calculated_summary_schema, + test_grand_calculated_summary_answers, + list_store, + progress_store, + mocker, + block_id, + return_to_answer_id, + return_to, + return_to_block_id, +): + """ + Tests the change answer links for a calculated summary that has been reached by a change link on a grand calculated summary + """ + mocker.patch( + "app.jinja_filters.flask_babel.get_locale", + mocker.MagicMock(return_value="en_GB"), + ) + + block_ids = [ + "first-number-block", + "second-number-block", + "distance-calculated-summary-1", + "number-calculated-summary-1", + "third-number-block", + "fourth-number-block", + "distance-calculated-summary-2", + "number-calculated-summary-2", + ] + + calculated_summary_context = CalculatedSummaryContext( + language="en", + schema=test_grand_calculated_summary_schema, + answer_store=test_grand_calculated_summary_answers, + list_store=list_store, + progress_store=progress_store, + metadata=None, + response_metadata={}, + routing_path=RoutingPath(section_id="default-section", block_ids=block_ids), + current_location=Location(section_id="default-section", block_id=block_id), + return_to=return_to, + return_to_block_id=return_to_block_id, + ) + + context = calculated_summary_context.build_view_context() + assert "summary" in context + assert_summary_context(context) + context_summary = context["summary"] + + answer_change_link = context_summary["sections"][0]["groups"][0]["blocks"][0][ + "question" + ]["answers"][0]["link"] + assert f"return_to=calculated-summary,{return_to}" in answer_change_link + assert f"return_to_answer_id={return_to_answer_id}" in answer_change_link + assert f"return_to_block_id={block_id},{return_to_block_id}" in answer_change_link diff --git a/tests/app/views/contexts/test_grand_calculated_summary_context.py b/tests/app/views/contexts/test_grand_calculated_summary_context.py new file mode 100644 index 0000000000..3c53d30390 --- /dev/null +++ b/tests/app/views/contexts/test_grand_calculated_summary_context.py @@ -0,0 +1,111 @@ +import pytest + +from app.data_models.progress_store import CompletionStatus, ProgressStore +from app.questionnaire import Location +from app.questionnaire.routing_path import RoutingPath +from app.views.contexts.grand_calculated_summary_context import ( + GrandCalculatedSummaryContext, +) +from tests.app.views.contexts import assert_summary_context + + +@pytest.mark.usefixtures("app") +@pytest.mark.parametrize( + "block_id, title, value, return_to_answer_id", + ( + ( + "distance-grand-calculated-summary", + "We calculate the grand total weekly distance travelled to be 100 mi. Is this correct?", + "100 mi", + "distance-calculated-summary-1", + ), + ( + "number-grand-calculated-summary", + "We calculate the grand total journeys per week to be 10. Is this correct?", + "10", + "number-calculated-summary-1", + ), + ), +) +def test_build_view_context_for_grand_calculated_summary( + block_id, + title, + value, + test_grand_calculated_summary_schema, + test_grand_calculated_summary_answers, + list_store, + mocker, + return_to_answer_id, +): + mocker.patch( + "app.jinja_filters.flask_babel.get_locale", + mocker.MagicMock(return_value="en_GB"), + ) + + block_ids = [ + "first-number-block", + "second-number-block", + "distance-calculated-summary-1", + "number-calculated-summary-1", + "third-number-block", + "fourth-number-block", + "distance-calculated-summary-2", + "number-calculated-summary-2", + ] + + grand_calculated_summary_context = GrandCalculatedSummaryContext( + language="en", + schema=test_grand_calculated_summary_schema, + answer_store=test_grand_calculated_summary_answers, + list_store=list_store, + progress_store=ProgressStore( + in_progress_sections=[ + { + "section_id": "section-1", + "status": CompletionStatus.COMPLETED, + "block_ids": [ + "first-number-block", + "second-number-block", + "distance-calculated-summary-1", + "number-calculated-summary-1", + ], + }, + { + "section_id": "section-2", + "status": CompletionStatus.COMPLETED, + "block_ids": [ + "third-number-block", + "fourth-number-block", + "distance-calculated-summary-2", + "number-calculated-summary-2", + ], + }, + ] + ), + metadata=None, + response_metadata={}, + routing_path=RoutingPath(section_id="default-section", block_ids=block_ids), + current_location=Location(section_id="default-section", block_id=block_id), + return_to=None, + return_to_block_id=None, + ) + + context = grand_calculated_summary_context.build_view_context() + + assert "summary" in context + assert_summary_context(context, "calculated_summary") + assert len(context["summary"]) == 6 + context_summary = context["summary"] + assert context_summary.get("title") == title + + assert "calculated_question" in context_summary + assert context_summary["calculated_question"]["answers"][0]["value"] == value + + calculated_summary_change_link = context_summary["sections"][0]["groups"][0][ + "blocks" + ][0]["calculated_summary"]["answers"][0]["link"] + assert "return_to=grand-calculated-summary" in calculated_summary_change_link + assert ( + f"return_to_answer_id={return_to_answer_id}" in calculated_summary_change_link + ) + assert f"return_to_block_id={block_id}" in calculated_summary_change_link diff --git a/tests/functional/base_pages/grand-calculated-summary.page.js b/tests/functional/base_pages/grand-calculated-summary.page.js new file mode 100644 index 0000000000..4f1c9e3312 --- /dev/null +++ b/tests/functional/base_pages/grand-calculated-summary.page.js @@ -0,0 +1,17 @@ +import BasePage from "./base.page"; + +class GrandCalculatedSummaryPage extends BasePage { + grandCalculatedSummaryTitle() { + return '[data-qa="grand-calculated-summary-title"]'; + } + + grandCalculatedSummaryQuestion() { + return "[data-qa=grand-calculated-summary-question]"; + } + + grandCalculatedSummaryAnswer() { + return "[data-qa=grand-calculated-summary-answer]"; + } +} + +export default GrandCalculatedSummaryPage; diff --git a/tests/functional/generate_pages.py b/tests/functional/generate_pages.py index b20998f7df..0b5910ae9b 100755 --- a/tests/functional/generate_pages.py +++ b/tests/functional/generate_pages.py @@ -848,6 +848,10 @@ def process_block( base_page = "CalculatedSummaryPage" base_page_file = "calculated-summary.page" + if block["type"] == "GrandCalculatedSummary": + base_page = "GrandCalculatedSummaryPage" + base_page_file = "grand-calculated-summary.page" + if block["type"] == "Introduction": base_page = "IntroductionPageBase" base_page_file = "introduction.page" @@ -893,6 +897,21 @@ def process_block( process_calculated_summary(calculated_summary_answer_ids, page_spec) + elif block["type"] == "GrandCalculatedSummary": + values = _get_dictionaries_with_key( + "source", block["calculation"]["operation"] + ) + + calculated_summary_ids = [ + value["identifier"] + for value in values + if value["source"] == "calculated_summary" + ] + + # each calculated summary in a grand calculated summary is constructed such that it will have a single "answer" linking back to it + # so the processing for calculated summaries can be directly reused. + process_calculated_summary(calculated_summary_ids, page_spec) + elif block["type"] == "Interstitial": has_definition = False if "content_variants" in block: diff --git a/tests/functional/spec/features/grand_calculated_summary/grand_calculated_summary_cross_section_dependencies.spec.js b/tests/functional/spec/features/grand_calculated_summary/grand_calculated_summary_cross_section_dependencies.spec.js new file mode 100644 index 0000000000..13dd580147 --- /dev/null +++ b/tests/functional/spec/features/grand_calculated_summary/grand_calculated_summary_cross_section_dependencies.spec.js @@ -0,0 +1,120 @@ +import SkipFirstBlockPage from "../../../generated_pages/grand_calculated_summary_cross_section_dependencies/skip-first-block.page"; +import SecondNumberBlockPage from "../../../generated_pages/grand_calculated_summary_cross_section_dependencies/second-number-block.page"; +import HubPage from "../../../base_pages/hub.page"; +import CurrencySection1Page from "../../../generated_pages/grand_calculated_summary_cross_section_dependencies/currency-section-1.page"; +import QuestionsSectionSummaryPage from "../../../generated_pages/grand_calculated_summary_cross_section_dependencies/questions-section-summary.page"; +import ThirdNumberBlockPage from "../../../generated_pages/grand_calculated_summary_cross_section_dependencies/third-number-block.page"; +import SkipCalculatedSummaryPage from "../../../generated_pages/grand_calculated_summary_cross_section_dependencies/skip-calculated-summary.page"; +import CalculatedSummarySectionSummaryPage from "../../../generated_pages/grand_calculated_summary_cross_section_dependencies/calculated-summary-section-summary.page"; +import CurrencyQuestion3Page from "../../../generated_pages/grand_calculated_summary_cross_section_dependencies/currency-question-3.page"; +import CurrencyAllPage from "../../../generated_pages/grand_calculated_summary_cross_section_dependencies/currency-all.page"; +import FirstNumberBlockPartAPage from "../../../generated_pages/grand_calculated_summary_cross_section_dependencies/first-number-block-part-a.page"; +import FourthNumberBlockPage from "../../../generated_pages/grand_calculated_summary_cross_section_dependencies/fourth-number-block.page"; + +describe("Feature: Grand Calculated Summary", () => { + describe("Given I have a Grand Calculated Summary", () => { + before("Getting to the second calculated summary", async () => { + await browser.openQuestionnaire("test_grand_calculated_summary_cross_section_dependencies.json"); + await $(HubPage.submit()).click(); + await $(SkipFirstBlockPage.no()).click(); + await $(SkipFirstBlockPage.submit()).click(); + await $(FirstNumberBlockPartAPage.firstNumberA()).setValue(300); + await $(FirstNumberBlockPartAPage.submit()).click(); + await $(SecondNumberBlockPage.secondNumberA()).setValue(10); + await $(SecondNumberBlockPage.secondNumberB()).setValue(5); + await $(SecondNumberBlockPage.secondNumberC()).setValue(15); + await $(SecondNumberBlockPage.submit()).click(); + await $(CurrencySection1Page.submit()).click(); + await $(QuestionsSectionSummaryPage.submit()).click(); + // section 2 + await $(HubPage.submit()).click(); + await $(ThirdNumberBlockPage.thirdNumberPartA()).setValue(70); + await $(ThirdNumberBlockPage.submit()).click(); + }); + it("Given I don't skip the second calculated summary, it is included in the grand calculated summary", async () => { + await $(SkipCalculatedSummaryPage.no()).click(); + await $(SkipCalculatedSummaryPage.submit()).click(); + await $(CurrencyQuestion3Page.submit()).click(); + await $(CalculatedSummarySectionSummaryPage.submit()).click(); + await $(HubPage.submit()).click(); + await expect(await $(CurrencyAllPage.currencySection1()).getText()).to.contain("£330.00"); + await expect(await $(CurrencyAllPage.currencyQuestion3()).getText()).to.contain("£70.00"); + await expect(await $(CurrencyAllPage.grandCalculatedSummaryTitle()).getText()).to.contain( + "The grand calculated summary is calculated to be £400.00. Is this correct?" + ); + await $(CurrencyAllPage.submit()).click(); + }); + it("Given I go back and skip the second calculated summary, it is not included in the grand calculated summary", async () => { + await $(HubPage.summaryRowLink("calculated-summary-section")).click(); + await $(CalculatedSummarySectionSummaryPage.skipAnswer2Edit()).click(); + await $(SkipCalculatedSummaryPage.yes()).click(); + await $(SkipCalculatedSummaryPage.submit()).click(); + await $(CalculatedSummarySectionSummaryPage.submit()).click(); + // Currently the grand calculated summary remains 'Completed' because none of the answers have changed + await expect(await $(HubPage.summaryRowState("grand-calculated-summary-section")).getText()).to.equal("Completed"); + await $(HubPage.summaryRowLink("grand-calculated-summary-section")).click(); + await expect(await $(CurrencyAllPage.grandCalculatedSummaryTitle()).getText()).to.contain( + "The grand calculated summary is calculated to be £330.00. Is this correct?" + ); + await expect(await $(CurrencyAllPage.currencyQuestion3()).isExisting()).to.be.false; + }); + it("Given I confirm the grand calculated summary, then edit an answer for question 3, the grand calculated summary updates to be incomplete, because this is a dependency", async () => { + await $(CurrencyAllPage.submit()).click(); + await $(HubPage.summaryRowLink("calculated-summary-section")).click(); + await $(CalculatedSummarySectionSummaryPage.thirdNumberAnswerPartAEdit()).click(); + await $(ThirdNumberBlockPage.thirdNumberPartA()).setValue(130); + await $(ThirdNumberBlockPage.submit()).click(); + await $(CalculatedSummarySectionSummaryPage.submit()).click(); + // Although the calculated summary is not on the path, the answer is still a grand calculated summary dependency, so it updates progress + await expect(await $(HubPage.summaryRowState("grand-calculated-summary-section")).getText()).to.equal("Partially completed"); + await $(HubPage.summaryRowLink("grand-calculated-summary-section")).click(); + await expect(await $(CurrencyAllPage.grandCalculatedSummaryTitle()).getText()).to.contain( + "The grand calculated summary is calculated to be £330.00. Is this correct?" + ); + await expect(await $(CurrencyAllPage.currencyQuestion3()).isExisting()).to.be.false; + await $(CurrencyAllPage.submit()).click(); + }); + it("Given I change my response to include the calculated summary, the grand calculated summary updates to re-include it", async () => { + await $(HubPage.summaryRowLink("calculated-summary-section")).click(); + await $(CalculatedSummarySectionSummaryPage.skipAnswer2Edit()).click(); + await $(SkipCalculatedSummaryPage.no()).click(); + await $(SkipCalculatedSummaryPage.submit()).click(); + await $(CurrencyQuestion3Page.submit()).click(); + await $(CalculatedSummarySectionSummaryPage.submit()).click(); + // Currently, the grand calculated summary does not return to in-progress, because none of the answers it depends on have changed + await expect(await $(HubPage.summaryRowState("grand-calculated-summary-section")).getText()).to.equal("Completed"); + await $(HubPage.summaryRowLink("grand-calculated-summary-section")).click(); + await expect(await $(CurrencyAllPage.grandCalculatedSummaryTitle()).getText()).to.contain( + "The grand calculated summary is calculated to be £460.00. Is this correct?" + ); + }); + it("Given I provide an answer to question 3b from the grand calculated summary, this opens up an additional question, and when I press continue I am taken to this question first, then the calculated summary, and then the grand calculated summary", async () => { + await $(CurrencyAllPage.currencyQuestion3Edit()).click(); + await $(CurrencyQuestion3Page.thirdNumberAnswerPartBEdit()).click(); + await $(ThirdNumberBlockPage.thirdNumberPartB()).setValue(10); + await $(ThirdNumberBlockPage.submit()).click(); + await expect(await browser.getUrl()).to.contain(FourthNumberBlockPage.pageName); + await $(FourthNumberBlockPage.fourthNumber()).setValue(1); + await $(FourthNumberBlockPage.submit()).click(); + await expect(await browser.getUrl()).to.contain(CurrencyQuestion3Page.pageName); + await $(CurrencyQuestion3Page.submit()).click(); + await expect(await browser.getUrl()).to.contain(CurrencyAllPage.pageName); + await expect(await $(CurrencyAllPage.grandCalculatedSummaryTitle()).getText()).to.contain( + "The grand calculated summary is calculated to be £471.00. Is this correct?" + ); + await $(CurrencyAllPage.submit()).click(); + }); + it("Given I go back to section one and skip the first block, it is not included in the first calculated summary and consequently not included in the grand calculated summary", async () => { + await $(HubPage.summaryRowLink("questions-section")).click(); + await $(QuestionsSectionSummaryPage.skipAnswer1Edit()).click(); + await $(SkipFirstBlockPage.yes()).click(); + await $(SkipFirstBlockPage.submit()).click(); + await $(QuestionsSectionSummaryPage.submit()).click(); + await $(HubPage.summaryRowLink("grand-calculated-summary-section")).click(); + await expect(await $(CurrencyAllPage.currencySection1()).getText()).to.contain("£30.00"); + await expect(await $(CurrencyAllPage.grandCalculatedSummaryTitle()).getText()).to.contain( + "The grand calculated summary is calculated to be £171.00. Is this correct?" + ); + }); + }); +}); diff --git a/tests/functional/spec/features/grand_calculated_summary/grand_calculated_summary_multiple_sections.spec.js b/tests/functional/spec/features/grand_calculated_summary/grand_calculated_summary_multiple_sections.spec.js new file mode 100644 index 0000000000..d9a3ed2a9c --- /dev/null +++ b/tests/functional/spec/features/grand_calculated_summary/grand_calculated_summary_multiple_sections.spec.js @@ -0,0 +1,154 @@ +import HubPage from "../../../base_pages/hub.page"; +import Block1Page from "../../../generated_pages/grand_calculated_summary_multiple_sections/block-1.page"; +import Block2Page from "../../../generated_pages/grand_calculated_summary_multiple_sections/block-2.page"; +import CalculatedSummary1Page from "../../../generated_pages/grand_calculated_summary_multiple_sections/calculated-summary-1.page"; +import Block3Page from "../../../generated_pages/grand_calculated_summary_multiple_sections/block-3.page"; +import Block4Page from "../../../generated_pages/grand_calculated_summary_multiple_sections/block-4.page"; +import CalculatedSummary2Page from "../../../generated_pages/grand_calculated_summary_multiple_sections/calculated-summary-2.page"; +import CalculatedSummary3Page from "../../../generated_pages/grand_calculated_summary_multiple_sections/calculated-summary-3.page"; +import CalculatedSummary4Page from "../../../generated_pages/grand_calculated_summary_multiple_sections/calculated-summary-4.page"; +import GrandCalculatedSummary1Page from "../../../generated_pages/grand_calculated_summary_multiple_sections/grand-calculated-summary-1.page"; +import GrandCalculatedSummary2Page from "../../../generated_pages/grand_calculated_summary_multiple_sections/grand-calculated-summary-2.page"; +import Section1SummaryPage from "../../../generated_pages/grand_calculated_summary_multiple_sections/section-1-summary.page"; + +describe("Feature: Grand Calculated Summary", () => { + describe("Given I have a Grand Calculated Summary across multiple sections", () => { + before("Reaching the grand calculated summary section", async () => { + await browser.openQuestionnaire("test_grand_calculated_summary_multiple_sections.json"); + await $(HubPage.submit()).click(); + + // complete 2 questions in section 1 + await $(Block1Page.q1A1()).setValue(10); + await $(Block1Page.q1A2()).setValue(20); + await $(Block1Page.submit()).click(); + await $(Block2Page.q2A1()).setValue(30); + await $(Block2Page.q2A2()).setValue(40); + await $(Block2Page.submit()).click(); + await $(CalculatedSummary1Page.submit()).click(); + + // and the one for section 2 + await $(Block3Page.q3A1()).setValue(100); + await $(Block3Page.q3A2()).setValue(200); + await $(Block3Page.submit()).click(); + await $(CalculatedSummary2Page.submit()).click(); + await $(CalculatedSummary3Page.submit()).click(); + await $(GrandCalculatedSummary1Page.submit()).click(); + await $(Section1SummaryPage.submit()).click(); + await $(HubPage.submit()).click(); + await $(Block4Page.q4A1()).setValue(5); + await $(Block4Page.q4A2()).setValue(10); + await $(Block4Page.submit()).click(); + await $(CalculatedSummary4Page.submit()).click(); + await $(HubPage.submit()).click(); + }); + + it("Given I click on the change link for a calculated summary then press continue, I am taken back to the grand calculated summary", async () => { + await expect(await $(GrandCalculatedSummary2Page.grandCalculatedSummaryTitle()).getText()).to.contain( + "Grand Calculated Summary for section 1 and 2 is calculated to be £415.00. Is this correct?" + ); + await $(GrandCalculatedSummary2Page.calculatedSummary1Edit()).click(); + await expect(await browser.getUrl()).to.contain(CalculatedSummary1Page.pageName); + + await $(CalculatedSummary1Page.submit()).click(); + await expect(await browser.getUrl()).to.contain(GrandCalculatedSummary2Page.pageName); + }); + + it("Given I go back to the calculated summary and then to a question and edit the answer. I am first taken back to the each calculated summary that uses the answer, the grand calculated summary in section 1, and then the updated grand calculated summary in section 3.", async () => { + await $(GrandCalculatedSummary2Page.calculatedSummary4Edit()).click(); + await expect(await $(CalculatedSummary4Page.calculatedSummaryTitle()).getText()).to.contain( + "Calculated Summary for Question 4 is calculated to be £15.00. Is this correct?" + ); + await $(CalculatedSummary4Page.q4A1Edit()).click(); + await expect(await browser.getUrl()).to.contain(Block4Page.pageName); + + await $(Block4Page.q4A1()).setValue(50); + await $(Block4Page.submit()).click(); + + // first taken back to the calculated summary which has updated + await expect(await browser.getUrl()).to.contain(CalculatedSummary4Page.pageName); + await expect(await $(CalculatedSummary4Page.calculatedSummaryTitle()).getText()).to.contain( + "Calculated Summary for Question 4 is calculated to be £60.00. Is this correct?" + ); + await $(CalculatedSummary4Page.submit()).click(); + + // then taken back to the grand calculated summary which has also been updated correctly + await expect(await browser.getUrl()).to.contain(GrandCalculatedSummary2Page.pageName); + await expect(await $(GrandCalculatedSummary2Page.grandCalculatedSummaryTitle()).getText()).to.contain( + "Grand Calculated Summary for section 1 and 2 is calculated to be £460.00. Is this correct?" + ); + }); + + it("Given I go back to another calculated summary and edit multiple answers, I am still correctly routed back to the grand calculated summary", async () => { + await $(GrandCalculatedSummary2Page.calculatedSummary1Edit()).click(); + await expect(await $(CalculatedSummary1Page.calculatedSummaryTitle()).getText()).to.contain( + "Calculated Summary for Question 1 is calculated to be £100.00. Is this correct?" + ); + + // change first answer + await $(CalculatedSummary1Page.q1A1Edit()).click(); + await expect(await browser.getUrl()).to.contain(Block1Page.pageName); + await $(Block1Page.q1A1()).setValue(100); + await $(Block1Page.submit()).click(); + + // go to each calculated summary that uses the answer in turn, then each grand calculated summary up to the one we were editing + await expect(await browser.getUrl()).to.contain(CalculatedSummary1Page.pageName); + await expect(await $(CalculatedSummary1Page.calculatedSummaryTitle()).getText()).to.contain( + "Calculated Summary for Question 1 is calculated to be £190.00. Is this correct?" + ); + + // change another answer + await $(CalculatedSummary1Page.q2A2Edit()).click(); + await expect(await browser.getUrl()).to.contain(Block2Page.pageName); + await $(Block2Page.q2A2()).setValue(400); + await $(Block2Page.submit()).click(); + + // back at updated calculated summary + await expect(await $(CalculatedSummary1Page.calculatedSummaryTitle()).getText()).to.contain( + "Calculated Summary for Question 1 is calculated to be £550.00. Is this correct?" + ); + + // Go to each calculated/grand calculated summary including this answer and reconfirm before being taken back to grand calculated summary + await $(CalculatedSummary1Page.submit()).click(); + await expect(await browser.getUrl()).to.contain(CalculatedSummary3Page.pageName); + await $(CalculatedSummary3Page.submit()).click(); + await expect(await browser.getUrl()).to.contain(GrandCalculatedSummary1Page.pageName); + await $(GrandCalculatedSummary1Page.submit()).click(); + await expect(await browser.getUrl()).to.contain(GrandCalculatedSummary2Page.pageName); + await expect(await $(GrandCalculatedSummary2Page.grandCalculatedSummaryTitle()).getText()).to.contain( + "Grand Calculated Summary for section 1 and 2 is calculated to be £910.00. Is this correct?" + ); + }); + + it("Given I edit an answer included in a grand calculated summary, both the calculated and grand calculated summary sections should return to partially completed.", async () => { + await $(GrandCalculatedSummary2Page.submit()).click(); + await expect(await $(HubPage.summaryRowState("section-3")).getText()).to.equal("Completed"); + + // Now edit an answer from section 2 and go back to the hub + await $(HubPage.summaryRowLink("section-3")).click(); + await $(GrandCalculatedSummary2Page.calculatedSummary4Edit()).click(); + await $(CalculatedSummary4Page.q4A1Edit()).click(); + await $(Block4Page.q4A1()).setValue(1); + await $(Block4Page.submit()).click(); + await $(CalculatedSummary4Page.previous()).click(); + await $(Block4Page.previous()).click(); + + // calculated summary section should be in progress + await expect(await $(HubPage.summaryRowState("section-2")).getText()).to.equal("Partially completed"); + // TODO: grand calculated summary should not show, but this requires progress source, until this is implemented, it should at least show as in progress + await expect(await $(HubPage.summaryRowState("section-3")).getText()).to.equal("Partially completed"); + }); + + it("Given I set both answers to block 4 to zero which removes the Grand Calculated Summary from the path, I am routed back to the Hub after the calculated summary", async () => { + await $(HubPage.summaryRowLink("section-3")).click(); + await $(GrandCalculatedSummary2Page.calculatedSummary4Edit()).click(); + await $(CalculatedSummary4Page.q4A1Edit()).click(); + await $(Block4Page.q4A1()).setValue(0); + await $(Block4Page.q4A2()).setValue(0); + await $(Block4Page.submit()).click(); + await $(CalculatedSummary4Page.submit()).click(); + // should be back at Hub, and grand calculated summary section not present + await expect(await browser.getUrl()).to.contain(HubPage.pageName); + await expect(await $(HubPage.summaryRowLink("section-3")).isExisting()).to.be.false; + }); + }); +}); diff --git a/tests/functional/spec/features/grand_calculated_summary/grand_calculated_summary_overlapping_answers.spec.js b/tests/functional/spec/features/grand_calculated_summary/grand_calculated_summary_overlapping_answers.spec.js new file mode 100644 index 0000000000..c466bad957 --- /dev/null +++ b/tests/functional/spec/features/grand_calculated_summary/grand_calculated_summary_overlapping_answers.spec.js @@ -0,0 +1,130 @@ +import HubPage from "../../../base_pages/hub.page"; +import IntroductionBlockPage from "../../../generated_pages/grand_calculated_summary_overlapping_answers/introduction-block.page"; +import Block1Page from "../../../generated_pages/grand_calculated_summary_overlapping_answers/block-1.page"; +import Block2Page from "../../../generated_pages/grand_calculated_summary_overlapping_answers/block-2.page"; +import CalculatedSummary1Page from "../../../generated_pages/grand_calculated_summary_overlapping_answers/calculated-summary-1.page"; +import CalculatedSummary2Page from "../../../generated_pages/grand_calculated_summary_overlapping_answers/calculated-summary-2.page"; +import Block3Page from "../../../generated_pages/grand_calculated_summary_overlapping_answers/block-3.page"; +import CalculatedSummary4Page from "../../../generated_pages/grand_calculated_summary_overlapping_answers/calculated-summary-4.page"; +import GrandCalculatedSummaryShoppingPage from "../../../generated_pages/grand_calculated_summary_overlapping_answers/grand-calculated-summary-shopping.page"; +import Section1SummaryPage from "../../../generated_pages/grand_calculated_summary_overlapping_answers/section-1-summary.page"; + +describe("Feature: Grand Calculated Summary", () => { + describe("Given I have a Grand Calculated Summary with overlapping answers", () => { + before("completing the survey", async () => { + await browser.openQuestionnaire("test_grand_calculated_summary_overlapping_answers.json"); + await $(IntroductionBlockPage.submit()).click(); + + // grand calculated summary should not be enabled until section-1 complete + await expect(await $(HubPage.summaryRowLink("section-3")).isExisting()).to.be.false; + + await $(HubPage.submit()).click(); + await $(Block1Page.q1A1()).setValue(100); + await $(Block1Page.q1A2()).setValue(200); + await $(Block1Page.submit()).click(); + await $(Block2Page.q2A1()).setValue(10); + await $(Block2Page.q2A2()).setValue(20); + await $(Block2Page.submit()).click(); + await $(CalculatedSummary1Page.submit()).click(); + await $(CalculatedSummary2Page.submit()).click(); + await $(Block3Page.yesExtraBreadAndCheese()).click(); + await $(Block3Page.submit()).click(); + await $(CalculatedSummary4Page.submit()).click(); + await $(Section1SummaryPage.submit()).click(); + await $(HubPage.submit()).click(); + await expect(await $(GrandCalculatedSummaryShoppingPage.grandCalculatedSummaryTitle()).getText()).to.contain( + "Grand Calculated Summary of purchases this week comes to £360.00. Is this correct?" + ); + await $(GrandCalculatedSummaryShoppingPage.submit()).click(); + }); + + it("Given I edit an answer that is only used in a single calculated summary, I am routed back to the calculated summary and then the grand calculated summary", async () => { + await $(HubPage.summaryRowLink("section-3")).click(); + await $(GrandCalculatedSummaryShoppingPage.calculatedSummary2Edit()).click(); + await $(CalculatedSummary2Page.q1A2Edit()).click(); + await $(Block1Page.q1A2()).setValue(300); + await $(Block1Page.submit()).click(); + + // taken back to calculated summary + await expect(await browser.getUrl()).to.contain(CalculatedSummary2Page.pageName); + await $(CalculatedSummary2Page.submit()).click(); + + // then grand calculated summary + await expect(await browser.getUrl()).to.contain(GrandCalculatedSummaryShoppingPage.pageName); + await expect(await $(GrandCalculatedSummaryShoppingPage.grandCalculatedSummaryTitle()).getText()).to.contain( + "Grand Calculated Summary of purchases this week comes to £460.00. Is this correct?" + ); + }); + + it("Given I edit an answer that is used in two calculated summaries, if I edit it from the first calculated summary change link, I taken through each block between the question and the second calculated summary before returning to the grand calculated summary", async () => { + await $(GrandCalculatedSummaryShoppingPage.calculatedSummary2Edit()).click(); + await $(CalculatedSummary2Page.q2A2Edit()).click(); + await $(Block2Page.q2A2()).setValue(400); + await $(Block2Page.submit()).click(); + + // taken back to the FIRST calculated summary which uses it + await expect(await browser.getUrl()).to.contain(CalculatedSummary2Page.pageName); + await expect(await $(CalculatedSummary2Page.calculatedSummaryTitle()).getText()).to.contain( + "Total of eggs and cheese is calculated to be £700.00. Is this correct?" + ); + await $(CalculatedSummary2Page.submit()).click(); + + // taken back to the SECOND calculated summary which uses it + await expect(await browser.getUrl()).to.contain(CalculatedSummary4Page.pageName); + await expect(await $(CalculatedSummary4Page.calculatedSummaryTitle()).getText()).to.contain( + "Total extra items cost is calculated to be £410.00. Is this correct?" + ); + await $(CalculatedSummary4Page.submit()).click(); + + // then grand calculated summary + await expect(await browser.getUrl()).to.contain(GrandCalculatedSummaryShoppingPage.pageName); + await expect(await $(GrandCalculatedSummaryShoppingPage.grandCalculatedSummaryTitle()).getText()).to.contain( + "Grand Calculated Summary of purchases this week comes to £1,220.00. Is this correct?" + ); + }); + + it("Given I edit an answer that is used in two calculated summaries, if I edit it from the second calculated summary change link, I taken through each block between the question and the second calculated summary before returning to the grand calculated summary", async () => { + await $(GrandCalculatedSummaryShoppingPage.calculatedSummary4Edit()).click(); + await $(CalculatedSummary4Page.q2A2Edit()).click(); + await $(Block2Page.q2A2()).setValue(500); + await $(Block2Page.submit()).click(); + + // taken back to the FIRST calculated summary which uses it + await expect(await browser.getUrl()).to.contain(CalculatedSummary2Page.pageName); + await expect(await $(CalculatedSummary2Page.calculatedSummaryTitle()).getText()).to.contain( + "Total of eggs and cheese is calculated to be £800.00. Is this correct?" + ); + await $(CalculatedSummary2Page.submit()).click(); + + // taken back to the SECOND calculated summary which uses it + await expect(await browser.getUrl()).to.contain(CalculatedSummary4Page.pageName); + await expect(await $(CalculatedSummary4Page.calculatedSummaryTitle()).getText()).to.contain( + "Total extra items cost is calculated to be £510.00. Is this correct?" + ); + await $(CalculatedSummary4Page.submit()).click(); + + // then grand calculated summary + await expect(await browser.getUrl()).to.contain(GrandCalculatedSummaryShoppingPage.pageName); + await expect(await $(GrandCalculatedSummaryShoppingPage.grandCalculatedSummaryTitle()).getText()).to.contain( + "Grand Calculated Summary of purchases this week comes to £1,420.00. Is this correct?" + ); + await $(GrandCalculatedSummaryShoppingPage.submit()).click(); + }); + + it("Given I change an answer and return to the Hub before all calculated summaries are confirmed, the grand calculated summary section becomes inaccessible", async () => { + await $(HubPage.summaryRowLink("section-3")).click(); + await $(GrandCalculatedSummaryShoppingPage.calculatedSummary4Edit()).click(); + await $(CalculatedSummary4Page.q2A2Edit()).click(); + await $(Block2Page.q2A2()).setValue(100); + await $(Block2Page.submit()).click(); + + // confirm one of the calculated summaries but return to the hub instead of confirming the other + await $(CalculatedSummary2Page.submit()).click(); + await browser.url(HubPage.url()); + + // calculated summary 4 is not confirmed so GCS doesn't show + await expect(await $(HubPage.summaryRowState("section-1")).getText()).to.equal("Partially completed"); + await expect(await $(HubPage.summaryRowLink("section-3")).isExisting()).to.be.false; + }); + }); +}); diff --git a/tests/integration/questionnaire/test_questionnaire_grand_calculated_summary.py b/tests/integration/questionnaire/test_questionnaire_grand_calculated_summary.py new file mode 100644 index 0000000000..3750564cc3 --- /dev/null +++ b/tests/integration/questionnaire/test_questionnaire_grand_calculated_summary.py @@ -0,0 +1,163 @@ +from . import QuestionnaireTestCase + + +class TestQuestionnaireGrandCalculatedSummary(QuestionnaireTestCase): + BASE_URL = "/questionnaire/" + + def test_grand_calculated_summary(self): + self.launchSurvey("test_grand_calculated_summary") + # section-1 two types of unit questions + self.post({"q1-a1": 20, "q1-a2": 5}) + self.post({"q2-a1": 100, "q2-a2": 3}) + self.post() + self.post() + # section-2 two more of each question type + self.post({"q3-a1": 40, "q3-a2": 2}) + self.post({"q4-a1": 10, "q4-a2": 3}) + self.post() + self.post() + # check the two grand calculated summaries + self.assertInBody( + "We calculate the grand total weekly distance travelled to be 170 mi. Is this correct?" + ) + self.post() + self.assertInBody( + "We calculate the grand total journeys per week to be 13. Is this correct?" + ) + + def test_grand_calculated_summary_multiple_sections(self): + self.launchSurvey("test_grand_calculated_summary_multiple_sections") + # section 1 + self.post() + self.post({"q1-a1": 10, "q1-a2": 20}) + self.post({"q2-a1": 30, "q2-a2": 40}) + self.post() + self.post({"q3-a1": 50, "q3-a2": 60}) + self.post() + # confirm calculated and grand calculated summary + self.assertInBody( + "Calculated summary for section 1 is calculated to be £210.00. Is this correct?" + ) + self.post() + self.assertInBody( + "Grand Calculated Summary which should match the previous calculated summary is calculated to be £210.00. Is this correct?" + ) + self.post() + self.post() + # section 2 + self.post() + self.post({"q4-a1": 100, "q4-a2": 200}) + self.post() + # grand calculated summary section with calculated summaries from multiple sections + self.post() + self.assertInBody( + "Grand Calculated Summary for section 1 and 2 is calculated to be £510.00. Is this correct?" + ) + + def _complete_upto_grand_calculated_summary_cross_section_dependencies(self): + """ + Completes first two sections of the schema testing grand calculated summaries + depending on calculated summaries in other sections + """ + # Complete the first section + self.post() + self.post({"skip-answer-1": "Yes"}) + self.post({"second-number-answer-a": "30", "second-number-answer-b": "60"}) + self.assertInBody( + "We calculate your total monthly expenditure on household bills to be £90.00. Is this correct?" + ) + self.post() + self.post() + # Complete the second section + self.post() + self.post({"third-number-answer-part-a": "70"}) + + def test_grand_calculated_summary_cross_section_dependencies_with_skip(self): + self.launchSurvey("test_grand_calculated_summary_cross_section_dependencies") + self._complete_upto_grand_calculated_summary_cross_section_dependencies() + + # skip the calculated summary and go straight to section summary + self.post({"skip-answer-2": "Yes"}) + self.post() + + # grand calculated summary which doesn't include skipped calculated summary + self.post() + self.assertInBody( + "The grand calculated summary is calculated to be £90.00. Is this correct?" + ) + + def test_grand_calculated_summary_cross_section_dependencies_no_skip(self): + self.launchSurvey("test_grand_calculated_summary_cross_section_dependencies") + self._complete_upto_grand_calculated_summary_cross_section_dependencies() + + # don't skip calculated summary, confirm it, and go to section summary + self.post({"skip-answer-2": "No"}) + self.post() + self.post() + + # grand calculated summary will now include the previous calculated summary + self.post() + self.assertInBody( + "The grand calculated summary is calculated to be £160.00. Is this correct?" + ) + + def test_grand_calculated_summary_cross_section_dependencies_extra_question(self): + self.launchSurvey("test_grand_calculated_summary_cross_section_dependencies") + self._complete_upto_grand_calculated_summary_cross_section_dependencies() + + # edit question to unlock the extra one + self.previous() + self.post( + {"third-number-answer-part-a": "70", "third-number-answer-part-b": "20"} + ) + self.post({"fourth-number-answer": "40"}) + self.post({"skip-answer-2": "No"}) + self.post() + self.post() + + # grand calculated summary will now include the extra question answer + self.post() + self.assertInBody( + "The grand calculated summary is calculated to be £220.00. Is this correct?" + ) + + def _complete_upto_grand_calculated_summary_overlapping_answers( + self, radio_answer: str + ): + self.post() + self.post() + self.post({"q1-a1": "100", "q1-a2": "200"}) + self.post({"q2-a1": "10", "q2-a2": "20"}) + self.post() + self.post() + self.post({"radio-extra": radio_answer}) + if radio_answer != "No": + # in the no overlap case, the calculated summary is skipped entirely + self.post() + self.post() + self.post() + + def test_grand_calculated_summary_overlapping_answers_full_overlap(self): + self.launchSurvey("test_grand_calculated_summary_overlapping_answers") + self._complete_upto_grand_calculated_summary_overlapping_answers( + "Yes, I am going to buy two of everything" + ) + self.assertInBody( + "Grand Calculated Summary of purchases this week comes to £660.00. Is this correct?" + ) + + def test_grand_calculated_summary_overlapping_answers_partial_overlap(self): + self.launchSurvey("test_grand_calculated_summary_overlapping_answers") + self._complete_upto_grand_calculated_summary_overlapping_answers( + "Yes, extra bread and cheese" + ) + self.assertInBody( + "Grand Calculated Summary of purchases this week comes to £360.00. Is this correct?" + ) + + def test_grand_calculated_summary_overlapping_answers_no_overlap(self): + self.launchSurvey("test_grand_calculated_summary_overlapping_answers") + self._complete_upto_grand_calculated_summary_overlapping_answers("No") + self.assertInBody( + "Grand Calculated Summary of purchases this week comes to £330.00. Is this correct?" + )