diff --git a/app/blueprints/fund_builder/forms/section.py b/app/blueprints/application/forms.py similarity index 100% rename from app/blueprints/fund_builder/forms/section.py rename to app/blueprints/application/forms.py diff --git a/app/blueprints/application/routes.py b/app/blueprints/application/routes.py new file mode 100644 index 00000000..648a74ff --- /dev/null +++ b/app/blueprints/application/routes.py @@ -0,0 +1,246 @@ +import os +import secrets +import shutil +import string + +from flask import ( + Blueprint, + after_this_request, + redirect, + render_template, + request, + send_file, + url_for, +) + +from app.all_questions.metadata_utils import generate_print_data_for_sections +from app.blueprints.application.forms import SectionForm +from app.blueprints.application.services import create_export_zip +from app.db.queries.application import ( + delete_form_from_section, + delete_section_from_round, + get_all_template_forms, + get_form_by_id, + get_section_by_id, + insert_new_section, + move_form_down, + move_form_up, + move_section_down, + move_section_up, + update_section, +) +from app.db.queries.clone import clone_single_form +from app.db.queries.fund import get_fund_by_id +from app.db.queries.round import get_round_by_id +from app.export_config.generate_all_questions import print_html +from app.export_config.generate_assessment_config import ( + generate_assessment_config_for_round, +) +from app.export_config.generate_form import build_form_json +from app.export_config.generate_fund_round_config import generate_config_for_round +from app.export_config.generate_fund_round_form_jsons import ( + generate_form_jsons_for_round, +) +from app.export_config.generate_fund_round_html import generate_all_round_html +from config import Config + +INDEX_BP_DASHBOARD = "index_bp.dashboard" + +application_bp = Blueprint( + "application_bp", + __name__, + url_prefix="/rounds", + template_folder="templates", +) + + +@application_bp.route("//sections") +def build_application(round_id): + """ + Renders a template displaying application configuration info for the chosen round + """ + round = get_round_by_id(round_id) + fund = get_fund_by_id(round.fund_id) + breadcrumb_items = [ + {"text": "Home", "href": url_for(INDEX_BP_DASHBOARD)}, + {"text": fund.name_json["en"], "href": url_for("fund_bp.view_fund", fund_id=fund.fund_id)}, + {"text": round.title_json["en"], "href": "#"}, + ] + return render_template("build_application.html", round=round, fund=fund, breadcrumb_items=breadcrumb_items) + + +@application_bp.route("//sections/all-questions", methods=["GET"]) +def view_all_questions(round_id): + """ + Generates the form data for all sections in the selected round, then uses that to generate the 'All Questions' + data for that round and returns that to render in a template. + """ + round = get_round_by_id(round_id) + fund = get_fund_by_id(round.fund_id) + sections_in_round = round.sections + section_data = [] + for section in sections_in_round: + forms = [{"name": form.runner_publish_name, "form_data": build_form_json(form)} for form in section.forms] + section_data.append({"section_title": section.name_in_apply_json["en"], "forms": forms}) + + print_data = generate_print_data_for_sections( + section_data, + lang="en", + ) + html = print_html(print_data) + return render_template( + "view_questions.html", + round=round, + fund=fund, + question_html=html, + title=f"All Questions for {fund.short_name} - {round.short_name}", + ) + + +@application_bp.route("//sections/create_export_files", methods=["GET"]) +def create_export_files(round_id): + round_short_name = get_round_by_id(round_id).short_name + # Construct the path to the output directory relative to this file's location + random_post_fix = "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(5)) + base_output_dir = Config.TEMP_FILE_PATH / f"{round_short_name}-{random_post_fix}" + generate_form_jsons_for_round(round_id, base_output_dir) + generate_all_round_html(round_id, base_output_dir) + fund_config, round_config = generate_config_for_round(round_id, base_output_dir) + generate_assessment_config_for_round(fund_config, round_config, base_output_dir) + output_zip_path = create_export_zip( + directory_to_zip=base_output_dir, zip_file_name=round_short_name, random_post_fix=random_post_fix + ) + + # Ensure the file is removed after sending it + @after_this_request + def remove_file(response): + os.remove(output_zip_path) + shutil.rmtree(base_output_dir) + return response + + # Return the zipped folder for the user to download + return send_file(output_zip_path, as_attachment=True, download_name=f"{round_short_name}.zip") + + +@application_bp.route("//sections/create", methods=["GET", "POST"]) +@application_bp.route("//sections/", methods=["GET", "POST"]) +def section(round_id, section_id=None): + round_obj = get_round_by_id(round_id) + fund_obj = get_fund_by_id(round_obj.fund_id) + form: SectionForm = SectionForm() + form.round_id.data = round_id + params = { + "round_id": str(round_id), + } + existing_section = None + if form.validate_on_submit(): + count_existing_sections = len(round_obj.sections) + if form.section_id.data: + update_section( + form.section_id.data, + { + "name_in_apply_json": {"en": form.name_in_apply_en.data}, + }, + ) + else: + insert_new_section( + { + "round_id": form.round_id.data, + "name_in_apply_json": {"en": form.name_in_apply_en.data}, + "index": max(count_existing_sections + 1, 1), + } + ) + + # flash(f"Saved section {form.name_in_apply_en.data}") + return redirect(url_for("application_bp.build_application", round_id=round_obj.round_id)) + if section_id: + existing_section = get_section_by_id(section_id) + form.section_id.data = section_id + form.name_in_apply_en.data = existing_section.name_in_apply_json["en"] + params["forms_in_section"] = existing_section.forms + params["available_template_forms"] = [ + {"text": f"{f.template_name} - {f.name_in_apply_json['en']}", "value": str(f.form_id)} + for f in get_all_template_forms() + ] + + params["breadcrumb_items"] = [ + {"text": "Home", "href": url_for(INDEX_BP_DASHBOARD)}, + {"text": fund_obj.name_json["en"], "href": url_for("fund_bp.view_fund", fund_id=fund_obj.fund_id)}, + { + "text": round_obj.title_json["en"], + "href": url_for("application_bp.build_application", round_id=round_obj.round_id), + }, + {"text": existing_section.name_in_apply_json["en"] if existing_section else "Add Section", "href": "#"}, + ] + return render_template("section.html", form=form, **params) + + +@application_bp.route("//sections//delete", methods=["GET"]) +def delete_section(round_id, section_id): + delete_section_from_round(round_id=round_id, section_id=section_id, cascade=True) + return redirect(url_for("application_bp.build_application", round_id=round_id)) + + +@application_bp.route("//sections//move-up", methods=["GET"]) +def move_section_up_route(round_id, section_id): + move_section_up(round_id=round_id, section_id=section_id) + return redirect(url_for("application_bp.build_application", round_id=round_id)) + + +@application_bp.route("//sections//move-down", methods=["GET"]) +def move_section_down_route(round_id, section_id): + move_section_down(round_id=round_id, section_id=section_id) + return redirect(url_for("application_bp.build_application", round_id=round_id)) + + +@application_bp.route("//sections//forms/add", methods=["POST"]) +def add_form(round_id, section_id): + template_id = request.form.get("template_id") + section = get_section_by_id(section_id=section_id) + new_section_index = max(len(section.forms) + 1, 1) + clone_single_form(form_id=template_id, new_section_id=section_id, section_index=new_section_index) + return redirect(url_for("application_bp.section", round_id=round_id, section_id=section_id)) + + +@application_bp.route("//sections//forms//delete", methods=["GET"]) +def delete_form(round_id, section_id, form_id): + delete_form_from_section(section_id=section_id, form_id=form_id, cascade=True) + return redirect(url_for("application_bp.section", round_id=round_id, section_id=section_id)) + + +@application_bp.route("//sections//forms//move-up", methods=["GET"]) +def move_form_up_route(round_id, section_id, form_id): + move_form_up(section_id=section_id, form_id=form_id) + return redirect(url_for("application_bp.section", round_id=round_id, section_id=section_id)) + + +@application_bp.route("//sections//forms//move-down", methods=["GET"]) +def move_form_down_route(round_id, section_id, form_id): + move_form_down(section_id=section_id, form_id=form_id) + return redirect(url_for("application_bp.section", round_id=round_id, section_id=section_id)) + + +@application_bp.route("//sections//forms//all-questions", methods=["GET"]) +def view_form_questions(round_id, section_id, form_id): + """ + Generates the form data for this form, then uses that to generate the 'All Questions' + data for that form and returns that to render in a template. + """ + round = get_round_by_id(round_id) + fund = get_fund_by_id(round.fund_id) + form = get_form_by_id(form_id=form_id) + section_data = [ + { + "section_title": f"Preview of form [{form.name_in_apply_json['en']}]", + "forms": [{"name": form.runner_publish_name, "form_data": build_form_json(form)}], + } + ] + + print_data = generate_print_data_for_sections( + section_data, + lang="en", + ) + html = print_html(print_data, True) + return render_template( + "view_questions.html", round=round, fund=fund, question_html=html, title=form.name_in_apply_json["en"] + ) diff --git a/app/blueprints/application/services.py b/app/blueprints/application/services.py new file mode 100644 index 00000000..16235641 --- /dev/null +++ b/app/blueprints/application/services.py @@ -0,0 +1,12 @@ +import shutil + +from config import Config + + +def create_export_zip(directory_to_zip, zip_file_name, random_post_fix) -> str: + # Output zip file path (temporary) + output_zip_path = Config.TEMP_FILE_PATH / f"{zip_file_name}-{random_post_fix}" + + # Create a zip archive of the directory + shutil.make_archive(base_name=output_zip_path, format="zip", root_dir=directory_to_zip) + return f"{output_zip_path}.zip" diff --git a/app/blueprints/fund_builder/templates/build_application.html b/app/blueprints/application/templates/build_application.html similarity index 69% rename from app/blueprints/fund_builder/templates/build_application.html rename to app/blueprints/application/templates/build_application.html index bd523d2e..e92b7d1d 100644 --- a/app/blueprints/fund_builder/templates/build_application.html +++ b/app/blueprints/application/templates/build_application.html @@ -12,21 +12,21 @@

{{ fund.short_name }} - {{round.title_json["en"]}} ( {{ govukButton({ "text": "View All Questions", - "href": url_for("build_fund_bp.view_all_questions", round_id=round.round_id) , - "classes": "govuk-button--secondary" + "href": url_for("application_bp.view_all_questions", round_id=round.round_id) , + "classes": "govuk-button--secondary" }) }} {{ govukButton({ "text": "Create export files for round", - "href": url_for("build_fund_bp.create_export_files", round_id=round.round_id) , - "classes": "govuk-button--secondary" + "href": url_for("application_bp.create_export_files", round_id=round.round_id) , + "classes": "govuk-button--secondary" }) }}
    {{ govukButton({ - "text": "Add Section", - "href": url_for("build_fund_bp.section", round_id=round.round_id) , - "classes": "govuk-button--secondary" + "text": "Add Section", + "href": url_for("application_bp.section", round_id=round.round_id), + "classes": "govuk-button--secondary" }) }} {% for section in round.sections %} @@ -35,13 +35,13 @@

    {{ fund.short_name }} - {{round.title_json["en"]}} (

    {{ section.index }}. {{ section.name_in_apply_json["en"] }}

    - Edit  - Remove  + Edit  + Remove  {% if section.index > 1 %} - Move up  + Move up  {% endif %} {% if section.index < round.sections | length %} - Move down + Move down {% endif %}
      @@ -53,9 +53,9 @@

      - View  - Download  - Preview  + View  + Download  + Preview  {% endfor %} diff --git a/app/blueprints/fund_builder/templates/section.html b/app/blueprints/application/templates/section.html similarity index 79% rename from app/blueprints/fund_builder/templates/section.html rename to app/blueprints/application/templates/section.html index bb48991f..9b16bbef 100644 --- a/app/blueprints/fund_builder/templates/section.html +++ b/app/blueprints/application/templates/section.html @@ -37,14 +37,14 @@

      {{ app_form.section_index }}. {{ app_form.name_in_ap Remove  + href="{{ url_for('application_bp.delete_form', round_id=round_id, section_id=app_form.section_id, form_id=app_form.form_id) }}">Remove  {% if app_form.section_index > 1 %} Move up  + href="{{ url_for('application_bp.move_form_up_route', round_id=round_id, section_id=app_form.section_id, form_id=app_form.form_id) }}">Move up  {% endif %} {% if app_form.section_index < forms_in_section | length %} Move down + href="{{ url_for('application_bp.move_form_down_route', round_id=round_id, section_id=app_form.section_id, form_id=app_form.form_id) }}"> Move down {% endif %} @@ -52,7 +52,7 @@

      {{ app_form.section_index }}. {{ app_form.name_in_ap

    Add Form From Template

    + action="{{ url_for('application_bp.add_form', round_id=round_id, section_id=form.section_id.data) }}"> {{ govukSelect({ "id": "template_id", "name": "template_id", diff --git a/app/blueprints/fund_builder/templates/view_questions.html b/app/blueprints/application/templates/view_questions.html similarity index 100% rename from app/blueprints/fund_builder/templates/view_questions.html rename to app/blueprints/application/templates/view_questions.html diff --git a/app/blueprints/fund_builder/forms/fund.py b/app/blueprints/fund/forms.py similarity index 100% rename from app/blueprints/fund_builder/forms/fund.py rename to app/blueprints/fund/forms.py diff --git a/app/blueprints/fund/routes.py b/app/blueprints/fund/routes.py new file mode 100644 index 00000000..4b0cbe23 --- /dev/null +++ b/app/blueprints/fund/routes.py @@ -0,0 +1,114 @@ +from datetime import datetime + +from flask import ( + Blueprint, + flash, + redirect, + render_template, + request, + url_for, +) + +from app.blueprints.fund.forms import FundForm +from app.db.models.fund import Fund, FundingType +from app.db.queries.fund import add_fund, get_all_funds, get_fund_by_id, update_fund +from app.shared.helpers import all_funds_as_govuk_select_items, error_formatter + +INDEX_BP_DASHBOARD = "index_bp.dashboard" + +# Blueprint for routes used by v1 of FAB - using the DB +fund_bp = Blueprint( + "fund_bp", + __name__, + url_prefix="/funds", + template_folder="templates", +) + + +@fund_bp.route("/view", methods=["GET", "POST"]) +def view_fund(): + """ + Renders a template providing a drop down list of funds. If a fund is selected, renders its config info + """ + params = {"all_funds": all_funds_as_govuk_select_items(get_all_funds())} + fund = None + if request.method == "POST": + fund_id = request.form.get("fund_id") + else: + fund_id = request.args.get("fund_id") + if fund_id: + fund = get_fund_by_id(fund_id) + params["fund"] = fund + params["selected_fund_id"] = fund_id + params["breadcrumb_items"] = [ + {"text": "Home", "href": url_for(INDEX_BP_DASHBOARD)}, + {"text": fund.title_json["en"] if fund else "Manage Application Configuration", "href": "#"}, + ] + + return render_template("fund_config.html", **params) + + +@fund_bp.route("/create", methods=["GET", "POST"]) +@fund_bp.route("/", methods=["GET", "POST"]) +def fund(fund_id=None): + """ + Renders a template to allow a user to add or update a fund, when saved validates the form data and saves to DB + """ + if fund_id: + fund = get_fund_by_id(fund_id) + fund_data = { + "fund_id": fund.fund_id, + "name_en": fund.name_json.get("en", ""), + "name_cy": fund.name_json.get("cy", ""), + "title_en": fund.title_json.get("en", ""), + "title_cy": fund.title_json.get("cy", ""), + "short_name": fund.short_name, + "description_en": fund.description_json.get("en", ""), + "description_cy": fund.description_json.get("cy", ""), + "welsh_available": "true" if fund.welsh_available else "false", + "funding_type": fund.funding_type.value, + "ggis_scheme_reference_number": ( + fund.ggis_scheme_reference_number if fund.ggis_scheme_reference_number else "" + ), + } + form = FundForm(data=fund_data) + else: + form = FundForm() + + if form.validate_on_submit(): + if fund_id: + fund.name_json["en"] = form.name_en.data + fund.name_json["cy"] = form.name_cy.data + fund.title_json["en"] = form.title_en.data + fund.title_json["cy"] = form.title_cy.data + fund.description_json["en"] = form.description_en.data + fund.description_json["cy"] = form.description_cy.data + fund.welsh_available = form.welsh_available.data == "true" + fund.short_name = form.short_name.data + fund.audit_info = {"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "update"} + fund.funding_type = form.funding_type.data + fund.ggis_scheme_reference_number = ( + form.ggis_scheme_reference_number.data if form.ggis_scheme_reference_number.data else "" + ) + update_fund(fund) + flash(f"Updated fund {form.title_en.data}") + return redirect(url_for("fund_bp.view_fund", fund_id=fund.fund_id)) + + new_fund = Fund( + name_json={"en": form.name_en.data}, + title_json={"en": form.title_en.data}, + description_json={"en": form.description_en.data}, + welsh_available=form.welsh_available.data == "true", + short_name=form.short_name.data, + audit_info={"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "create"}, + funding_type=FundingType(form.funding_type.data), + ggis_scheme_reference_number=( + form.ggis_scheme_reference_number.data if form.ggis_scheme_reference_number.data else "" + ), + ) + add_fund(new_fund) + flash(f"Created fund {form.name_en.data}") + return redirect(url_for(INDEX_BP_DASHBOARD)) + + error = error_formatter(form) + return render_template("fund.html", form=form, fund_id=fund_id, error=error) diff --git a/app/blueprints/fund_builder/templates/fund.html b/app/blueprints/fund/templates/fund.html similarity index 100% rename from app/blueprints/fund_builder/templates/fund.html rename to app/blueprints/fund/templates/fund.html diff --git a/app/blueprints/fund_builder/templates/fund_config.html b/app/blueprints/fund/templates/fund_config.html similarity index 98% rename from app/blueprints/fund_builder/templates/fund_config.html rename to app/blueprints/fund/templates/fund_config.html index 4e2f7902..1f012753 100644 --- a/app/blueprints/fund_builder/templates/fund_config.html +++ b/app/blueprints/fund/templates/fund_config.html @@ -90,7 +90,7 @@

    Fund Meta Data

    }) }} {{ govukButton({ "text": "Edit Fund", - "href": url_for("build_fund_bp.fund", fund_id=fund.fund_id), + "href": url_for("fund_bp.fund", fund_id=fund.fund_id), "classes": "govuk-button--secondary" }) }}

    Application Rounds

    @@ -365,15 +365,15 @@

    Application Rounds

    ] }) + govukButton({ "text": "Build Application", - "href": url_for("build_fund_bp.build_application", round_id=round.round_id), + "href": url_for("application_bp.build_application", round_id=round.round_id), "classes": "govuk-button--secondary" }) + govukButton({ "text": "Clone this round", - "href": url_for("build_fund_bp.clone_round", round_id=round.round_id, fund_id=fund.fund_id), + "href": url_for("round_bp.clone_round", round_id=round.round_id, fund_id=fund.fund_id), "classes": "govuk-button--secondary" }) + govukButton({ "text": "Edit Round", - "href": url_for("build_fund_bp.round", round_id=round.round_id), + "href": url_for("round_bp.round", round_id=round.round_id), "classes": "govuk-button--secondary" }) diff --git a/app/blueprints/fund_builder/routes.py b/app/blueprints/fund_builder/routes.py deleted file mode 100644 index 79e82b79..00000000 --- a/app/blueprints/fund_builder/routes.py +++ /dev/null @@ -1,676 +0,0 @@ -import json -import os -import secrets -import shutil -import string -from datetime import datetime -from random import randint - -import requests -from flask import ( - Blueprint, - Response, - after_this_request, - flash, - g, - redirect, - render_template, - request, - send_file, - url_for, -) -from fsd_utils.authentication.decorators import login_requested - -from app.all_questions.metadata_utils import generate_print_data_for_sections -from app.blueprints.fund_builder.forms.fund import FundForm -from app.blueprints.fund_builder.forms.round import RoundForm, get_datetime -from app.blueprints.fund_builder.forms.section import SectionForm -from app.db.models.fund import Fund, FundingType -from app.db.models.round import Round -from app.db.queries.application import ( - clone_single_form, - clone_single_round, - delete_form_from_section, - delete_section_from_round, - get_all_template_forms, - get_form_by_id, - get_section_by_id, - insert_new_section, - move_form_down, - move_form_up, - move_section_down, - move_section_up, - update_section, -) -from app.db.queries.fund import add_fund, get_all_funds, get_fund_by_id, update_fund -from app.db.queries.round import add_round, get_round_by_id, update_round -from app.export_config.generate_all_questions import print_html -from app.export_config.generate_assessment_config import ( - generate_assessment_config_for_round, -) -from app.export_config.generate_form import build_form_json -from app.export_config.generate_fund_round_config import generate_config_for_round -from app.export_config.generate_fund_round_form_jsons import ( - generate_form_jsons_for_round, -) -from app.export_config.generate_fund_round_html import generate_all_round_html -from app.shared.helpers import error_formatter -from config import Config - -BUILD_FUND_BP_DASHBOARD = "build_fund_bp.dashboard" - -# Blueprint for routes used by v1 of FAB - using the DB -build_fund_bp = Blueprint( - "build_fund_bp", - __name__, - url_prefix="/", - template_folder="templates", -) - - -@build_fund_bp.route("/") -@login_requested -def index(): - if not g.is_authenticated: - return redirect(url_for("build_fund_bp.login")) - return redirect(url_for("build_fund_bp.dashboard")) - - -@build_fund_bp.route("/login", methods=["GET"]) -def login(): - return render_template("login.html") - - -@build_fund_bp.route("/dashboard", methods=["GET"]) -def dashboard(): - return render_template("index.html") - - -@build_fund_bp.route("/fund/round//section", methods=["GET", "POST"]) -def section(round_id): - round_obj = get_round_by_id(round_id) - fund_obj = get_fund_by_id(round_obj.fund_id) - form: SectionForm = SectionForm() - form.round_id.data = round_id - params = { - "round_id": str(round_id), - } - existing_section = None - # TODO there must be a better way than a pile of ifs... - if request.args.get("action") == "remove": - delete_section_from_round(round_id=round_id, section_id=request.args.get("section_id"), cascade=True) - return redirect(url_for("build_fund_bp.build_application", round_id=round_id)) - if request.args.get("action") == "move_up": - move_section_up(round_id=round_id, section_index_to_move_up=int(request.args.get("index"))) - return redirect(url_for("build_fund_bp.build_application", round_id=round_id)) - if request.args.get("action") == "move_down": - move_section_down(round_id=round_id, section_index_to_move_down=int(request.args.get("index"))) - return redirect(url_for("build_fund_bp.build_application", round_id=round_id)) - if form.validate_on_submit(): - count_existing_sections = len(round_obj.sections) - if form.section_id.data: - update_section( - form.section_id.data, - { - "name_in_apply_json": {"en": form.name_in_apply_en.data}, - }, - ) - else: - insert_new_section( - { - "round_id": form.round_id.data, - "name_in_apply_json": {"en": form.name_in_apply_en.data}, - "index": max(count_existing_sections + 1, 1), - } - ) - - # flash(f"Saved section {form.name_in_apply_en.data}") - return redirect(url_for("build_fund_bp.build_application", round_id=round_obj.round_id)) - if section_id := request.args.get("section_id"): - existing_section = get_section_by_id(section_id) - form.section_id.data = section_id - form.name_in_apply_en.data = existing_section.name_in_apply_json["en"] - params["forms_in_section"] = existing_section.forms - params["available_template_forms"] = [ - {"text": f"{f.template_name} - {f.name_in_apply_json['en']}", "value": str(f.form_id)} - for f in get_all_template_forms() - ] - - params["breadcrumb_items"] = [ - {"text": "Home", "href": url_for(BUILD_FUND_BP_DASHBOARD)}, - {"text": fund_obj.name_json["en"], "href": url_for("build_fund_bp.view_fund", fund_id=fund_obj.fund_id)}, - { - "text": round_obj.title_json["en"], - "href": url_for("build_fund_bp.build_application", fund_id=fund_obj.fund_id, round_id=round_obj.round_id), - }, - {"text": existing_section.name_in_apply_json["en"] if existing_section else "Add Section", "href": "#"}, - ] - return render_template("section.html", form=form, **params) - - -@build_fund_bp.route("/fund/round//section//forms", methods=["POST", "GET"]) -def configure_forms_in_section(round_id, section_id): - if request.method == "GET": - if request.args.get("action") == "remove": - form_id = request.args.get("form_id") - delete_form_from_section(section_id=section_id, form_id=form_id, cascade=True) - if request.args.get("action") == "move_up": - move_form_up(section_id=section_id, form_index_to_move_up=int(request.args.get("index"))) - if request.args.get("action") == "move_down": - move_form_down(section_id=section_id, form_index_to_move_down=int(request.args.get("index"))) - - if request.method == "POST": - template_id = request.form.get("template_id") - section = get_section_by_id(section_id=section_id) - new_section_index = max(len(section.forms) + 1, 1) - clone_single_form(form_id=template_id, new_section_id=section_id, section_index=new_section_index) - - return redirect(url_for("build_fund_bp.section", round_id=round_id, section_id=section_id)) - - -def all_funds_as_govuk_select_items(all_funds: list) -> list: - """ - Reformats a list of funds into a list of display/value items that can be passed to a govUk select macro - in the html - """ - return [{"text": f"{f.short_name} - {f.name_json['en']}", "value": str(f.fund_id)} for f in all_funds] - - -@build_fund_bp.route("/fund/view", methods=["GET", "POST"]) -def view_fund(): - """ - Renders a template providing a drop down list of funds. If a fund is selected, renders its config info - """ - params = {"all_funds": all_funds_as_govuk_select_items(get_all_funds())} - fund = None - if request.method == "POST": - fund_id = request.form.get("fund_id") - else: - fund_id = request.args.get("fund_id") - if fund_id: - fund = get_fund_by_id(fund_id) - params["fund"] = fund - params["selected_fund_id"] = fund_id - params["breadcrumb_items"] = [ - {"text": "Home", "href": url_for(BUILD_FUND_BP_DASHBOARD)}, - {"text": fund.title_json["en"] if fund else "Manage Application Configuration", "href": "#"}, - ] - - return render_template("fund_config.html", **params) - - -@build_fund_bp.route("/fund/round//application_config") -def build_application(round_id): - """ - Renders a template displaying application configuration info for the chosen round - """ - round = get_round_by_id(round_id) - fund = get_fund_by_id(round.fund_id) - breadcrumb_items = [ - {"text": "Home", "href": url_for(BUILD_FUND_BP_DASHBOARD)}, - {"text": fund.name_json["en"], "href": url_for("build_fund_bp.view_fund", fund_id=fund.fund_id)}, - {"text": round.title_json["en"], "href": "#"}, - ] - return render_template("build_application.html", round=round, fund=fund, breadcrumb_items=breadcrumb_items) - - -@build_fund_bp.route("/fund//round//clone") -def clone_round(round_id, fund_id): - cloned = clone_single_round( - round_id=round_id, - new_fund_id=fund_id, - new_short_name=f"R-C{randint(0, 999)}", # NOSONAR - ) - flash(f"Cloned new round: {cloned.short_name}") - - return redirect(url_for("build_fund_bp.view_fund", fund_id=fund_id)) - - -@build_fund_bp.route("/fund", methods=["GET", "POST"]) -@build_fund_bp.route("/fund/", methods=["GET", "POST"]) -def fund(fund_id=None): - """ - Renders a template to allow a user to add or update a fund, when saved validates the form data and saves to DB - """ - if fund_id: - fund = get_fund_by_id(fund_id) - fund_data = { - "fund_id": fund.fund_id, - "name_en": fund.name_json.get("en", ""), - "name_cy": fund.name_json.get("cy", ""), - "title_en": fund.title_json.get("en", ""), - "title_cy": fund.title_json.get("cy", ""), - "short_name": fund.short_name, - "description_en": fund.description_json.get("en", ""), - "description_cy": fund.description_json.get("cy", ""), - "welsh_available": "true" if fund.welsh_available else "false", - "funding_type": fund.funding_type.value, - "ggis_scheme_reference_number": ( - fund.ggis_scheme_reference_number if fund.ggis_scheme_reference_number else "" - ), - } - form = FundForm(data=fund_data) - else: - form = FundForm() - - if form.validate_on_submit(): - if fund_id: - fund.name_json["en"] = form.name_en.data - fund.name_json["cy"] = form.name_cy.data - fund.title_json["en"] = form.title_en.data - fund.title_json["cy"] = form.title_cy.data - fund.description_json["en"] = form.description_en.data - fund.description_json["cy"] = form.description_cy.data - fund.welsh_available = form.welsh_available.data == "true" - fund.short_name = form.short_name.data - fund.audit_info = {"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "update"} - fund.funding_type = form.funding_type.data - fund.ggis_scheme_reference_number = ( - form.ggis_scheme_reference_number.data if form.ggis_scheme_reference_number.data else "" - ) - update_fund(fund) - flash(f"Updated fund {form.title_en.data}") - return redirect(url_for("build_fund_bp.view_fund", fund_id=fund.fund_id)) - - new_fund = Fund( - name_json={"en": form.name_en.data}, - title_json={"en": form.title_en.data}, - description_json={"en": form.description_en.data}, - welsh_available=form.welsh_available.data == "true", - short_name=form.short_name.data, - audit_info={"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "create"}, - funding_type=FundingType(form.funding_type.data), - ggis_scheme_reference_number=( - form.ggis_scheme_reference_number.data if form.ggis_scheme_reference_number.data else "" - ), - ) - add_fund(new_fund) - flash(f"Created fund {form.name_en.data}") - return redirect(url_for(BUILD_FUND_BP_DASHBOARD)) - - error = error_formatter(form) - return render_template("fund.html", form=form, fund_id=fund_id, error=error) - - -@build_fund_bp.route("/round", methods=["GET", "POST"]) -@build_fund_bp.route("/round/", methods=["GET", "POST"]) -def round(round_id=None): - """ - Renders a template to select a fund and add or update a round to that fund. If saved, validates the round form data - and saves to DB - """ - form = RoundForm() - all_funds = get_all_funds() - params = {"all_funds": all_funds_as_govuk_select_items(all_funds)} - params["selected_fund_id"] = request.form.get("fund_id", None) - params["welsh_availability"] = json.dumps({str(fund.fund_id): fund.welsh_available for fund in all_funds}) - - if round_id: - round = get_round_by_id(round_id) - form = populate_form_with_round_data(round) - - if form.validate_on_submit(): - if round_id: - update_existing_round(round, form) - flash(f"Updated round {round.title_json['en']}") - return redirect(url_for("build_fund_bp.view_fund", fund_id=round.fund_id)) - create_new_round(form) - flash(f"Created round {form.title_en.data}") - return redirect(url_for(BUILD_FUND_BP_DASHBOARD)) - - params["round_id"] = round_id - params["form"] = form - - error = error_formatter(params["form"]) - return render_template("round.html", **params, error=error) - - -def _convert_json_data_for_form(data) -> str: - if isinstance(data, dict): - return json.dumps(data) - return str(data) - - -def _convert_form_data_to_json(data) -> dict: - if data: - return json.loads(data) - return {} - - -def populate_form_with_round_data(round): - """ - Populate a RoundForm with data from a Round object. - - :param Round round: The round object to populate the form with - :return: A RoundForm populated with the round data - """ - round_data = { - "fund_id": round.fund_id, - "round_id": round.round_id, - "title_en": round.title_json.get("en", ""), - "title_cy": round.title_json.get("cy", ""), - "short_name": round.short_name, - "opens": round.opens, - "deadline": round.deadline, - "assessment_start": round.assessment_start, - "reminder_date": round.reminder_date, - "assessment_deadline": round.assessment_deadline, - "prospectus_link": round.prospectus_link, - "privacy_notice_link": round.privacy_notice_link, - "contact_us_banner_en": round.contact_us_banner_json.get("en", "") if round.contact_us_banner_json else "", - "contact_us_banner_cy": round.contact_us_banner_json.get("cy", "") if round.contact_us_banner_json else "", - "reference_contact_page_over_email": "true" if round.reference_contact_page_over_email else "false", - "contact_email": round.contact_email, - "contact_phone": round.contact_phone, - "contact_textphone": round.contact_textphone, - "support_times": round.support_times, - "support_days": round.support_days, - "instructions_en": round.instructions_json.get("en", "") if round.instructions_json else "", - "instructions_cy": round.instructions_json.get("cy", "") if round.instructions_json else "", - "feedback_link": round.feedback_link, - "project_name_field_id": round.project_name_field_id, - "application_guidance_en": ( - round.application_guidance_json.get("en", "") if round.application_guidance_json else "" - ), - "application_guidance_cy": ( - round.application_guidance_json.get("cy", "") if round.application_guidance_json else "" - ), - "guidance_url": round.guidance_url, - "all_uploaded_documents_section_available": ( - "true" if round.all_uploaded_documents_section_available else "false" - ), - "application_fields_download_available": "true" if round.application_fields_download_available else "false", - "display_logo_on_pdf_exports": "true" if round.display_logo_on_pdf_exports else "false", - "mark_as_complete_enabled": "true" if round.mark_as_complete_enabled else "false", - "is_expression_of_interest": "true" if round.is_expression_of_interest else "false", - "has_feedback_survey": ( - "true" - if round.feedback_survey_config and round.feedback_survey_config.get("has_feedback_survey", "") == "true" - else "false" - ), - "has_section_feedback": ( - "true" - if round.feedback_survey_config and round.feedback_survey_config.get("has_section_feedback", "") == "true" - else "false" - ), - "has_research_survey": ( - "true" - if round.feedback_survey_config and round.feedback_survey_config.get("has_research_survey", "") == "true" - else "false" - ), - "is_feedback_survey_optional": ( - "true" - if round.feedback_survey_config - and round.feedback_survey_config.get("is_feedback_survey_optional", "") == "true" - else "false" - ), - "is_section_feedback_optional": ( - "true" - if round.feedback_survey_config - and round.feedback_survey_config.get("is_section_feedback_optional", "") == "true" - else "false" - ), - "is_research_survey_optional": ( - "true" - if round.feedback_survey_config - and round.feedback_survey_config.get("is_research_survey_optional", "") == "true" - else "false" - ), - "eligibility_config": ( - "true" - if round.eligibility_config and round.eligibility_config.get("has_eligibility", "") == "true" - else "false" - ), - "eoi_decision_schema_en": ( - _convert_json_data_for_form(round.eoi_decision_schema.get("en", "")) if round.eoi_decision_schema else "" - ), - "eoi_decision_schema_cy": ( - _convert_json_data_for_form(round.eoi_decision_schema.get("cy", "")) if round.eoi_decision_schema else "" - ), - } - return RoundForm(data=round_data) - - -def update_existing_round(round, form): - """ - Update a Round object with the data from a RoundForm. - - :param Round round: The round object to update - :param RoundForm form: The form with the new round data - """ - round.title_json = {"en": form.title_en.data or None, "cy": form.title_cy.data or None} - round.short_name = form.short_name.data - round.feedback_survey_config = { - "has_feedback_survey": form.has_feedback_survey.data == "true", - "has_section_feedback": form.has_section_feedback.data == "true", - "has_research_survey": form.has_research_survey.data == "true", - "is_feedback_survey_optional": form.is_feedback_survey_optional.data == "true", - "is_section_feedback_optional": form.is_section_feedback_optional.data == "true", - "is_research_survey_optional": form.is_research_survey_optional.data == "true", - } - round.opens = get_datetime(form.opens) - round.deadline = get_datetime(form.deadline) - round.assessment_start = get_datetime(form.assessment_start) - round.reminder_date = get_datetime(form.reminder_date) - round.assessment_deadline = get_datetime(form.assessment_deadline) - round.prospectus_link = form.prospectus_link.data - round.privacy_notice_link = form.privacy_notice_link.data - round.reference_contact_page_over_email = form.reference_contact_page_over_email.data == "true" - round.contact_email = form.contact_email.data - round.contact_phone = form.contact_phone.data - round.contact_textphone = form.contact_textphone.data - round.support_times = form.support_times.data - round.support_days = form.support_days.data - round.feedback_link = form.feedback_link.data - round.project_name_field_id = form.project_name_field_id.data - round.guidance_url = form.guidance_url.data - round.all_uploaded_documents_section_available = form.all_uploaded_documents_section_available.data == "true" - round.application_fields_download_available = form.application_fields_download_available.data == "true" - round.display_logo_on_pdf_exports = form.display_logo_on_pdf_exports.data == "true" - round.mark_as_complete_enabled = form.mark_as_complete_enabled.data == "true" - round.is_expression_of_interest = form.is_expression_of_interest.data == "true" - round.short_name = form.short_name.data - round.contact_us_banner_json = { - "en": form.contact_us_banner_en.data or None, - "cy": form.contact_us_banner_cy.data or None, - } - round.instructions_json = {"en": form.instructions_en.data or None, "cy": form.instructions_cy.data or None} - round.application_guidance_json = { - "en": form.application_guidance_en.data or None, - "cy": form.application_guidance_cy.data or None, - } - round.guidance_url = form.guidance_url.data - round.all_uploaded_documents_section_available = form.all_uploaded_documents_section_available.data == "true" - round.application_fields_download_available = form.application_fields_download_available.data == "true" - round.display_logo_on_pdf_exports = form.display_logo_on_pdf_exports.data == "true" - round.mark_as_complete_enabled = form.mark_as_complete_enabled.data == "true" - round.is_expression_of_interest = form.is_expression_of_interest.data == "true" - round.eligibility_config = {"has_eligibility": form.eligibility_config.data == "true"} - round.eoi_decision_schema = { - "en": _convert_form_data_to_json(form.eoi_decision_schema_en.data), - "cy": _convert_form_data_to_json(form.eoi_decision_schema_cy.data), - } - round.audit_info = {"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "update"} - update_round(round) - - -def create_new_round(form): - """ - Create a new Round object with the data from a RoundForm and save it to the database. - - :param RoundForm form: The form with the new round data - """ - new_round = Round( - fund_id=form.fund_id.data, - audit_info={"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "create"}, - title_json={"en": form.title_en.data or None, "cy": form.title_cy.data or None}, - short_name=form.short_name.data, - opens=get_datetime(form.opens), - deadline=get_datetime(form.deadline), - assessment_start=get_datetime(form.assessment_start), - reminder_date=get_datetime(form.reminder_date), - assessment_deadline=get_datetime(form.assessment_deadline), - prospectus_link=form.prospectus_link.data, - privacy_notice_link=form.privacy_notice_link.data, - contact_us_banner_json={ - "en": form.contact_us_banner_en.data or None, - "cy": form.contact_us_banner_cy.data or None, - }, - reference_contact_page_over_email=form.reference_contact_page_over_email.data == "true", - contact_email=form.contact_email.data, - contact_phone=form.contact_phone.data, - contact_textphone=form.contact_textphone.data, - support_times=form.support_times.data, - support_days=form.support_days.data, - instructions_json={"en": form.instructions_en.data or None, "cy": form.instructions_cy.data or None}, - feedback_link=form.feedback_link.data, - project_name_field_id=form.project_name_field_id.data, - application_guidance_json={ - "en": form.application_guidance_en.data or None, - "cy": form.application_guidance_cy.data or None, - }, - guidance_url=form.guidance_url.data, - all_uploaded_documents_section_available=form.all_uploaded_documents_section_available.data == "true", - application_fields_download_available=form.application_fields_download_available.data == "true", - display_logo_on_pdf_exports=form.display_logo_on_pdf_exports.data == "true", - mark_as_complete_enabled=form.mark_as_complete_enabled.data == "true", - is_expression_of_interest=form.is_expression_of_interest.data == "true", - feedback_survey_config={ - "has_feedback_survey": form.has_feedback_survey.data == "true", - "has_section_feedback": form.has_section_feedback.data == "true", - "has_research_survey": form.has_research_survey.data == "true", - "is_feedback_survey_optional": form.is_feedback_survey_optional.data == "true", - "is_section_feedback_optional": form.is_section_feedback_optional.data == "true", - "is_research_survey_optional": form.is_research_survey_optional.data == "true", - }, - eligibility_config={"has_eligibility": form.eligibility_config.data == "true"}, - eoi_decision_schema={ - "en": _convert_form_data_to_json(form.eoi_decision_schema_en.data), - "cy": _convert_form_data_to_json(form.eoi_decision_schema_cy.data), - }, - ) - add_round(new_round) - - -@build_fund_bp.route("/preview/", methods=["GET"]) -def preview_form(form_id): - """ - Generates the form json for a chosen form, does not persist this, but publishes it to the form runner using the - 'runner_publish_name' of that form. Returns a redirect to that form in the form-runner - """ - form = get_form_by_id(form_id) - form_json = build_form_json(form) - form_id = form.runner_publish_name - - try: - publish_response = requests.post( - url=f"{Config.FORM_RUNNER_URL}/publish", json={"id": form_id, "configuration": form_json} - ) - if not str(publish_response.status_code).startswith("2"): - return "Error during form publish", 500 - except Exception as e: - return f"unable to publish form: {str(e)}", 500 - return redirect(f"{Config.FORM_RUNNER_URL_REDIRECT}/{form_id}") - - -@build_fund_bp.route("/download/", methods=["GET"]) -def download_form_json(form_id): - """ - Generates form json for the selected form and returns it as a file download - """ - form = get_form_by_id(form_id) - form_json = build_form_json(form) - - return Response( - response=json.dumps(form_json), - mimetype="application/json", - headers={"Content-Disposition": f"attachment;filename=form-{randint(0, 999)}.json"}, # nosec B311 - ) - - -@build_fund_bp.route("/fund/round//all_questions", methods=["GET"]) -def view_all_questions(round_id): - """ - Generates the form data for all sections in the selected round, then uses that to generate the 'All Questions' - data for that round and returns that to render in a template. - """ - round = get_round_by_id(round_id) - fund = get_fund_by_id(round.fund_id) - sections_in_round = round.sections - section_data = [] - for section in sections_in_round: - forms = [{"name": form.runner_publish_name, "form_data": build_form_json(form)} for form in section.forms] - section_data.append({"section_title": section.name_in_apply_json["en"], "forms": forms}) - - print_data = generate_print_data_for_sections( - section_data, - lang="en", - ) - html = print_html(print_data) - return render_template( - "view_questions.html", - round=round, - fund=fund, - question_html=html, - title=f"All Questions for {fund.short_name} - {round.short_name}", - ) - - -@build_fund_bp.route("/fund/round//all_questions/", methods=["GET"]) -def view_form_questions(round_id, form_id): - """ - Generates the form data for this form, then uses that to generate the 'All Questions' - data for that form and returns that to render in a template. - """ - round = get_round_by_id(round_id) - fund = get_fund_by_id(round.fund_id) - form = get_form_by_id(form_id=form_id) - section_data = [ - { - "section_title": f"Preview of form [{form.name_in_apply_json['en']}]", - "forms": [{"name": form.runner_publish_name, "form_data": build_form_json(form)}], - } - ] - - print_data = generate_print_data_for_sections( - section_data, - lang="en", - ) - html = print_html(print_data, True) - return render_template( - "view_questions.html", round=round, fund=fund, question_html=html, title=form.name_in_apply_json["en"] - ) - - -def create_export_zip(directory_to_zip, zip_file_name, random_post_fix) -> str: - # Output zip file path (temporary) - output_zip_path = Config.TEMP_FILE_PATH / f"{zip_file_name}-{random_post_fix}" - - # Create a zip archive of the directory - shutil.make_archive(base_name=output_zip_path, format="zip", root_dir=directory_to_zip) - return f"{output_zip_path}.zip" - - -@build_fund_bp.route("/create_export_files/", methods=["GET"]) -def create_export_files(round_id): - round_short_name = get_round_by_id(round_id).short_name - # Construct the path to the output directory relative to this file's location - random_post_fix = "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(5)) - base_output_dir = Config.TEMP_FILE_PATH / f"{round_short_name}-{random_post_fix}" - generate_form_jsons_for_round(round_id, base_output_dir) - generate_all_round_html(round_id, base_output_dir) - fund_config, round_config = generate_config_for_round(round_id, base_output_dir) - generate_assessment_config_for_round(fund_config, round_config, base_output_dir) - output_zip_path = create_export_zip( - directory_to_zip=base_output_dir, zip_file_name=round_short_name, random_post_fix=random_post_fix - ) - - # Ensure the file is removed after sending it - @after_this_request - def remove_file(response): - os.remove(output_zip_path) - shutil.rmtree(base_output_dir) - return response - - # Return the zipped folder for the user to download - return send_file(output_zip_path, as_attachment=True, download_name=f"{round_short_name}.zip") diff --git a/app/blueprints/fund_builder/templates/view_assessment_config.html b/app/blueprints/fund_builder/templates/view_assessment_config.html deleted file mode 100644 index 552e60cb..00000000 --- a/app/blueprints/fund_builder/templates/view_assessment_config.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "base.html" %} -{% set pageHeading %}View Full Assessment Config {% endset %} -{%- from "govuk_frontend_jinja/components/button/macro.html" import govukButton -%} -{%- from "govuk_frontend_jinja/components/accordion/macro.html" import govukAccordion -%} -{%- from "govuk_frontend_jinja/components/summary-list/macro.html" import govukSummaryList -%} -{%- from "govuk_frontend_jinja/components/select/macro.html" import govukSelect -%} -{% block content %} -
    -
    -

    {{ pageHeading }}

    -

    {{ fund.short_name }} - {{ round.short_name }}

    -
    -
    - {% for criteria in round.criteria %} -
    -
    -

    {{ criteria.index }}. {{ criteria.name }} (weighting: {{criteria.weighting}})

    - {% for subcriteria in criteria.subcriteria %} -

    Form {{ subcriteria.criteria_index }}. {{ subcriteria.name }}

    - {% for theme in subcriteria.themes %} -
    {{ theme.subcriteria_index }}. {{ theme.name }} -
      - {%for component in theme.components %} -
    • {{component.theme_index}} - {{component.title}} ({{component.assessment_display_type}})
    • - {% endfor %}
    -
    - {% endfor %} - {% endfor %} -
    -
    - {% endfor %} - -{% endblock content %} diff --git a/app/blueprints/index/routes.py b/app/blueprints/index/routes.py new file mode 100644 index 00000000..ef0aa118 --- /dev/null +++ b/app/blueprints/index/routes.py @@ -0,0 +1,81 @@ +import json +from random import randint + +import requests +from flask import ( + Blueprint, + Response, + g, + redirect, + render_template, + url_for, +) +from fsd_utils.authentication.decorators import login_requested + +from app.db.queries.application import get_form_by_id +from app.export_config.generate_form import build_form_json +from config import Config + +INDEX_BP_DASHBOARD = "index_bp.dashboard" + +# Blueprint for routes used by v1 of FAB - using the DB +index_bp = Blueprint( + "index_bp", + __name__, + url_prefix="/", + template_folder="templates", +) + + +@index_bp.route("/") +@login_requested +def index(): + if not g.is_authenticated: + return redirect(url_for("index_bp.login")) + return redirect(url_for("index_bp.dashboard")) + + +@index_bp.route("/login", methods=["GET"]) +def login(): + return render_template("login.html") + + +@index_bp.route("/dashboard", methods=["GET"]) +def dashboard(): + return render_template("dashboard.html") + + +@index_bp.route("/preview/", methods=["GET"]) +def preview_form(form_id): + """ + Generates the form json for a chosen form, does not persist this, but publishes it to the form runner using the + 'runner_publish_name' of that form. Returns a redirect to that form in the form-runner + """ + form = get_form_by_id(form_id) + form_json = build_form_json(form) + form_id = form.runner_publish_name + + try: + publish_response = requests.post( + url=f"{Config.FORM_RUNNER_URL}/publish", json={"id": form_id, "configuration": form_json} + ) + if not str(publish_response.status_code).startswith("2"): + return "Error during form publish", 500 + except Exception as e: + return f"unable to publish form: {str(e)}", 500 + return redirect(f"{Config.FORM_RUNNER_URL_REDIRECT}/{form_id}") + + +@index_bp.route("/download/", methods=["GET"]) +def download_form_json(form_id): + """ + Generates form json for the selected form and returns it as a file download + """ + form = get_form_by_id(form_id) + form_json = build_form_json(form) + + return Response( + response=json.dumps(form_json), + mimetype="application/json", + headers={"Content-Disposition": f"attachment;filename=form-{randint(0, 999)}.json"}, # nosec B311 + ) diff --git a/app/blueprints/fund_builder/templates/index.html b/app/blueprints/index/templates/dashboard.html similarity index 91% rename from app/blueprints/fund_builder/templates/index.html rename to app/blueprints/index/templates/dashboard.html index d2d248b9..5c85bab0 100644 --- a/app/blueprints/fund_builder/templates/index.html +++ b/app/blueprints/index/templates/dashboard.html @@ -21,13 +21,13 @@

    What do you want to do?

    Fund Configuration (PoC)

    • - Create a Fund + Create a Fund
    • - Create a Round + Create a Round
    • - Manage Application Configuration + Manage Application Configuration
    • Manage Templates diff --git a/app/blueprints/fund_builder/templates/login.html b/app/blueprints/index/templates/login.html similarity index 100% rename from app/blueprints/fund_builder/templates/login.html rename to app/blueprints/index/templates/login.html diff --git a/app/blueprints/fund_builder/forms/round.py b/app/blueprints/round/forms.py similarity index 100% rename from app/blueprints/fund_builder/forms/round.py rename to app/blueprints/round/forms.py diff --git a/app/blueprints/round/routes.py b/app/blueprints/round/routes.py new file mode 100644 index 00000000..add03389 --- /dev/null +++ b/app/blueprints/round/routes.py @@ -0,0 +1,76 @@ +import json +from random import randint + +from flask import ( + Blueprint, + flash, + redirect, + render_template, + request, + url_for, +) + +from app.blueprints.round.forms import RoundForm +from app.blueprints.round.services import ( + create_new_round, + populate_form_with_round_data, + update_existing_round, +) +from app.db.queries.clone import clone_single_round +from app.db.queries.fund import get_all_funds +from app.db.queries.round import get_round_by_id +from app.shared.helpers import all_funds_as_govuk_select_items, error_formatter + +INDEX_BP_DASHBOARD = "index_bp.dashboard" + +round_bp = Blueprint( + "round_bp", + __name__, + url_prefix="/rounds", + template_folder="templates", +) + + +@round_bp.route("/create", methods=["GET", "POST"]) +@round_bp.route("/", methods=["GET", "POST"]) +def round(round_id=None): + """ + Renders a template to select a fund and add or update a round to that fund. If saved, validates the round form data + and saves to DB + """ + form = RoundForm() + all_funds = get_all_funds() + params = {"all_funds": all_funds_as_govuk_select_items(all_funds)} + params["selected_fund_id"] = request.form.get("fund_id", None) + params["welsh_availability"] = json.dumps({str(fund.fund_id): fund.welsh_available for fund in all_funds}) + + if round_id: + existing_round = get_round_by_id(round_id) + form = populate_form_with_round_data(existing_round, RoundForm) + + if form.validate_on_submit(): + if round_id: + update_existing_round(existing_round, form) + flash(f"Updated round {existing_round.title_json['en']}") + return redirect(url_for("fund_bp.view_fund", fund_id=existing_round.fund_id)) + + new_round = create_new_round(form) + flash(f"Created round {new_round.title_json['en']}") + return redirect(url_for(INDEX_BP_DASHBOARD)) + + params["round_id"] = round_id + params["form"] = form + error = error_formatter(params["form"]) + + return render_template("round.html", **params, error=error) + + +@round_bp.route("//clone") +def clone_round(round_id, fund_id): + cloned = clone_single_round( + round_id=round_id, + new_fund_id=fund_id, + new_short_name=f"R-C{randint(0, 999)}", # NOSONAR + ) + flash(f"Cloned new round: {cloned.short_name}") + return redirect(url_for("fund_bp.view_fund", fund_id=fund_id)) diff --git a/app/blueprints/round/services.py b/app/blueprints/round/services.py new file mode 100644 index 00000000..8b87fed9 --- /dev/null +++ b/app/blueprints/round/services.py @@ -0,0 +1,221 @@ +import json +from datetime import datetime + +from app.blueprints.round.forms import get_datetime +from app.db.models.round import Round +from app.db.queries.round import add_round, update_round + + +def convert_json_data_for_form(data) -> str: + if isinstance(data, dict): + return json.dumps(data) + return str(data) + + +def convert_form_data_to_json(data) -> dict: + if data: + return json.loads(data) + return {} + + +def populate_form_with_round_data(round_obj, form_class): + round_data = { + "fund_id": round_obj.fund_id, + "round_id": round_obj.round_id, + "title_en": round_obj.title_json.get("en", ""), + "title_cy": round_obj.title_json.get("cy", ""), + "short_name": round_obj.short_name, + "opens": round_obj.opens, + "deadline": round_obj.deadline, + "assessment_start": round_obj.assessment_start, + "reminder_date": round_obj.reminder_date, + "assessment_deadline": round_obj.assessment_deadline, + "prospectus_link": round_obj.prospectus_link, + "privacy_notice_link": round_obj.privacy_notice_link, + "contact_us_banner_en": round_obj.contact_us_banner_json.get("en", "") + if round_obj.contact_us_banner_json + else "", + "contact_us_banner_cy": round_obj.contact_us_banner_json.get("cy", "") + if round_obj.contact_us_banner_json + else "", + "reference_contact_page_over_email": "true" if round_obj.reference_contact_page_over_email else "false", + "contact_email": round_obj.contact_email, + "contact_phone": round_obj.contact_phone, + "contact_textphone": round_obj.contact_textphone, + "support_times": round_obj.support_times, + "support_days": round_obj.support_days, + "instructions_en": round_obj.instructions_json.get("en", "") if round_obj.instructions_json else "", + "instructions_cy": round_obj.instructions_json.get("cy", "") if round_obj.instructions_json else "", + "feedback_link": round_obj.feedback_link, + "project_name_field_id": round_obj.project_name_field_id, + "application_guidance_en": ( + round_obj.application_guidance_json.get("en", "") if round_obj.application_guidance_json else "" + ), + "application_guidance_cy": ( + round_obj.application_guidance_json.get("cy", "") if round_obj.application_guidance_json else "" + ), + "guidance_url": round_obj.guidance_url, + "all_uploaded_documents_section_available": ( + "true" if round_obj.all_uploaded_documents_section_available else "false" + ), + "application_fields_download_available": ( + "true" if round_obj.application_fields_download_available else "false" + ), + "display_logo_on_pdf_exports": "true" if round_obj.display_logo_on_pdf_exports else "false", + "mark_as_complete_enabled": "true" if round_obj.mark_as_complete_enabled else "false", + "is_expression_of_interest": "true" if round_obj.is_expression_of_interest else "false", + "has_feedback_survey": ( + "true" + if round_obj.feedback_survey_config and round_obj.feedback_survey_config.get("has_feedback_survey") + else "false" + ), + "has_section_feedback": ( + "true" + if round_obj.feedback_survey_config and round_obj.feedback_survey_config.get("has_section_feedback") + else "false" + ), + "has_research_survey": ( + "true" + if round_obj.feedback_survey_config and round_obj.feedback_survey_config.get("has_research_survey") + else "false" + ), + "is_feedback_survey_optional": ( + "true" + if round_obj.feedback_survey_config and round_obj.feedback_survey_config.get("is_feedback_survey_optional") + else "false" + ), + "is_section_feedback_optional": ( + "true" + if round_obj.feedback_survey_config and round_obj.feedback_survey_config.get("is_section_feedback_optional") + else "false" + ), + "is_research_survey_optional": ( + "true" + if round_obj.feedback_survey_config and round_obj.feedback_survey_config.get("is_research_survey_optional") + else "false" + ), + "eligibility_config": ( + "true" + if round_obj.eligibility_config and round_obj.eligibility_config.get("has_eligibility") == "true" + else "false" + ), + "eoi_decision_schema_en": ( + convert_json_data_for_form(round_obj.eoi_decision_schema.get("en", "")) + if round_obj.eoi_decision_schema + else "" + ), + "eoi_decision_schema_cy": ( + convert_json_data_for_form(round_obj.eoi_decision_schema.get("cy", "")) + if round_obj.eoi_decision_schema + else "" + ), + } + return form_class(data=round_data) + + +def update_existing_round(round_obj, form, user="dummy_user"): + round_obj.title_json = {"en": form.title_en.data or None, "cy": form.title_cy.data or None} + round_obj.short_name = form.short_name.data + round_obj.feedback_survey_config = { + "has_feedback_survey": form.has_feedback_survey.data == "true", + "has_section_feedback": form.has_section_feedback.data == "true", + "has_research_survey": form.has_research_survey.data == "true", + "is_feedback_survey_optional": form.is_feedback_survey_optional.data == "true", + "is_section_feedback_optional": form.is_section_feedback_optional.data == "true", + "is_research_survey_optional": form.is_research_survey_optional.data == "true", + } + + # IMPORTANT: convert date sub-form dicts into Python datetime objects + round_obj.opens = get_datetime(form.opens) + round_obj.deadline = get_datetime(form.deadline) + round_obj.assessment_start = get_datetime(form.assessment_start) + round_obj.reminder_date = get_datetime(form.reminder_date) + round_obj.assessment_deadline = get_datetime(form.assessment_deadline) + + round_obj.prospectus_link = form.prospectus_link.data + round_obj.privacy_notice_link = form.privacy_notice_link.data + round_obj.reference_contact_page_over_email = form.reference_contact_page_over_email.data == "true" + round_obj.contact_email = form.contact_email.data + round_obj.contact_phone = form.contact_phone.data + round_obj.contact_textphone = form.contact_textphone.data + round_obj.support_times = form.support_times.data + round_obj.support_days = form.support_days.data + round_obj.feedback_link = form.feedback_link.data + round_obj.project_name_field_id = form.project_name_field_id.data + round_obj.guidance_url = form.guidance_url.data + round_obj.all_uploaded_documents_section_available = form.all_uploaded_documents_section_available.data == "true" + round_obj.application_fields_download_available = form.application_fields_download_available.data == "true" + round_obj.display_logo_on_pdf_exports = form.display_logo_on_pdf_exports.data == "true" + round_obj.mark_as_complete_enabled = form.mark_as_complete_enabled.data == "true" + round_obj.is_expression_of_interest = form.is_expression_of_interest.data == "true" + round_obj.contact_us_banner_json = { + "en": form.contact_us_banner_en.data or None, + "cy": form.contact_us_banner_cy.data or None, + } + round_obj.instructions_json = {"en": form.instructions_en.data or None, "cy": form.instructions_cy.data or None} + round_obj.application_guidance_json = { + "en": form.application_guidance_en.data or None, + "cy": form.application_guidance_cy.data or None, + } + round_obj.eligibility_config = {"has_eligibility": form.eligibility_config.data == "true"} + round_obj.eoi_decision_schema = { + "en": convert_form_data_to_json(form.eoi_decision_schema_en.data), + "cy": convert_form_data_to_json(form.eoi_decision_schema_cy.data), + } + round_obj.audit_info = {"user": user, "timestamp": datetime.now().isoformat(), "action": "update"} + update_round(round_obj) + + +def create_new_round(form, user="dummy_user"): + new_round = Round( + fund_id=form.fund_id.data, + audit_info={"user": user, "timestamp": datetime.now().isoformat(), "action": "create"}, + title_json={"en": form.title_en.data or None, "cy": form.title_cy.data or None}, + short_name=form.short_name.data, + # Again convert each date dict to a datetime object + opens=get_datetime(form.opens), + deadline=get_datetime(form.deadline), + assessment_start=get_datetime(form.assessment_start), + reminder_date=get_datetime(form.reminder_date), + assessment_deadline=get_datetime(form.assessment_deadline), + prospectus_link=form.prospectus_link.data, + privacy_notice_link=form.privacy_notice_link.data, + contact_us_banner_json={ + "en": form.contact_us_banner_en.data or None, + "cy": form.contact_us_banner_cy.data or None, + }, + reference_contact_page_over_email=form.reference_contact_page_over_email.data == "true", + contact_email=form.contact_email.data, + contact_phone=form.contact_phone.data, + contact_textphone=form.contact_textphone.data, + support_times=form.support_times.data, + support_days=form.support_days.data, + instructions_json={"en": form.instructions_en.data or None, "cy": form.instructions_cy.data or None}, + feedback_link=form.feedback_link.data, + project_name_field_id=form.project_name_field_id.data, + application_guidance_json={ + "en": form.application_guidance_en.data or None, + "cy": form.application_guidance_cy.data or None, + }, + guidance_url=form.guidance_url.data, + all_uploaded_documents_section_available=form.all_uploaded_documents_section_available.data == "true", + application_fields_download_available=form.application_fields_download_available.data == "true", + display_logo_on_pdf_exports=form.display_logo_on_pdf_exports.data == "true", + mark_as_complete_enabled=form.mark_as_complete_enabled.data == "true", + is_expression_of_interest=form.is_expression_of_interest.data == "true", + feedback_survey_config={ + "has_feedback_survey": form.has_feedback_survey.data == "true", + "has_section_feedback": form.has_section_feedback.data == "true", + "has_research_survey": form.has_research_survey.data == "true", + "is_feedback_survey_optional": form.is_feedback_survey_optional.data == "true", + "is_section_feedback_optional": form.is_section_feedback_optional.data == "true", + "is_research_survey_optional": form.is_research_survey_optional.data == "true", + }, + eligibility_config={"has_eligibility": form.eligibility_config.data == "true"}, + eoi_decision_schema={ + "en": convert_form_data_to_json(form.eoi_decision_schema_en.data), + "cy": convert_form_data_to_json(form.eoi_decision_schema_cy.data), + }, + ) + add_round(new_round) + return new_round diff --git a/app/blueprints/fund_builder/templates/round.html b/app/blueprints/round/templates/round.html similarity index 100% rename from app/blueprints/fund_builder/templates/round.html rename to app/blueprints/round/templates/round.html diff --git a/app/blueprints/fund_builder/forms/templates.py b/app/blueprints/template/forms.py similarity index 100% rename from app/blueprints/fund_builder/forms/templates.py rename to app/blueprints/template/forms.py diff --git a/app/blueprints/template/routes.py b/app/blueprints/template/routes.py new file mode 100644 index 00000000..c0584d21 --- /dev/null +++ b/app/blueprints/template/routes.py @@ -0,0 +1,104 @@ +import json + +from flask import Blueprint, redirect, render_template, url_for +from werkzeug.utils import secure_filename + +from app.blueprints.template.forms import TemplateFormForm, TemplateUploadForm +from app.blueprints.template.services import build_rows, json_import +from app.db.queries.application import ( + delete_form, + get_all_template_forms, + get_all_template_sections, + get_form_by_id, + get_form_by_template_name, + update_form, +) +from app.shared.helpers import error_formatter + +template_bp = Blueprint( + "template_bp", + __name__, + url_prefix="/templates", + template_folder="templates", +) + + +@template_bp.route("", methods=["GET", "POST"]) +def view_templates(): + sections = get_all_template_sections() + forms = get_all_template_forms() + form = TemplateUploadForm() + params = { + "sections": sections, + "forms": forms, + "form_template_rows": build_rows(forms), + "uploadform": form, + "breadcrumb_items": [ + {"text": "Home", "href": url_for("index_bp.dashboard")}, + {"text": "Manage Templates", "href": "#"}, + ], + } + if form.validate_on_submit(): + template_name = form.template_name.data + file = form.file.data + if get_form_by_template_name(template_name): + form.error = "Template name already exists" + return render_template("view_templates.html", **params) + + if file: + try: + filename = secure_filename(file.filename) + file_data = file.read().decode("utf-8") + form_data = json.loads(file_data) + json_import(data=form_data, template_name=template_name, filename=filename) + except Exception as e: + print(e) + form.error = "Invalid file: Please upload valid JSON file" + return render_template("view_templates.html", **params) + + return redirect(url_for("template_bp.view_templates")) + + error = None + if "uploadform" in params: + error = error_formatter(params["uploadform"]) + return render_template("view_templates.html", **params, error=error) + + +@template_bp.route("/", methods=["GET", "POST"]) +def edit_template(form_id): + template_form = TemplateFormForm() + if template_form.validate_on_submit(): + update_form( + form_id=form_id, + new_form_config={ + "runner_publish_name": template_form.url_path.data, + "name_in_apply_json": {"en": template_form.tasklist_name.data}, + "template_name": template_form.template_name.data, + }, + ) + return redirect(url_for("template_bp.view_templates")) + existing_form = get_form_by_id(form_id=form_id) + template_form.form_id.data = form_id + template_form.template_name.data = existing_form.template_name + template_form.tasklist_name.data = existing_form.name_in_apply_json["en"] + template_form.url_path.data = existing_form.runner_publish_name + params = { + "breadcrumb_items": [ + {"text": "Home", "href": url_for("index_bp.dashboard")}, + {"text": "Manage Templates", "href": url_for("template_bp.view_templates")}, + {"text": "Edit Template", "href": "#"}, + ], + "template_form": template_form, + } + error = error_formatter(template_form) + return render_template( + "edit_form_template.html", + **params, + error=error, + ) + + +@template_bp.route("//delete", methods=["GET"]) +def delete_template(form_id): + delete_form(form_id=form_id, cascade=True) + return redirect(url_for("template_bp.view_templates")) diff --git a/app/blueprints/template/services.py b/app/blueprints/template/services.py new file mode 100644 index 00000000..a9e6f8d6 --- /dev/null +++ b/app/blueprints/template/services.py @@ -0,0 +1,30 @@ +from flask import url_for + +from app.db.models.application_config import Form + + +def json_import(data, template_name, filename): + from app.import_config.load_form_json import load_json_from_file + + load_json_from_file(data, template_name, filename) + + +def build_rows(forms: list[Form]) -> list[dict]: + rows = [] + for form in forms: + row = [ + { + "html": "{form.template_name}" + }, + {"text": form.name_in_apply_json["en"]}, + {"text": form.runner_publish_name}, + { + "html": "Edit  " + "Delete" + }, + ] + rows.append(row) + return rows diff --git a/app/blueprints/templates/templates/edit_form_template.html b/app/blueprints/template/templates/edit_form_template.html similarity index 98% rename from app/blueprints/templates/templates/edit_form_template.html rename to app/blueprints/template/templates/edit_form_template.html index aa287370..09557704 100644 --- a/app/blueprints/templates/templates/edit_form_template.html +++ b/app/blueprints/template/templates/edit_form_template.html @@ -6,7 +6,7 @@ {% extends "base.html" %} {% set pageHeading %} -Rename Template +Edit Template {% endset %} {% block content %}
      diff --git a/app/blueprints/templates/templates/view_templates.html b/app/blueprints/template/templates/view_templates.html similarity index 100% rename from app/blueprints/templates/templates/view_templates.html rename to app/blueprints/template/templates/view_templates.html diff --git a/app/blueprints/templates/routes.py b/app/blueprints/templates/routes.py deleted file mode 100644 index 4cd83887..00000000 --- a/app/blueprints/templates/routes.py +++ /dev/null @@ -1,135 +0,0 @@ -import json - -from flask import Blueprint, redirect, render_template, request, url_for -from werkzeug.utils import secure_filename - -from app.blueprints.fund_builder.forms.templates import TemplateFormForm, TemplateUploadForm -from app.db.models.application_config import Form -from app.db.queries.application import ( - delete_form, - get_all_template_forms, - get_all_template_sections, - get_form_by_id, - get_form_by_template_name, - update_form, -) -from app.shared.helpers import error_formatter - -# Blueprint for routes used by FAB PoC to manage templates -template_bp = Blueprint( - "template_bp", - __name__, - url_prefix="/templates", - template_folder="templates", -) - - -def json_import(data, template_name, filename): - from app.import_config.load_form_json import load_json_from_file - - load_json_from_file(data=data, template_name=template_name, filename=filename) - - -def _build_rows(forms: list[Form]) -> list[dict]: - rows = [] - for form in forms: - row = [ - { - "html": "{form.template_name}" - }, - {"text": form.name_in_apply_json["en"]}, - {"text": form.runner_publish_name}, - { - "html": "Delete  " - "Rename" - }, - ] - rows.append(row) - return rows - - -@template_bp.route("/all", methods=["GET", "POST"]) -def view_templates(): - sections = get_all_template_sections() - forms = get_all_template_forms() - form = TemplateUploadForm() - params = { - "sections": sections, - "forms": forms, - "form_template_rows": _build_rows(forms), - "uploadform": form, - "breadcrumb_items": [ - {"text": "Home", "href": url_for("build_fund_bp.dashboard")}, - {"text": "Manage Templates", "href": "#"}, - ], - } - if form.validate_on_submit(): - template_name = form.template_name.data - file = form.file.data - if get_form_by_template_name(template_name): - form.error = "Template name already exists" - return render_template("view_templates.html", **params) - - if file: - try: - filename = secure_filename(file.filename) - file_data = file.read().decode("utf-8") - form_data = json.loads(file_data) - json_import(data=form_data, template_name=template_name, filename=filename) - except Exception as e: - print(e) - form.error = "Invalid file: Please upload valid JSON file" - return render_template("view_templates.html", **params) - - return redirect(url_for("template_bp.view_templates")) - - error = None - if "uploadform" in params: - error = error_formatter(params["uploadform"]) - return render_template("view_templates.html", **params, error=error) - - -@template_bp.route("/forms/", methods=["GET", "POST"]) -def edit_form_template(form_id): - template_form = TemplateFormForm() - params = { - "breadcrumb_items": [ - {"text": "Home", "href": url_for("build_fund_bp.dashboard")}, - {"text": "Manage Templates", "href": url_for("template_bp.view_templates")}, - {"text": "Rename Template", "href": "#"}, - ], - } - - if request.method == "POST": - if template_form.validate_on_submit(): - update_form( - form_id=form_id, - new_form_config={ - "runner_publish_name": template_form.url_path.data, - "name_in_apply_json": {"en": template_form.tasklist_name.data}, - "template_name": template_form.template_name.data, - }, - ) - return redirect(url_for("template_bp.view_templates")) - params["template_form"] = template_form - error = None - if "template_form" in params: - error = error_formatter(params["template_form"]) - return render_template("edit_form_template.html", **params, error=error) - - if request.args.get("action") == "remove": - delete_form(form_id=form_id, cascade=True) - if request.args.get("action") == "edit": - existing_form = get_form_by_id(form_id=form_id) - template_form = TemplateFormForm() - template_form.form_id.data = form_id - template_form.template_name.data = existing_form.template_name - template_form.tasklist_name.data = existing_form.name_in_apply_json["en"] - template_form.url_path.data = existing_form.runner_publish_name - params["template_form"] = template_form - return render_template("edit_form_template.html", **params) - - return redirect(url_for("template_bp.view_templates")) diff --git a/app/create_app.py b/app/create_app.py index 7ead37c7..c915cff2 100644 --- a/app/create_app.py +++ b/app/create_app.py @@ -6,13 +6,16 @@ from fsd_utils.logging import logging from jinja2 import ChoiceLoader, PackageLoader, PrefixLoader -from app.blueprints.fund_builder.routes import build_fund_bp -from app.blueprints.templates.routes import template_bp +from app.blueprints.application.routes import application_bp +from app.blueprints.fund.routes import fund_bp +from app.blueprints.index.routes import index_bp +from app.blueprints.round.routes import round_bp +from app.blueprints.template.routes import template_bp PUBLIC_ROUTES = [ "static", - "build_fund_bp.index", - "build_fund_bp.login", + "index_bp.index", + "index_bp.login", ] @@ -29,7 +32,10 @@ def protect_private_routes(flask_app: Flask) -> Flask: def create_app() -> Flask: init_sentry() flask_app = Flask("__name__", static_url_path="/assets") - flask_app.register_blueprint(build_fund_bp) + flask_app.register_blueprint(index_bp) + flask_app.register_blueprint(fund_bp) + flask_app.register_blueprint(round_bp) + flask_app.register_blueprint(application_bp) flask_app.register_blueprint(template_bp) protect_private_routes(flask_app) diff --git a/app/db/queries/application.py b/app/db/queries/application.py index d59f9e75..f3662f56 100644 --- a/app/db/queries/application.py +++ b/app/db/queries/application.py @@ -4,7 +4,6 @@ from app.db import db from app.db.models import Component, Form, FormSection, Lizt, Page, Section -from app.db.models.round import Round from app.db.queries.round import get_round_by_id @@ -48,184 +47,6 @@ def get_list_by_id(list_id: str) -> Lizt: return lizt -def _initiate_cloned_component(to_clone: Component, new_page_id=None, new_theme_id=None): - clone = Component(**to_clone.as_dict()) - - clone.component_id = uuid4() - clone.page_id = new_page_id - clone.theme_id = new_theme_id - clone.is_template = False - clone.source_template_id = to_clone.component_id - clone.template_name = None - return clone - - -def _initiate_cloned_page(to_clone: Page, new_form_id=None): - clone = Page(**to_clone.as_dict()) - clone.page_id = uuid4() - clone.form_id = new_form_id - clone.is_template = False - clone.source_template_id = to_clone.page_id - clone.template_name = None - clone.components = [] - return clone - - -def _initiate_cloned_form(to_clone: Form, new_section_id: str, section_index=0) -> Form: - clone = Form(**to_clone.as_dict()) - clone.form_id = uuid4() - clone.section_id = new_section_id - clone.is_template = False - clone.source_template_id = to_clone.form_id - clone.template_name = None - clone.pages = [] - clone.section_index = section_index - return clone - - -def _initiate_cloned_section(to_clone: Section, new_round_id: str) -> Form: - clone = Section(**to_clone.as_dict()) - clone.round_id = new_round_id - clone.section_id = uuid4() - clone.is_template = False - clone.source_template_id = to_clone.section_id - clone.template_name = None - clone.pages = [] - return clone - - -def clone_single_section(section_id: str, new_round_id=None) -> Section: - section_to_clone: Section = db.session.query(Section).where(Section.section_id == section_id).one_or_none() - clone = _initiate_cloned_section(section_to_clone, new_round_id) - - cloned_forms = [] - cloned_pages = [] - cloned_components = [] - # loop through forms in this section and clone each one - for form_to_clone in section_to_clone.forms: - cloned_form = _initiate_cloned_form(form_to_clone, clone.section_id, section_index=form_to_clone.section_index) - # loop through pages in this section and clone each one - for page_to_clone in form_to_clone.pages: - cloned_page = _initiate_cloned_page(page_to_clone, new_form_id=cloned_form.form_id) - cloned_pages.append(cloned_page) - # clone the components on this page - cloned_components.extend( - _initiate_cloned_components_for_page(page_to_clone.components, cloned_page.page_id) - ) - - cloned_forms.append(cloned_form) - - db.session.add_all([clone, *cloned_forms, *cloned_pages, *cloned_components]) - cloned_pages = _fix_cloned_default_pages(cloned_pages) - db.session.commit() - - return clone - - -def _fix_cloned_default_pages(cloned_pages: list[Page]): - # Go through each page - # Get the page ID of the default next page (this will be a template page) - # Find the cloned page that was created from that template - # Get that cloned page's ID - # Update this default_next_page to point to the cloned page - - for clone in cloned_pages: - if clone.default_next_page_id: - template_id = clone.default_next_page_id - concrete_next_page = next(p for p in cloned_pages if p.source_template_id == template_id) - clone.default_next_page_id = concrete_next_page.page_id - - return cloned_pages - - -def clone_single_form(form_id: str, new_section_id=None, section_index=0) -> Form: - form_to_clone: Form = db.session.query(Form).where(Form.form_id == form_id).one_or_none() - clone = _initiate_cloned_form(form_to_clone, new_section_id, section_index=section_index) - - cloned_pages = [] - cloned_components = [] - for page_to_clone in form_to_clone.pages: - cloned_page = _initiate_cloned_page(page_to_clone, new_form_id=clone.form_id) - cloned_pages.append(cloned_page) - cloned_components.extend(_initiate_cloned_components_for_page(page_to_clone.components, cloned_page.page_id)) - db.session.add_all([clone, *cloned_pages, *cloned_components]) - cloned_pages = _fix_cloned_default_pages(cloned_pages) - db.session.commit() - - return clone - - -def _initiate_cloned_components_for_page( - components_to_clone: list[Component], new_page_id: str = None, new_theme_id: str = None -): - cloned_components = [] - for component_to_clone in components_to_clone: - cloned_component = _initiate_cloned_component( - component_to_clone, new_page_id=new_page_id, new_theme_id=None - ) # TODO how should themes work when cloning? - cloned_components.append(cloned_component) - return cloned_components - - -def clone_single_page(page_id: str, new_form_id=None) -> Page: - page_to_clone: Page = db.session.query(Page).where(Page.page_id == page_id).one_or_none() - clone = _initiate_cloned_page(page_to_clone, new_form_id) - - cloned_components = _initiate_cloned_components_for_page(page_to_clone.components, new_page_id=clone.page_id) - db.session.add_all([clone, *cloned_components]) - db.session.commit() - - return clone - - -def clone_single_component(component_id: str, new_page_id=None, new_theme_id=None) -> Component: - component_to_clone: Component = ( - db.session.query(Component).where(Component.component_id == component_id).one_or_none() - ) - clone = _initiate_cloned_component(component_to_clone, new_page_id, new_theme_id) - - db.session.add(clone) - db.session.commit() - - return clone - - -# TODO do we need this? -def clone_multiple_components(component_ids: list[str], new_page_id=None, new_theme_id=None) -> list[Component]: - components_to_clone: list[Component] = ( - db.session.query(Component).filter(Component.component_id.in_(component_ids)).all() - ) - clones = [ - _initiate_cloned_component(to_clone=to_clone, new_page_id=new_page_id, new_theme_id=new_theme_id) - for to_clone in components_to_clone - ] - db.session.add_all(clones) - db.session.commit() - - return clones - - -def clone_single_round(round_id, new_fund_id, new_short_name) -> Round: - round_to_clone = db.session.query(Round).where(Round.round_id == round_id).one_or_none() - cloned_round = Round(**round_to_clone.as_dict()) - cloned_round.fund_id = new_fund_id - cloned_round.short_name = new_short_name - cloned_round.round_id = uuid4() - cloned_round.is_template = False - cloned_round.source_template_id = round_to_clone.round_id - cloned_round.template_name = None - cloned_round.sections = [] - cloned_round.section_base_path = None - - db.session.add(cloned_round) - db.session.commit() - - for section in round_to_clone.sections: - clone_single_section(section.section_id, cloned_round.round_id) - - return cloned_round - - # CRUD operations for Section, Form, Page, and Component # CRUD SECTION def insert_new_section(new_section_config): @@ -626,7 +447,7 @@ def swap_elements_in_list(containing_list: list, index_a: int, index_b: int) -> return containing_list -def move_section_down(round_id, section_index_to_move_down: int): +def move_section_down(round_id, section_id): """Moves a section one place down in the ordered list of sections in a round. In this case down means visually down, so the index number will increase by 1. @@ -644,44 +465,32 @@ def move_section_down(round_id, section_index_to_move_down: int): Args: round_id (UUID): Round ID to move this section within - section_index_to_move_down (int): Current Section.index value of the section to move + section_id (UUID): ID of the section to move down """ - round: Round = get_round_by_id(round_id) - list_index_to_move_down = section_index_to_move_down - 1 # Need the 0-based index inside the list - round.sections = swap_elements_in_list(round.sections, list_index_to_move_down, list_index_to_move_down + 1) + round = get_round_by_id(round_id) + section = get_section_by_id(section_id) + list_index = section.index - 1 # Convert from 1-based to 0-based index + round.sections = swap_elements_in_list(round.sections, list_index, list_index + 1) db.session.commit() -def move_section_up(round_id, section_index_to_move_up: int): +def move_section_up(round_id, section_id): """Moves a section one place up in the ordered list of sections in a round. In this case up means visually up, so the index number will decrease by 1. - - Element | Section.index | Index in list - A | 1 | 0 - B | 2 | 1 - C | 3 | 2 - - Then move B up, which results in A moving down - - Element | Section.index | Index in list - B | 1 | 0 - A | 2 | 1 - C | 3 | 2 - Args: round_id (UUID): Round ID to move this section within - section_index_to_move_up (int): Current Section.index value of the section to move + section_id (UUID): ID of the section to move up """ - round: Round = get_round_by_id(round_id) - list_index_to_move_up = section_index_to_move_up - 1 # Need the 0-based index inside the list - round.sections = swap_elements_in_list(round.sections, list_index_to_move_up, list_index_to_move_up - 1) - + round = get_round_by_id(round_id) + section = get_section_by_id(section_id) + list_index = section.index - 1 # Convert from 1-based to 0-based index + round.sections = swap_elements_in_list(round.sections, list_index, list_index - 1) db.session.commit() -def move_form_down(section_id, form_index_to_move_down: int): +def move_form_down(section_id, form_id): """Moves a form one place down in the ordered list of forms in a section. In this case down means visually down, so the index number will increase by 1. @@ -699,16 +508,16 @@ def move_form_down(section_id, form_index_to_move_down: int): Args: section_id (UUID): Section ID to move this form within - form_index_to_move_down (int): Current Form.section_index value of the form to move + form_id (UUID): ID of the form to move down """ - section: Section = get_section_by_id(section_id) - list_index_to_move_down = form_index_to_move_down - 1 # Need the 0-based index inside the list - - section.forms = swap_elements_in_list(section.forms, list_index_to_move_down, list_index_to_move_down + 1) + section = get_section_by_id(section_id) + form = get_form_by_id(form_id) + list_index = form.section_index - 1 # Convert from 1-based to 0-based index + section.forms = swap_elements_in_list(section.forms, list_index, list_index + 1) db.session.commit() -def move_form_up(section_id, form_index_to_move_up: int): +def move_form_up(section_id, form_id): """Moves a form one place up in the ordered list of forms in a section. In this case up means visually up, so the index number will decrease by 1. @@ -727,13 +536,13 @@ def move_form_up(section_id, form_index_to_move_up: int): Args: section_id (UUID): Section ID to move this form within - form_index_to_move_up (int): Current Form.section_index value of the form to up + form_id (UUID): ID of the form to move up """ - section: Section = get_section_by_id(section_id) - list_index_to_move_up = form_index_to_move_up - 1 # Need the 0-based index inside the list - - section.forms = swap_elements_in_list(section.forms, list_index_to_move_up, list_index_to_move_up - 1) + section = get_section_by_id(section_id) + form = get_form_by_id(form_id) + list_index = form.section_index - 1 # Convert from 1-based to 0-based index + section.forms = swap_elements_in_list(section.forms, list_index, list_index - 1) db.session.commit() diff --git a/app/db/queries/clone.py b/app/db/queries/clone.py new file mode 100644 index 00000000..2ec009c7 --- /dev/null +++ b/app/db/queries/clone.py @@ -0,0 +1,182 @@ +from uuid import uuid4 + +from app.db import db +from app.db.models import Component, Form, Page, Round, Section + + +def _initiate_cloned_component(to_clone: Component, new_page_id=None, new_theme_id=None): + clone = Component(**to_clone.as_dict()) + + clone.component_id = uuid4() + clone.page_id = new_page_id + clone.theme_id = new_theme_id + clone.is_template = False + clone.source_template_id = to_clone.component_id + clone.template_name = None + return clone + + +def _initiate_cloned_page(to_clone: Page, new_form_id=None): + clone = Page(**to_clone.as_dict()) + clone.page_id = uuid4() + clone.form_id = new_form_id + clone.is_template = False + clone.source_template_id = to_clone.page_id + clone.template_name = None + clone.components = [] + return clone + + +def _initiate_cloned_form(to_clone: Form, new_section_id: str, section_index=0) -> Form: + clone = Form(**to_clone.as_dict()) + clone.form_id = uuid4() + clone.section_id = new_section_id + clone.is_template = False + clone.source_template_id = to_clone.form_id + clone.template_name = None + clone.pages = [] + clone.section_index = section_index + return clone + + +def _initiate_cloned_section(to_clone: Section, new_round_id: str) -> Form: + clone = Section(**to_clone.as_dict()) + clone.round_id = new_round_id + clone.section_id = uuid4() + clone.is_template = False + clone.source_template_id = to_clone.section_id + clone.template_name = None + clone.pages = [] + return clone + + +def clone_single_section(section_id: str, new_round_id=None) -> Section: + section_to_clone: Section = db.session.query(Section).where(Section.section_id == section_id).one_or_none() + clone = _initiate_cloned_section(section_to_clone, new_round_id) + + cloned_forms = [] + cloned_pages = [] + cloned_components = [] + # loop through forms in this section and clone each one + for form_to_clone in section_to_clone.forms: + cloned_form = _initiate_cloned_form(form_to_clone, clone.section_id, section_index=form_to_clone.section_index) + # loop through pages in this section and clone each one + for page_to_clone in form_to_clone.pages: + cloned_page = _initiate_cloned_page(page_to_clone, new_form_id=cloned_form.form_id) + cloned_pages.append(cloned_page) + # clone the components on this page + cloned_components.extend( + _initiate_cloned_components_for_page(page_to_clone.components, cloned_page.page_id) + ) + + cloned_forms.append(cloned_form) + + db.session.add_all([clone, *cloned_forms, *cloned_pages, *cloned_components]) + cloned_pages = _fix_cloned_default_pages(cloned_pages) + db.session.commit() + + return clone + + +def _fix_cloned_default_pages(cloned_pages: list[Page]): + # Go through each page + # Get the page ID of the default next page (this will be a template page) + # Find the cloned page that was created from that template + # Get that cloned page's ID + # Update this default_next_page to point to the cloned page + + for clone in cloned_pages: + if clone.default_next_page_id: + template_id = clone.default_next_page_id + concrete_next_page = next(p for p in cloned_pages if p.source_template_id == template_id) + clone.default_next_page_id = concrete_next_page.page_id + + return cloned_pages + + +def clone_single_form(form_id: str, new_section_id=None, section_index=0) -> Form: + form_to_clone: Form = db.session.query(Form).where(Form.form_id == form_id).one_or_none() + clone = _initiate_cloned_form(form_to_clone, new_section_id, section_index=section_index) + + cloned_pages = [] + cloned_components = [] + for page_to_clone in form_to_clone.pages: + cloned_page = _initiate_cloned_page(page_to_clone, new_form_id=clone.form_id) + cloned_pages.append(cloned_page) + cloned_components.extend(_initiate_cloned_components_for_page(page_to_clone.components, cloned_page.page_id)) + db.session.add_all([clone, *cloned_pages, *cloned_components]) + cloned_pages = _fix_cloned_default_pages(cloned_pages) + db.session.commit() + + return clone + + +def _initiate_cloned_components_for_page( + components_to_clone: list[Component], new_page_id: str = None, new_theme_id: str = None +): + cloned_components = [] + for component_to_clone in components_to_clone: + cloned_component = _initiate_cloned_component( + component_to_clone, new_page_id=new_page_id, new_theme_id=None + ) # TODO how should themes work when cloning? + cloned_components.append(cloned_component) + return cloned_components + + +def clone_single_page(page_id: str, new_form_id=None) -> Page: + page_to_clone: Page = db.session.query(Page).where(Page.page_id == page_id).one_or_none() + clone = _initiate_cloned_page(page_to_clone, new_form_id) + + cloned_components = _initiate_cloned_components_for_page(page_to_clone.components, new_page_id=clone.page_id) + db.session.add_all([clone, *cloned_components]) + db.session.commit() + + return clone + + +def clone_single_component(component_id: str, new_page_id=None, new_theme_id=None) -> Component: + component_to_clone: Component = ( + db.session.query(Component).where(Component.component_id == component_id).one_or_none() + ) + clone = _initiate_cloned_component(component_to_clone, new_page_id, new_theme_id) + + db.session.add(clone) + db.session.commit() + + return clone + + +# TODO do we need this? +def clone_multiple_components(component_ids: list[str], new_page_id=None, new_theme_id=None) -> list[Component]: + components_to_clone: list[Component] = ( + db.session.query(Component).filter(Component.component_id.in_(component_ids)).all() + ) + clones = [ + _initiate_cloned_component(to_clone=to_clone, new_page_id=new_page_id, new_theme_id=new_theme_id) + for to_clone in components_to_clone + ] + db.session.add_all(clones) + db.session.commit() + + return clones + + +def clone_single_round(round_id, new_fund_id, new_short_name) -> Round: + round_to_clone = db.session.query(Round).where(Round.round_id == round_id).one_or_none() + cloned_round = Round(**round_to_clone.as_dict()) + cloned_round.fund_id = new_fund_id + cloned_round.short_name = new_short_name + cloned_round.round_id = uuid4() + cloned_round.is_template = False + cloned_round.source_template_id = round_to_clone.round_id + cloned_round.template_name = None + cloned_round.sections = [] + cloned_round.section_base_path = None + + db.session.add(cloned_round) + db.session.commit() + + for section in round_to_clone.sections: + clone_single_section(section.section_id, cloned_round.round_id) + + return cloned_round diff --git a/app/shared/helpers.py b/app/shared/helpers.py index 36ab32ce..bc3f8595 100644 --- a/app/shared/helpers.py +++ b/app/shared/helpers.py @@ -63,3 +63,11 @@ def no_spaces_between_letters(form, field): return if re.search(r"\b\w+\s+\w+\b", field.data): # Matches sequences with spaces in between raise ValidationError("Spaces between letters are not allowed.") + + +def all_funds_as_govuk_select_items(all_funds: list) -> list: + """ + Reformats a list of funds into a list of display/value items that can be passed to a govUk select macro + in the html + """ + return [{"text": f"{f.short_name} - {f.name_json['en']}", "value": str(f.fund_id)} for f in all_funds] diff --git a/app/templates/base.html b/app/templates/base.html index fa9b5247..6e03ada6 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -12,7 +12,7 @@ {{ govukHeader({ 'useTudorCrown': 'yes', 'serviceName': 'Fund Application Builder', - 'serviceUrl': url_for('build_fund_bp.index'), + 'serviceUrl': url_for('index_bp.index'), }) }} {% endblock header %} diff --git a/tests/blueprints/application/__init__.py b/tests/blueprints/application/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/blueprints/fund/__init__.py b/tests/blueprints/fund/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/blueprints/fund/test_routes.py b/tests/blueprints/fund/test_routes.py new file mode 100644 index 00000000..ba10d1a8 --- /dev/null +++ b/tests/blueprints/fund/test_routes.py @@ -0,0 +1,110 @@ +import pytest + +from app.db.models import Fund +from app.db.models.fund import FundingType +from app.db.queries.fund import get_fund_by_id +from tests.helpers import submit_form + + +@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") +def test_create_fund(flask_test_client): + """ + Tests that a fund can be successfully created using the /funds/create route + Verifies that the created fund has the correct attributes + """ + create_data = { + "name_en": "New Fund", + "title_en": "New Fund Title", + "description_en": "New Fund Description", + "welsh_available": "false", + "short_name": "NF5432", + "funding_type": FundingType.COMPETITIVE.value, + "ggis_scheme_reference_number": "G1-SCH-0000092415", + } + + response = submit_form(flask_test_client, "/funds/create", create_data) + assert response.status_code == 200 + created_fund = Fund.query.filter_by(short_name="NF5432").first() + assert created_fund is not None + for key, value in create_data.items(): + if key == "csrf_token": + continue + if key.endswith("_en"): + assert created_fund.__getattribute__(key[:-3] + "_json")["en"] == value + elif key == "welsh_available": + assert created_fund.welsh_available is False + elif key == "funding_type": + assert created_fund.funding_type.value == value + elif key == "ggis_scheme_reference_number": + assert created_fund.ggis_scheme_reference_number == value + else: + assert created_fund.__getattribute__(key) == value + + +@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") +def test_create_fund_with_existing_short_name(flask_test_client): + """ + Tests that a fund can be successfully created using the /funds/create route + Verifies that the created fund has the correct attributes + """ + create_data = { + "name_en": "New Fund 2", + "title_en": "New Fund Title 2", + "description_en": "New Fund Description 2", + "welsh_available": "false", + "short_name": "SMP1", + "funding_type": FundingType.COMPETITIVE.value, + "ggis_scheme_reference_number": "G1-SCH-0000092415", + } + response = submit_form(flask_test_client, "/funds/create", create_data) + assert response.status_code == 200 + create_data = { + "name_en": "New Fund 3", + "title_en": "New Fund Title 3", + "description_en": "New Fund Description 3", + "welsh_available": "false", + "short_name": "SMP1", + "funding_type": FundingType.COMPETITIVE.value, + "ggis_scheme_reference_number": "G1-SCH-0000092415", + } + response = submit_form(flask_test_client, "/funds/create", create_data) + assert response.status_code == 200 + html = response.data.decode("utf-8") + assert ( + 'Short name: Given fund short name already exists.' in html + ), "Not having the fund short name already exists error" + + +@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") +def test_update_fund(flask_test_client, seed_dynamic_data): + """ + Tests that a fund can be successfully updated using the /funds/ route + Verifies that the updated fund has the correct attributes + """ + update_data = { + "name_en": "Updated Fund", + "title_en": "Updated Fund Title", + "description_en": "Updated Fund Description", + "welsh_available": "true", + "short_name": "UF1234", + "submit": "Submit", + "funding_type": "EOI", + "ggis_scheme_reference_number": "G3-SCH-0000092414", + } + + test_fund = seed_dynamic_data["funds"][0] + response = submit_form(flask_test_client, f"/funds/{test_fund.fund_id}", update_data) + assert response.status_code == 200 + + updated_fund = get_fund_by_id(test_fund.fund_id) + for key, value in update_data.items(): + if key == "csrf_token": + continue + if key.endswith("_en"): + assert updated_fund.__getattribute__(key[:-3] + "_json")["en"] == value + elif key == "welsh_available": + assert updated_fund.welsh_available is True + elif key == "funding_type": + assert updated_fund.funding_type.value == value + elif key != "submit": + assert updated_fund.__getattribute__(key) == value diff --git a/tests/blueprints/index/test_routes.py b/tests/blueprints/index/test_routes.py new file mode 100644 index 00000000..88c10c3a --- /dev/null +++ b/tests/blueprints/index/test_routes.py @@ -0,0 +1,49 @@ +import pytest + + +def test_index_redirects_to_login_for_unauthenticated_user(flask_test_client): + """ + Tests that unauthenticated users are redirected to the login page when trying to access the index route. + """ + response = flask_test_client.get("/") + assert response.status_code == 302 + assert response.location == "/login" + + +@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") +def test_index_redirects_to_dashboard_for_authenticated_user(flask_test_client): + """ + Tests that authenticated users are redirected to the dashboard when trying to access the index route. + """ + response = flask_test_client.get("/") + assert response.status_code == 302 + assert response.location == "/dashboard" + + +def test_login_renders_for_unauthenticated_user(flask_test_client): + """ + Tests that the login page renders correctly for unauthenticated users. + """ + response = flask_test_client.get("/login") + assert response.status_code == 200 + assert b"Sign in to use FAB" in response.data + + +@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") +def test_dashboard_renders_for_internal_user(flask_test_client): + """ + Tests that authenticated internal users can access the dashboard. + """ + response = flask_test_client.get("/dashboard") + assert response.status_code == 200 + assert b"What do you want to do?" in response.data + + +@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_external_user") +def test_dashboard_forbidden_for_external_user(flask_test_client): + """ + Tests that authenticated external users are forbidden from accessing the dashboard. + """ + response = flask_test_client.get("/dashboard") + assert response.status_code == 403 + assert b"You do not have permission to access this page" in response.data diff --git a/tests/blueprints/round/__init__.py b/tests/blueprints/round/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/blueprints/fund_builder/forms/test_round.py b/tests/blueprints/round/test_forms.py similarity index 71% rename from tests/blueprints/fund_builder/forms/test_round.py rename to tests/blueprints/round/test_forms.py index df568ce2..6a195db2 100644 --- a/tests/blueprints/fund_builder/forms/test_round.py +++ b/tests/blueprints/round/test_forms.py @@ -1,7 +1,9 @@ +from unittest.mock import MagicMock + import pytest from wtforms.validators import ValidationError -from app.blueprints.fund_builder.forms.round import validate_flexible_url +from app.blueprints.round.forms import validate_flexible_url, validate_json_field class MockField: @@ -64,3 +66,25 @@ def test_validate_flexible_url_none_value(): """Test that None value is handled gracefully""" field = MockField(None) validate_flexible_url(None, field) # Should not raise any exception + + +@pytest.mark.parametrize("input_json_string", [(None), (""), ("{}"), (""), ("{}"), ('{"1":"2"}')]) +def test_validate_json_input_valid(input_json_string): + field = MagicMock() + field.data = input_json_string + validate_json_field(None, field) + + +@pytest.mark.parametrize( + "input_json_string, exp_error_msg", + [ + ('{"1":', "Expecting value: line 1 column 6 (char 5)]"), + ('{"1":"quotes not closed}', "Unterminated string starting at: line 1 column 6 (char 5)"), + ], +) +def test_validate_json_input_invalid(input_json_string, exp_error_msg): + field = MagicMock() + field.data = input_json_string + with pytest.raises(ValidationError) as error: + validate_json_field(None, field) + assert exp_error_msg in str(error) diff --git a/tests/blueprints/round/test_routes.py b/tests/blueprints/round/test_routes.py new file mode 100644 index 00000000..e75f2480 --- /dev/null +++ b/tests/blueprints/round/test_routes.py @@ -0,0 +1,199 @@ +import pytest + +from app.db.models import Round +from app.db.queries.round import get_round_by_id +from tests.helpers import submit_form + + +@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") +def test_create_round_with_existing_short_name(flask_test_client, seed_dynamic_data): + """ + Tests that a round can be successfully created using the /rounds/create route + Verifies that the created round has the correct attributes + """ + test_fund = seed_dynamic_data["funds"][0] + new_round_data = { + "fund_id": test_fund.fund_id, + "title_en": "New Round", + "short_name": "NR123", + "opens-day": "01", + "opens-month": "10", + "opens-year": "2024", + "opens-hour": "09", + "opens-minute": "00", + "deadline-day": "01", + "deadline-month": "12", + "deadline-year": "2024", + "deadline-hour": "17", + "deadline-minute": "00", + "assessment_start-day": "02", + "assessment_start-month": "12", + "assessment_start-year": "2024", + "assessment_start-hour": "09", + "assessment_start-minute": "00", + "reminder_date-day": "15", + "reminder_date-month": "11", + "reminder_date-year": "2024", + "reminder_date-hour": "09", + "reminder_date-minute": "00", + "assessment_deadline-day": "15", + "assessment_deadline-month": "12", + "assessment_deadline-year": "2024", + "assessment_deadline-hour": "17", + "assessment_deadline-minute": "00", + "prospectus_link": "http://example.com/prospectus", + "privacy_notice_link": "http://example.com/privacy", + "contact_email": "contact@example.com", + "submit": "Submit", + "contact_phone": "1234567890", + "contact_textphone": "0987654321", + "support_times": "9am - 5pm", + "support_days": "Monday to Friday", + "feedback_link": "http://example.com/feedback", + "project_name_field_id": 1, + "guidance_url": "http://example.com/guidance", + } + + error_html = ( + 'Short name: Given short name already exists in the fund funding to improve testing.' + ) + + # Test works fine with first round + response = submit_form(flask_test_client, "/rounds/create", new_round_data) + assert response.status_code == 200 + assert error_html not in response.data.decode("utf-8"), "Error HTML found in response" + + # Test works fine with second round but with different short name + new_round_data = {**new_round_data, "short_name": "NR1234"} + response = submit_form(flask_test_client, "/rounds/create", new_round_data) + assert response.status_code == 200 + assert error_html not in response.data.decode("utf-8"), "Error HTML found in response" + + # Test doesn't work with third round with same short name as firsrt + new_round_data = {**new_round_data, "short_name": "NR123"} + response = submit_form(flask_test_client, "/rounds/create", new_round_data) + assert response.status_code == 200 + assert error_html in response.data.decode("utf-8"), "Error HTML not found in response" + + +@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") +def test_create_new_round(flask_test_client, seed_dynamic_data): + """ + Tests that a round can be successfully created using the /rounds/create route + Verifies that the created round has the correct attributes + """ + test_fund = seed_dynamic_data["funds"][0] + new_round_data = { + "fund_id": test_fund.fund_id, + "title_en": "New Round", + "short_name": "NR123", + "opens-day": "01", + "opens-month": "10", + "opens-year": "2024", + "opens-hour": "09", + "opens-minute": "00", + "deadline-day": "01", + "deadline-month": "12", + "deadline-year": "2024", + "deadline-hour": "17", + "deadline-minute": "00", + "assessment_start-day": "02", + "assessment_start-month": "12", + "assessment_start-year": "2024", + "assessment_start-hour": "09", + "assessment_start-minute": "00", + "reminder_date-day": "15", + "reminder_date-month": "11", + "reminder_date-year": "2024", + "reminder_date-hour": "09", + "reminder_date-minute": "00", + "assessment_deadline-day": "15", + "assessment_deadline-month": "12", + "assessment_deadline-year": "2024", + "assessment_deadline-hour": "17", + "assessment_deadline-minute": "00", + "prospectus_link": "http://example.com/prospectus", + "privacy_notice_link": "http://example.com/privacy", + "contact_email": "contact@example.com", + "submit": "Submit", + "contact_phone": "1234567890", + "contact_textphone": "0987654321", + "support_times": "9am - 5pm", + "support_days": "Monday to Friday", + "feedback_link": "http://example.com/feedback", + "project_name_field_id": 1, + "guidance_url": "http://example.com/guidance", + } + + response = submit_form(flask_test_client, "/rounds/create", new_round_data) + assert response.status_code == 200 + + new_round = Round.query.filter_by(short_name="NR123").first() + assert new_round is not None + assert new_round.title_json["en"] == "New Round" + assert new_round.short_name == "NR123" + + +@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") +def test_update_existing_round(flask_test_client, seed_dynamic_data): + """ + Tests that a round can be successfully updated using the /rounds/ route + Verifies that the updated round has the correct attributes + """ + update_round_data = { + "title_en": "Updated Round", + "short_name": "UR123", + "opens-day": "01", + "opens-month": "10", + "opens-year": "2024", + "opens-hour": "09", + "opens-minute": "00", + "deadline-day": "01", + "deadline-month": "12", + "deadline-year": "2024", + "deadline-hour": "17", + "deadline-minute": "00", + "assessment_start-day": "02", + "assessment_start-month": "12", + "assessment_start-year": "2024", + "assessment_start-hour": "09", + "assessment_start-minute": "00", + "reminder_date-day": "15", + "reminder_date-month": "11", + "reminder_date-year": "2024", + "reminder_date-hour": "09", + "reminder_date-minute": "00", + "assessment_deadline-day": "15", + "assessment_deadline-month": "12", + "assessment_deadline-year": "2024", + "assessment_deadline-hour": "17", + "assessment_deadline-minute": "00", + "prospectus_link": "http://example.com/updated_prospectus", + "privacy_notice_link": "http://example.com/updated_privacy", + "contact_email": "updated_contact@example.com", + "submit": "Submit", + "contact_phone": "1234567890", + "contact_textphone": "0987654321", + "support_times": "9am - 5pm", + "support_days": "Monday to Friday", + "feedback_link": "http://example.com/feedback", + "project_name_field_id": 1, + "guidance_url": "http://example.com/guidance", + "has_feedback_survey": "true", + } + + test_round = seed_dynamic_data["rounds"][0] + response = submit_form(flask_test_client, f"/rounds/{test_round.round_id}", update_round_data) + assert response.status_code == 200 + + updated_round = get_round_by_id(test_round.round_id) + assert updated_round.title_json["en"] == "Updated Round" + assert updated_round.short_name == "UR123" + assert updated_round.feedback_survey_config == { + "has_feedback_survey": True, + "has_section_feedback": False, + "has_research_survey": False, + "is_feedback_survey_optional": False, + "is_section_feedback_optional": False, + "is_research_survey_optional": False, + } diff --git a/tests/blueprints/template/__init__.py b/tests/blueprints/template/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/conftest.py b/tests/conftest.py index 1daab543..86e85b1c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,8 @@ import shutil +from unittest.mock import patch import pytest +from flask import current_app from flask_migrate import upgrade from sqlalchemy import text @@ -71,3 +73,35 @@ def flask_test_client(): upgrade() with app_context.app.test_client() as test_client: yield test_client + + +@pytest.fixture +def set_auth_cookie(flask_test_client): + # This fixture sets the authentication cookie on every test. + user_token_cookie_name = current_app.config.get("FSD_USER_TOKEN_COOKIE_NAME", "fsd_user_token") + flask_test_client.set_cookie(key=user_token_cookie_name, value="dummy_jwt_token") + yield + + +@pytest.fixture +def patch_validate_token_rs256_internal_user(): + # This fixture patches validate_token_rs256 for all tests automatically. + with patch("fsd_utils.authentication.decorators.validate_token_rs256") as mock_validate_token_rs256: + mock_validate_token_rs256.return_value = { + "accountId": "test-account-id", + "roles": [], + "email": "test@communities.gov.uk", + } + yield mock_validate_token_rs256 + + +@pytest.fixture +def patch_validate_token_rs256_external_user(): + # This fixture patches validate_token_rs256 for all tests automatically. + with patch("fsd_utils.authentication.decorators.validate_token_rs256") as mock_validate_token_rs256: + mock_validate_token_rs256.return_value = { + "accountId": "test-account-id", + "roles": [], + "email": "test@gmail.com", + } + yield mock_validate_token_rs256 diff --git a/tests/test_clone.py b/tests/test_clone.py index 7c7a6b56..ec54c5da 100644 --- a/tests/test_clone.py +++ b/tests/test_clone.py @@ -2,11 +2,10 @@ import pytest -from app.blueprints.fund_builder.routes import clone_single_round from app.db.models import Component, ComponentType, Page from app.db.models.application_config import Form, Section from app.db.models.round import Round -from app.db.queries.application import ( +from app.db.queries.clone import ( _fix_cloned_default_pages, _initiate_cloned_component, _initiate_cloned_form, @@ -16,6 +15,7 @@ clone_single_component, clone_single_form, clone_single_page, + clone_single_round, clone_single_section, ) @@ -23,7 +23,7 @@ @pytest.fixture def mock_new_uuid(mocker): new_id = uuid4() - mocker.patch("app.db.queries.application.uuid4", return_value=new_id) + mocker.patch("app.db.queries.clone.uuid4", return_value=new_id) yield new_id diff --git a/tests/test_config_export.py b/tests/test_config_export.py index b03546c0..2efccc4d 100644 --- a/tests/test_config_export.py +++ b/tests/test_config_export.py @@ -6,7 +6,7 @@ import pytest -from app.blueprints.fund_builder.routes import create_export_zip +from app.blueprints.application.routes import create_export_zip from app.export_config.generate_fund_round_config import generate_config_for_round from app.export_config.generate_fund_round_form_jsons import ( generate_form_jsons_for_round, diff --git a/tests/test_db.py b/tests/test_db.py index 647d0e0d..e9266f2a 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -400,7 +400,7 @@ def test_section_sorting_move_down(seed_dynamic_data, _db, index_to_move, exp_ne assert section_that_moves_up.index == index_to_move + 1 section_id_that_moves_up = section_that_moves_up.section_id - move_section_down(round_id=round_id, section_index_to_move_down=index_to_move) + move_section_down(round_id=round_id, section_id=section_to_move_down.section_id) updated_round = get_round_by_id(round_id) # total sections shouldn't change @@ -468,7 +468,7 @@ def test_move_section_up(seed_dynamic_data, _db, index_to_move, exp_new_index): id_that_gets_moved_down = section_that_gets_moved_down.section_id assert section_that_gets_moved_down.index == index_to_move - 1 - move_section_up(round_id=round_id, section_index_to_move_up=index_to_move) + move_section_up(round_id=round_id, section_id=id_to_move) updated_round = get_round_by_id(round_id) assert len(updated_round.sections) == 3 @@ -508,7 +508,7 @@ def test_move_form_up(seed_dynamic_data, _db, index_to_move, exp_new_index): id_to_move_down = section.forms[index_to_move - 2].form_id assert section.forms[index_to_move - 2].section_index == exp_new_index - move_form_up(section_id, index_to_move) + move_form_up(section_id, id_to_move_up) updated_section = get_section_by_id(section_id) assert len(updated_section.forms) == 3 @@ -542,7 +542,7 @@ def test_move_form_down(seed_dynamic_data, _db, index_to_move, exp_new_index): id_to_move_up = section.forms[index_to_move].form_id assert section.forms[index_to_move].section_index == index_to_move + 1 - move_form_down(section_id, index_to_move) + move_form_down(section_id, id_to_move_down) updated_section = get_section_by_id(section_id) assert len(updated_section.forms) == 3 diff --git a/tests/test_routes.py b/tests/test_routes.py deleted file mode 100644 index 10c9c418..00000000 --- a/tests/test_routes.py +++ /dev/null @@ -1,412 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from flask import current_app -from wtforms.validators import ValidationError - -from app.blueprints.fund_builder.forms.round import validate_json_field -from app.db.models import Fund, Round -from app.db.models.fund import FundingType -from app.db.queries.fund import get_fund_by_id -from app.db.queries.round import get_round_by_id -from tests.helpers import submit_form - - -@pytest.fixture -def set_auth_cookie(flask_test_client): - # This fixture sets the authentication cookie on every test. - user_token_cookie_name = current_app.config.get("FSD_USER_TOKEN_COOKIE_NAME", "fsd_user_token") - flask_test_client.set_cookie(key=user_token_cookie_name, value="dummy_jwt_token") - yield - - -@pytest.fixture -def patch_validate_token_rs256_internal_user(): - # This fixture patches validate_token_rs256 for all tests automatically. - with patch("fsd_utils.authentication.decorators.validate_token_rs256") as mock_validate_token_rs256: - mock_validate_token_rs256.return_value = { - "accountId": "test-account-id", - "roles": [], - "email": "test@communities.gov.uk", - } - yield mock_validate_token_rs256 - - -@pytest.fixture -def patch_validate_token_rs256_external_user(): - # This fixture patches validate_token_rs256 for all tests automatically. - with patch("fsd_utils.authentication.decorators.validate_token_rs256") as mock_validate_token_rs256: - mock_validate_token_rs256.return_value = { - "accountId": "test-account-id", - "roles": [], - "email": "test@gmail.com", - } - yield mock_validate_token_rs256 - - -@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") -def test_create_fund(flask_test_client): - """ - Tests that a fund can be successfully created using the /fund route - Verifies that the created fund has the correct attributes - """ - create_data = { - "name_en": "New Fund", - "title_en": "New Fund Title", - "description_en": "New Fund Description", - "welsh_available": "false", - "short_name": "NF5432", - "funding_type": FundingType.COMPETITIVE.value, - "ggis_scheme_reference_number": "G1-SCH-0000092415", - } - - response = submit_form(flask_test_client, "/fund", create_data) - assert response.status_code == 200 - created_fund = Fund.query.filter_by(short_name="NF5432").first() - assert created_fund is not None - for key, value in create_data.items(): - if key == "csrf_token": - continue - if key.endswith("_en"): - assert created_fund.__getattribute__(key[:-3] + "_json")["en"] == value - elif key == "welsh_available": - assert created_fund.welsh_available is False - elif key == "funding_type": - assert created_fund.funding_type.value == value - elif key == "ggis_scheme_reference_number": - assert created_fund.ggis_scheme_reference_number == value - else: - assert created_fund.__getattribute__(key) == value - - -@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") -def test_create_fund_with_existing_short_name(flask_test_client): - """ - Tests that a fund can be successfully created using the /fund route - Verifies that the created fund has the correct attributes - """ - create_data = { - "name_en": "New Fund 2", - "title_en": "New Fund Title 2", - "description_en": "New Fund Description 2", - "welsh_available": "false", - "short_name": "SMP1", - "funding_type": FundingType.COMPETITIVE.value, - "ggis_scheme_reference_number": "G1-SCH-0000092415", - } - response = submit_form(flask_test_client, "/fund", create_data) - assert response.status_code == 200 - create_data = { - "name_en": "New Fund 3", - "title_en": "New Fund Title 3", - "description_en": "New Fund Description 3", - "welsh_available": "false", - "short_name": "SMP1", - "funding_type": FundingType.COMPETITIVE.value, - "ggis_scheme_reference_number": "G1-SCH-0000092415", - } - response = submit_form(flask_test_client, "/fund", create_data) - assert response.status_code == 200 - html = response.data.decode("utf-8") - assert ( - 'Short name: Given fund short name already exists.' in html - ), "Not having the fund short name already exists error" - - -@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") -def test_update_fund(flask_test_client, seed_dynamic_data): - """ - Tests that a fund can be successfully updated using the /fund/ route - Verifies that the updated fund has the correct attributes - """ - update_data = { - "name_en": "Updated Fund", - "title_en": "Updated Fund Title", - "description_en": "Updated Fund Description", - "welsh_available": "true", - "short_name": "UF1234", - "submit": "Submit", - "funding_type": "EOI", - "ggis_scheme_reference_number": "G3-SCH-0000092414", - } - - test_fund = seed_dynamic_data["funds"][0] - response = submit_form(flask_test_client, f"/fund/{test_fund.fund_id}", update_data) - assert response.status_code == 200 - - updated_fund = get_fund_by_id(test_fund.fund_id) - for key, value in update_data.items(): - if key == "csrf_token": - continue - if key.endswith("_en"): - assert updated_fund.__getattribute__(key[:-3] + "_json")["en"] == value - elif key == "welsh_available": - assert updated_fund.welsh_available is True - elif key == "funding_type": - assert updated_fund.funding_type.value == value - elif key != "submit": - assert updated_fund.__getattribute__(key) == value - - -@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") -def test_create_round_with_existing_short_name(flask_test_client, seed_dynamic_data): - """ - Tests that a round can be successfully created using the /round route - Verifies that the created round has the correct attributes - """ - test_fund = seed_dynamic_data["funds"][0] - new_round_data = { - "fund_id": test_fund.fund_id, - "title_en": "New Round", - "short_name": "NR123", - "opens-day": "01", - "opens-month": "10", - "opens-year": "2024", - "opens-hour": "09", - "opens-minute": "00", - "deadline-day": "01", - "deadline-month": "12", - "deadline-year": "2024", - "deadline-hour": "17", - "deadline-minute": "00", - "assessment_start-day": "02", - "assessment_start-month": "12", - "assessment_start-year": "2024", - "assessment_start-hour": "09", - "assessment_start-minute": "00", - "reminder_date-day": "15", - "reminder_date-month": "11", - "reminder_date-year": "2024", - "reminder_date-hour": "09", - "reminder_date-minute": "00", - "assessment_deadline-day": "15", - "assessment_deadline-month": "12", - "assessment_deadline-year": "2024", - "assessment_deadline-hour": "17", - "assessment_deadline-minute": "00", - "prospectus_link": "http://example.com/prospectus", - "privacy_notice_link": "http://example.com/privacy", - "contact_email": "contact@example.com", - "submit": "Submit", - "contact_phone": "1234567890", - "contact_textphone": "0987654321", - "support_times": "9am - 5pm", - "support_days": "Monday to Friday", - "feedback_link": "http://example.com/feedback", - "project_name_field_id": 1, - "guidance_url": "http://example.com/guidance", - } - - error_html = ( - 'Short name: Given short name already exists in the fund funding to improve testing.' - ) - - # Test works fine with first round - response = submit_form(flask_test_client, "/round", new_round_data) - assert response.status_code == 200 - assert error_html not in response.data.decode("utf-8"), "Error HTML found in response" - - # Test works fine with second round but with different short name - new_round_data = {**new_round_data, "short_name": "NR1234"} - response = submit_form(flask_test_client, "/round", new_round_data) - assert response.status_code == 200 - assert error_html not in response.data.decode("utf-8"), "Error HTML found in response" - - # Test doesn't work with third round with same short name as firsrt - new_round_data = {**new_round_data, "short_name": "NR123"} - response = submit_form(flask_test_client, "/round", new_round_data) - assert response.status_code == 200 - assert error_html in response.data.decode("utf-8"), "Error HTML not found in response" - - -@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") -def test_create_new_round(flask_test_client, seed_dynamic_data): - """ - Tests that a round can be successfully created using the /round route - Verifies that the created round has the correct attributes - """ - test_fund = seed_dynamic_data["funds"][0] - new_round_data = { - "fund_id": test_fund.fund_id, - "title_en": "New Round", - "short_name": "NR123", - "opens-day": "01", - "opens-month": "10", - "opens-year": "2024", - "opens-hour": "09", - "opens-minute": "00", - "deadline-day": "01", - "deadline-month": "12", - "deadline-year": "2024", - "deadline-hour": "17", - "deadline-minute": "00", - "assessment_start-day": "02", - "assessment_start-month": "12", - "assessment_start-year": "2024", - "assessment_start-hour": "09", - "assessment_start-minute": "00", - "reminder_date-day": "15", - "reminder_date-month": "11", - "reminder_date-year": "2024", - "reminder_date-hour": "09", - "reminder_date-minute": "00", - "assessment_deadline-day": "15", - "assessment_deadline-month": "12", - "assessment_deadline-year": "2024", - "assessment_deadline-hour": "17", - "assessment_deadline-minute": "00", - "prospectus_link": "http://example.com/prospectus", - "privacy_notice_link": "http://example.com/privacy", - "contact_email": "contact@example.com", - "submit": "Submit", - "contact_phone": "1234567890", - "contact_textphone": "0987654321", - "support_times": "9am - 5pm", - "support_days": "Monday to Friday", - "feedback_link": "http://example.com/feedback", - "project_name_field_id": 1, - "guidance_url": "http://example.com/guidance", - } - - response = submit_form(flask_test_client, "/round", new_round_data) - assert response.status_code == 200 - - new_round = Round.query.filter_by(short_name="NR123").first() - assert new_round is not None - assert new_round.title_json["en"] == "New Round" - assert new_round.short_name == "NR123" - - -@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") -def test_update_existing_round(flask_test_client, seed_dynamic_data): - """ - Tests that a round can be successfully updated using the /round/ route - Verifies that the updated round has the correct attributes - """ - update_round_data = { - "title_en": "Updated Round", - "short_name": "UR123", - "opens-day": "01", - "opens-month": "10", - "opens-year": "2024", - "opens-hour": "09", - "opens-minute": "00", - "deadline-day": "01", - "deadline-month": "12", - "deadline-year": "2024", - "deadline-hour": "17", - "deadline-minute": "00", - "assessment_start-day": "02", - "assessment_start-month": "12", - "assessment_start-year": "2024", - "assessment_start-hour": "09", - "assessment_start-minute": "00", - "reminder_date-day": "15", - "reminder_date-month": "11", - "reminder_date-year": "2024", - "reminder_date-hour": "09", - "reminder_date-minute": "00", - "assessment_deadline-day": "15", - "assessment_deadline-month": "12", - "assessment_deadline-year": "2024", - "assessment_deadline-hour": "17", - "assessment_deadline-minute": "00", - "prospectus_link": "http://example.com/updated_prospectus", - "privacy_notice_link": "http://example.com/updated_privacy", - "contact_email": "updated_contact@example.com", - "submit": "Submit", - "contact_phone": "1234567890", - "contact_textphone": "0987654321", - "support_times": "9am - 5pm", - "support_days": "Monday to Friday", - "feedback_link": "http://example.com/feedback", - "project_name_field_id": 1, - "guidance_url": "http://example.com/guidance", - "has_feedback_survey": "true", - } - - test_round = seed_dynamic_data["rounds"][0] - response = submit_form(flask_test_client, f"/round/{test_round.round_id}", update_round_data) - assert response.status_code == 200 - - updated_round = get_round_by_id(test_round.round_id) - assert updated_round.title_json["en"] == "Updated Round" - assert updated_round.short_name == "UR123" - assert updated_round.feedback_survey_config == { - "has_feedback_survey": True, - "has_section_feedback": False, - "has_research_survey": False, - "is_feedback_survey_optional": False, - "is_section_feedback_optional": False, - "is_research_survey_optional": False, - } - - -@pytest.mark.parametrize("input_json_string", [(None), (""), ("{}"), (""), ("{}"), ('{"1":"2"}')]) -def test_validate_json_input_valid(input_json_string): - field = MagicMock() - field.data = input_json_string - validate_json_field(None, field) - - -@pytest.mark.parametrize( - "input_json_string, exp_error_msg", - [ - ('{"1":', "Expecting value: line 1 column 6 (char 5)]"), - ('{"1":"quotes not closed}', "Unterminated string starting at: line 1 column 6 (char 5)"), - ], -) -def test_validate_json_input_invalid(input_json_string, exp_error_msg): - field = MagicMock() - field.data = input_json_string - with pytest.raises(ValidationError) as error: - validate_json_field(None, field) - assert exp_error_msg in str(error) - - -def test_index_redirects_to_login_for_unauthenticated_user(flask_test_client): - """ - Tests that unauthenticated users are redirected to the login page when trying to access the index route. - """ - response = flask_test_client.get("/") - assert response.status_code == 302 - assert response.location == "/login" - - -@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") -def test_index_redirects_to_dashboard_for_authenticated_user(flask_test_client): - """ - Tests that authenticated users are redirected to the dashboard when trying to access the index route. - """ - response = flask_test_client.get("/") - assert response.status_code == 302 - assert response.location == "/dashboard" - - -def test_login_renders_for_unauthenticated_user(flask_test_client): - """ - Tests that the login page renders correctly for unauthenticated users. - """ - response = flask_test_client.get("/login") - assert response.status_code == 200 - assert b"Sign in to use FAB" in response.data - - -@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") -def test_dashboard_renders_for_internal_user(flask_test_client): - """ - Tests that authenticated internal users can access the dashboard. - """ - response = flask_test_client.get("/dashboard") - assert response.status_code == 200 - assert b"What do you want to do?" in response.data - - -@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_external_user") -def test_dashboard_forbidden_for_external_user(flask_test_client): - """ - Tests that authenticated external users are forbidden from accessing the dashboard. - """ - response = flask_test_client.get("/dashboard") - assert response.status_code == 403 - assert b"You do not have permission to access this page" in response.data