diff --git a/app/blueprints/fund_builder/routes.py b/app/blueprints/fund_builder/routes.py index dd6ab4b..52c5536 100644 --- a/app/blueprints/fund_builder/routes.py +++ b/app/blueprints/fund_builder/routes.py @@ -16,6 +16,7 @@ from app.blueprints.fund_builder.forms.round import RoundForm from app.db.models.fund import Fund from app.db.models.round import Round +from app.db.queries.application import clone_single_round from app.db.queries.application import get_form_by_id from app.db.queries.fund import add_fund from app.db.queries.fund import get_all_funds @@ -67,6 +68,15 @@ def view_app_config(round_id): return render_template("view_application_config.html", round=round, fund=fund) +@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)}") + 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/round//assessment_config") def view_assess_config(round_id): """ diff --git a/app/blueprints/fund_builder/templates/view_fund_config.html b/app/blueprints/fund_builder/templates/view_fund_config.html index d54706d..6813973 100644 --- a/app/blueprints/fund_builder/templates/view_fund_config.html +++ b/app/blueprints/fund_builder/templates/view_fund_config.html @@ -159,6 +159,12 @@

{{round.title_json["en"]}}

"href": url_for("build_fund_bp.view_assess_config", 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), + "classes": "govuk-button--secondary" + }) }} {% endfor %} {% endset %} diff --git a/app/db/migrations/versions/~2024_07_19_1136-3fffc621bff4_.py b/app/db/migrations/versions/~2024_07_19_1136-3fffc621bff4_.py new file mode 100644 index 0000000..a9636f0 --- /dev/null +++ b/app/db/migrations/versions/~2024_07_19_1136-3fffc621bff4_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: 3fffc621bff4 +Revises: 5c63de4e4e49 +Create Date: 2024-07-19 11:36:32.716999 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "3fffc621bff4" +down_revision = "5c63de4e4e49" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("page", schema=None) as batch_op: + batch_op.add_column(sa.Column("controller", sa.String(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("page", schema=None) as batch_op: + batch_op.drop_column("controller") + + # ### end Alembic commands ### diff --git a/app/db/migrations/versions/~2024_07_19_1233-3de2807b6917_.py b/app/db/migrations/versions/~2024_07_19_1233-3de2807b6917_.py new file mode 100644 index 0000000..d184a72 --- /dev/null +++ b/app/db/migrations/versions/~2024_07_19_1233-3de2807b6917_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: 3de2807b6917 +Revises: 3fffc621bff4 +Create Date: 2024-07-19 12:33:29.898715 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "3de2807b6917" +down_revision = "3fffc621bff4" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("round", schema=None) as batch_op: + batch_op.add_column(sa.Column("source_template_id", sa.UUID(), nullable=True)) + batch_op.add_column(sa.Column("template_name", sa.String(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("round", schema=None) as batch_op: + batch_op.drop_column("template_name") + batch_op.drop_column("source_template_id") + + # ### end Alembic commands ### diff --git a/app/db/migrations/versions/~2024_07_19_1320-da88c6b36588_.py b/app/db/migrations/versions/~2024_07_19_1320-da88c6b36588_.py new file mode 100644 index 0000000..5adba7a --- /dev/null +++ b/app/db/migrations/versions/~2024_07_19_1320-da88c6b36588_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: da88c6b36588 +Revises: 3de2807b6917 +Create Date: 2024-07-19 13:20:24.997440 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "da88c6b36588" +down_revision = "3de2807b6917" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("round", schema=None) as batch_op: + batch_op.add_column(sa.Column("prospectus_link", sa.String(), nullable=False)) + batch_op.add_column(sa.Column("privacy_notice_link", sa.String(), nullable=False)) + batch_op.drop_column("privacy_notice") + batch_op.drop_column("prospectus") + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("round", schema=None) as batch_op: + batch_op.add_column(sa.Column("prospectus", sa.VARCHAR(), autoincrement=False, nullable=False)) + batch_op.add_column(sa.Column("privacy_notice", sa.VARCHAR(), autoincrement=False, nullable=False)) + batch_op.drop_column("privacy_notice_link") + batch_op.drop_column("prospectus_link") + + # ### end Alembic commands ### diff --git a/app/db/models/application_config.py b/app/db/models/application_config.py index 49a41a7..ad59dde 100644 --- a/app/db/models/application_config.py +++ b/app/db/models/application_config.py @@ -120,6 +120,7 @@ class Page(BaseModel): "Component", order_by="Component.page_index", collection_class=ordering_list("page_index") ) source_template_id = Column(UUID(as_uuid=True), nullable=True) + controller = Column(String(), nullable=True) def __repr__(self): return f"Page(/{self.display_path} - {self.name_in_apply_json['en']}, Components: {self.components})" diff --git a/app/db/models/round.py b/app/db/models/round.py index c86b372..c3ceca2 100644 --- a/app/db/models/round.py +++ b/app/db/models/round.py @@ -5,7 +5,9 @@ from sqlalchemy import Column from sqlalchemy import DateTime from sqlalchemy import ForeignKey +from sqlalchemy import String from sqlalchemy import UniqueConstraint +from sqlalchemy import inspect from sqlalchemy.dialects.postgresql import JSON from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped @@ -23,29 +25,35 @@ class Round(BaseModel): __table_args__ = (UniqueConstraint("fund_id", "short_name"),) round_id = Column( - "round_id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, nullable=False, ) fund_id = Column( - "fund_id", UUID(as_uuid=True), ForeignKey("fund.fund_id"), nullable=False, ) - title_json = Column("title_json", JSON(none_as_null=True), nullable=False, unique=False) - short_name = Column("short_name", db.String(), nullable=False, unique=False) - opens = Column("opens", DateTime()) - deadline = Column("deadline", DateTime()) - assessment_start = Column("assessment_start", DateTime()) - reminder_date = Column("reminder_date", DateTime()) - assessment_deadline = Column("assessment_deadline", DateTime()) - prospectus_link = Column("prospectus", db.String(), nullable=False, unique=False) - privacy_notice_link = Column("privacy_notice", db.String(), nullable=False, unique=False) - audit_info = Column("audit_info", JSON(none_as_null=True)) - is_template = Column("is_template", Boolean, default=False, nullable=False) + title_json = Column(JSON(none_as_null=True), nullable=False, unique=False) + short_name = Column(db.String(), nullable=False, unique=False) + opens = Column(DateTime()) + deadline = Column(DateTime()) + assessment_start = Column(DateTime()) + reminder_date = Column(DateTime()) + assessment_deadline = Column(DateTime()) + prospectus_link = Column(db.String(), nullable=False, unique=False) + privacy_notice_link = Column(db.String(), nullable=False, unique=False) + audit_info = Column(JSON(none_as_null=True)) + is_template = Column(Boolean, default=False, nullable=False) + source_template_id = Column(UUID(as_uuid=True), nullable=True) + template_name = Column(String(), nullable=True) sections: Mapped[list["Section"]] = relationship("Section") criteria: Mapped[list["Criteria"]] = relationship("Criteria") # several other fields to add + + def __repr__(self): + return f"Round({self.short_name - self.title_json['en']}, Sections: {self.sections})" + + def as_dict(self): + return {col.name: self.__getattribute__(col.name) for col in inspect(self).mapper.columns} diff --git a/app/db/queries/application.py b/app/db/queries/application.py index 0938d57..727c651 100644 --- a/app/db/queries/application.py +++ b/app/db/queries/application.py @@ -5,6 +5,8 @@ from app.db.models import Form from app.db.models import Lizt from app.db.models import Page +from app.db.models.application_config import Section +from app.db.models.round import Round def get_form_for_component(component: Component) -> Form: @@ -62,18 +64,90 @@ def _initiate_cloned_page(to_clone: Page, new_form_id=None): return clone -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) +def _initiate_cloned_form(to_clone: Form, new_section_id: str) -> 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 = [] + 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) + # 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]) + db.session.commit() + + return clone + + +def clone_single_form(form_id: str, new_section_id=None) -> 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) + cloned_pages = [] cloned_components = [] - for component_to_clone in page_to_clone.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]) + 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=clone.page_id, new_theme_id=None + 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) - # clone.components = cloned_components + 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() @@ -105,3 +179,23 @@ def clone_multiple_components(component_ids: list[str], new_page_id=None, new_th 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.short_name = new_short_name + cloned_round.round_id = uuid4() + cloned_round.fund_id = new_fund_id + cloned_round.is_template = False + cloned_round.source_template_id = round_to_clone.round_id + cloned_round.template_name = None + cloned_round.sections = [] + + 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/question_reuse/generate_form.py b/app/question_reuse/generate_form.py index 011e39d..c16f3c4 100644 --- a/app/question_reuse/generate_form.py +++ b/app/question_reuse/generate_form.py @@ -1,8 +1,4 @@ import copy -import json -import os - -import click from app.db.models import Component from app.db.models import Form @@ -50,8 +46,10 @@ } -# Takes in a simple set of conditions and builds them into the form runner format def build_conditions(component: Component) -> list: + """ + Takes in a simple set of conditions and builds them into the form runner format + """ results = [] for condition in component.conditions: result = { @@ -82,6 +80,9 @@ def build_conditions(component: Component) -> list: def build_component(component: Component) -> dict: + """ + Builds the component json in form runner format for the supplied Component object + """ built_component = { "options": component.options or {}, "type": component.type.value, @@ -91,6 +92,7 @@ def build_component(component: Component) -> dict: "name": component.runner_component_name, "metadata": {"fund_builder_id": str(component.component_id)}, } + # add a reference to the relevant list if this component use a list if component.lizt: built_component.update({"list": component.lizt.name}) built_component["metadata"].update({"fund_builder_list_id": str(component.list_id)}) @@ -98,6 +100,16 @@ def build_component(component: Component) -> dict: def build_page(page: Page = None, page_display_path: str = None) -> dict: + """ + Builds the form runner JSON structure for the supplied page. If that page is None, retrieves a template + page with the display_path matching page_display_path. + + This accounts for conditional logic where the destination target will be the display path of a template + page, but that page does not actually live in the main hierarchy as branching logic uses a fixed set of + conditions at this stage. + + Then builds all the components on this page and adds them to the page json structure + """ if not page: page = get_template_page_by_display_path(page_display_path) built_page = copy.deepcopy(BASIC_PAGE_STRUCTURE) @@ -108,8 +120,9 @@ def build_page(page: Page = None, page_display_path: str = None) -> dict: } ) # Having a 'null' controller element breaks the form-json, needs to not be there if blank - # if controller := input_page.get("controller", None): - # page["controller"] = controller + if page.controller: + built_page["controller"] = page.controller + for component in page.components: built_component = build_component(component) @@ -121,6 +134,7 @@ def build_page(page: Page = None, page_display_path: str = None) -> dict: # Goes through the set of pages and updates the conditions and next properties to account for branching def build_navigation(partial_form_json: dict, input_pages: list[Page]) -> dict: # TODO order by index not order in list + # Think this is sorted now that the collection is sorted by index, but needs testing for i in range(0, len(input_pages)): if i < len(input_pages) - 1: next_path = input_pages[i + 1].display_path @@ -165,7 +179,7 @@ def build_navigation(partial_form_json: dict, input_pages: list[Page]) -> dict: } ) - # If there were no conditions and we just continue to the next page + # If there were no conditions we just continue to the next page if not has_conditions: this_page_in_results["next"].append({"path": f"/{next_path}"}) @@ -185,93 +199,71 @@ def build_lists(pages: list[dict]) -> list: return lists -def build_start_page_content_component(content: str, pages) -> dict: +def build_start_page(content: str, form: Form) -> dict: + """ + Builds the start page which contains just an html component comprising a bullet + list of the headings of all pages in this form + """ + start_page = copy.deepcopy(BASIC_PAGE_STRUCTURE) + start_page.update( + { + "title": form.name_in_apply_json["en"], + "path": f"/intro-{human_to_kebab_case(form.name_in_apply_json['en'])}", + "controller": "./pages/start.js", + "next": [{"path": f"/{form.pages[0].display_path}"}], + } + ) ask_about = '

We will ask you about:

    ' - for page in pages: - ask_about += f"
  • {page['title']}
  • " + for page in form.pages: + ask_about += f"
  • {page.name_in_apply_json['en']}
  • " ask_about += "
" - result = { - "name": "start-page-content", - "options": {}, - "type": "Html", - "content": f'

{content}

{ask_about}', - "schema": {}, - } - return result + start_page["components"].append( + { + "name": "start-page-content", + "options": {}, + "type": "Html", + "content": f'

{content}

{ask_about}', + "schema": {}, + } + ) + return start_page def human_to_kebab_case(word: str) -> str | None: + """ + Converts the supplied string into all lower case, and replaces spaces with hyphens + """ if word: return word.replace(" ", "-").strip().lower() def build_form_json(form: Form) -> dict: + """ + Takes in a single Form object and then generates the form runner json for that form. + + Inserts a start page to the beginning of the form, and the summary page at the end. + """ results = copy.deepcopy(BASIC_FORM_STRUCTURE) results["name"] = form.name_in_apply_json["en"] + # Build the basic page structure for page in form.pages: results["pages"].append(build_page(page=page)) - start_page = copy.deepcopy(BASIC_PAGE_STRUCTURE) - start_page.update( - { - "title": form.name_in_apply_json["en"], - "path": f"/intro-{human_to_kebab_case(form.name_in_apply_json['en'])}", - "controller": "./pages/start.js", - "next": [{"path": f"/{form.pages[0].display_path}"}], - } - ) - intro_content = build_start_page_content_component(content=None, pages=results["pages"]) - start_page["components"].append(intro_content) - + # Create the start page + start_page = build_start_page(content=None, form=form) results["pages"].append(start_page) results["startPage"] = start_page["path"] + # Build navigation and add any pages from branching logic results = build_navigation(results, form.pages) + # Build the list values results["lists"] = build_lists(results["pages"]) + # Add on the summary page results["pages"].append(SUMMARY_PAGE) return results - - -@click.command() -@click.option( - "--input_folder", - default="./question_reuse/test_data/in/", - help="Input configuration", - prompt=True, -) -@click.option( - "--input_file", - default="org-info_basic_name_address.json", - help="Input configuration", - prompt=True, -) -@click.option( - "--output_folder", - default="../digital-form-builder/runner/dist/server/forms/", - help="Output destination", - prompt=True, -) -@click.option( - "--output_file", - default="single_name_address.json", - help="Output destination", - prompt=True, -) -def generate_form_json(input_folder, input_file, output_folder, output_file): - with open(os.path.join(input_folder, input_file), "r") as f: - input_data = json.load(f) - - form_json = build_form_json(input_data) - - with open(os.path.join(output_folder, output_file), "w") as f: - json.dump(form_json, f) - - -if __name__ == "__main__": - generate_form_json() diff --git a/tests/test_clone.py b/tests/test_clone.py index 1719243..30ef412 100644 --- a/tests/test_clone.py +++ b/tests/test_clone.py @@ -5,11 +5,17 @@ from app.db.models import Component from app.db.models import ComponentType from app.db.models import Page +from app.db.models.application_config import Form +from app.db.models.application_config import Section from app.db.queries.application import _initiate_cloned_component +from app.db.queries.application import _initiate_cloned_form from app.db.queries.application import _initiate_cloned_page +from app.db.queries.application import _initiate_cloned_section from app.db.queries.application import clone_multiple_components from app.db.queries.application import clone_single_component +from app.db.queries.application import clone_single_form from app.db.queries.application import clone_single_page +from app.db.queries.application import clone_single_section @pytest.fixture @@ -24,6 +30,54 @@ def mock_new_uuid(mocker): # ===================================================================================================================== +def test_initiate_cloned_section(mock_new_uuid): + clone: Section = Section( + section_id="old-id", + name_in_apply_json={"en": "test section 1"}, + round_id="old-section-id", + is_template=True, + template_name="Template Section", + ) + result: Section = _initiate_cloned_section(to_clone=clone, new_round_id="new-round") + assert result + assert result.section_id == mock_new_uuid + + # Check other bits are the same + assert result.name_in_apply_json == clone.name_in_apply_json + + # check template settings + assert result.is_template is False + assert result.source_template_id == "old-id" + assert result.template_name is None + + assert result.round_id == "new-round" + + +def test_initiate_cloned_form(mock_new_uuid): + clone: Form = Form( + form_id="old-id", + name_in_apply_json={"en": "test form 1"}, + section_id="old-section-id", + is_template=True, + template_name="Template Page", + runner_publish_name="template-form-1", + ) + result: Form = _initiate_cloned_form(to_clone=clone, new_section_id="new-section") + assert result + assert result.form_id == mock_new_uuid + + # Check other bits are the same + assert result.name_in_apply_json == clone.name_in_apply_json + assert result.runner_publish_name == clone.runner_publish_name + + # check template settings + assert result.is_template is False + assert result.source_template_id == "old-id" + assert result.template_name is None + + assert result.section_id == "new-section" + + def test_initiate_cloned_page(mock_new_uuid): clone: Page = Page( page_id="old-id", @@ -324,3 +378,361 @@ def test_clone_page_with_components(seed_dynamic_data, _db): assert len(old_page_from_db.components) == 3 for component in old_page_from_db.components: assert str(component.component_id) in old_component_ids + + +@pytest.mark.seed_config( + { + "forms": [ + Form( + form_id=uuid4(), + section_id=None, + name_in_apply_json={"en": "UT Form 1"}, + section_index=2, + runner_publish_name="ut-form-1", + ) + ] + } +) +def test_clone_form_no_pages(seed_dynamic_data, _db): + old_form = _db.session.get(Form, seed_dynamic_data["forms"][0].form_id) + assert old_form + + result = clone_single_form(form_id=old_form.form_id, new_section_id=None) + assert result + assert result.form_id != old_form.form_id + + cloned_form = _db.session.get(Form, result.form_id) + assert cloned_form + assert len(cloned_form.pages) == 0 + + old_form_from_db = _db.session.get(Form, old_form.form_id) + assert old_form_from_db + + +form_id_2 = uuid4() + + +@pytest.mark.seed_config( + { + "forms": [ + Form( + form_id=form_id_2, + section_id=None, + name_in_apply_json={"en": "UT Form 2"}, + section_index=2, + runner_publish_name="ut-form-2", + ) + ], + "pages": [ + Page( + page_id=uuid4(), + form_id=form_id_2, + display_path="testing-clone-from-form", + is_template=True, + name_in_apply_json={"en": "Clone testing"}, + form_index=0, + ) + ], + } +) +def test_clone_form_with_page(seed_dynamic_data, _db): + old_form = _db.session.get(Form, seed_dynamic_data["forms"][0].form_id) + assert old_form + + result = clone_single_form(form_id=old_form.form_id, new_section_id=None) + assert result + assert result.form_id != old_form.form_id + + cloned_form = _db.session.get(Form, result.form_id) + assert cloned_form + assert len(cloned_form.pages) == 1 + new_page_id = cloned_form.pages[0].page_id + + old_form_from_db = _db.session.get(Form, old_form.form_id) + assert old_form_from_db + assert len(old_form_from_db.pages) == 1 + old_page_id = old_form_from_db.pages[0].page_id + + assert old_page_id != new_page_id + + +form_id_3 = uuid4() +page_id_2 = uuid4() +page_id_3 = uuid4() + + +@pytest.mark.seed_config( + { + "forms": [ + Form( + form_id=form_id_3, + section_id=None, + name_in_apply_json={"en": "UT Form 2"}, + section_index=2, + runner_publish_name="ut-form-2", + ) + ], + "pages": [ + Page( + page_id=page_id_2, + form_id=form_id_3, + display_path="testing-clone-from-form-2", + is_template=True, + name_in_apply_json={"en": "Clone testing"}, + form_index=0, + ), + Page( + page_id=page_id_3, + form_id=form_id_3, + display_path="testing-clone-from-form-3", + is_template=True, + name_in_apply_json={"en": "Clone testing"}, + form_index=0, + ), + ], + "components": [ + Component( + component_id=uuid4(), + page_id=page_id_2, + title="Template qustion 1?", + type=ComponentType.YES_NO_FIELD, + page_index=1, + theme_id=None, + theme_index=2, + options={"hideTitle": False, "classes": "test-class"}, + runner_component_name="template_question_name_1", + is_template=True, + ), + Component( + component_id=uuid4(), + page_id=page_id_2, + title="Template qustion 2?", + type=ComponentType.YES_NO_FIELD, + page_index=1, + theme_id=None, + theme_index=2, + options={"hideTitle": False, "classes": "test-class"}, + runner_component_name="template_question_name_2", + is_template=True, + ), + Component( + component_id=uuid4(), + page_id=page_id_3, + title="Template qustion 3?", + type=ComponentType.YES_NO_FIELD, + page_index=1, + theme_id=None, + theme_index=2, + options={"hideTitle": False, "classes": "test-class"}, + runner_component_name="template_question_name_3", + is_template=True, + ), + ], + } +) +def test_clone_form_with_pages_and_components(seed_dynamic_data, _db): + old_form = _db.session.get(Form, seed_dynamic_data["forms"][0].form_id) + assert old_form + + result = clone_single_form(form_id=old_form.form_id, new_section_id=None) + assert result + assert result.form_id != old_form.form_id + + cloned_form = _db.session.get(Form, result.form_id) + assert cloned_form + assert len(cloned_form.pages) == 2 + new_page_id_1 = next(p.page_id for p in cloned_form.pages if p.display_path == "testing-clone-from-form-2") + new_page_id_2 = next(p.page_id for p in cloned_form.pages if p.display_path == "testing-clone-from-form-3") + new_page_1: Page = _db.session.get(Page, new_page_id_1) + new_page_2: Page = _db.session.get(Page, new_page_id_2) + + old_form_from_db = _db.session.get(Form, old_form.form_id) + assert old_form_from_db + assert len(old_form_from_db.pages) == 2 + + # Set old page id 1 and 2 to be the ids of the old pages that correspond to the new + # pages 1 and 2 by matching display path + old_page_id_1 = next(p.page_id for p in old_form_from_db.pages if p.display_path == new_page_1.display_path) + old_page_id_2 = next(p.page_id for p in old_form_from_db.pages if p.display_path == new_page_2.display_path) + + assert new_page_id_1 not in [old_page_id_1, old_page_id_2] + assert new_page_id_2 not in [old_page_id_1, old_page_id_2] + + # Check pages and components + + assert len(new_page_1.components) == 2 + assert len(new_page_2.components) == 1 + + old_page_1 = _db.session.get(Page, old_page_id_1) + old_page_2 = _db.session.get(Page, old_page_id_2) + + old_component_ids_1 = [c.component_id for c in old_page_1.components] + old_component_ids_2 = [c.component_id for c in old_page_2.components] + + # check the new components are different than the old ones + assert new_page_1.components[0].component_id not in old_component_ids_1 + assert new_page_1.components[1].component_id not in old_component_ids_1 + assert new_page_2.components[0].component_id not in old_component_ids_2 + + +@pytest.mark.seed_config( + { + "sections": [ + Section( + section_id=uuid4(), + name_in_apply_json={"en": "UT Section 1"}, + index=2, + ) + ] + } +) +def test_clone_section_no_forms(seed_dynamic_data, _db): + old_section = _db.session.get(Section, seed_dynamic_data["sections"][0].section_id) + assert old_section + + result = clone_single_section(section_id=old_section.section_id, new_round_id=None) + assert result + assert result.section_id != old_section.section_id + + cloned_section = _db.session.get(Section, result.section_id) + assert cloned_section + assert len(cloned_section.forms) == 0 + + old_section_from_db = _db.session.get(Section, old_section.section_id) + assert old_section_from_db + + +section_id_to_clone = uuid4() +form_id_to_clone = uuid4() +page_id_to_clone_1 = uuid4() +page_id_to_clone_2 = uuid4() + + +@pytest.mark.seed_config( + { + "sections": [ + Section( + section_id=section_id_to_clone, + name_in_apply_json={"en": "UT Section 2"}, + index=2, + ) + ], + "forms": [ + Form( + form_id=form_id_to_clone, + section_id=section_id_to_clone, + name_in_apply_json={"en": "UT Form 2"}, + section_index=2, + runner_publish_name="ut-form-2", + ) + ], + "pages": [ + Page( + page_id=page_id_to_clone_1, + form_id=form_id_to_clone, + display_path="testing-clone-from-section-1", + is_template=True, + name_in_apply_json={"en": "Clone testing"}, + form_index=0, + ), + Page( + page_id=page_id_to_clone_2, + form_id=form_id_to_clone, + display_path="testing-clone-from-section-2", + is_template=True, + name_in_apply_json={"en": "Clone testing"}, + form_index=0, + ), + ], + "components": [ + Component( + component_id=uuid4(), + page_id=page_id_to_clone_1, + title="Template qustion 1?", + type=ComponentType.YES_NO_FIELD, + page_index=1, + theme_id=None, + theme_index=2, + options={"hideTitle": False, "classes": "test-class"}, + runner_component_name="template_question_name_1", + is_template=True, + ), + Component( + component_id=uuid4(), + page_id=page_id_to_clone_1, + title="Template qustion 2?", + type=ComponentType.YES_NO_FIELD, + page_index=1, + theme_id=None, + theme_index=2, + options={"hideTitle": False, "classes": "test-class"}, + runner_component_name="template_question_name_2", + is_template=True, + ), + Component( + component_id=uuid4(), + page_id=page_id_to_clone_2, + title="Template qustion 3?", + type=ComponentType.YES_NO_FIELD, + page_index=1, + theme_id=None, + theme_index=2, + options={"hideTitle": False, "classes": "test-class"}, + runner_component_name="template_question_name_3", + is_template=True, + ), + ], + } +) +def test_clone_section_with_forms(seed_dynamic_data, _db): + old_section = _db.session.get(Section, seed_dynamic_data["sections"][0].section_id) + assert old_section + + result = clone_single_section(section_id=old_section.section_id, new_round_id=None) + assert result + assert result.section_id != old_section.section_id + + cloned_section = _db.session.get(Section, result.section_id) + assert cloned_section + assert len(cloned_section.forms) == 1 + + # check the old section still exists + old_section_from_db = _db.session.get(Section, old_section.section_id) + assert old_section_from_db + + # validate the form + cloned_form = _db.session.get(Form, cloned_section.forms[0].form_id) + assert cloned_form + assert len(cloned_form.pages) == 2 + new_page_id_1 = next(p.page_id for p in cloned_form.pages if p.display_path == "testing-clone-from-section-1") + new_page_id_2 = next(p.page_id for p in cloned_form.pages if p.display_path == "testing-clone-from-section-2") + new_page_1: Page = _db.session.get(Page, new_page_id_1) + new_page_2: Page = _db.session.get(Page, new_page_id_2) + + old_form_from_db = _db.session.get(Form, seed_dynamic_data["forms"][0].form_id) + assert old_form_from_db + assert len(old_form_from_db.pages) == 2 + + # Set old page id 1 and 2 to be the ids of the old pages that correspond to the new + # pages 1 and 2 by matching display path + old_page_id_1 = next(p.page_id for p in old_form_from_db.pages if p.display_path == new_page_1.display_path) + old_page_id_2 = next(p.page_id for p in old_form_from_db.pages if p.display_path == new_page_2.display_path) + + assert new_page_id_1 not in [old_page_id_1, old_page_id_2] + assert new_page_id_2 not in [old_page_id_1, old_page_id_2] + + # Check pages and components + + assert len(new_page_1.components) == 2 + assert len(new_page_2.components) == 1 + + old_page_1 = _db.session.get(Page, old_page_id_1) + old_page_2 = _db.session.get(Page, old_page_id_2) + + old_component_ids_1 = [c.component_id for c in old_page_1.components] + old_component_ids_2 = [c.component_id for c in old_page_2.components] + + # check the new components are different than the old ones + assert new_page_1.components[0].component_id not in old_component_ids_1 + assert new_page_1.components[1].component_id not in old_component_ids_1 + assert new_page_2.components[0].component_id not in old_component_ids_2 diff --git a/tests/test_integration.py b/tests/test_integration.py index 1fb87fa..73552fe 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -181,7 +181,7 @@ def test_build_form_json_with_conditions(seed_dynamic_data): # TODO this fails with components from a template (branching logic) -def test_build_assessment_config(seed_dynamic_data): +def test_build_assessment_config_no_branching(seed_dynamic_data): f: Fund = get_fund_by_id(seed_dynamic_data["funds"][0].fund_id) criteria = f.rounds[0].criteria[0]