diff --git a/app/forms/questionnaire_form.py b/app/forms/questionnaire_form.py index a60548c61f..63f2780376 100644 --- a/app/forms/questionnaire_form.py +++ b/app/forms/questionnaire_form.py @@ -545,7 +545,7 @@ def _get_value_source_resolver(list_item: str | None = None) -> ValueSourceResol question_title = question.get("title") value_source_resolved_for_location = _get_value_source_resolver(list_item_id) - for answer in question["answers"]: + for answer in question.get("answers", []): if "list_item_id" in answer: value_source_resolver = _get_value_source_resolver(answer["list_item_id"]) else: diff --git a/app/jinja_filters.py b/app/jinja_filters.py index a932c71008..3f19b4d77f 100644 --- a/app/jinja_filters.py +++ b/app/jinja_filters.py @@ -628,6 +628,7 @@ def map_summary_item_config( else: list_collector_rows = map_list_collector_config( list_items=block["list"]["list_items"], + editable=block["list"]["editable"], edit_link_text=edit_link_text, edit_link_aria_label=edit_link_aria_label, remove_link_text=remove_link_text, @@ -654,6 +655,7 @@ def map_summary_item_config_processor() -> dict[str, Callable]: @blueprint.app_template_filter() # type: ignore def map_list_collector_config( list_items: list[dict[str, str | int]], + editable: bool = True, render_icon: bool = False, edit_link_text: str = "", edit_link_aria_label: str = "", @@ -672,7 +674,7 @@ def map_list_collector_config( edit_link_aria_label_text = None remove_link_aria_label_text = None - if edit_link_text: + if edit_link_text and editable: url = ( f'{list_item.get("edit_link")}{item_anchor}' if item_anchor @@ -694,7 +696,7 @@ def map_list_collector_config( actions.append(edit_link) - if not list_item.get("primary_person") and remove_link_text: + if not list_item.get("primary_person") and remove_link_text and editable: if remove_link_aria_label: remove_link_aria_label_text = remove_link_aria_label.format( item_name=item_name diff --git a/app/questionnaire/questionnaire_schema.py b/app/questionnaire/questionnaire_schema.py index d7b842df16..d60687af0c 100644 --- a/app/questionnaire/questionnaire_schema.py +++ b/app/questionnaire/questionnaire_schema.py @@ -19,6 +19,8 @@ DEFAULT_LANGUAGE_CODE = "en" +LIST_COLLECTORS_WITH_REPEATING_BLOCKS = {"ListCollector", "ListCollectorContent"} + LIST_COLLECTOR_CHILDREN = [ "ListAddQuestion", "ListEditQuestion", @@ -354,11 +356,12 @@ def _get_blocks_by_id(self) -> dict[str, ImmutableDict]: self._parent_id_map[block_id] = group["id"] blocks[block_id] = block - if block["type"] in ( + if block["type"] in { "ListCollector", + "ListCollectorContent", "PrimaryPersonListCollector", "RelationshipCollector", - ): + }: self._list_collector_section_ids_by_list_name[ block["for_list"] ].append(self._parent_id_map[group["id"]]) @@ -619,25 +622,26 @@ def _update_answer_dependencies_for_list_source( ) for list_collector in list_collectors: - add_block_question = self.get_add_block_for_list_collector( - list_collector["id"] # type: ignore - )["question"] - answer_ids_for_block = list( - self.get_answers_for_question_by_id(add_block_question) - ) - for block_answer_id in answer_ids_for_block: - self._answer_dependencies_map[block_answer_id] |= { - self._get_answer_dependent_for_block_id( - block_id=block_id, for_list=list_name - ) - if self.is_block_in_repeating_section(block_id) - # non-repeating blocks such as dynamic-answers could depend on the list - else self._get_answer_dependent_for_block_id(block_id=block_id) - } - self._list_dependent_block_additional_dependencies[block_id] = set( - answer_ids_for_block - ) - # removing an item from a list will require any dependent calculated summaries to be re-confirmed, so cache dependencies + if add_block := self.get_add_block_for_list_collector( # type: ignore + list_collector["id"] + ): + add_block_question = add_block["question"] + answer_ids_for_block = list( + self.get_answers_for_question_by_id(add_block_question) + ) + for block_answer_id in answer_ids_for_block: + self._answer_dependencies_map[block_answer_id] |= { + self._get_answer_dependent_for_block_id( + block_id=block_id, for_list=list_name + ) + if self.is_block_in_repeating_section(block_id) + # non-repeating blocks such as dynamic-answers could depend on the list + else self._get_answer_dependent_for_block_id(block_id=block_id) + } + self._list_dependent_block_additional_dependencies[block_id] = set( + answer_ids_for_block + ) + # removing an item from a list will require any dependent calculated summaries to be re-confirmed, so cache dependencies if remove_block_id := self.get_remove_block_id_for_list(list_name): self._list_dependent_block_additional_dependencies[block_id].update( self.get_answer_ids_for_block(remove_block_id) @@ -759,7 +763,10 @@ def get_driving_question_for_list( def get_remove_block_id_for_list(self, list_name: str) -> str | None: for block in self.get_blocks(): - if block["type"] == "ListCollector" and block["for_list"] == list_name: + if ( + is_list_collector_block_editable(block) + and block["for_list"] == list_name + ): remove_block_id: str = block["remove_block"]["id"] return remove_block_id @@ -970,13 +977,15 @@ def get_list_collectors_for_list_for_sections( for section_id in sections: if section := self.get_section(section_id): collector_type = ( - "PrimaryPersonListCollector" if primary else "ListCollector" + {"PrimaryPersonListCollector"} + if primary + else LIST_COLLECTORS_WITH_REPEATING_BLOCKS ) blocks.extend( block for block in self.get_blocks_for_section(section) - if block["type"] == collector_type and block["for_list"] == for_list + if block["type"] in collector_type and block["for_list"] == for_list ) return blocks @@ -1171,7 +1180,7 @@ def _block_for_answer(self, answer_id: str) -> ImmutableDict | None: if ( parent_block - and parent_block["type"] == "ListCollector" + and parent_block["type"] in LIST_COLLECTORS_WITH_REPEATING_BLOCKS and block_id not in self.list_collector_repeating_block_ids ): return parent_block @@ -1530,3 +1539,7 @@ def get_calculation_block_ids_for_grand_calculated_summary( calculation_block=grand_calculated_summary_block, source_type="calculated_summary", ) + + +def is_list_collector_block_editable(block: Mapping) -> bool: + return bool(block["type"] == "ListCollector") diff --git a/app/questionnaire/router.py b/app/questionnaire/router.py index 0170131fc7..366bcdb69e 100644 --- a/app/questionnaire/router.py +++ b/app/questionnaire/router.py @@ -532,9 +532,9 @@ def _get_enabled_section_keys( self, ) -> Generator[SectionKey, None, None]: for section_id in self.enabled_section_ids: - repeating_list = self._schema.get_repeating_list_for_section(section_id) - - if repeating_list: + if repeating_list := self._schema.get_repeating_list_for_section( + section_id + ): for list_item_id in self._list_store[repeating_list]: section_key = SectionKey(section_id, list_item_id) yield section_key diff --git a/app/views/contexts/list_context.py b/app/views/contexts/list_context.py index 937d577f39..8894a0ac1f 100644 --- a/app/views/contexts/list_context.py +++ b/app/views/contexts/list_context.py @@ -42,7 +42,7 @@ def __call__( "list": { "list_items": list_items, "editable": any([edit_block_id, remove_block_id]), - } + }, } # pylint: disable=too-many-locals diff --git a/app/views/contexts/section_summary_context.py b/app/views/contexts/section_summary_context.py index 86c3e05a6c..1c2f4254fd 100644 --- a/app/views/contexts/section_summary_context.py +++ b/app/views/contexts/section_summary_context.py @@ -9,12 +9,12 @@ ProgressStore, SupplementaryDataStore, ) -from app.questionnaire import QuestionnaireSchema +from app.questionnaire import Location, QuestionnaireSchema +from app.questionnaire.questionnaire_schema import LIST_COLLECTORS_WITH_REPEATING_BLOCKS from app.questionnaire.routing_path import RoutingPath from app.utilities import safe_content from ...data_models.metadata_proxy import MetadataProxy -from ...utilities.types import LocationType from .context import Context from .summary import Group from .summary.list_collector_block import ListCollectorBlock @@ -31,7 +31,7 @@ def __init__( metadata: Optional[MetadataProxy], response_metadata: MutableMapping, routing_path: RoutingPath, - current_location: LocationType, + current_location: Location, supplementary_data_store: SupplementaryDataStore, ) -> None: super().__init__( @@ -212,7 +212,7 @@ def _get_refactored_groups(original_groups: dict) -> list[dict[str, Any]]: non_list_collector_blocks: list[dict[str, str]] = [] list_collector_blocks: list[dict[str, str]] = [] for block in group["blocks"]: - if block["type"] == "ListCollector": + if block["type"] in LIST_COLLECTORS_WITH_REPEATING_BLOCKS: # if list collector block encountered, close the previously started non list collector blocks list if exists if non_list_collector_blocks: previously_started_group = { diff --git a/app/views/contexts/summary/group.py b/app/views/contexts/summary/group.py index 910faf920a..131692647e 100644 --- a/app/views/contexts/summary/group.py +++ b/app/views/contexts/summary/group.py @@ -1,4 +1,4 @@ -from typing import Iterable, Mapping, MutableMapping +from typing import Iterable, Mapping, MutableMapping, Type from werkzeug.datastructures import ImmutableDict @@ -11,11 +11,18 @@ from app.data_models.metadata_proxy import MetadataProxy from app.questionnaire import QuestionnaireSchema from app.questionnaire.placeholder_renderer import PlaceholderRenderer +from app.questionnaire.questionnaire_schema import ( + LIST_COLLECTORS_WITH_REPEATING_BLOCKS, + is_list_collector_block_editable, +) from app.survey_config.link import Link from app.utilities.types import LocationType 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 +from app.views.contexts.summary.list_collector_content_block import ( + ListCollectorContentBlock, +) class Group: @@ -107,7 +114,18 @@ def _build_blocks_and_links( if parent_list_collector_block_id not in routing_path_block_ids: continue - list_collector_block = ListCollectorBlock( + list_collector_block_class: Type[ + ListCollectorBlock | ListCollectorContentBlock + ] = ( + ListCollectorBlock + if is_list_collector_block_editable( + # Type ignore: return types differ + schema.get_block(parent_list_collector_block_id) # type: ignore + ) + else ListCollectorContentBlock + ) + + list_collector_block = list_collector_block_class( routing_path_block_ids=routing_path_block_ids, answer_store=answer_store, list_store=list_store, @@ -174,7 +192,7 @@ def _build_blocks_and_links( ] ) - elif block["type"] == "ListCollector": + elif block["type"] in LIST_COLLECTORS_WITH_REPEATING_BLOCKS: section: ImmutableDict | None = schema.get_section(location.section_id) summary_item: ImmutableDict | None @@ -183,7 +201,12 @@ def _build_blocks_and_links( section_id=section["id"], # type: ignore list_name=block["for_list"], ): - list_collector_block = ListCollectorBlock( + list_collector_block_class = ( + ListCollectorBlock + if is_list_collector_block_editable(block) + else ListCollectorContentBlock + ) + list_collector_block = list_collector_block_class( routing_path_block_ids=routing_path_block_ids, answer_store=answer_store, list_store=list_store, @@ -195,14 +218,17 @@ def _build_blocks_and_links( language=language, return_to=return_to, supplementary_data_store=supplementary_data_store, + return_to_block_id=return_to_block_id, ) - list_summary_element = list_collector_block.list_summary_element( summary_item ) blocks.extend([list_summary_element]) - if not view_submitted_response: + if ( + not view_submitted_response + and is_list_collector_block_editable(block) + ): self.links["add_link"] = Link( target="_self", text=list_summary_element["add_link_text"], diff --git a/app/views/contexts/summary/list_collector_base_block.py b/app/views/contexts/summary/list_collector_base_block.py new file mode 100644 index 0000000000..6d4c39967c --- /dev/null +++ b/app/views/contexts/summary/list_collector_base_block.py @@ -0,0 +1,204 @@ +from collections import defaultdict +from typing import Iterable, Mapping, MutableMapping, Sequence + +from werkzeug.datastructures import ImmutableDict + +from app.data_models import AnswerStore, ProgressStore, SupplementaryDataStore +from app.data_models.list_store import ListModel, ListStore +from app.data_models.metadata_proxy import MetadataProxy +from app.questionnaire import Location, QuestionnaireSchema +from app.questionnaire.placeholder_renderer import PlaceholderRenderer +from app.questionnaire.questionnaire_schema import is_list_collector_block_editable +from app.utilities.types import LocationType +from app.views.contexts import list_context +from app.views.contexts.summary.block import Block + + +class ListCollectorBaseBlock: + def __init__( + self, + *, + routing_path_block_ids: Iterable[str], + answer_store: AnswerStore, + list_store: ListStore, + progress_store: ProgressStore, + metadata: MetadataProxy | None, + response_metadata: MutableMapping, + schema: QuestionnaireSchema, + location: LocationType, + language: str, + supplementary_data_store: SupplementaryDataStore, + return_to: str | None, + return_to_block_id: str | None = None, + ) -> None: + self._location = location + self._placeholder_renderer = PlaceholderRenderer( + language=language, + answer_store=answer_store, + list_store=list_store, + metadata=metadata, + response_metadata=response_metadata, + schema=schema, + progress_store=progress_store, + location=location, + supplementary_data_store=supplementary_data_store, + ) + self._list_store = list_store + self._schema = schema + self._location = location + # type ignore added as section should exist + self._section: ImmutableDict = self._schema.get_section(self._location.section_id) # type: ignore + self._language = language + self._answer_store = answer_store + self._metadata = metadata + self._response_metadata = response_metadata + self._routing_path_block_ids = routing_path_block_ids + self._progress_store = progress_store + self._supplementary_data_store = supplementary_data_store + self._return_to = return_to + self._return_to_block_id = return_to_block_id + + @property + def list_context(self) -> list_context.ListContext: + return list_context.ListContext( + self._language, + self._schema, + self._answer_store, + self._list_store, + self._progress_store, + self._metadata, + self._response_metadata, + self._supplementary_data_store, + ) + + def _list_collector_block_on_path(self, for_list: str) -> list[ImmutableDict]: + list_collector_blocks = list( + self._schema.get_list_collectors_for_list_for_sections( + [self._section["id"]], for_list=for_list + ) + ) + + return [ + list_collector_block + for list_collector_block in list_collector_blocks + if list_collector_block["id"] in self._routing_path_block_ids + ] + + def _list_collector_block( + self, for_list: str, list_collector_blocks_on_path: list[ImmutableDict] + ) -> ImmutableDict: + list_collector_blocks = list( + self._schema.get_list_collectors_for_list_for_sections( + [self._section["id"]], for_list=for_list + ) + ) + return ( + list_collector_blocks_on_path[0] + if list_collector_blocks_on_path + else list_collector_blocks[0] + ) + + def _get_related_answer_blocks_by_list_item_id( + self, *, list_model: ListModel, repeating_blocks: Sequence[ImmutableDict] + ) -> dict[str, list[dict]] | None: + section_id = self._section["id"] + + related_answers = self._schema.get_related_answers_for_list_for_section( + section_id=section_id, list_name=list_model.name + ) + + blocks: list[dict | ImmutableDict] = [] + + if related_answers: + blocks += self._get_blocks_for_related_answers(related_answers) + + if len(list_model): + blocks += repeating_blocks + + if not blocks: + return None + + related_answers_blocks = {} + + for list_id in list_model: + serialized_blocks = [ + # related answers for repeating blocks may use placeholders, so each block needs rendering here + self._placeholder_renderer.render( + data_to_render=Block( + block, + answer_store=self._answer_store, + list_store=self._list_store, + metadata=self._metadata, + response_metadata=self._response_metadata, + schema=self._schema, + location=Location( + list_name=list_model.name, + list_item_id=list_id, + section_id=section_id, + ), + return_to=self._return_to, + return_to_block_id=self._return_to_block_id, + progress_store=self._progress_store, + supplementary_data_store=self._supplementary_data_store, + language=self._language, + ).serialize(), + list_item_id=list_id, + ) + for block in blocks + ] + + related_answers_blocks[list_id] = serialized_blocks + + return related_answers_blocks + + def _get_blocks_for_related_answers(self, related_answers: tuple) -> list[dict]: + blocks = [] + answers_by_block = defaultdict(list) + + for answer in related_answers: + answer_id = answer["identifier"] + # block is not optional at this point + block: Mapping = self._schema.get_block_for_answer_id(answer_id) # type: ignore + + block_to_keep = ( + block["edit_block"] + if is_list_collector_block_editable(block) + else block + ) + answers_by_block[block_to_keep].append(answer_id) + + for immutable_block, answer_ids in answers_by_block.items(): + mutable_block = self._schema.get_mutable_deepcopy(immutable_block) + + # We need to filter out answers for both variants and normal questions + for variant_or_block in mutable_block.get( + "question_variants", [mutable_block] + ): + answers = [ + answer + for answer in variant_or_block["question"].get("answers", {}) + if answer["id"] in answer_ids + ] + # Mutate the answers to only keep the related answers + variant_or_block["question"]["answers"] = answers + + blocks.append(mutable_block) + + return blocks + + def get_repeating_block_related_answer_blocks( + self, block: ImmutableDict + ) -> list[dict]: + """ + Given a repeating block question to render, + return the list of rendered question blocks for each list item id + """ + list_name = self._schema.list_names_by_list_repeating_block_id[block["id"]] + list_model = self._list_store[list_name] + blocks: list[dict] = [] + if answer_blocks_by_list_item_id := self._get_related_answer_blocks_by_list_item_id( + list_model=list_model, repeating_blocks=[block] + ): + for answer_blocks in answer_blocks_by_list_item_id.values(): + blocks.extend(answer_blocks) + return blocks diff --git a/app/views/contexts/summary/list_collector_block.py b/app/views/contexts/summary/list_collector_block.py index b6b510bd5c..04b4ccdd08 100644 --- a/app/views/contexts/summary/list_collector_block.py +++ b/app/views/contexts/summary/list_collector_block.py @@ -1,62 +1,11 @@ -from collections import defaultdict -from typing import Iterable, Mapping, MutableMapping, Sequence +from typing import Mapping from flask import url_for -from werkzeug.datastructures import ImmutableDict -from app.data_models import AnswerStore, ProgressStore, SupplementaryDataStore -from app.data_models.list_store import ListModel, ListStore -from app.data_models.metadata_proxy import MetadataProxy -from app.questionnaire import Location, QuestionnaireSchema -from app.questionnaire.placeholder_renderer import PlaceholderRenderer -from app.utilities.types import LocationType -from app.views.contexts.list_context import ListContext -from app.views.contexts.summary.block import Block +from app.views.contexts.summary.list_collector_base_block import ListCollectorBaseBlock -class ListCollectorBlock: - def __init__( - self, - routing_path_block_ids: Iterable[str], - answer_store: AnswerStore, - list_store: ListStore, - progress_store: ProgressStore, - metadata: MetadataProxy | None, - response_metadata: MutableMapping, - schema: QuestionnaireSchema, - location: LocationType, - language: str, - supplementary_data_store: SupplementaryDataStore, - return_to: str | None, - return_to_block_id: str | None = None, - ) -> None: - self._location = location - self._placeholder_renderer = PlaceholderRenderer( - language=language, - answer_store=answer_store, - list_store=list_store, - metadata=metadata, - response_metadata=response_metadata, - schema=schema, - progress_store=progress_store, - location=location, - supplementary_data_store=supplementary_data_store, - ) - self._list_store = list_store - self._schema = schema - self._location = location - # type ignore added as section should exist - self._section: ImmutableDict = self._schema.get_section(self._location.section_id) # type: ignore - self._language = language - self._answer_store = answer_store - self._metadata = metadata - self._response_metadata = response_metadata - self._routing_path_block_ids = routing_path_block_ids - self._progress_store = progress_store - self._supplementary_data_store = supplementary_data_store - self._return_to = return_to - self._return_to_block_id = return_to_block_id - +class ListCollectorBlock(ListCollectorBaseBlock): # pylint: disable=too-many-locals def list_summary_element(self, summary: Mapping) -> dict: list_collector_block = None @@ -70,24 +19,14 @@ def list_summary_element(self, summary: Mapping) -> dict: ) = (None, None, None, None, None, None) list_model = self._list_store[summary["for_list"]] - list_collector_blocks = list( - self._schema.get_list_collectors_for_list( - for_list=summary["for_list"], section_id=self._section["id"] - ) - ) - add_link = self._add_link(summary, list_collector_block) - 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 - ] + list_collector_blocks_on_path = self._list_collector_block_on_path( + summary["for_list"] + ) - list_collector_block = ( - list_collector_blocks_on_path[0] - if list_collector_blocks_on_path - else list_collector_blocks[0] + list_collector_block = self._list_collector_block( + summary["for_list"], list_collector_blocks_on_path ) rendered_summary = self._placeholder_renderer.render( @@ -107,8 +46,10 @@ def list_summary_element(self, summary: Mapping) -> dict: item_label = self._schema.get_item_label(section_id, list_model.name) if len(list_model) == 1 and list_model.primary_person: - if primary_list_collectors := self._schema.get_list_collectors_for_list( - for_list=summary["for_list"], primary=True + if primary_list_collectors := self._schema.get_list_collectors_for_list_for_sections( + sections=[self._section["id"]], + for_list=summary["for_list"], + primary=True, ): for primary_person_block in primary_list_collectors: primary_person_edit_block_id = edit_block_id = primary_person_block[ @@ -139,36 +80,6 @@ def list_summary_element(self, summary: Mapping) -> dict: **list_summary_context, } - def get_repeating_block_related_answer_blocks( - self, block: ImmutableDict - ) -> list[dict]: - """ - Given a repeating block question to render, - return the list of rendered question blocks for each list item id - """ - list_name = self._schema.list_names_by_list_repeating_block_id[block["id"]] - list_model = self._list_store[list_name] - blocks: list[dict] = [] - if answer_blocks_by_list_item_id := self._get_related_answer_blocks_by_list_item_id( - list_model=list_model, repeating_blocks=[block] - ): - for answer_blocks in answer_blocks_by_list_item_id.values(): - blocks.extend(answer_blocks) - return blocks - - @property - def list_context(self) -> ListContext: - return ListContext( - self._language, - self._schema, - self._answer_store, - self._list_store, - self._progress_store, - self._metadata, - self._response_metadata, - self._supplementary_data_store, - ) - def _add_link( self, summary: Mapping, @@ -190,89 +101,3 @@ def _add_link( block_id=driving_question_block["id"], return_to=self._return_to, ) - - def _get_related_answer_blocks_by_list_item_id( - self, *, list_model: ListModel, repeating_blocks: Sequence[ImmutableDict] - ) -> dict[str, list[dict]] | None: - section_id = self._section["id"] - - related_answers = self._schema.get_related_answers_for_list_for_section( - section_id=section_id, list_name=list_model.name - ) - - blocks: list[dict | ImmutableDict] = [] - - if related_answers: - blocks += self._get_blocks_for_related_answers(related_answers) - - if len(list_model): - blocks += repeating_blocks - - if not blocks: - return None - - related_answers_blocks = {} - - for list_id in list_model: - serialized_blocks = [ - # related answers for repeating blocks may use placeholders, so each block needs rendering here - self._placeholder_renderer.render( - data_to_render=Block( - block, - answer_store=self._answer_store, - list_store=self._list_store, - metadata=self._metadata, - response_metadata=self._response_metadata, - schema=self._schema, - location=Location( - list_name=list_model.name, - list_item_id=list_id, - section_id=section_id, - ), - return_to=self._return_to, - return_to_block_id=self._return_to_block_id, - progress_store=self._progress_store, - supplementary_data_store=self._supplementary_data_store, - language=self._language, - ).serialize(), - list_item_id=list_id, - ) - for block in blocks - ] - - related_answers_blocks[list_id] = serialized_blocks - - return related_answers_blocks - - def _get_blocks_for_related_answers(self, related_answers: tuple) -> list[dict]: - blocks = [] - answers_by_block = defaultdict(list) - - for answer in related_answers: - answer_id = answer["identifier"] - # block is not optional at this point - block: Mapping = self._schema.get_block_for_answer_id(answer_id) # type: ignore - - block_to_keep = ( - block["edit_block"] if block["type"] == "ListCollector" else block - ) - answers_by_block[block_to_keep].append(answer_id) - - for immutable_block, answer_ids in answers_by_block.items(): - mutable_block = self._schema.get_mutable_deepcopy(immutable_block) - - # We need to filter out answers for both variants and normal questions - for variant_or_block in mutable_block.get( - "question_variants", [mutable_block] - ): - answers = [ - answer - for answer in variant_or_block["question"].get("answers", {}) - if answer["id"] in answer_ids - ] - # Mutate the answers to only keep the related answers - variant_or_block["question"]["answers"] = answers - - blocks.append(mutable_block) - - return blocks diff --git a/app/views/contexts/summary/list_collector_content_block.py b/app/views/contexts/summary/list_collector_content_block.py new file mode 100644 index 0000000000..b0e0ff5a8e --- /dev/null +++ b/app/views/contexts/summary/list_collector_content_block.py @@ -0,0 +1,52 @@ +from typing import Any, Mapping + +from app.views.contexts.summary.list_collector_base_block import ListCollectorBaseBlock + + +class ListCollectorContentBlock(ListCollectorBaseBlock): + # pylint: disable=too-many-locals + def list_summary_element(self, summary: Mapping[str, Any]) -> dict[str, Any]: + related_answers = None + + item_label = None + + current_list = self._list_store[summary["for_list"]] + + list_collector_blocks_on_path = self._list_collector_block_on_path( + summary["for_list"] + ) + + list_collector_block = self._list_collector_block( + summary["for_list"], list_collector_blocks_on_path + ) + + rendered_summary = self._placeholder_renderer.render( + data_to_render=summary, list_item_id=self._location.list_item_id + ) + + if list_collector_blocks_on_path: + repeating_blocks = list_collector_block.get("repeating_blocks", []) + related_answers = self._get_related_answer_blocks_by_list_item_id( + list_model=current_list, repeating_blocks=repeating_blocks + ) + item_label = self._schema.get_item_label( + self._section["id"], current_list.name + ) + + list_summary_context = self.list_context( + list_collector_block["summary"], + for_list=list_collector_block["for_list"], + section_id=self._location.section_id, + has_repeating_blocks=bool(list_collector_block.get("repeating_blocks")), + return_to=self._return_to, + ) + + return { + "title": rendered_summary["title"], + "type": rendered_summary["type"], + "empty_list_text": rendered_summary.get("empty_list_text"), + "list_name": rendered_summary["for_list"], + "related_answers": related_answers, + "item_label": item_label, + **list_summary_context, + } diff --git a/app/views/handlers/block.py b/app/views/handlers/block.py index 586fc26c97..bf7793beec 100644 --- a/app/views/handlers/block.py +++ b/app/views/handlers/block.py @@ -25,7 +25,7 @@ def __init__( schema: QuestionnaireSchema, questionnaire_store: QuestionnaireStore, language: str, - current_location: LocationType, + current_location: Location, request_args: MutableMapping, form_data: ImmutableMultiDict, ): diff --git a/app/views/handlers/block_factory.py b/app/views/handlers/block_factory.py index 440933048c..2fb7816a7c 100644 --- a/app/views/handlers/block_factory.py +++ b/app/views/handlers/block_factory.py @@ -13,6 +13,7 @@ from app.views.handlers.content import Content from app.views.handlers.list_add_question import ListAddQuestion from app.views.handlers.list_collector import ListCollector +from app.views.handlers.list_collector_content import ListCollectorContent from app.views.handlers.list_edit_question import ListEditQuestion from app.views.handlers.list_remove_question import ListRemoveQuestion from app.views.handlers.list_repeating_question import ListRepeatingQuestion @@ -26,6 +27,7 @@ "ConfirmationQuestion": Question, "ListCollectorDrivingQuestion": Question, "ListCollector": ListCollector, + "ListCollectorContent": ListCollectorContent, "ListAddQuestion": ListAddQuestion, "ListEditQuestion": ListEditQuestion, "ListRemoveQuestion": ListRemoveQuestion, diff --git a/app/views/handlers/list_action.py b/app/views/handlers/list_action.py index f7db203cad..69066c1fa7 100644 --- a/app/views/handlers/list_action.py +++ b/app/views/handlers/list_action.py @@ -68,7 +68,7 @@ def get_next_location_url(self) -> str: return url if self.router.is_block_complete( - # Type ignore: the parent_location property above is initialised with a block_id so it won't be None + # Type ignore: block_id would exist at this point block_id=self.parent_location.block_id, # type: ignore section_id=self.parent_location.section_id, list_item_id=self.parent_location.list_item_id, diff --git a/app/views/handlers/list_collector.py b/app/views/handlers/list_collector.py index 384693e7b2..5ec262621a 100644 --- a/app/views/handlers/list_collector.py +++ b/app/views/handlers/list_collector.py @@ -52,8 +52,7 @@ def get_next_location_url(self) -> str: return super().get_next_location_url() - def get_context(self) -> dict[str, dict]: - question_context = super().get_context() + def _get_list_context(self) -> dict[str, dict]: list_context = ListContext( self._language, self._schema, @@ -65,18 +64,22 @@ def get_context(self) -> dict[str, dict]: self._questionnaire_store.supplementary_data_store, ) - return { - **question_context, - **list_context( - self.rendered_block["summary"], - for_list=self.list_name, - edit_block_id=self.rendered_block["edit_block"]["id"], - remove_block_id=self.rendered_block["remove_block"]["id"], - return_to=self._return_to, - section_id=self.current_location.section_id, - has_repeating_blocks=bool(self.repeating_block_ids), - ), - } + return list_context( + self.rendered_block["summary"], + for_list=self.list_name, + edit_block_id=self.rendered_block.get("edit_block", {}).get("id"), + remove_block_id=self.rendered_block.get("remove_block", {}).get("id"), + return_to=self._return_to, + section_id=self.current_location.section_id, + has_repeating_blocks=bool(self.repeating_block_ids), + ) + + def _get_additional_view_context(self) -> dict: + """This is only needed so we can use it in List Collector Content class where we override the default behaviour of the Question class""" + return super().get_context() + + def get_context(self) -> dict: + return {**self._get_additional_view_context(), **self._get_list_context()} def handle_post(self) -> None: answer_action = self._get_answer_action() diff --git a/app/views/handlers/list_collector_content.py b/app/views/handlers/list_collector_content.py new file mode 100644 index 0000000000..676e25e3e8 --- /dev/null +++ b/app/views/handlers/list_collector_content.py @@ -0,0 +1,15 @@ +from app.views.handlers.list_collector import ListCollector +from app.views.handlers.question import Question + + +class ListCollectorContent(ListCollector): + def _get_additional_view_context(self) -> dict: + return self.rendered_block.get("content", {}) + + def handle_post(self) -> None: + if self._is_list_collector_complete(): + self._routing_path = self.router.routing_path( + section_id=self._current_location.section_id, + list_item_id=self._current_location.list_item_id, + ) + return super(Question, self).handle_post() diff --git a/app/views/handlers/list_repeating_question.py b/app/views/handlers/list_repeating_question.py index 29287ca6e1..1dec1bb3ab 100644 --- a/app/views/handlers/list_repeating_question.py +++ b/app/views/handlers/list_repeating_question.py @@ -1,5 +1,4 @@ from flask import url_for -from werkzeug.datastructures import ImmutableDict from app.views.handlers.list_edit_question import ListEditQuestion @@ -43,15 +42,20 @@ def get_previous_location_url(self) -> str: return_to_block_id=self._return_to_block_id, ) - # Type ignore: edit_block will exist at this point - edit_block: ImmutableDict = self._schema.get_edit_block_for_list_collector( # type: ignore + if edit_block := self._schema.get_edit_block_for_list_collector( self.parent_block["id"] - ) - return url_for( - "questionnaire.block", - list_name=self.current_location.list_name, - list_item_id=self.current_location.list_item_id, - block_id=edit_block["id"], + ): + return url_for( + "questionnaire.block", + list_name=self.current_location.list_name, + list_item_id=self.current_location.list_item_id, + block_id=edit_block["id"], + return_to=self._return_to, + return_to_answer_id=self._return_to_answer_id, + return_to_block_id=self._return_to_block_id, + ) + + return self.parent_location.url( return_to=self._return_to, return_to_answer_id=self._return_to_answer_id, return_to_block_id=self._return_to_block_id, diff --git a/app/views/handlers/question.py b/app/views/handlers/question.py index 2e9ef2b780..f087e90bf5 100644 --- a/app/views/handlers/question.py +++ b/app/views/handlers/question.py @@ -24,7 +24,7 @@ def _has_redirect_to_list_add_action(answer_action: Mapping | None) -> bool: @cached_property def form(self) -> QuestionnaireForm: - question_json = self.rendered_block["question"] + question_json = self.rendered_block.get("question", {}) if self._form_data: return generate_form( @@ -82,6 +82,11 @@ def rendered_block(self) -> dict: ) self._set_page_title(page_title) + + # We inherit from question in list collector content block which doesn't have "question" sub-block + if not transformed_block.get("question"): + return transformed_block + rendered_question = self.placeholder_renderer.render( data_to_render=transformed_block["question"], list_item_id=self._current_location.list_item_id, @@ -162,6 +167,10 @@ def _is_list_just_primary(self, list_items: list[str], list_name: str) -> bool: ) def _get_answer_action(self) -> dict | None: + # When used by list collector content class rendered block we won't have "question" sub-block + if not self.rendered_block.get("question"): + return None + answers = self.rendered_block["question"]["answers"] for answer in answers: diff --git a/schemas/test/en/test_list_collector_content_page.json b/schemas/test/en/test_list_collector_content_page.json new file mode 100644 index 0000000000..d31345c3ef --- /dev/null +++ b/schemas/test/en/test_list_collector_content_page.json @@ -0,0 +1,550 @@ +{ + "mime_type": "application/json/ons/eq", + "language": "en", + "schema_version": "0.0.1", + "data_version": "0.0.3", + "survey_id": "0", + "title": "Test List Collector Section Summary Items", + "theme": "default", + "description": "A questionnaire to test list collector section summary items", + "metadata": [ + { + "name": "user_id", + "type": "string" + }, + { + "name": "period_id", + "type": "string" + }, + { + "name": "ru_name", + "type": "string" + } + ], + "questionnaire_flow": { + "type": "Hub", + "options": { + "required_completed_sections": ["section-companies"] + } + }, + "post_submission": { + "view_response": true + }, + "sections": [ + { + "id": "section-companies", + "title": "General insurance business", + "summary": { + "show_on_completion": true, + "items": [ + { + "type": "List", + "for_list": "companies", + "title": "Companies or UK branches", + "item_anchor_answer_id": "company-or-branch-name", + "item_label": "Name of UK company or branch", + "add_link_text": "Add another UK company or branch", + "empty_list_text": "No UK company or branch added", + "related_answers": [ + { + "source": "answers", + "identifier": "registration-number" + }, + { + "source": "answers", + "identifier": "authorised-insurer-radio" + } + ] + } + ], + "show_non_item_answers": true + }, + "groups": [ + { + "id": "group-companies", + "blocks": [ + { + "type": "ListCollectorDrivingQuestion", + "id": "any-companies-or-branches", + "for_list": "companies", + "question": { + "type": "General", + "id": "any-companies-or-branches-question", + "title": "Do any companies or branches within your United Kingdom group undertake general insurance business?", + "answers": [ + { + "type": "Radio", + "id": "any-companies-or-branches-answer", + "mandatory": true, + "options": [ + { + "label": "Yes", + "value": "Yes", + "action": { + "type": "RedirectToListAddBlock", + "params": { + "block_id": "add-company", + "list_name": "companies" + } + } + }, + { + "label": "No", + "value": "No" + } + ] + } + ] + }, + "routing_rules": [ + { + "when": { + "==": [ + { + "source": "answers", + "identifier": "any-companies-or-branches-answer" + }, + "No" + ] + }, + "block": "confirmation-checkbox" + }, + { + "block": "any-other-companies-or-branches" + } + ] + }, + { + "id": "any-other-companies-or-branches", + "type": "ListCollector", + "for_list": "companies", + "question": { + "id": "any-other-companies-or-branches-question", + "type": "General", + "title": "Do you need to add any other UK companies or branches that undertake general insurance business?", + "answers": [ + { + "id": "any-other-companies-or-branches-answer", + "mandatory": true, + "type": "Radio", + "options": [ + { + "label": "Yes", + "value": "Yes", + "action": { + "type": "RedirectToListAddBlock" + } + }, + { + "label": "No", + "value": "No" + } + ] + } + ] + }, + "add_block": { + "id": "add-company", + "type": "ListAddQuestion", + "question": { + "id": "add-question-companies", + "type": "General", + "title": "Give details about the company or branch that undertakes general insurance business", + "answers": [ + { + "id": "company-or-branch-name", + "label": "Name of UK company or branch", + "mandatory": true, + "type": "TextField" + }, + { + "id": "registration-number", + "label": "Registration number", + "mandatory": true, + "type": "Number", + "maximum": { + "value": 999, + "exclusive": false + }, + "decimal_places": 0 + }, + { + "type": "Radio", + "label": "Is this UK company or branch an authorised insurer?", + "id": "authorised-insurer-radio", + "mandatory": false, + "options": [ + { + "label": "Yes", + "value": "Yes" + }, + { + "label": "No", + "value": "No" + } + ] + } + ] + } + }, + "edit_block": { + "id": "edit-company", + "type": "ListEditQuestion", + "question": { + "id": "edit-question-companies", + "type": "General", + "title": "What is the name of the company?", + "answers": [ + { + "id": "company-or-branch-name", + "label": "Name of UK company or branch", + "mandatory": true, + "type": "TextField" + }, + { + "id": "registration-number", + "label": "Registration number", + "mandatory": true, + "type": "Number" + }, + { + "type": "Radio", + "label": "Is this UK company or branch an authorised insurer?", + "id": "authorised-insurer-radio", + "mandatory": false, + "options": [ + { + "label": "Yes", + "value": "Yes" + }, + { + "label": "No", + "value": "No" + } + ] + } + ] + } + }, + "remove_block": { + "id": "remove-company", + "type": "ListRemoveQuestion", + "question": { + "id": "remove-question-companies", + "type": "General", + "title": "Are you sure you want to remove this company or UK branch?", + "answers": [ + { + "id": "remove-confirmation", + "mandatory": true, + "type": "Radio", + "options": [ + { + "label": "Yes", + "value": "Yes", + "action": { + "type": "RemoveListItemAndAnswers" + } + }, + { + "label": "No", + "value": "No" + } + ] + } + ] + } + }, + "summary": { + "title": "Companies or UK branches", + "item_title": { + "text": "{company_name}", + "placeholders": [ + { + "placeholder": "company_name", + "value": { + "source": "answers", + "identifier": "company-or-branch-name" + } + } + ] + } + } + }, + { + "type": "Question", + "id": "confirmation-checkbox", + "question": { + "answers": [ + { + "id": "confirmation-checkbox-answer", + "mandatory": false, + "options": [ + { + "label": "Yes", + "value": "Yes" + }, + { + "label": "No", + "value": "No" + } + ], + "type": "Radio" + } + ], + "id": "confirmation-checkbox-question", + "title": "Are all companies or branches based in UK?", + "type": "General" + }, + "skip_conditions": { + "when": { + "!=": [ + { + "count": [ + { + "source": "list", + "identifier": "companies" + } + ] + }, + 3 + ] + } + } + } + ] + } + ] + }, + { + "id": "section-list-collector-contents", + "title": "List Collector Contents", + "summary": { + "show_on_completion": true, + "items": [ + { + "type": "List", + "for_list": "companies", + "title": "Companies or UK branches", + "item_label": "Name of UK company or branch" + } + ], + "show_non_item_answers": true + }, + "groups": [ + { + "id": "group-list-collector-contents", + "title": "Companies", + "blocks": [ + { + "type": "Question", + "id": "responsible-party", + "question": { + "type": "General", + "id": "responsible-party-question", + "title": "Are you the responsible party for reporting trading details for a company of branch?", + "answers": [ + { + "type": "Radio", + "id": "responsible-party-answer", + "mandatory": true, + "options": [ + { + "label": "Yes", + "value": "Yes" + }, + { + "label": "No", + "value": "No" + } + ] + } + ] + }, + "routing_rules": [ + { + "block": "list-collector-content", + "when": { + "==": [ + "Yes", + { + "source": "answers", + "identifier": "responsible-party-answer" + } + ] + } + }, + { + "section": "End" + } + ] + }, + { + "id": "list-collector-content", + "type": "ListCollectorContent", + "page_title": "Companies", + "for_list": "companies", + "summary": { + "title": "Companies or UK branches", + "item_title": { + "text": "{company_name}", + "placeholders": [ + { + "placeholder": "company_name", + "value": { + "source": "answers", + "identifier": "company-or-branch-name" + } + } + ] + } + }, + "content": { + "title": "Companies", + "contents": [ + { + "guidance": { + "contents": [ + { + "description": "Include all companies" + } + ] + } + }, + { + "definition": { + "title": "Companies definition", + "contents": [ + { + "description": "Legal entities formed by a group of individuals to engage in and operate a business enterprise in a commercial or industrial capacity." + } + ] + } + }, + { + "description": "You have previously reported the following companies. Press continue to updated registration and trading information." + } + ] + }, + "repeating_blocks": [ + { + "id": "companies-repeating-block-1", + "type": "ListRepeatingQuestion", + "question": { + "id": "companies-repeating-block-1-question", + "type": "General", + "title": { + "text": "Give details about {company_name}", + "placeholders": [ + { + "placeholder": "company_name", + "value": { + "source": "answers", + "identifier": "company-or-branch-name" + } + } + ] + }, + "answers": [ + { + "id": "registration-number-repeating-block", + "label": "Registration number (Mandatory)", + "mandatory": true, + "type": "Number", + "maximum": { + "value": 999, + "exclusive": false + }, + "decimal_places": 0 + }, + { + "id": "registration-date-repeating-block", + "label": "Date of Registration (Mandatory)", + "mandatory": true, + "type": "Date", + "maximum": { + "value": "now" + } + } + ] + } + }, + { + "id": "companies-repeating-block-2", + "type": "ListRepeatingQuestion", + "question": { + "id": "companies-repeating-block-2-question", + "type": "General", + "title": { + "text": "Give details about how {company_name} has been trading over the past {date_difference}.", + "placeholders": [ + { + "placeholder": "company_name", + "value": { + "source": "answers", + "identifier": "company-or-branch-name" + } + }, + { + "placeholder": "date_difference", + "transforms": [ + { + "transform": "calculate_date_difference", + "arguments": { + "first_date": { + "source": "answers", + "identifier": "registration-date-repeating-block" + }, + "second_date": { + "value": "now" + } + } + } + ] + } + ] + }, + "answers": [ + { + "type": "Radio", + "label": "Has this company been trading in the UK? (Mandatory)", + "id": "authorised-trader-uk-radio-repeating-block", + "mandatory": true, + "options": [ + { + "label": "Yes", + "value": "Yes" + }, + { + "label": "No", + "value": "No" + } + ] + }, + { + "type": "Radio", + "label": "Has this company been trading in the EU? (Not mandatory)", + "id": "authorised-trader-eu-radio-repeating-block", + "mandatory": false, + "options": [ + { + "label": "Yes", + "value": "Yes" + }, + { + "label": "No", + "value": "No" + } + ] + } + ] + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/templates/listcollectorcontent.html b/templates/listcollectorcontent.html new file mode 100644 index 0000000000..214f6a0bb1 --- /dev/null +++ b/templates/listcollectorcontent.html @@ -0,0 +1,25 @@ +{% extends 'layouts/_questionnaire.html' %} +{% import 'macros/helpers.html' as helpers %} + +{% set save_on_signout = true %} + +{% set continue_button_text = _("Continue") %} + +{% set title = content.title %} +{% set contents = content.contents %} + +{% block form_content %} + +