From 4651359124fd0f2047d94ac0bf28e0fd3db3428d Mon Sep 17 00:00:00 2001 From: Hamza Bin Khalid <66220499+hamzabinkhalid@users.noreply.github.com> Date: Mon, 30 Sep 2024 10:03:07 +0100 Subject: [PATCH] FS-4652: implement fund and round update features (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * FS-4652: Implement Fund and Round Update Features - New Routes: Added /fund/ and /round/ routes to enable updating fund and round details via forms. - Frontend Enhancements: Introduced “Edit” buttons in the fund_config template for easier access to update forms. - JSON Column Mutability: Addressed SQLAlchemy’s default immutability of JSON columns by using MutableDict.as_mutable to ensure changes are tracked and persisted. - Form and Database Synchronization: Fixed discrepancies between form inputs and database fields, particularly for minute fields. - Testing Improvements: Created test_routes.py to cover the new routes. Developed helpers.py to include utility functions for the new tests. * - Dynamic handling of fund and round template based on create or update operation * - Code refactor - Logging statements added for related db operations --- app/blueprints/fund_builder/forms/round.py | 18 +- app/blueprints/fund_builder/routes.py | 232 ++++++++++++++---- .../fund_builder/templates/fund.html | 2 +- .../fund_builder/templates/fund_config.html | 9 + .../fund_builder/templates/round.html | 19 +- app/create_app.py | 4 + app/db/models/fund.py | 9 +- app/db/models/round.py | 11 +- app/db/queries/fund.py | 8 + app/db/queries/round.py | 8 + app/templates/macros/wtfToGovUk.html | 6 +- tests/conftest.py | 26 ++ tests/helpers.py | 148 +++++++++++ tests/test_routes.py | 173 +++++++++++++ 14 files changed, 594 insertions(+), 79 deletions(-) create mode 100644 tests/helpers.py create mode 100644 tests/test_routes.py diff --git a/app/blueprints/fund_builder/forms/round.py b/app/blueprints/fund_builder/forms/round.py index 6885f3f9..90d41186 100644 --- a/app/blueprints/fund_builder/forms/round.py +++ b/app/blueprints/fund_builder/forms/round.py @@ -19,9 +19,9 @@ def get_datetime(form_field): month = int(form_field.month.data) year = int(form_field.year.data) hour = int(form_field.hour.data) - minutes = int(form_field.minutes.data) + minute = int(form_field.minute.data) try: - form_field_datetime = datetime.datetime(year, month, day, hour=hour, minute=minutes).strftime("%m-%d-%Y %H:%M") + form_field_datetime = datetime.datetime(year, month, day, hour=hour, minute=minute).strftime("%m-%d-%Y %H:%M") return form_field_datetime except ValueError: raise ValidationError(f"Invalid date entered for {form_field}") @@ -32,7 +32,7 @@ class DateInputForm(Form): month = StringField("Month", validators=[DataRequired(), Length(min=1, max=2)]) year = StringField("Year", validators=[DataRequired(), Length(min=1, max=4)]) hour = StringField("Hour", validators=[DataRequired(), Length(min=1, max=2)]) - minutes = StringField("Minutes", validators=[DataRequired(), Length(min=1, max=2)]) + minute = StringField("Minute", validators=[DataRequired(), Length(min=1, max=2)]) def validate_day(self, field): try: @@ -58,17 +58,17 @@ def validate_year(self, field): def validate_hour(self, field): try: - day = int(field.data) - if day < 0 or day > 23: + hour = int(field.data) + if hour < 0 or hour > 23: raise ValidationError("Hour must be between 0 and 23 inclusive.") except ValueError: raise ValidationError("Invalid Day") - def validate_minutes(self, field): + def validate_minute(self, field): try: - day = int(field.data) - if day < 0 or day >= 60: - raise ValidationError("Minutes must be between 0 and 59 inclusive.") + minute = int(field.data) + if minute < 0 or minute >= 60: + raise ValidationError("Minute must be between 0 and 59 inclusive.") except ValueError: raise ValidationError("Invalid Day") diff --git a/app/blueprints/fund_builder/routes.py b/app/blueprints/fund_builder/routes.py index a280d969..a23d2648 100644 --- a/app/blueprints/fund_builder/routes.py +++ b/app/blueprints/fund_builder/routes.py @@ -38,8 +38,10 @@ from app.db.queries.fund import add_fund from app.db.queries.fund import get_all_funds from app.db.queries.fund import get_fund_by_id +from app.db.queries.fund import update_fund from app.db.queries.round import add_round from app.db.queries.round import get_round_by_id +from app.db.queries.round import update_round from app.export_config.generate_all_questions import print_html from app.export_config.generate_form import build_form_json from app.export_config.generate_fund_round_config import ( @@ -52,6 +54,8 @@ from app.export_config.generate_fund_round_html import generate_all_round_html from config import Config +BUILD_FUND_BP_INDEX = "build_fund_bp.index" + # Blueprint for routes used by v1 of FAB - using the DB build_fund_bp = Blueprint( "build_fund_bp", @@ -171,7 +175,7 @@ def view_fund(): params["fund"] = fund params["selected_fund_id"] = fund_id params["breadcrumb_items"] = [ - {"text": "Home", "href": url_for("build_fund_bp.index")}, + {"text": "Home", "href": url_for(BUILD_FUND_BP_INDEX)}, {"text": fund.title_json["en"] if fund else "Manage Application Configuration", "href": "#"}, ] @@ -204,14 +208,37 @@ def clone_round(round_id, fund_id): @build_fund_bp.route("/fund", methods=["GET", "POST"]) -def fund(): +@build_fund_bp.route("/fund/", methods=["GET", "POST"]) +def fund(fund_id=None): """ - Renders a template to allow a user to add a fund, when saved validates the form data and saves to DB + Renders a template to allow a user to add or update a fund, when saved validates the form data and saves to DB """ - form: FundForm = FundForm() + if fund_id: + fund = get_fund_by_id(fund_id) + fund_data = { + "fund_id": fund.fund_id, + "name_en": fund.name_json.get("en", ""), + "title_en": fund.title_json.get("en", ""), + "short_name": fund.short_name, + "description_en": fund.description_json.get("en", ""), + "welsh_available": "true" if fund.welsh_available else "false", + } + form = FundForm(data=fund_data) + else: + form = FundForm() + if form.validate_on_submit(): - add_fund( - Fund( + if fund_id: + fund.name_json["en"] = form.name_en.data + fund.title_json["en"] = form.title_en.data + fund.description_json["en"] = form.description_en.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"} + update_fund(fund) + flash(f"Updated fund {form.title_en.data}") + else: + new_fund = Fund( name_json={"en": form.name_en.data}, title_json={"en": form.title_en.data}, description_json={"en": form.description_en.data}, @@ -219,65 +246,170 @@ def fund(): short_name=form.short_name.data, audit_info={"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "create"}, ) - ) - flash(f"Saved fund {form.name_en.data}") - return redirect(url_for("build_fund_bp.index")) + add_fund(new_fund) + flash(f"Created fund {form.name_en.data}") + return redirect(url_for(BUILD_FUND_BP_INDEX)) - return render_template("fund.html", form=form) + return render_template("fund.html", form=form, fund_id=fund_id) @build_fund_bp.route("/round", methods=["GET", "POST"]) -def round(): +@build_fund_bp.route("/round/", methods=["GET", "POST"]) +def round(round_id=None): """ - Renders a template to select a fund and add a round to that fund. If saved, validates the round form data + 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 """ all_funds = get_all_funds() - form: RoundForm = RoundForm() - if form.validate_on_submit(): - add_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}, - 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_json.data, "cy": 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_json.data, "cy": None}, - feedback_link=form.feedback_link.data, - project_name_field_id=form.project_name_field_id.data, - application_guidance_json={"en": form.application_guidance_json.data, "cy": 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=form.feedback_survey_config.data, - eoi_decision_schema=form.eoi_decision_schema.data, - ) - ) + form = RoundForm() - flash(f"Saved round {form.title_en.data}") - return redirect(url_for("build_fund_bp.index")) + 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']}") + else: + create_new_round(form) + flash(f"Created round {form.title_en.data}") + return redirect(url_for(BUILD_FUND_BP_INDEX)) return render_template( "round.html", form=form, all_funds=all_funds_as_govuk_select_items(all_funds), + round_id=round_id, + ) + + +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, + "title_en": round.title_json.get("en", ""), + "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_json": round.contact_us_banner_json.get("en", "") 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_json": round.instructions_json.get("en", "") if round.instructions_json else "", + "feedback_link": round.feedback_link, + "project_name_field_id": round.project_name_field_id, + "application_guidance_json": ( + round.application_guidance_json.get("en", "") 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", + "feedback_survey_config": round.feedback_survey_config, + "eligibility_config": round.eligibility_config.get("has_eligibility", "false"), + "eoi_decision_schema": round.eoi_decision_schema.get("en", ""), + } + 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 + round.short_name = form.short_name.data + round.contact_us_banner_json["en"] = form.contact_us_banner_json.data + round.instructions_json["en"] = form.instructions_json.data + round.application_guidance_json["en"] = form.application_guidance_json.data + round.feedback_survey_config = form.feedback_survey_config.data + round.eligibility_config["has_eligibility"] = form.eligibility_config.data + round.eoi_decision_schema["en"] = form.eoi_decision_schema.data + 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.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}, + 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_json.data, "cy": 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_json.data, "cy": None}, + feedback_link=form.feedback_link.data, + project_name_field_id=form.project_name_field_id.data, + application_guidance_json={"en": form.application_guidance_json.data, "cy": 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=form.feedback_survey_config.data, + eligibility_config={"has_eligibility": form.eligibility_config.data}, + eoi_decision_schema={"en": form.eoi_decision_schema.data, "cy": None}, ) + add_round(new_round) @build_fund_bp.route("/preview/", methods=["GET"]) diff --git a/app/blueprints/fund_builder/templates/fund.html b/app/blueprints/fund_builder/templates/fund.html index f68f6cbf..5406d986 100644 --- a/app/blueprints/fund_builder/templates/fund.html +++ b/app/blueprints/fund_builder/templates/fund.html @@ -5,7 +5,7 @@ {% block content %}
-

Create a Fund

+

{{ 'Update a Fund' if fund_id else 'Create a Fund' }}

diff --git a/app/blueprints/fund_builder/templates/fund_config.html b/app/blueprints/fund_builder/templates/fund_config.html index 4c75aa9e..a5192dcb 100644 --- a/app/blueprints/fund_builder/templates/fund_config.html +++ b/app/blueprints/fund_builder/templates/fund_config.html @@ -68,6 +68,11 @@

Fund Meta Data

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

Application Rounds

{% set rounds_content = [] %} {% for round in fund.rounds %} @@ -306,6 +311,10 @@

Application Rounds

"text": "Clone this round", "href": url_for("build_fund_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), + "classes": "govuk-button--secondary" }) } }) %} diff --git a/app/blueprints/fund_builder/templates/round.html b/app/blueprints/fund_builder/templates/round.html index 5f1db746..7835e636 100644 --- a/app/blueprints/fund_builder/templates/round.html +++ b/app/blueprints/fund_builder/templates/round.html @@ -8,18 +8,21 @@ {% block content %}
-

Create a Round

+

{{ 'Update a Round' if round_id else 'Create a Round' }}

{{ form.hidden_tag()}} - {{ govukSelect({ - "id": form.fund_id.id, - "name": form.fund_id.name, - "label": { - "text": form.fund_id.label - }, - "items": all_funds})}} + {% if not round_id %} + {{ govukSelect({ + "id": form.fund_id.id, + "name": form.fund_id.name, + "label": { + "text": form.fund_id.label + }, + "items": all_funds + })}} + {% endif %} {{input(form.title_en)}} {{input(form.short_name)}} {{dateinput(form.opens)}} diff --git a/app/create_app.py b/app/create_app.py index f5bf0c00..0d4254de 100644 --- a/app/create_app.py +++ b/app/create_app.py @@ -11,6 +11,7 @@ from app.blueprints.templates.routes import template_bp from app.db.models import Fund # noqa:F401 from app.db.models import Round # noqa:F401 +from fsd_utils.logging import logging def create_app() -> Flask: @@ -40,6 +41,9 @@ def create_app() -> Flask: compare_server_default=True, ) + # Initialise logging + logging.init_app(flask_app) + # Bundle and compile assets assets = Environment() assets.init_app(flask_app) diff --git a/app/db/models/fund.py b/app/db/models/fund.py index 7bcd113a..fa077439 100644 --- a/app/db/models/fund.py +++ b/app/db/models/fund.py @@ -7,6 +7,7 @@ from sqlalchemy import ForeignKey from sqlalchemy.dialects.postgresql import JSON from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.ext.mutable import MutableDict from sqlalchemy.orm import Mapped from sqlalchemy.orm import relationship from sqlalchemy.types import Boolean @@ -43,10 +44,12 @@ class Fund(BaseModel): default=uuid.uuid4, nullable=False, ) - name_json = Column("name_json", JSON(none_as_null=True), nullable=False, unique=False) - title_json = Column("title_json", JSON(none_as_null=True), nullable=False, unique=False) + name_json = Column("name_json", MutableDict.as_mutable(JSON(none_as_null=True)), nullable=False, unique=False) + title_json = Column("title_json", MutableDict.as_mutable(JSON(none_as_null=True)), nullable=False, unique=False) short_name = Column("short_name", db.String(15), nullable=False, unique=True) - description_json = Column("description_json", JSON(none_as_null=True), nullable=False, unique=False) + description_json = Column( + "description_json", MutableDict.as_mutable(JSON(none_as_null=True)), nullable=False, unique=False + ) welsh_available = Column("welsh_available", Boolean, default=False, nullable=False) is_template = Column("is_template", Boolean, default=False, nullable=False) audit_info = Column("audit_info", JSON(none_as_null=True)) diff --git a/app/db/models/round.py b/app/db/models/round.py index bd0ff723..c31db495 100644 --- a/app/db/models/round.py +++ b/app/db/models/round.py @@ -10,6 +10,7 @@ from sqlalchemy import inspect from sqlalchemy.dialects.postgresql import JSON from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.ext.mutable import MutableDict from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.orm import Mapped from sqlalchemy.orm import relationship @@ -36,7 +37,7 @@ class Round(BaseModel): ForeignKey("fund.fund_id"), nullable=False, ) - title_json = Column(JSON(none_as_null=True), nullable=False, unique=False) + title_json = Column(MutableDict.as_mutable(JSON(none_as_null=True)), nullable=False, unique=False) short_name = Column(db.String(), nullable=False, unique=False) opens = Column(DateTime()) deadline = Column(DateTime()) @@ -58,17 +59,17 @@ class Round(BaseModel): criteria: Mapped[list["Criteria"]] = relationship("Criteria") # several other fields to add application_reminder_sent = Column(Boolean, default=False, nullable=False) - contact_us_banner_json = Column(JSON(none_as_null=True), nullable=True, unique=False) + contact_us_banner_json = Column(MutableDict.as_mutable(JSON(none_as_null=True)), nullable=True, unique=False) reference_contact_page_over_email = Column(Boolean, default=False, nullable=False) contact_email = Column(db.String(), nullable=True, unique=False) contact_phone = Column(db.String(), nullable=True, unique=False) contact_textphone = Column(db.String(), nullable=True, unique=False) support_times = Column(db.String(), nullable=False, unique=False) support_days = Column(db.String(), nullable=False, unique=False) - instructions_json = Column(JSON(none_as_null=True), nullable=True, unique=False) + instructions_json = Column(MutableDict.as_mutable(JSON(none_as_null=True)), nullable=True, unique=False) feedback_link = Column(db.String(), unique=False) project_name_field_id = Column(db.String(), unique=False, nullable=False) - application_guidance_json = Column(JSON(none_as_null=True), nullable=True, unique=False) + application_guidance_json = Column(MutableDict.as_mutable(JSON(none_as_null=True)), nullable=True, unique=False) guidance_url = Column(db.String(), nullable=True, unique=False) all_uploaded_documents_section_available = Column(Boolean, default=False, nullable=False) application_fields_download_available = Column(Boolean, default=False, nullable=False) @@ -76,7 +77,7 @@ class Round(BaseModel): mark_as_complete_enabled = Column(Boolean, default=False, nullable=False) is_expression_of_interest = Column(Boolean, default=False, nullable=False) feedback_survey_config = Column(JSON(none_as_null=True), nullable=True, unique=False) - eligibility_config = Column(JSON(none_as_null=True), nullable=True, unique=False) + eligibility_config = Column(MutableDict.as_mutable(JSON(none_as_null=True)), nullable=True, unique=False) eoi_decision_schema = Column(JSON(none_as_null=True), nullable=True, unique=False) def __repr__(self): diff --git a/app/db/queries/fund.py b/app/db/queries/fund.py index 1b7b8b5c..bd602f78 100644 --- a/app/db/queries/fund.py +++ b/app/db/queries/fund.py @@ -3,6 +3,7 @@ from app.db import db from app.db.models.fund import Fund from app.db.models.fund import Organisation +from flask import current_app def add_organisation(organisation: Organisation) -> Organisation: @@ -14,6 +15,13 @@ def add_organisation(organisation: Organisation) -> Organisation: def add_fund(fund: Fund) -> Fund: db.session.add(fund) db.session.commit() + current_app.logger.info(f"Fund added with fund_id: '{fund.fund_id}.") + return fund + + +def update_fund(fund: Fund) -> Fund: + db.session.commit() + current_app.logger.info(f"Fund updated with fund_id: '{fund.fund_id}.") return fund diff --git a/app/db/queries/round.py b/app/db/queries/round.py index 5f7a9f48..af053b4c 100644 --- a/app/db/queries/round.py +++ b/app/db/queries/round.py @@ -2,11 +2,19 @@ from app.db import db from app.db.models.round import Round +from flask import current_app def add_round(round: Round) -> Round: db.session.add(round) db.session.commit() + current_app.logger.info(f"Round added with round_id: '{round.round_id}.") + return round + + +def update_round(round: Round) -> Round: + db.session.commit() + current_app.logger.info(f"Round updated with round_id: '{round.round_id}.") return round diff --git a/app/templates/macros/wtfToGovUk.html b/app/templates/macros/wtfToGovUk.html index 7a4c83af..ec079692 100644 --- a/app/templates/macros/wtfToGovUk.html +++ b/app/templates/macros/wtfToGovUk.html @@ -81,13 +81,13 @@ "classes": "govuk-input--width-2", }, { - "name": "minutes", - "value": form_field.minutes.data, + "name": "minute", + "value": form_field.minute.data, "classes": "govuk-input--width-2", } ], "errorMessage": {"text": "Enter valid datetime" } if (form_field.day.errors or form_field.month.errors - or form_field.year.errors or form_field.hour.errors or form_field.minutes.errors| length > 0) else None, + or form_field.year.errors or form_field.hour.errors or form_field.minute.errors| length > 0) else None, "hint":{"text":form_field.description} })}} {%endmacro%} diff --git a/tests/conftest.py b/tests/conftest.py index 4168c315..15ff5edd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,10 +5,36 @@ from app.create_app import create_app from tasks.test_data import init_unit_test_data from tasks.test_data import insert_test_data +from tests.helpers import create_test_fund +from tests.helpers import create_test_organisation +from tests.helpers import create_test_round pytest_plugins = ["fsd_test_utils.fixtures.db_fixtures"] +@pytest.fixture +def test_fund(flask_test_client, _db, clear_test_data): + """ + Create a test fund using the test client and add it to the db. + + Yields: + Fund: The created fund. + """ + org = create_test_organisation(flask_test_client) + return create_test_fund(flask_test_client, org) + + +@pytest.fixture +def test_round(flask_test_client, test_fund): + """ + Create a test round using the test client and add it to the db. + + Yields: + Round: The created round. + """ + return create_test_round(flask_test_client, test_fund) + + @pytest.fixture(scope="function") def seed_dynamic_data(request, app, clear_test_data, _db, enable_preserve_test_data): marker = request.node.get_closest_marker("seed_config") diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 00000000..e3a8479e --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,148 @@ +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import select + +from app.db import db +from app.db.models import Fund +from app.db.models import Organisation +from app.db.models import Round +from app.db.queries.fund import add_fund +from app.db.queries.fund import add_organisation +from app.db.queries.round import add_round + + +def get_round_by_title(title: str) -> Round: + """ + Retrieves a Round object by its title + + Args: + title: The title of the round to retrieve + + Returns: + The Round object with the given title + + Raises: + ValueError: If no Round with the given title is found + """ + stmt = select(Round).where(Round.title_json["en"].astext == title) + round = db.session.scalars(stmt).first() + if not round: + raise ValueError(f"Round with title '{title}' not found") + return round + + +def create_test_organisation(flask_test_client): + """ + Creates a test Organisation and persists it to the database. + + Args: + flask_test_client: The test client to use for the request + + Returns: + The Organisation object created + """ + return add_organisation( + Organisation( + name=f"test_org_{uuid4().hex[:8]}", + short_name=f"X{uuid4().hex[:8]}", + logo_uri="http://www.example.com", + funds=[], + ) + ) + + +def create_test_fund(flask_test_client, organisation): + """ + Creates a test Fund and persists it to the database. + + Args: + flask_test_client: The test client to use for the request + organisation: The Organisation object to use as the owner of the fund + + Returns: + The Fund object created + """ + return add_fund( + Fund( + name_json={"en": "Test Fund"}, + title_json={"en": "Test Fund Title"}, + description_json={"en": "Test Fund Description"}, + welsh_available=False, + short_name=f"F{uuid4().hex[:8]}", + owner_organisation_id=organisation.organisation_id, + ) + ) + + +def create_test_round(flask_test_client, fund): + """ + Creates a test Round and persists it to the database. + + Args: + flask_test_client: The test client to use for the request + fund: The Fund object to use as the owner of the round + + Returns: + The Round object created + """ + return add_round( + Round( + fund_id=fund.fund_id, + title_json={"en": "Test Round"}, + short_name=f"R{uuid4().hex[:8]}", + opens=datetime.now(), + deadline=datetime.now(), + assessment_start=datetime.now(), + reminder_date=datetime.now(), + assessment_deadline=datetime.now(), + prospectus_link="http://www.example.com", + privacy_notice_link="http://www.example.com", + contact_email="test@example.com", + 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", + eligibility_config={"has_eligibility": "false"}, + eoi_decision_schema={"en": "eoi_decision_schema", "cy": ""}, + contact_us_banner_json={"en": "contact_us_banner", "cy": ""}, + instructions_json={"en": "instructions", "cy": ""}, + application_guidance_json={"en": "application_guidance", "cy": ""}, + ) + ) + + +def get_csrf_token(response): + """ + Extracts the CSRF token from the given response. + + Args: + response: The response to extract the CSRF token from + + Returns: + The CSRF token + """ + return response.data.decode().split('name="csrf_token" type="hidden" value="')[1].split('"')[0] + + +def submit_form(flask_test_client, url, data): + """ + Submits a form given a flask test client, url, and the form data. + + Args: + flask_test_client: The flask test client to use. + url: The url of the form to submit. + data: The data to submit on the form. + + Returns: + The response from submitting the form. + """ + response = flask_test_client.get(url) + csrf_token = get_csrf_token(response) + data["csrf_token"] = csrf_token + return flask_test_client.post( + url, data=data, follow_redirects=True, headers={"Content-Type": "application/x-www-form-urlencoded"} + ) diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 00000000..b2fc8cfd --- /dev/null +++ b/tests/test_routes.py @@ -0,0 +1,173 @@ +from app.db.models import Fund +from app.db.models import Round +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 + + +def test_create_fund(flask_test_client, _db, clear_test_data): + """ + 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", + } + + 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 + else: + assert created_fund.__getattribute__(key) == value + + +def test_update_fund(flask_test_client, test_fund): + """ + 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", + } + + 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 != "submit": + assert updated_fund.__getattribute__(key) == value + + +def test_create_new_round(flask_test_client, test_fund): + """ + Tests that a round can be successfully created using the /round route + Verifies that the created round has the correct attributes + """ + 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" + + +def test_update_existing_round(flask_test_client, test_round): + """ + 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", + } + + 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"