diff --git a/app/db/queries/fund.py b/app/db/queries/fund.py index 6fe0121d..686a5647 100644 --- a/app/db/queries/fund.py +++ b/app/db/queries/fund.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import joinedload from app.db import db -from app.db.models import Round, Component, Page, Form +from app.db.models import Round, Component, Page, Form, Lizt from app.db.models.fund import Fund, Organisation from app.db.queries.util import delete_all_related_objects @@ -48,11 +48,14 @@ def _delete_sections_for_fund_round(fund: Fund): for round_detail in fund.rounds: for section in round_detail.sections: if section: + lizt_ids = [component.list_id for form in section.forms for page in form.pages for component in + page.components] page_ids = [page.page_id for form in section.forms for page in form.pages] form_ids = [form.form_id for form in section.forms] section_ids = [section.section_id] delete_all_related_objects(db=db, model=Component, column=Component.page_id, ids=page_ids) + delete_all_related_objects(db=db, model=Lizt, column=Lizt.list_id, ids=lizt_ids) delete_all_related_objects(db=db, model=Page, column=Page.form_id, ids=form_ids) delete_all_related_objects(db=db, model=Form, column=Form.section_id, ids=section_ids) @@ -61,7 +64,7 @@ def _delete_sections_for_fund_round(fund: Fund): def delete_selected_fund(fund_id): - fund: Fund = db.session.query(Fund).options(joinedload(Fund.rounds).joinedload(Round.sections)).get(fund_id) + fund: Fund = db.session.get(Fund, fund_id, options=[joinedload(Fund.rounds).joinedload(Round.sections)]) if not fund: raise ValueError(f"Fund with id {fund_id} not found") try: diff --git a/app/db/queries/round.py b/app/db/queries/round.py index 431309ba..0a45e38f 100644 --- a/app/db/queries/round.py +++ b/app/db/queries/round.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import joinedload from app.db import db -from app.db.models import Fund, Component, Page, Form +from app.db.models import Fund, Component, Page, Form, Lizt from app.db.models.round import Round from app.db.queries.util import delete_all_related_objects @@ -39,11 +39,14 @@ def get_all_rounds() -> list[Round]: def _delete_sections_for_round(round_detail: Round): for section_detail in round_detail.sections: + lizt_ids = [component.list_id for form in section_detail.forms for page in form.pages for component in + page.components] page_ids = [page.page_id for form in section_detail.forms for page in form.pages] form_ids = [form.form_id for form in section_detail.forms] section_ids = [section_detail.section_id] delete_all_related_objects(db=db, model=Component, column=Component.page_id, ids=page_ids) + delete_all_related_objects(db=db, model=Lizt, column=Lizt.list_id, ids=lizt_ids) delete_all_related_objects(db=db, model=Page, column=Page.form_id, ids=form_ids) delete_all_related_objects(db=db, model=Form, column=Form.section_id, ids=section_ids) @@ -52,7 +55,7 @@ def _delete_sections_for_round(round_detail: Round): def delete_selected_round(round_id): - round_detail: Round = db.session.query(Round).options(joinedload(Round.sections)).get(round_id) + round_detail: Round = db.session.get(Round, round_id, options=[joinedload(Round.sections)]) if not round_detail: raise ValueError(f"Round with id {round_id} not found") try: diff --git a/tests/blueprints/fund/test_routes.py b/tests/blueprints/fund/test_routes.py index b68af8f8..be137526 100644 --- a/tests/blueprints/fund/test_routes.py +++ b/tests/blueprints/fund/test_routes.py @@ -2,10 +2,12 @@ from bs4 import BeautifulSoup from flask import g -from app.db.models import Fund +from app.db.models import Fund, Round, Form, Section, Component, Lizt from app.db.models.fund import FundingType from app.db.queries.fund import get_fund_by_id from tests.helpers import submit_form +from config import Config +from sqlalchemy.orm import joinedload @pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") @@ -329,8 +331,8 @@ def test_view_fund_details(flask_test_client, seed_dynamic_data): html = response.data.decode("utf-8") assert f'

{test_fund.name_json["en"]}

' in html assert ( - f'Change' - f' Grant name' in html # noqa: E501 + f'Change' + f' Grant name' in html # noqa: E501 ) assert 'Back' in html @@ -356,3 +358,36 @@ def test_create_fund_welsh_error_messages(flask_test_client, seed_dynamic_data): assert b"Enter the Welsh grant name" in response.data # Validation error message assert b"Enter the Welsh application name" in response.data # Validation error message assert b"Enter the Welsh grant description" in response.data # Validation error message + + +@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") +def test_delete_fund_feature_disabled(flask_test_client, monkeypatch, seed_fund_without_assessment): + """Test that the delete endpoint returns 403 when a feature flag is disabled.""" + test_fund: Fund = seed_fund_without_assessment["funds"][0] + monkeypatch.setattr(Config, "FEATURE_FLAGS", {"feature_delete": False}) + response = flask_test_client.get(f"/grants/{test_fund.fund_id}/delete", follow_redirects=True) + assert response.status_code == 403 + assert b"Delete Feature Disabled" in response.data + + +@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") +def test_delete_fund_feature_enabled(_db, flask_test_client, monkeypatch, seed_fund_without_assessment): + """Test that the delete endpoint redirects when a feature flag is enabled.""" + test_fund: Fund = seed_fund_without_assessment["funds"][0] + monkeypatch.setattr(Config, "FEATURE_FLAGS", {"feature_delete": True}) + output: Fund = _db.session.get(Fund, test_fund.fund_id, + options=[joinedload(Fund.rounds).joinedload(Round.sections)]) + assert output is not None, "No values present in the db" + response = flask_test_client.get(f"/grants/{test_fund.fund_id}/delete", follow_redirects=True) + assert response.status_code == 200 # Assuming redirection to a valid page + _db.session.commit() + output_f = _db.session.get(Fund, test_fund.fund_id, options=[joinedload(Fund.rounds).joinedload(Round.sections)]) + assert output_f is None, "Grant delete did not happened" + output_r = _db.session.query(Round).all() + assert not output_r, "Round delete did not happened" + output_s = _db.session.query(Section).all() + assert not output_s, "Section delete did not happened" + output_c = _db.session.query(Component).all() + assert not output_c, "Component delete did not happened" + output_l = _db.session.query(Lizt).all() + assert not output_l, "Lizt delete did not happened" diff --git a/tests/blueprints/round/test_routes.py b/tests/blueprints/round/test_routes.py index 6d8413d2..0602545d 100644 --- a/tests/blueprints/round/test_routes.py +++ b/tests/blueprints/round/test_routes.py @@ -2,9 +2,11 @@ from bs4 import BeautifulSoup from flask import g, url_for -from app.db.models import Round +from app.db.models import Round, Fund, Section, Component, Lizt from app.db.queries.round import get_round_by_id from tests.helpers import submit_form +from config import Config +from sqlalchemy.orm import joinedload round_data_info = { "opens": ["01", "10", "2024", "09", "00"], @@ -330,3 +332,36 @@ def test_clone_round(flask_test_client, seed_dynamic_data): soup = BeautifulSoup(response.data, "html.parser") notification = soup.find("div", {"class": "govuk-notification-banner__content"}) assert notification.text.strip() == "Error copying application" + + +@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") +def test_delete_fund_feature_disabled(flask_test_client, monkeypatch, seed_fund_without_assessment): + """Test that the delete endpoint returns 403 when a feature flag is disabled.""" + test_round: Round = seed_fund_without_assessment["rounds"][0] + monkeypatch.setattr(Config, "FEATURE_FLAGS", {"feature_delete": False}) + response = flask_test_client.get(f"/rounds/{test_round.round_id}/delete", follow_redirects=True) + assert response.status_code == 403 + assert b"Delete Feature Disabled" in response.data + + +@pytest.mark.usefixtures("set_auth_cookie", "patch_validate_token_rs256_internal_user") +def test_delete_fund_feature_enabled(_db, flask_test_client, monkeypatch, seed_fund_without_assessment): + """Test that the delete endpoint redirects when a feature flag is enabled.""" + test_round: Round = seed_fund_without_assessment["rounds"][0] + monkeypatch.setattr(Config, "FEATURE_FLAGS", {"feature_delete": True}) + output: Fund = _db.session.get(Fund, test_round.fund_id, + options=[joinedload(Fund.rounds).joinedload(Round.sections)]) + assert output is not None, "No values present in the db" + response = flask_test_client.get(f"/rounds/{test_round.round_id}/delete", follow_redirects=True) + assert response.status_code == 200 # Assuming redirection to a valid page + _db.session.commit() + output_f = _db.session.get(Fund, test_round.fund_id, options=[joinedload(Fund.rounds).joinedload(Round.sections)]) + assert output_f is not None, "Grant deleted" + output_r = _db.session.query(Round).all() + assert not output_r, "Round delete did not happened" + output_s = _db.session.query(Section).all() + assert not output_s, "Section delete did not happened" + output_c = _db.session.query(Component).all() + assert not output_c, "Component delete did not happened" + output_l = _db.session.query(Lizt).all() + assert not output_l, "Lizt delete did not happened" \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 52cbebae..a3ce8cee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ from app.create_app import create_app from app.import_config.load_form_json import load_form_jsons from config import Config -from tests.seed_test_data import init_unit_test_data, insert_test_data +from tests.seed_test_data import init_unit_test_data, insert_test_data, fund_without_assessment pytest_plugins = ["fsd_test_utils.fixtures.db_fixtures"] @@ -47,6 +47,29 @@ def seed_dynamic_data(request, app, clear_test_data, _db, enable_preserve_test_d _db.session.commit() +@pytest.fixture(scope="function") +def seed_fund_without_assessment(request, app, clear_test_data, _db, enable_preserve_test_data): + marker = request.node.get_closest_marker("seed_config") + + if marker is None: + fab_seed_data = fund_without_assessment() + else: + fab_seed_data = marker.args[0] + insert_test_data(db=_db, test_data=fab_seed_data) + yield fab_seed_data + # cleanup data after test + # rollback incase of any errors during test session + _db.session.rollback() + # disable foreign key checks + _db.session.execute(text("SET session_replication_role = replica")) + # delete all data from tables + for table in reversed(_db.metadata.sorted_tables): + _db.session.execute(table.delete()) + # reset foreign key checks + _db.session.execute(text("SET session_replication_role = DEFAULT")) + _db.session.commit() + + @pytest.fixture(scope="function") def db_with_templates(app, _db): """Ensures a clean database but with templates already loaded""" diff --git a/tests/seed_test_data.py b/tests/seed_test_data.py index f747c085..dcd5d8b7 100644 --- a/tests/seed_test_data.py +++ b/tests/seed_test_data.py @@ -39,7 +39,7 @@ "project_name_field_id": 1, "prospectus_link": "https://www.gov.uk/government/organisations/ministry-of-housing-communities-local-government", "privacy_notice_link": "https://www.gov.uk/government/organisations/" - "ministry-of-housing-communities-local-government", + "ministry-of-housing-communities-local-government", "contact_email": "help@fab.gov.uk", "instructions_json": {}, "feedback_link": "https://www.gov.uk/government/organisations/ministry-of-housing-communities-local-government", @@ -71,7 +71,7 @@ page_five_id = uuid4() alt_page_id = uuid4() - +#NOSONAR Ignore since this data is related to unit tests def init_salmon_fishing_fund(): organisation_uuid = uuid4() o: Organisation = Organisation( @@ -355,7 +355,7 @@ def init_salmon_fishing_fund(): "organisations": [o], } - +#NOSONAR Ignore since this data is related to unit tests def init_unit_test_data() -> dict: organisation_uuid = uuid4() o: Organisation = Organisation( @@ -481,6 +481,142 @@ def init_unit_test_data() -> dict: "themes": [t1], } +#NOSONAR Ignore since this data is related to unit tests +def fund_without_assessment() -> dict: + organisation_uuid = uuid4() + o: Organisation = Organisation( + organisation_id=organisation_uuid, + name=f"Ministry of Testing - {str(organisation_uuid)[:5]}", + short_name=f"MoT-{str(organisation_uuid)[:5]}", + logo_uri="https://www.google.com", + audit_info={"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "create"}, + ) + + f2: Fund = Fund( + fund_id=uuid4(), + name_json={"en": "Unit Test Fund 2"}, + title_json={"en": "funding to improve testing"}, + description_json={"en": "A £10m fund to improve testing across the devolved nations."}, + welsh_available=False, + short_name=f"UTF{randint(0, 999)}", + owner_organisation_id=o.organisation_id, + funding_type=FundingType.COMPETITIVE, + ggis_scheme_reference_number="G3-SCH-0000092414", + ) + + f2_r1: Round = Round( + round_id=uuid4(), + fund_id=f2.fund_id, + audit_info={"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "create"}, + title_json={"en": "round the first"}, + short_name=f"UTR{randint(0, 999)}", + opens=datetime.now(), + deadline=datetime.now(), + assessment_start=datetime.now(), + reminder_date=datetime.now(), + assessment_deadline=datetime.now(), + prospectus_link="https://www.google.com", + privacy_notice_link="https://www.google.com", + contact_email="test@test.com", + feedback_link="https://www.google.com", + project_name_field_id="12312312312", + guidance_url="https://www.google.com", + feedback_survey_config={ + "has_feedback_survey": False, + "has_section_feedback": False, + "has_research_survey": False, + "is_feedback_survey_optional": False, + "is_section_feedback_optional": False, + "is_research_survey_optional": False, + }, + eligibility_config={"has_eligibility": False}, + eoi_decision_schema={"en": {"valid": True}, "cy": {"valid": False}}, + ) + + f2_r1_s1: Section = Section( + section_id=uuid4(), index=1, round_id=f2_r1.round_id, name_in_apply_json={"en": "Organisation Information 2"} + ) + + f2_r1_s2: Section = Section( + section_id=uuid4(), index=1, round_id=f2_r1.round_id, name_in_apply_json={"en": "Organisation Information 3"} + ) + + f2_r1_s1_f1: Form = Form( + form_id=uuid4(), + section_id=f2_r1_s1.section_id, + name_in_apply_json={"en": "About your organisation"}, + section_index=1, + runner_publish_name="about-your-org", + template_name="About your organization template", + ) + + f2_r1_s1_f2: Form = Form( + form_id=uuid4(), + section_id=f2_r1_s2.section_id, + name_in_apply_json={"en": "About your organisation 2"}, + section_index=1, + runner_publish_name="about-your-org", + template_name="About your organization template", + ) + + f2_r1_s1_f1_p1: Page = Page( + page_id=uuid4(), + form_id=f2_r1_s1_f1.form_id, + display_path="organisation-name", + name_in_apply_json={"en": "Organisation Name"}, + form_index=1, + default_next_page_id=None, + ) + + f2_r1_s1_f1_p2: Page = Page( + page_id=uuid4(), + form_id=f2_r1_s1_f2.form_id, + display_path="organisation-name", + name_in_apply_json={"en": "Organisation Name"}, + form_index=1, + default_next_page_id=None, + ) + + f2_r1_s1_f1_p1_c1: Component = Component( + component_id=uuid4(), + page_id=f2_r1_s1_f1_p1.page_id, + title="What is your organisation's name?", + hint_text="This must match the registered legal organisation name", + type=ComponentType.TEXT_FIELD, + page_index=1, + options={"hideTitle": False, "classes": ""}, + runner_component_name="organisation_name", + ) + + l1: Lizt = Lizt( + list_id=uuid4(), + name="classifications_list", + type="string", + items=[{"text": "Charity", "value": "charity"}, {"text": "Public Limited Company", "value": "plc"}], + is_template=True, + ) + + f2_r1_s1_f1_p1_c2_with_list: Component = Component( + component_id=uuid4(), + page_id=f2_r1_s1_f1_p2.page_id, + title="How is your organisation classified?", + type=ComponentType.RADIOS_FIELD, + page_index=2, + options={"hideTitle": False, "classes": ""}, + runner_component_name="organisation_classification", + list_id=l1.list_id, + ) + return { + "lists": [l1], + "funds": [f2], + "organisations": [o], + "rounds": [f2_r1], + "sections": [f2_r1_s1, f2_r1_s2], + "forms": [f2_r1_s1_f1, f2_r1_s1_f2], + "pages": [f2_r1_s1_f1_p1, f2_r1_s1_f1_p2], + "components": [f2_r1_s1_f1_p1_c1, f2_r1_s1_f1_p1_c2_with_list] + } + def add_default_page_paths(db, default_next_page_config): # set up the default paths