From 95e6cd79b53da15cfe52e922cdd3594123749c1c Mon Sep 17 00:00:00 2001 From: said-moj <45761276+said-moj@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:21:37 +0000 Subject: [PATCH 1/5] LGA-3495 - Add means test answers to the reviews page (#113) * Add means test answers to the reviews page. * Store only the category code in session * Create a new CategoryAnswer model to hold category and onward questions answers --- app/categories/constants.py | 47 ++- app/categories/models.py | 38 +++ app/categories/views.py | 138 +++++---- app/main/filters.py | 4 +- app/means_test/__init__.py | 3 + app/means_test/api.py | 1 + app/means_test/fields.py | 5 +- app/means_test/forms/__init__.py | 106 +++++++ app/means_test/forms/benefits.py | 3 +- app/means_test/forms/review.py | 6 + app/means_test/views.py | 142 ++++++++- app/session.py | 106 +++++-- app/templates/categories/landing.html | 8 +- .../means_test/components/progress-bar.html | 2 +- app/templates/means_test/review.html | 64 +++- app/translations/cy/LC_MESSAGES/messages.mo | Bin 75862 -> 76114 bytes app/translations/cy/LC_MESSAGES/messages.po | 12 + app/translations/en/LC_MESSAGES/messages.pot | 12 + tests/functional_tests/means_test/conftest.py | 65 +++- .../means_test/test_benefits_page.py | 2 +- .../means_test/test_outgoings.py | 8 +- .../means_test/test_progress.py | 4 +- .../means_test/test_review.py | 277 ++++++++++++++++++ tests/unit_tests/categories/test_session.py | 105 ++++--- tests/unit_tests/categories/test_views.py | 53 +++- .../means_test/test_form_summary.py | 107 +++++++ .../means_test/test_views_summary.py | 226 ++++++++++++++ 27 files changed, 1390 insertions(+), 154 deletions(-) create mode 100644 app/categories/models.py create mode 100644 app/means_test/forms/review.py create mode 100644 tests/functional_tests/means_test/test_review.py create mode 100644 tests/unit_tests/means_test/test_form_summary.py create mode 100644 tests/unit_tests/means_test/test_views_summary.py diff --git a/app/categories/constants.py b/app/categories/constants.py index 0138e77f2..f2e20790d 100644 --- a/app/categories/constants.py +++ b/app/categories/constants.py @@ -13,9 +13,14 @@ class Category: # Internal code code: Optional[str] = None children: dict[str, "Category"] | None = field(default_factory=dict) + parent_code: Optional[str] = None _referrer_text: Optional[LazyString] = None exit_page: Optional[bool] = False + @property + def url_friendly_name(self): + return self.code.replace("_", "-").lower() + @property def display_text(self): return self.title @@ -26,15 +31,23 @@ def sub(self): class Subcategory: def __init__(self, category): self.children: dict[str, Category] = category.children + self.category: Category = category def __getattr__(self, item): + if item not in self.children: + raise AttributeError( + f"Could not find {item} in category {self.category.title}" + ) return self.children.get(item) return Subcategory(self) @classmethod def from_dict(cls, data: dict) -> "Category": - children: dict = data.pop("children", {}) + data = data.copy() + children = {} + if "children" in data: + children: dict = data.pop("children") category = cls(**data) if children: for name, child in children.items(): @@ -474,21 +487,27 @@ def __str__(self): def init_children(category: Category) -> None: for child in category.children.values(): child.chs_code = child.chs_code or category.chs_code + child.parent_code = category.code child.article_category_name = ( child.article_category_name or category.article_category_name ) -ALL_CATEGORIES = [ - DOMESTIC_ABUSE, - FAMILY, - HOUSING, - DISCRIMINATION, - EDUCATION, - COMMUNITY_CARE, - BENEFITS, - PUBLIC_LAW, - ASYLUM_AND_IMMIGRATION, - MENTAL_CAPACITY, -] -list(map(init_children, ALL_CATEGORIES)) +ALL_CATEGORIES = { + DOMESTIC_ABUSE.code: DOMESTIC_ABUSE, + FAMILY.code: FAMILY, + HOUSING.code: HOUSING, + DISCRIMINATION.code: DISCRIMINATION, + EDUCATION.code: EDUCATION, + COMMUNITY_CARE.code: COMMUNITY_CARE, + BENEFITS.code: BENEFITS, + PUBLIC_LAW.code: PUBLIC_LAW, + ASYLUM_AND_IMMIGRATION.code: ASYLUM_AND_IMMIGRATION, + MENTAL_CAPACITY.code: MENTAL_CAPACITY, +} + +list(map(init_children, ALL_CATEGORIES.values())) + + +def get_category_from_code(code: str) -> Category: + return ALL_CATEGORIES[code] diff --git a/app/categories/models.py b/app/categories/models.py new file mode 100644 index 000000000..44be24cac --- /dev/null +++ b/app/categories/models.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + +from flask import url_for +from app.categories.constants import Category + + +class QuestionType(str, Enum): + SUB_CATEGORY = "sub_category" + ONWARD = "onward_question" + + +@dataclass +class CategoryAnswer: + question: str + answer_value: str + answer_label: str + category: Category + question_page: str + next_page: str + question_type: Optional[QuestionType] = field(default=QuestionType.SUB_CATEGORY) + + @property + def edit_url(self): + return url_for(self.question_page) + + @property + def next_url(self): + return url_for(self.next_page) + + @property + def question_type_is_sub_category(self): + return self.question_type == QuestionType.SUB_CATEGORY + + @property + def question_type_is_onward(self): + return self.question_type == QuestionType.ONWARD diff --git a/app/categories/views.py b/app/categories/views.py index cc47f8c5b..8963c1b32 100644 --- a/app/categories/views.py +++ b/app/categories/views.py @@ -3,6 +3,7 @@ from flask import render_template, redirect, url_for, session, request from app.categories.forms import QuestionForm from app.categories.constants import Category +from app.categories.models import CategoryAnswer, QuestionType class CategoryPage(View): @@ -13,21 +14,17 @@ class CategoryPage(View): def __init__(self, template, *args, **kwargs): self.template = template - def update_session(self, question: str, answer: str, category: Category) -> None: + def update_session(self, category_answer: CategoryAnswer) -> None: """ Update the session with the current page and answer. """ - session.set_category_question_answer( - question_title=question, - answer=answer, - category=category, - ) + session.set_category_question_answer(category_answer) def dispatch_request(self): category = getattr(self, "category", None) if category is not None: - session["category"] = category + session.category = category response = self.process_request() if not response: @@ -41,76 +38,103 @@ def process_request(self): class CategoryLandingPage(CategoryPage): template: str = "categories/landing.html" - routing_map: dict[str, str] = {} + routing_map: dict[str, list] = {} + """ + A dictionary that organizes category listings into different sections: "main", "more", and "other". + + - "main" and "more" contain lists of tuples, where each tuple consists of: + - `category`: Category object. + - `route`: String - an intermediary route than stores the selected category before redirecting to the target + + - "other" is a string representing an intermediary route than stores the selected answer before redirecting to the target + """ + listing: dict[str, list] = {} + + def __init__(self, route_endpoint: str = None, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.routing_map and route_endpoint: + self.listing["main"] = [] + for category, next_page in self.routing_map["main"]: + self.listing["main"].append( + (category, f"categories.{route_endpoint}.{category.code}") + ) + + self.listing["more"] = [] + for category, next_page in self.routing_map["more"]: + self.listing["more"].append( + (category, f"categories.{route_endpoint}.{category.code}") + ) + + self.listing["other"] = f"categories.{route_endpoint}.other" def process_request(self): return render_template( - self.template, category=self.category, routing_map=self.routing_map + self.template, category=self.category, listing=self.listing ) @classmethod def register_routes(cls, blueprint: Blueprint, path: str = None): if not path: - path = cls.category.code.lower().replace("_", "-") + path = cls.category.url_friendly_name blueprint.add_url_rule( f"/{path}/", - view_func=cls.as_view("landing", template=cls.template), + view_func=cls.as_view( + "landing", route_endpoint=blueprint.name, template=cls.template + ), ) cls.register_sub_routes(blueprint, path, cls.routing_map["main"]) cls.register_sub_routes(blueprint, path, cls.routing_map["more"]) if "other" in cls.routing_map and cls.routing_map["other"] is not None: + category_answer = CategoryAnswer( + question=cls.question_title, + question_page=f"categories.{blueprint.name}.landing", + answer_value="other", + answer_label="Other", + next_page=cls.routing_map["other"], + category=cls.category, + ) blueprint.add_url_rule( f"/{path}/answer/other", - view_func=CategoryAnswerPage.as_view( - "other", - question=cls.question_title, - answer="other", - next_page=cls.routing_map["other"], - category=cls.category, - ), + view_func=CategoryAnswerPage.as_view("other", category_answer), ) @classmethod def register_sub_routes(cls, blueprint: Blueprint, path, routes): for sub_category, next_page in routes: + category_answer = CategoryAnswer( + question=cls.question_title, + question_page=f"categories.{blueprint.name}.landing", + answer_value=sub_category.code, + answer_label=sub_category.title, + next_page=next_page, + category=sub_category, + ) blueprint.add_url_rule( - f"/{path}/answer/{sub_category.code.replace('_', '-')}", + f"/{path}/answer/{sub_category.url_friendly_name}", view_func=CategoryAnswerPage.as_view( - sub_category.code, - question=cls.question_title, - answer=sub_category.code, - next_page=next_page, - category=sub_category, + sub_category.code, category_answer ), ) class CategoryAnswerPage(View): - def __init__(self, question, answer, next_page, category): - self.question = question - self.answer = answer - self.next_page = next_page - self.category = category + def __init__(self, category_answer: CategoryAnswer): + self.category_answer = category_answer def update_session(self) -> None: """ Update the session with the current page and answer. """ - session["previous_page"] = request.endpoint - session.set_category_question_answer( - question_title=self.question, - answer=self.answer, - category=self.category, - ) + session.set_category_question_answer(self.category_answer) def dispatch_request(self): self.update_session() - if isinstance(self.next_page, dict): - return redirect(url_for(**self.next_page)) - return redirect(url_for(self.next_page)) + if isinstance(self.category_answer.next_page, dict): + return redirect(url_for(**self.category_answer.next_page)) + return redirect(url_for(self.category_answer.next_page)) class QuestionPage(CategoryPage): @@ -138,14 +162,14 @@ def __init__(self, form_class: type[QuestionForm], template=None): self.category = form_class.category super().__init__(self.template) - def get_next_page(self, answer: str) -> redirect: + def get_next_page(self, answer: str) -> str: """Determine and redirect to the next page based on the user's answer. Args: answer: The user's selected answer Returns: - A Flask redirect response to the next appropriate page + A string representing the next page to take the user to Raises: ValueError if the answer does not have a mapping to a next page @@ -156,21 +180,39 @@ def get_next_page(self, answer: str) -> redirect: ] # We should only route to these pages if they are the only answer if len(answer) == 1 and answer[0] in optional_answers: - return redirect(url_for(self.form_class.next_step_mapping[answer[0]])) + return url_for(self.form_class.next_step_mapping[answer[0]]) if isinstance(answer, list): for a in answer: if a in self.form_class.next_step_mapping and a not in optional_answers: - return redirect(url_for(self.form_class.next_step_mapping[a])) + return url_for(self.form_class.next_step_mapping[a]) answer = "*" if answer not in self.form_class.next_step_mapping: raise ValueError(f"No mapping found for answer: {answer}") next_page = self.form_class.next_step_mapping[answer] + if isinstance(next_page, dict): - return redirect(url_for(**next_page)) - return redirect(url_for(next_page)) + return url_for(**next_page) + return url_for(next_page) + + def update_session(self, form: QuestionForm) -> None: + answer = form.question.data + answer = answer if isinstance(answer, list) else [answer] + answer_labels = [ + label for value, label in form.question.choices if value in answer + ] + category_answer = CategoryAnswer( + question=form.title, + answer_value=form.question.data, + answer_label=answer_labels if len(answer) > 1 else answer_labels[0], + category=form.category, + next_page=self.get_next_page(form.question.data), + question_page=request.url_rule.endpoint, + question_type=QuestionType.ONWARD, + ) + super().update_session(category_answer) def process_request(self): """Handle requests for the question page, including form submissions. @@ -183,13 +225,11 @@ def process_request(self): Either a redirect to the next page or the rendered template """ form = self.form_class(request.args) - session["category"] = form.category + session.category = form.category if form.submit.data and form.validate(): - self.update_session( - question=form.title, answer=form.question.data, category=form.category - ) - return self.get_next_page(form.question.data) + self.update_session(form) + return redirect(self.get_next_page(form.question.data)) # Pre-populate form with previous answer if it exists previous_answer = session.get_category_question_answer(form.title) diff --git a/app/main/filters.py b/app/main/filters.py index 6e6015d63..e3f82bd01 100644 --- a/app/main/filters.py +++ b/app/main/filters.py @@ -8,12 +8,12 @@ @bp.app_template_filter("markdown") -def render_markdown(text): +def render_markdown(text, **kwargs): """Renders Markdown text as HTML, this can be invoked in Jinja templates using the markdown filter: {{ "# Hello, World!" | markdown }} We use markupsafe to ensure characters are escaped correctly so they can be safely rendered. """ - return Markup(markdown(text)) + return Markup(markdown(text, **kwargs)) @bp.app_template_filter("dict") diff --git a/app/means_test/__init__.py b/app/means_test/__init__.py index 88186eb89..3238b4ac4 100644 --- a/app/means_test/__init__.py +++ b/app/means_test/__init__.py @@ -1,9 +1,12 @@ from flask import Blueprint +from flask_babel import lazy_gettext as _ bp = Blueprint("means_test", __name__) YES = "1" +YES_LABEL = _("Yes") NO = "0" +NO_LABEL = _("No") def is_yes(value): diff --git a/app/means_test/api.py b/app/means_test/api.py index 6908fd2b4..871ef9bef 100644 --- a/app/means_test/api.py +++ b/app/means_test/api.py @@ -30,6 +30,7 @@ def is_eligible(reference): def get_means_test_payload(eligibility_data) -> dict: + # Todo: Need to add notes about = eligibility_data.forms.get("about-you", {}) savings_form = eligibility_data.forms.get("savings", {}) income_form = eligibility_data.forms.get("income", {}) diff --git a/app/means_test/fields.py b/app/means_test/fields.py index b1c1c6663..b2b467201 100644 --- a/app/means_test/fields.py +++ b/app/means_test/fields.py @@ -145,7 +145,10 @@ def clean_input(value): return re.sub(r"[£\s,]", "", value.strip()) def process_formdata(self, valuelist): - if valuelist: + if valuelist and isinstance(valuelist[0], int): + # The form is being restored from the session where the conversion has already taken place + self.data = valuelist[0] + elif valuelist: self._user_input = valuelist[0] # Clean the input diff --git a/app/means_test/forms/__init__.py b/app/means_test/forms/__init__.py index 1f719d108..0345890dc 100644 --- a/app/means_test/forms/__init__.py +++ b/app/means_test/forms/__init__.py @@ -1,8 +1,14 @@ from flask_wtf import FlaskForm from govuk_frontend_wtf.wtforms_widgets import GovSubmitInput from wtforms.fields.simple import SubmitField +from wtforms.fields.choices import SelectField, SelectMultipleField +from wtforms.csrf.core import CSRFTokenField from flask_babel import lazy_gettext as _ from flask import session +from app.means_test.fields import MoneyIntervalField, MoneyInterval, MoneyField +import decimal +from wtforms.fields.core import Field +from app.means_test.validators import ValidateIf, StopValidation, ValidateIfSession class BaseMeansTestForm(FlaskForm): @@ -47,3 +53,103 @@ def render_conditional(self, field, sub_field, conditional_value) -> str: conditional = {"value": conditional_value, "html": sub_field_rendered} field.render_kw = {"conditional": conditional} return field() + + def filter_summary(self, summary: dict) -> dict: + """Override this method to remove items from review page for this given form""" + return summary + + def is_unvalidated_conditional_field(self, field: Field): + # Return True if field has a ValidateIf or ValidateIfSession validator that raise a StopValidation + for validator in field.validators: + if isinstance(validator, (ValidateIf, ValidateIfSession)): + try: + validator(self, field) + except StopValidation: + return True + except Exception: + return False + return False + + def summary(self) -> dict: + """ + Generates a summary of all fields in this form, including their labels (questions) and formatted values (answers). + Monetary fields will have a '£' prefix, and selection fields will display their choice label. + + Fields with no data or empty values will be excluded. + + :return: A dictionary summarizing the form fields. + Each entry follows this structure: + { + "question": field label, + "answer": formatted field value (or choice label for selection fields), + "id": field ID + } + """ + summary = {} + for field_name, field_instance in self._fields.items(): + if isinstance(field_instance, (SubmitField, CSRFTokenField)): + continue + + if field_instance.data in [None, "None"]: + continue + + # Skip fields that use ValidateIf or ValidateIfSession validator that raise a StopValidation + if self.is_unvalidated_conditional_field(field_instance): + continue + + question = str(field_instance.label.text) + answer = field_instance.data + + if isinstance(field_instance, SelectField): + answer = self.get_selected_answer(field_instance) + elif isinstance(field_instance, MoneyIntervalField): + answer = self.get_money_interval_field_answers(field_instance) + elif isinstance(field_instance, MoneyField): + answer = self.get_money_field_answers(field_instance) + + # Skip empty answers + if answer is None: + continue + + summary[field_instance.name] = { + "question": question, + "answer": answer, + "id": field_instance.id, + } + + summary = self.filter_summary(summary) + return summary + + @staticmethod + def get_selected_answer(field_instance): + def selected_answers_only(choice): + return field_instance.data and choice[0] in field_instance.data + + # Remove any unselected choices + selected_choices = list(filter(selected_answers_only, field_instance.choices)) + # Get the labels of the selected answers + answer_labels = [str(choice[1]) for choice in selected_choices] + if not isinstance(field_instance, SelectMultipleField): + return answer_labels[0] + return answer_labels + + @staticmethod + def get_money_interval_field_answers(field_instance): + if field_instance.data["per_interval_value"] is None: + return None + + if field_instance.data["per_interval_value"] == 0: + return "£0" + amount = decimal.Decimal(int(field_instance.data["per_interval_value"]) / 100) + amount = amount.quantize(decimal.Decimal("0.01")) + interval = MoneyInterval._intervals[field_instance.data["interval_period"]][ + "label" + ] + return f"£{amount} ({interval})" + + @staticmethod + def get_money_field_answers(field_instance): + if field_instance.data == 0: + return "£0" + + return f"£{field_instance._value()}" diff --git a/app/means_test/forms/benefits.py b/app/means_test/forms/benefits.py index ae7790f53..a7c2a3715 100644 --- a/app/means_test/forms/benefits.py +++ b/app/means_test/forms/benefits.py @@ -12,7 +12,6 @@ ValidateIfType, ) from app.means_test import YES, NO - from dataclasses import dataclass, field @@ -121,7 +120,7 @@ def data(self): @classmethod def should_show(cls) -> bool: - return session.get("eligibility").on_benefits + return session.get_eligibility().on_benefits def get_payload(self) -> dict: """Returns the benefits payload for the user and the partner. diff --git a/app/means_test/forms/review.py b/app/means_test/forms/review.py new file mode 100644 index 000000000..1624d529e --- /dev/null +++ b/app/means_test/forms/review.py @@ -0,0 +1,6 @@ +from flask_babel import lazy_gettext as _ +from app.means_test.forms import BaseMeansTestForm + + +class ReviewForm(BaseMeansTestForm): + title = _("Check your answers and confirm") diff --git a/app/means_test/views.py b/app/means_test/views.py index bbfd013c5..c7cf2f888 100644 --- a/app/means_test/views.py +++ b/app/means_test/views.py @@ -1,17 +1,20 @@ +from typing import List from flask.views import View, MethodView from flask import render_template, url_for, redirect, session, request +from flask_babel import lazy_gettext as _, gettext from werkzeug.datastructures import MultiDict from app.means_test.api import update_means_test, get_means_test_payload -from app.means_test.forms import BaseMeansTestForm from app.means_test.forms.about_you import AboutYouForm from app.means_test.forms.benefits import BenefitsForm, AdditionalBenefitsForm from app.means_test.forms.property import MultiplePropertiesForm from app.means_test.forms.income import IncomeForm from app.means_test.forms.savings import SavingsForm from app.means_test.forms.outgoings import OutgoingsForm +from app.means_test.forms.review import ReviewForm, BaseMeansTestForm +from app.categories.models import CategoryAnswer -class MeansTest(View): +class FormsMixin: forms = { "about-you": AboutYouForm, "benefits": BenefitsForm, @@ -22,6 +25,8 @@ class MeansTest(View): "outgoings": OutgoingsForm, } + +class MeansTest(FormsMixin, View): def __init__(self, current_form_class, current_name): self.form_class = current_form_class self.current_name = current_name @@ -46,10 +51,8 @@ def handle_multiple_properties_ajax_request(self, form): def dispatch_request(self): eligibility = session.get_eligibility() - form_data = MultiDict(eligibility.forms.get(self.current_name, {})) - form = self.form_class( - formdata=request.form or None, data=form_data if not request.form else None - ) + form_data = eligibility.forms.get(self.current_name, {}) + form = self.form_class(formdata=request.form or None, data=form_data) if isinstance(form, MultiplePropertiesForm): response = self.handle_multiple_properties_ajax_request(form) if response is not None: @@ -120,6 +123,129 @@ def get_form_progress(self, current_form: BaseMeansTestForm) -> dict: } -class CheckYourAnswers(MethodView): +class CheckYourAnswers(FormsMixin, MethodView): + template = "check-your-answers.html" + def get(self): - return render_template("means_test/review.html", data=session.get_eligibility()) + eligibility = session.get_eligibility() + means_test_summary = {} + for key, form_class in self.forms.items(): + if key not in eligibility.forms: + continue + form_data = MultiDict(eligibility.forms.get(key, {})) + form = form_class(form_data) + if form.should_show(): + means_test_summary[str(form.page_title)] = self.get_form_summary( + form, key + ) + + params = { + "means_test_summary": means_test_summary, + "form": ReviewForm(), + "category": session.category, + "category_answers": self.get_category_answers_summary(), + } + return render_template("means_test/review.html", **params) + + @staticmethod + def get_category_answers_summary(): + def get_your_problem__no_description(): + return [ + { + "key": {"text": _("The problem you need help with")}, + "value": {"text": session.category.title}, + "actions": { + "items": [ + {"text": _("Change"), "href": url_for("categories.index")} + ], + }, + }, + ] + + def get_your_problem__with_description(first_answer): + value = "\n".join( + [ + f"**{str(first_answer.category.title)}**", + str(first_answer.category.description), + ] + ) + return [ + { + "key": {"text": _("The problem you need help with")}, + "value": {"markdown": value}, + "actions": { + "items": [{"text": _("Change"), "href": first_answer.edit_url}], + }, + }, + ] + + answers: List[CategoryAnswer] = session.category_answers + if not answers: + return [] + + category = session.category + category_has_children = bool(getattr(category, "children")) + if category_has_children: + if answers[0].question_type_is_sub_category: + results = get_your_problem__with_description(answers.pop(0)) + else: + # Sometimes there is only one answer and it was an onward question. + # However we still need to show 2 items: 'The problem you need help with' and the onward question + # Example journey: + # -> Children, families, relationships + # -> If there is domestic abuse in your family + # -> Are you worried about someone's safety? + # + # Then the two items need be shown in `About the problem` section: + # The `The problem you need help with` should be Domestic abuse with its description + # And the `Are you worried about someone's safety` onward question + first_answer = CategoryAnswer(**answers[0].__dict__) + first_answer.question_page = "categories.index" + results = get_your_problem__with_description(first_answer) + else: + # if a category doesn't have children then it does not have subpages so we don't show the category description + results = get_your_problem__no_description() + + for answer in answers: + answer_key = "text" + answer_label = answer.answer_label + if isinstance(answer_label, list): + # Multiple items need to be separated by a new line + answer_key = "markdown" + answer_label = "\n".join([gettext(label) for label in answer_label]) + else: + answer_label = gettext(answer_label) + + results.append( + { + "key": {"text": gettext(answer.question)}, + "value": {answer_key: answer_label}, + "actions": { + "items": [{"text": _("Change"), "href": answer.edit_url}] + }, + } + ) + return results + + @staticmethod + def get_form_summary(form: BaseMeansTestForm, form_name: str) -> list: + summary = [] + for item in form.summary().values(): + answer_key = "text" + if isinstance(item["answer"], list): + # Multiple items need to be separated by a new line + answer_key = "markdown" + item["answer"] = "\n".join(item["answer"]) + + change_link = url_for(f"means_test.{form_name}", _anchor=item["id"]) + summary.append( + { + "key": {"text": item["question"]}, + "value": {answer_key: item["answer"]}, + "actions": {"items": [{"href": change_link, "text": _("Change")}]}, + } + ) + return summary + + def post(self): + return self.get() diff --git a/app/session.py b/app/session.py index a19065fb6..bde3315b7 100644 --- a/app/session.py +++ b/app/session.py @@ -1,15 +1,17 @@ from flask.sessions import SecureCookieSession, SecureCookieSessionInterface -from app.categories.constants import Category +from app.categories.constants import Category, get_category_from_code from flask import session from dataclasses import dataclass from datetime import timedelta +from app.categories.models import CategoryAnswer +from flask_babel import LazyString @dataclass class Eligibility: - def __init__(self, forms, _notes): + def __init__(self, forms, _notes=None): self.forms = forms - self._notes = _notes + self._notes = _notes or {} forms: dict[str, dict] @@ -141,9 +143,31 @@ def category(self) -> Category | None: category_dict = self.get("category") if category_dict is None: return None - if isinstance(category_dict, Category): - return category_dict - return Category.from_dict(category_dict) + + return self._category_from_dict_from_session_storage(category_dict) + + @category.setter + def category(self, category: Category): + current_category = self.category + if current_category and current_category.code != category.code: + self["category_answers"] = [] + self["category"] = self._category_to_dict_for_session_storage(category) + + @staticmethod + def _category_to_dict_for_session_storage(category: Category): + data = {"code": category.code} + if category.parent_code: + data["parent_code"] = category.parent_code + return data + + @staticmethod + def _category_from_dict_from_session_storage(category_dict: dict): + parent_code = category_dict.get("parent_code", None) + if parent_code: + category = get_category_from_code(parent_code) + return category.children[category_dict["code"]] + else: + return get_category_from_code(category_dict["code"]) @property def has_children(self): @@ -155,9 +179,39 @@ def has_dependants(self): # Todo: Needs implementation return True - def set_category_question_answer( - self, question_title: str, answer: str, category: Category - ) -> None: + @property + def category_answers(self) -> list[CategoryAnswer]: + items: list[dict] = self.get("category_answers", []) + category_answers = [] + for item in items: + answer = item.copy() + answer["category"] = self._category_from_dict_from_session_storage( + answer["category"] + ) + category_answers.append(CategoryAnswer(**answer)) + + return category_answers + + @staticmethod + def _untranslate_category_answer(category_answer: CategoryAnswer): + """Remove translation from the category_answer object""" + category_answer_dict = {} + for key, value in category_answer.__dict__.items(): + if isinstance(value, list): + values = [] + for item in value: + if isinstance(item, LazyString): + values.append(item._args[0]) + else: + values.append(item) + value = values + elif isinstance(value, LazyString): + value = value._args[0] + category_answer_dict[key] = value + + return CategoryAnswer(**category_answer_dict) + + def set_category_question_answer(self, category_answer: CategoryAnswer) -> None: """Store a question-answer pair with the question category in the session. Args: @@ -168,19 +222,30 @@ def set_category_question_answer( Side effects: Updates session['category_answers'] list """ + + # Remove translation from the category_answer object before saving + category_answer = self._untranslate_category_answer(category_answer) + if "category_answers" not in self: self["category_answers"] = [] - answers: list[dict[str, str]] = self["category_answers"] + answers: list[CategoryAnswer] = self.category_answers # Remove existing entry if present - answers = [entry for entry in answers if entry["question"] != question_title] + answers = [ + entry for entry in answers if entry.question != category_answer.question + ] + answers.append(category_answer) - answers.append( - {"question": question_title, "answer": answer, "category": category} - ) + category_answers = [] + for answer in answers: + answer_dict = answer.__dict__ + answer_dict["category"] = self._category_to_dict_for_session_storage( + answer.category + ) + category_answers.append(answer_dict) - self["category_answers"] = answers + self["category_answers"] = category_answers def get_category_question_answer(self, question_title: str) -> str | None: """Retrieve an answer for a question from the session. @@ -191,14 +256,11 @@ def get_category_question_answer(self, question_title: str) -> str | None: Returns: The stored answer string if found, None otherwise """ - if "category_answers" not in self: - return None - - answers: list[dict[str, str]] = self["category_answers"] - + answers = self.category_answers for answer in answers: - if answer["question"] == question_title: - return answer["answer"] + if answer.question == question_title: + return answer.answer_value + return None diff --git a/app/templates/categories/landing.html b/app/templates/categories/landing.html index 61a968091..8c22c6b90 100644 --- a/app/templates/categories/landing.html +++ b/app/templates/categories/landing.html @@ -25,18 +25,18 @@
;f8B@ukD{TOWuS^F8|gMLVhWx^-7sK){qSglns7eG
z;Swyxk8wJ-7-(zcFlyo#F${0wEPRSQRL$!-OxQ)^0F5em33bDVsA>)8(5k94FbjuZ
zByPtMcmm(R^jv!^kE8n2^NeA!<~`&RQ;vNdh=oY@%`uF@IMU$?q|u&64eW)@aUxd7
z-KflbgW>oCI`MC;jLso^5Me6T#b&7c<$D~1HHk|x0@tIq^e}QF&G{iDT8+2@_8p6G
zAbyR(m|SRA)E1+OcVYmZ##(q8_5N~0ZDx9)PQ_@{fYVV6Sc}u~eWZ>}3KhK`=L{qN
z88niH+p2yA^^CrW%g~#AYOhzLW_|)E;&s%@`i`^%mZC555>zJFU=SWcFFcLP;8|o2
z<{oOIeO;sM3`b*CIu!fha#WG5#d>%Sv#{)FTlJk$H8TTM6RS}Z+=n4}3hUxE%*4P~
zY(~0ZGVxH<0$gio)TVI+>);*KK$VN^=lcL`MZ6U?&@ZTJ4j5x^6oXnxFI0aas<=w9
zKCZvBP;w03b&%zG45&iH!CgW2~!G_~J#}SpO
zCD<4bdHR3HG~!z0?WxMa2E;Q^-#^VR8cM}=R3>~U*bj|HsEp*H2Aq$&?l30cO;oK^
zrXaN96x4)XK&5^T>bgCsV|@daiSorZGwE1|@l62@-FOk|jUQqxUPG;@+^hCRN!XA$
z2Q{%pn1uULH~s~6DymPi?{AN~-!N=~OHmU#fz9wK>iefjea)t97^V@wgG$jw)WrNI
z+jF0Q5yV|R@i1&oJQp>Qqo_ {{ _('If you need to hide what you’re doing on this website, or protect yourself from someone, it can be safer not to use your home computer or your own mobile phone.') }} {{ _('For example, use a computer at the library, or borrow a phone from someone you trust.') }} {{ _('When you use this website, and every time you use the internet, your device saves a record of:') }} {{ _('Be careful because:') }} {{ _('Try to only remove information about the websites you want to keep private.') }} {{ _('Find out how to do that on:') }}
+ {{ _('There is an ‘Exit this page’ button on some pages. If you click this button, all the information you’ve entered will be deleted. This website will close.')}}
+
+ {{ _('You’ll automatically go to the BBC Weather homepage:') }} {{ _('https://www.bbc.co.uk/weather/') }}
+ {{ _('Cookies and browsing history will still be saved to your device.') }} {{ _('On any page where you see the ‘Exit this page’ button, pressing the ‘Shift’ key 3 times will work the same way.') }} {{ _('To find this website again, search ‘Check if you can get legal aid’.') }} {{ _('Use the ‘private browsing’ settings. Go to the menu on your browser, click on ‘File’ and turn on:') }} {{ _('On Samsung internet, you need to turn on') }} {{ _('Secret mode.') }} {{ _('There are other ways someone can track you online, for example by using spying or tracking programmes. See the Refuge website') }} {{ _('Secure your tech.') }}PR4Y3D)iPLd5
zW*@REJdZ1ghaR?{1>Q&O{knNvg$*_JTQtUVVYPqr5W)qx5(j=}`=4Sf;xV7w2i0!u
zN*whC1&Je&x-^B%VmMYiY6pHD>k`Ktv)6Y
*Y5dre4cm(mf{`M>m$Fh@0*9|#AiKm=vf>0!CLe$!^U_J
z$KfMvjl<3{+N(6So#RUc);Z5#C%6?AXMbyd-8zjV!@PFEuDsJl`}^J&)P!bVvfuv$
zzO&Un5X;kFgsOoO)QaCjCtkt`{L4i{ds*$WeUv6)HgP7#<9uv`yDC`XxHG
QK}S=V2xM5+m?3X5b&F8>IO-+)uJ0sCW*>;YX{{ form.page_title }}
{% endblock %}
{% block formDescription %}
- {% if session.category.exit_page %}
+ {% if show_exit_this_page %}
{{ govukWarningText({
"text": _("If you’re in an emergency situation, please call the police on 999.")
}) }}
diff --git a/app/templates/contact/rfc.html b/app/templates/contact/rfc.html
index 4cb351c26..39ff473f0 100644
--- a/app/templates/contact/rfc.html
+++ b/app/templates/contact/rfc.html
@@ -1,7 +1,6 @@
{% extends "base.html" %}
{%- from 'components/back_link.html' import govukBackLink -%}
{%- from 'govuk_frontend_jinja/components/button/macro.html' import govukButton %}
-{%- from 'govuk_frontend_jinja/components/exit-this-page/macro.html' import govukExitThisPage -%}
{% block pageTitle %}Why do you want to contact Civil Legal Advice?{% endblock %}
diff --git a/app/templates/means_test/form-page.html b/app/templates/means_test/form-page.html
index b1984b3ee..178c701a4 100644
--- a/app/templates/means_test/form-page.html
+++ b/app/templates/means_test/form-page.html
@@ -1,6 +1,5 @@
{% extends "base.html" %}
{%- from 'components/back_link.html' import govukBackLink -%}
-{%- from 'govuk_frontend_jinja/components/exit-this-page/macro.html' import govukExitThisPage -%}
{%- from 'govuk_frontend_jinja/components/inset-text/macro.html' import govukInsetText %}
{%- from 'means_test/components/progress-bar.html' import progress as means_test_progress %}
diff --git a/app/translations/cy/LC_MESSAGES/messages.po b/app/translations/cy/LC_MESSAGES/messages.po
index 16ffda40b..e94a3240e 100644
--- a/app/translations/cy/LC_MESSAGES/messages.po
+++ b/app/translations/cy/LC_MESSAGES/messages.po
@@ -17,7 +17,10 @@ msgid ""
"Includes keeping you or your family safe, getting court orders and help "
"if someone is ignoring a court order. Also, if you’re being stalked, "
"threatened or harassed."
-msgstr "Mae hyn yn cynnwys eich cadw chi neu eich teulu’n ddiogel, cael gorchmynion llys a chymorth os bydd rhywun yn anwybyddu gorchymyn llys. Hefyd, os ydych chi’n cael eich stelcio, eich bygwth neu eich aflonyddu."
+msgstr ""
+"Mae hyn yn cynnwys eich cadw chi neu eich teulu’n ddiogel, cael "
+"gorchmynion llys a chymorth os bydd rhywun yn anwybyddu gorchymyn llys. "
+"Hefyd, os ydych chi’n cael eich stelcio, eich bygwth neu eich aflonyddu."
msgid "Leaving an abusive relationship"
msgstr "Gadael perthynas gamdriniol"
@@ -25,7 +28,9 @@ msgstr "Gadael perthynas gamdriniol"
msgid ""
"Help with divorce, separation, or leaving your partner. Includes legal "
"arrangements for children, money and housing."
-msgstr "Cymorth i ysgaru, i wahanu neu i adael eich partner. Mae hyn yn cynnwys trefniadau cyfreithiol ar gyfer plant, arian a thai."
+msgstr ""
+"Cymorth i ysgaru, i wahanu neu i adael eich partner. Mae hyn yn cynnwys "
+"trefniadau cyfreithiol ar gyfer plant, arian a thai."
msgid "Problems with an ex-partner: children or money"
msgstr "Problemau gyda chyn-bartner: plant neu arian"
@@ -34,7 +39,10 @@ msgid ""
"Includes arrangements for children and money. If an ex-partner is not "
"following agreements or court orders. If you’re worried about a child, or"
" if a child is taken or kept without your permission."
-msgstr "Mae'n cynnwys trefniadau ar gyfer plant ac arian. Os nad yw cynbartner yn dilyn cytundebau neu orchmynion llys. Os ydych chi'n poeni am blentyn, neu os yw plentyn yn cael ei gymryd neu ei gadw heb eich caniatâd."
+msgstr ""
+"Mae'n cynnwys trefniadau ar gyfer plant ac arian. Os nad yw cynbartner yn"
+" dilyn cytundebau neu orchmynion llys. Os ydych chi'n poeni am blentyn, "
+"neu os yw plentyn yn cael ei gymryd neu ei gadw heb eich caniatâd."
msgid "Problems with neighbours, landlords or other people"
msgstr "Problemau gyda chymdogion, landlordiaid neu bobl eraill"
@@ -48,7 +56,10 @@ msgstr "Tai, digartrefedd, colli eich cartref"
msgid ""
"Includes being forced to leave your home, problems with council housing, "
"if you’re homeless or might be homeless in the next 2 months."
-msgstr "Mae hyn yn cynnwys cael eich gorfodi i adael eich cartref, problemau gyda thai cyngor, os ydych chi'n ddigartref neu y gallech fod yn ddigartref yn ystod y 2 fis nesaf."
+msgstr ""
+"Mae hyn yn cynnwys cael eich gorfodi i adael eich cartref, problemau gyda"
+" thai cyngor, os ydych chi'n ddigartref neu y gallech fod yn ddigartref "
+"yn ystod y 2 fis nesaf."
msgid "Forced Marriage"
msgstr "Priodas dan orfod"
@@ -62,8 +73,7 @@ msgid "Female genital mutilation (FGM)"
msgstr "Anffurfio Organau Cenhedlu Benywod (FGM)"
msgid "If you or someone else is at risk of FGM."
-msgstr ""
-"Os ydych chi neu rywun arall mewn perygl o FGM."
+msgstr "Os ydych chi neu rywun arall mewn perygl o FGM."
msgid "Children, families, relationships"
msgstr "Plant, teuluoedd, perthnasoedd"
@@ -1703,6 +1713,9 @@ msgstr "Rhowch gyfanswm yr holl eitemau gwerthfawr dros £500"
msgid "Enter 0 if you have no valuable items worth over £500 each"
msgstr "Rhowch 0 os nad oes gennych eitemau gwerthfawr sy’n werth dros £500 yr un"
+msgid "Exit this page"
+msgstr "Gadael y dudalen hon"
+
msgid "Sorry, you’re not likely to get legal aid"
msgstr ""
diff --git a/app/translations/en/LC_MESSAGES/messages.pot b/app/translations/en/LC_MESSAGES/messages.pot
index c1d1d0ef7..bc4bc046b 100644
--- a/app/translations/en/LC_MESSAGES/messages.pot
+++ b/app/translations/en/LC_MESSAGES/messages.pot
@@ -8,14 +8,14 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2025-03-05 08:35+0000\n"
+"POT-Creation-Date: 2025-03-10 08:24+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME {{ _('Stay safe when you use this website') }}
+
+ {{ _('What this website saves to your device') }}
+
+
+
+ {{ _('How to delete cookies and your browsing history') }}
+
+
+
+
+
+ {{ _('How the ‘Exit this page’ button works on this website') }}
+ {{ _('How to use this website without storing cookies and browser history') }}
+
+
+ {{ _('Get help to stay safe online') }}
+ 8A#z7MQ8`Xmqbem3E
zTQP*s;rTdXvg!Fw>`wV7T!>w!m
Qy;noZX>fuv36~Dn;EV|u{={czEz6^EaZd`%~P?6}d)6D;VIGOS&tcSnA
zrnnB9;1
saqb42V;RS*;^-J`D(1bYIFY5kyCv6)$^b3(#oxt7mn8MvQYJt
z2M1