diff --git a/app/blueprints/fund_builder/routes.py b/app/blueprints/fund_builder/routes.py index 2478b2d0..c2bda87d 100644 --- a/app/blueprints/fund_builder/routes.py +++ b/app/blueprints/fund_builder/routes.py @@ -23,11 +23,12 @@ from app.db.models.round import Round from app.db.queries.application import clone_single_form from app.db.queries.application import clone_single_round +from app.db.queries.application import delete_form +from app.db.queries.application import delete_section from app.db.queries.application import get_all_template_forms from app.db.queries.application import get_form_by_id from app.db.queries.application import get_section_by_id from app.db.queries.application import insert_new_section -from app.db.queries.application import update_form from app.db.queries.application import update_section from app.db.queries.fund import add_fund from app.db.queries.fund import get_all_funds @@ -71,7 +72,7 @@ def section(round_id): } existing_section = None if request.args.get("action") == "remove": - update_section(request.args.get("section_id"), {"round_id": None}) # TODO remove properly + delete_section(section_id=request.args.get("section_id"), cascade=True) 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) @@ -119,8 +120,7 @@ def configure_forms_in_section(round_id, section_id): if request.method == "POST": if request.args.get("action") == "remove": form_id = request.args.get("form_id") - # TODO figure out if we want to do a soft or hard delete here - update_form(form_id, {"section_id": None}) + delete_form(form_id=form_id, cascade=True) else: template_id = request.form.get("template_id") section = get_section_by_id(section_id=section_id) diff --git a/app/db/queries/application.py b/app/db/queries/application.py index d9287ad7..e1701b41 100644 --- a/app/db/queries/application.py +++ b/app/db/queries/application.py @@ -1,5 +1,7 @@ from uuid import uuid4 +from sqlalchemy import delete + from app.db import db from app.db.models import Component from app.db.models import Form @@ -283,8 +285,12 @@ def update_section(section_id, new_section_config): return section -def delete_section(section_id): +def delete_section(section_id, cascade: bool = False): section = db.session.query(Section).where(Section.section_id == section_id).one_or_none() + if cascade: + _delete_all_components_in_pages(page_ids=[page.page_id for form in section.forms for page in form.pages]) + _delete_all_pages_in_forms(form_ids=[f.form_id for f in section.forms]) + _delete_all_forms_in_sections(section_ids=[section_id]) db.session.delete(section) db.session.commit() return section @@ -351,8 +357,17 @@ def update_form(form_id, new_form_config): return form -def delete_form(form_id): +def _delete_all_forms_in_sections(section_ids: list): + stmt = delete(Form).filter(Form.section_id.in_(section_ids)) + db.session.execute(stmt) + db.session.commit() + + +def delete_form(form_id, cascade: bool = False): form = db.session.query(Form).where(Form.form_id == form_id).one_or_none() + if cascade: + _delete_all_components_in_pages(page_ids=[p.page_id for p in form.pages]) + _delete_all_pages_in_forms(form_ids=[form_id]) db.session.delete(form) db.session.commit() return form @@ -419,8 +434,16 @@ def update_page(page_id, new_page_config): return page -def delete_page(page_id): +def _delete_all_pages_in_forms(form_ids: list): + stmt = delete(Page).filter(Page.form_id.in_(form_ids)) + db.session.execute(stmt) + db.session.commit() + + +def delete_page(page_id, cascade: bool = False): page = db.session.query(Page).where(Page.page_id == page_id).one_or_none() + if cascade: + _delete_all_components_in_pages(page_ids=[page_id]) db.session.delete(page) db.session.commit() return page @@ -516,3 +539,9 @@ def delete_component(component_id): db.session.delete(component) db.session.commit() return component + + +def _delete_all_components_in_pages(page_ids): + stmt = delete(Component).filter(Component.page_id.in_(page_ids)) + db.session.execute(stmt) + db.session.commit() diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index a52277c9..d4ff28aa 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -17,6 +17,7 @@ services: - DATABASE_URL=postgresql://postgres:password@fab-db:5432/fund_builder # pragma: allowlist secret - FLASK_DEBUG=0 - FLASK_ENV=development + - SECRET_KEY=local depends_on: [fab-db, form-runner] fab-db: @@ -50,4 +51,4 @@ services: - LOG_LEVEL=debug - 'NODE_CONFIG={"safelist": ["fab"]}' - PREVIEW_MODE=true - - NODE_ENV=development \ No newline at end of file + - NODE_ENV=development diff --git a/docker-compose.yml b/docker-compose.yml index dd12e8a9..9150423f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: environment: - DATABASE_URL=postgresql://postgres:password@fab-db:5432/fab - DATABASE_URL_UNIT_TEST=postgresql://postgres:password@fab-db:5432/fab_unit_test + - SECRET_KEY=local fab-db: diff --git a/tasks/db_tasks.py b/tasks/db_tasks.py index 362ad3c2..7a80d199 100644 --- a/tasks/db_tasks.py +++ b/tasks/db_tasks.py @@ -2,9 +2,11 @@ import sys from os import getenv +from flask_migrate import upgrade from invoke import task # noqa:E402 from app import app +from app.import_config.load_form_json import load_form_jsons sys.path.insert(1, ".") os.environ.update({"FLASK_ENV": "tasks"}) @@ -49,6 +51,7 @@ def recreate_local_dbs(c): print( f"{db_uri} db created...", ) + upgrade() @task @@ -66,6 +69,7 @@ def create_test_data(c): ) db.session.commit() insert_test_data(db=db, test_data=init_salmon_fishing_fund()) + load_form_jsons() @task diff --git a/tests/test_db_template_CRUD.py b/tests/test_db_application_CRUD.py similarity index 82% rename from tests/test_db_template_CRUD.py rename to tests/test_db_application_CRUD.py index 1627576b..acfaa7a5 100644 --- a/tests/test_db_template_CRUD.py +++ b/tests/test_db_application_CRUD.py @@ -1,11 +1,13 @@ import uuid from copy import deepcopy +import pytest from sqlalchemy.exc import IntegrityError from app.db.models import ComponentType from app.db.models.application_config import Component from app.db.models.application_config import Form +from app.db.models.application_config import Lizt from app.db.models.application_config import Page from app.db.models.application_config import Section from app.db.queries.application import delete_component @@ -106,6 +108,25 @@ def test_delete_section(flask_test_client, _db, clear_test_data, seed_dynamic_da assert _db.session.query(Section).filter(Section.section_id == new_section.section_id).one_or_none() is None +def test_failed_delete_section_cascade(flask_test_client, _db, clear_test_data, seed_dynamic_data): + new_section_config["round_id"] = None + section = insert_new_section(new_section_config) + # CREATE FK link to Form + new_form_config["section_id"] = section.section_id + form = insert_new_form(new_form_config) + # check inserted form has same section_id + assert form.section_id == section.section_id + assert isinstance(section, Section) + assert section.audit_info == new_section_config["audit_info"] + assert isinstance(form, Form) + new_form_id = form.form_id + + delete_section(form.section_id, cascade=True) + + assert _db.session.query(Section).filter(Section.section_id == section.section_id).one_or_none() is None + assert _db.session.query(Form).where(Form.form_id == new_form_id).one_or_none() is None + + def test_failed_delete_section_with_fk_to_forms(flask_test_client, _db, clear_test_data, seed_dynamic_data): new_section_config["round_id"] = None section = insert_new_section(new_section_config) @@ -117,12 +138,9 @@ def test_failed_delete_section_with_fk_to_forms(flask_test_client, _db, clear_te assert isinstance(section, Section) assert section.audit_info == new_section_config["audit_info"] - try: - delete_section(form.section_id) - assert False, "Expected IntegrityError was not raised" - except IntegrityError: - _db.session.rollback() # Rollback the failed transaction to maintain DB integrity - assert True # Explicitly pass the test to indicate the expected error was caught + with pytest.raises(IntegrityError): + delete_section(form.section_id, cascade=False) + _db.session.rollback() # Rollback the failed transaction to maintain DB integrity existing_section = _db.session.query(Section).filter(Section.section_id == section.section_id).one_or_none() assert existing_section is not None, "Section was unexpectedly deleted" @@ -226,6 +244,23 @@ def test_delete_form(flask_test_client, _db, clear_test_data, seed_dynamic_data) assert _db.session.query(Form).filter(Form.form_id == new_form.form_id).one_or_none() is None +def test_delete_form_cascade(flask_test_client, _db, clear_test_data, seed_dynamic_data): + new_form_config["section_id"] = None + new_form = insert_new_form(new_form_config) + # CREATE FK link to Form + new_page_config["form_id"] = new_form.form_id + new_page = insert_new_page(new_page_config) + + assert isinstance(new_form, Form) + assert new_form.audit_info == new_form_config["audit_info"] + assert isinstance(new_page, Page) + new_page_id = new_page.page_id + + delete_form(new_form.form_id, cascade=True) + assert _db.session.query(Form).filter(Form.form_id == new_form.form_id).one_or_none() is None + assert _db.session.query(Page).where(Page.page_id == new_page_id).one_or_none() is None + + def test_failed_delete_form_with_fk_to_page(flask_test_client, _db, clear_test_data, seed_dynamic_data): new_form_config["section_id"] = None form = insert_new_form(new_form_config) @@ -233,12 +268,9 @@ def test_failed_delete_form_with_fk_to_page(flask_test_client, _db, clear_test_d new_page_config["form_id"] = form.form_id page = insert_new_page(new_page_config) - try: - delete_form(page.form_id) - assert False, "Expected IntegrityError was not raised" - except IntegrityError: - _db.session.rollback() # Rollback the failed transaction to maintain DB integrity - assert True # Explicitly pass the test to indicate the expected error was caught + with pytest.raises(IntegrityError): + delete_form(page.form_id, cascade=False) + _db.session.rollback() # Rollback the failed transaction to maintain DB integrity existing_form = _db.session.query(Form).filter(Form.form_id == form.form_id).one_or_none() assert existing_form is not None, "Form was unexpectedly deleted" @@ -340,6 +372,27 @@ def test_delete_page(flask_test_client, _db, clear_test_data, seed_dynamic_data) assert _db.session.query(Page).filter(Page.page_id == new_page.page_id).one_or_none() is None +def test_delete_page_cascade(flask_test_client, _db, clear_test_data, seed_dynamic_data): + new_page_config["form_id"] = None + new_page = insert_new_page(new_page_config) + + assert isinstance(new_page, Page) + page_id_to_delete = new_page.page_id + assert new_page.audit_info == new_page_config["audit_info"] + + # create component on that page + new_component_config["page_id"] = new_page.page_id + new_component_config["list_id"] = None + new_component_config["theme_id"] = None + new_component = insert_new_component(new_component_config=new_component_config) + assert isinstance(new_component, Component) + component_id_to_delete = new_component.component_id + + delete_page(new_page.page_id, cascade=True) + assert _db.session.query(Page).where(Page.page_id == page_id_to_delete).one_or_none() is None + assert _db.session.query(Component).where(Component.component_id == component_id_to_delete).one_or_none() is None + + def test_failed_delete_page_with_fk_to_component(flask_test_client, _db, clear_test_data, seed_dynamic_data): new_page_config["form_id"] = None new_page = insert_new_page(new_page_config) @@ -353,12 +406,9 @@ def test_failed_delete_page_with_fk_to_component(flask_test_client, _db, clear_t assert isinstance(new_page, Page) assert new_page.audit_info == new_page_config["audit_info"] - try: - delete_page(component.page_id) - assert False, "Expected IntegrityError was not raised" - except IntegrityError: - _db.session.rollback() # Rollback the failed transaction to maintain DB integrity - assert True # Explicitly pass the test to indicate the expected error was caught + with pytest.raises(IntegrityError): + delete_page(new_page.page_id, cascade=False) + _db.session.rollback() # Rollback the failed transaction to maintain DB integrity existing_page = _db.session.query(Page).filter(Page.page_id == new_page.page_id).one_or_none() assert existing_page is not None, "Page was unexpectedly deleted" @@ -518,3 +568,40 @@ def test_delete_component(flask_test_client, _db, clear_test_data, seed_dynamic_ delete_component(component.component_id) assert _db.session.query(Component).filter(Component.component_id == component.component_id).one_or_none() is None + assert _db.session.query(Lizt).where(Lizt.list_id == list_id).one_or_none() is not None + + +def test_delete_section_with_full_cascade(flask_test_client, _db, clear_test_data, seed_dynamic_data): + new_section_config["round_id"] = None + new_section = insert_new_section(new_section_config) + assert isinstance(new_section, Section) + new_section_id = new_section.section_id + + # CREATE FK link to Form + new_form_config["section_id"] = new_section_id + new_form = insert_new_form(new_form_config) + assert isinstance(new_form, Form) + assert new_form.section_id == new_section_id + new_form_id = new_form.form_id + + # create FK link to page + new_page_config["form_id"] = new_form_id + new_page = insert_new_page(new_page_config) + assert isinstance(new_page, Page) + assert new_page.form_id == new_form_id + new_page_id = new_page.page_id + + # create component on that page + new_component_config["page_id"] = new_page_id + new_component_config["list_id"] = None + new_component_config["theme_id"] = None + new_component = insert_new_component(new_component_config=new_component_config) + assert isinstance(new_component, Component) + new_component_id = new_component.component_id + + # Should successfully delete everything with cascade == true + delete_section(new_section_id, cascade=True) + assert _db.session.query(Section).where(Section.section_id == new_section_id).one_or_none() is None + assert _db.session.query(Form).where(Form.form_id == new_form_id).one_or_none() is None + assert _db.session.query(Page).where(Page.page_id == new_page_id).one_or_none() is None + assert _db.session.query(Component).where(Component.component_id == new_component_id).one_or_none() is None