From 15f07ec7f0042998f30f247e68db0137e2960e9a Mon Sep 17 00:00:00 2001 From: Adam Wallace Date: Fri, 23 Aug 2024 10:18:09 +0100 Subject: [PATCH] FAB - Import structured application template into the FAB tool (#21) --- .gitignore | 3 + README.md | 2 +- app/all_questions/metadata_utils.py | 6 +- app/blueprints/fund_builder/routes.py | 42 +- .../templates/view_application_config.html | 6 + app/blueprints/self_serve/routes.py | 4 +- .../self_serve/templates/index.html | 27 +- .../R605/form_runner/about-your-org.json | 236 ---- .../R605/form_runner/contact-details.json | 105 -- .../R605/fund_store/fund_config_09-08-2024.py | 13 - .../fund_store/round_config_09-08-2024.py | 39 - .../fund_store/sections_config_09-08-2024.py | 13 - .../output/R605/html/full_application.html | 81 -- .../~2024_08_19_1303-4ec629449867_.py | 34 + .../~2024_08_21_1123-ed84bb152ee3_.py | 36 + .../~2024_08_21_1528-117417bed885_.py | 32 + app/db/models/application_config.py | 7 + .../README.md | 28 +- .../generate_all_questions.py | 0 .../generate_assessment_config.py | 0 .../generate_form.py | 83 +- .../generate_fund_round_config.py | 126 +-- .../generate_fund_round_form_jsons.py | 6 +- .../generate_fund_round_html.py | 6 +- .../scripts => export_config}/helpers.py | 19 +- app/import_config/README.md | 13 + .../asset-information-cof-r3-w2.json | 1003 +++++++++++++++++ app/import_config/load_form_json.py | 228 ++++ app/shared/data_classes.py | 126 +++ app/shared/helpers.py | 18 + requirements-dev.txt | 15 +- requirements.in | 2 + requirements.txt | 15 +- tasks/export_tasks.py | 6 +- tasks/test_data.py | 40 +- tests/test-import-form.json | 1003 +++++++++++++++++ tests/test_build_assessment_config.py | 12 +- tests/test_build_forms.py | 2 +- ...ig_generators.py => test_config_export.py} | 38 +- tests/test_config_import.py | 33 + tests/test_generate_form.py | 60 +- tests/test_integration.py | 11 +- 42 files changed, 2812 insertions(+), 767 deletions(-) delete mode 100644 app/config_generator/output/R605/form_runner/about-your-org.json delete mode 100644 app/config_generator/output/R605/form_runner/contact-details.json delete mode 100644 app/config_generator/output/R605/fund_store/fund_config_09-08-2024.py delete mode 100644 app/config_generator/output/R605/fund_store/round_config_09-08-2024.py delete mode 100644 app/config_generator/output/R605/fund_store/sections_config_09-08-2024.py delete mode 100644 app/config_generator/output/R605/html/full_application.html create mode 100644 app/db/migrations/versions/~2024_08_19_1303-4ec629449867_.py create mode 100644 app/db/migrations/versions/~2024_08_21_1123-ed84bb152ee3_.py create mode 100644 app/db/migrations/versions/~2024_08_21_1528-117417bed885_.py rename app/{config_generator => export_config}/README.md (93%) rename app/{config_generator => export_config}/generate_all_questions.py (100%) rename app/{config_generator/scripts => export_config}/generate_assessment_config.py (100%) rename app/{config_generator => export_config}/generate_form.py (74%) rename app/{config_generator/scripts => export_config}/generate_fund_round_config.py (61%) rename app/{config_generator/scripts => export_config}/generate_fund_round_form_jsons.py (95%) rename app/{config_generator/scripts => export_config}/generate_fund_round_html.py (91%) rename app/{config_generator/scripts => export_config}/helpers.py (83%) create mode 100644 app/import_config/README.md create mode 100644 app/import_config/files_to_import/asset-information-cof-r3-w2.json create mode 100644 app/import_config/load_form_json.py create mode 100644 app/shared/data_classes.py create mode 100644 app/shared/helpers.py create mode 100644 tests/test-import-form.json rename tests/{test_config_generators.py => test_config_export.py} (81%) create mode 100644 tests/test_config_import.py diff --git a/.gitignore b/.gitignore index 82f9275..8aca332 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Script output directory +app/export_config/output/ diff --git a/README.md b/README.md index 0b7b31f..7b8c6fd 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Contained in [db_tasks.py](./tasks/db_tasks.py) ## Configuration Export Tasks Contained in [export_tasks.py](./tasks/export_tasks.py) -The configuration output is generated by the [config_generator](./app/config_generator/README.md) module. This module contains functions to generate fund and round configuration, form JSONs, and HTML representations for a given funding round. +The configuration output is generated by the [config_generator](./app/export_config/README.md) module. This module contains functions to generate fund and round configuration, form JSONs, and HTML representations for a given funding round. ## Database ### Schema diff --git a/app/all_questions/metadata_utils.py b/app/all_questions/metadata_utils.py index dfdf660..08f1c15 100644 --- a/app/all_questions/metadata_utils.py +++ b/app/all_questions/metadata_utils.py @@ -300,7 +300,11 @@ def determine_title_and_text_for_component( extract_from_html(soup, text) update_wording_for_multi_input_fields(text) - if component["type"].casefold() in FIELD_TYPES_WITH_MAX_WORDS and not is_child: + if ( + component["type"].casefold() in FIELD_TYPES_WITH_MAX_WORDS + and not is_child + and "maxWords" in component["options"] + ): text.append(f"(Max {component['options']['maxWords']} words)") if "list" in component: diff --git a/app/blueprints/fund_builder/routes.py b/app/blueprints/fund_builder/routes.py index f937c34..4835340 100644 --- a/app/blueprints/fund_builder/routes.py +++ b/app/blueprints/fund_builder/routes.py @@ -1,21 +1,23 @@ import json +import os +import shutil from datetime import datetime from random import randint import requests from flask import Blueprint from flask import Response +from flask import after_this_request from flask import flash from flask import redirect from flask import render_template from flask import request +from flask import send_file from flask import url_for from app.all_questions.metadata_utils import generate_print_data_for_sections from app.blueprints.fund_builder.forms.fund import FundForm from app.blueprints.fund_builder.forms.round import RoundForm -from app.config_generator.generate_all_questions import print_html -from app.config_generator.generate_form import build_form_json from app.db.models.fund import Fund from app.db.models.round import Round from app.db.queries.application import clone_single_round @@ -25,6 +27,16 @@ from app.db.queries.fund import get_fund_by_id from app.db.queries.round import add_round from app.db.queries.round import get_round_by_id +from app.export_config.generate_all_questions import print_html +from app.export_config.generate_form import build_form_json +from app.export_config.generate_fund_round_config import ( + generate_application_display_config, +) +from app.export_config.generate_fund_round_config import generate_fund_config +from app.export_config.generate_fund_round_form_jsons import ( + generate_form_jsons_for_round, +) +from app.export_config.generate_fund_round_html import generate_all_round_html from config import Config # Blueprint for routes used by v1 of FAB - using the DB @@ -207,3 +219,29 @@ def view_all_questions(round_id): ) html = print_html(print_data) return render_template("view_questions.html", round=round, fund=fund, question_html=html) + + +@build_fund_bp.route("/create_export_files/", methods=["GET"]) +def create_export_files(round_id): + generate_form_jsons_for_round(round_id) + generate_all_round_html(round_id) + generate_application_display_config(round_id) + generate_fund_config(round_id) + round_short_name = get_round_by_id(round_id).short_name + + # Directory to zip + directory_to_zip = f"app/export/config_generator/output/{round_short_name}/" + # Output zip file path (temporary) + output_zip_path = f"app/export/config_generator/output/{round_short_name}.zip" + + # Create a zip archive of the directory + shutil.make_archive(output_zip_path.replace(".zip", ""), "zip", directory_to_zip) + + # Ensure the file is removed after sending it + @after_this_request + def remove_file(response): + os.remove(output_zip_path) + return response + + # Return the zipped folder for the user to download + return send_file(output_zip_path, as_attachment=True, download_name=f"{round_short_name}.zip") diff --git a/app/blueprints/fund_builder/templates/view_application_config.html b/app/blueprints/fund_builder/templates/view_application_config.html index 3075cc8..5d7e843 100644 --- a/app/blueprints/fund_builder/templates/view_application_config.html +++ b/app/blueprints/fund_builder/templates/view_application_config.html @@ -16,6 +16,12 @@

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

"classes": "govuk-button--secondary" }) }} + {{ govukButton({ + "text": "Create export files for round", + "href": url_for("build_fund_bp.create_export_files", round_id=round.round_id), + "classes": "govuk-button--secondary" + }) + }} {% for section in round.sections %}
diff --git a/app/blueprints/self_serve/routes.py b/app/blueprints/self_serve/routes.py index 5a32e8b..c30ba94 100644 --- a/app/blueprints/self_serve/routes.py +++ b/app/blueprints/self_serve/routes.py @@ -22,8 +22,8 @@ from app.blueprints.self_serve.forms.page_form import PageForm from app.blueprints.self_serve.forms.question_form import QuestionForm from app.blueprints.self_serve.forms.section_form import SectionForm -from app.config_generator.generate_all_questions import print_html -from app.config_generator.generate_form import build_form_json +from app.export_config.generate_all_questions import print_html +from app.export_config.generate_form import build_form_json FORM_RUNNER_URL = os.getenv("FORM_RUNNER_INTERNAL_HOST", "http://form-runner:3009") FORM_RUNNER_URL_REDIRECT = os.getenv("FORM_RUNNER_EXTERNAL_HOST", "http://localhost:3009") diff --git a/app/blueprints/self_serve/templates/index.html b/app/blueprints/self_serve/templates/index.html index 1c831d4..b3f3003 100644 --- a/app/blueprints/self_serve/templates/index.html +++ b/app/blueprints/self_serve/templates/index.html @@ -19,30 +19,23 @@ diff --git a/app/config_generator/output/R605/form_runner/about-your-org.json b/app/config_generator/output/R605/form_runner/about-your-org.json deleted file mode 100644 index babaaf8..0000000 --- a/app/config_generator/output/R605/form_runner/about-your-org.json +++ /dev/null @@ -1,236 +0,0 @@ -{ - "metadata": {}, - "startPage": "/intro-about-your-organisation", - "backLinkText": "Go back to application overview", - "pages": [ - { - "path": "/organisation-name", - "title": "Organisation Name", - "components": [ - { - "options": { - "hideTitle": false, - "classes": "" - }, - "type": "TextField", - "title": "What is your organisation's name?", - "hint": "This must match the regsitered legal organisation name", - "schema": {}, - "name": "organisation_name", - "metadata": { - "fund_builder_id": "6a7b3d94-b92a-4b49-a02e-ccbcdc05c547" - } - }, - { - "options": { - "hideTitle": false, - "classes": "" - }, - "type": "YesNoField", - "title": "Does your organisation use any other names?", - "hint": "", - "schema": {}, - "name": "does_your_organisation_use_other_names", - "metadata": { - "fund_builder_id": "e06916c2-f6ad-4257-b8b4-1d646a381963" - } - } - ], - "next": [ - { - "path": "/organisation-address", - "condition": "organisation_other_names_no" - }, - { - "path": "/organisation-alternative-names", - "condition": "organisation_other_names_yes" - } - ], - "options": {} - }, - { - "path": "/organisation-address", - "title": "Organisation Address", - "components": [ - { - "options": { - "hideTitle": false, - "classes": "" - }, - "type": "UkAddressField", - "title": "What is your organisation's address?", - "hint": "This must match the regsitered organisation address", - "schema": {}, - "name": "organisation_address", - "metadata": { - "fund_builder_id": "71c0e632-758b-4f5d-ad01-fa5559033693" - } - } - ], - "next": [ - { - "path": "/organisation-classification" - } - ], - "options": {} - }, - { - "path": "/organisation-classification", - "title": "Organisation Classification", - "components": [ - { - "options": { - "hideTitle": false, - "classes": "" - }, - "type": "RadiosField", - "title": "How is your organisation classified?", - "hint": "", - "schema": {}, - "name": "organisation_classification", - "metadata": { - "fund_builder_id": "0416472e-3e5c-4f8d-8585-81717daae6c0", - "fund_builder_list_id": "2502effd-4461-4a0f-8500-88501ff44540" - }, - "list": "classifications_list" - } - ], - "next": [ - { - "path": "/summary" - } - ], - "options": {} - }, - { - "path": "/intro-about-your-organisation", - "title": "About your organisation", - "components": [ - { - "name": "start-page-content", - "options": {}, - "type": "Html", - "content": "

None

We will ask you about:

  • Organisation Name
  • Organisation Address
  • Organisation Classification
", - "schema": {} - } - ], - "next": [ - { - "path": "/organisation-name" - } - ], - "options": {}, - "controller": "./pages/start.js" - }, - { - "path": "/organisation-alternative-names", - "title": "Alternative names of your organisation", - "components": [ - { - "options": { - "hideTitle": false, - "classes": "" - }, - "type": "TextField", - "title": "Alternative Name 1", - "hint": "", - "schema": {}, - "name": "alt_name_1", - "metadata": { - "fund_builder_id": "6d659633-c801-4aca-9ba1-135f5f6cfaa2" - } - } - ], - "next": [ - { - "path": "/organisation-address" - } - ], - "options": {} - }, - { - "path": "/summary", - "title": "Check your answers", - "components": [], - "next": [], - "section": "uLwBuz", - "controller": "./pages/summary.js" - } - ], - "lists": [ - { - "type": "string", - "items": [ - { - "text": "Charity", - "value": "charity" - }, - { - "text": "Public Limited Company", - "value": "plc" - } - ], - "name": "classifications_list" - } - ], - "conditions": [ - { - "displayName": "organisation_other_names_no", - "name": "organisation_other_names_no", - "value": { - "name": "organisation_other_names_no", - "conditions": [ - { - "field": { - "name": "does_your_organisation_use_other_names", - "type": "YesNoField", - "display": "Does your organisation use any other names?" - }, - "operator": "is", - "value": { - "type": "Value", - "value": "false", - "display": "false" - } - } - ] - } - }, - { - "displayName": "organisation_other_names_yes", - "name": "organisation_other_names_yes", - "value": { - "name": "organisation_other_names_yes", - "conditions": [ - { - "field": { - "name": "does_your_organisation_use_other_names", - "type": "YesNoField", - "display": "Does your organisation use any other names?" - }, - "operator": "is", - "value": { - "type": "Value", - "value": "true", - "display": "true" - } - } - ] - } - } - ], - "fees": [], - "sections": [], - "outputs": [ - { - "name": "update-form", - "title": "Update form in application store", - "type": "savePerPage", - "outputConfiguration": { - "savePerPageUrl": true - } - } - ], - "skipSummary": false, - "name": "About your organisation" -} diff --git a/app/config_generator/output/R605/form_runner/contact-details.json b/app/config_generator/output/R605/form_runner/contact-details.json deleted file mode 100644 index a2d4893..0000000 --- a/app/config_generator/output/R605/form_runner/contact-details.json +++ /dev/null @@ -1,105 +0,0 @@ -{ - "metadata": {}, - "startPage": "/intro-contact-details", - "backLinkText": "Go back to application overview", - "pages": [ - { - "path": "/lead-contact-details", - "title": "Lead Contact Details", - "components": [ - { - "options": { - "hideTitle": false, - "classes": "" - }, - "type": "TextField", - "title": "Main Contact Name", - "hint": "", - "schema": {}, - "name": "main_contact_name", - "metadata": { - "fund_builder_id": "3a2d94d0-fc66-4c8e-a6cf-cdc99a81086d" - } - }, - { - "options": { - "hideTitle": false, - "classes": "" - }, - "type": "EmailAddressField", - "title": "Main Contact Email", - "hint": "", - "schema": {}, - "name": "main_contact_email", - "metadata": { - "fund_builder_id": "42cf792d-ee38-44f1-a720-07e8b874a56b" - } - }, - { - "options": { - "hideTitle": false, - "classes": "" - }, - "type": "UkAddressField", - "title": "Main Contact Address", - "hint": "", - "schema": {}, - "name": "main_contact_address", - "metadata": { - "fund_builder_id": "2574c33f-10f9-4796-83ad-d9f0ce9dfa86" - } - } - ], - "next": [ - { - "path": "/summary" - } - ], - "options": {} - }, - { - "path": "/intro-contact-details", - "title": "Contact Details", - "components": [ - { - "name": "start-page-content", - "options": {}, - "type": "Html", - "content": "

None

We will ask you about:

  • Lead Contact Details
", - "schema": {} - } - ], - "next": [ - { - "path": "/lead-contact-details" - } - ], - "options": {}, - "controller": "./pages/start.js" - }, - { - "path": "/summary", - "title": "Check your answers", - "components": [], - "next": [], - "section": "uLwBuz", - "controller": "./pages/summary.js" - } - ], - "lists": [], - "conditions": [], - "fees": [], - "sections": [], - "outputs": [ - { - "name": "update-form", - "title": "Update form in application store", - "type": "savePerPage", - "outputConfiguration": { - "savePerPageUrl": true - } - } - ], - "skipSummary": false, - "name": "Contact Details" -} diff --git a/app/config_generator/output/R605/fund_store/fund_config_09-08-2024.py b/app/config_generator/output/R605/fund_store/fund_config_09-08-2024.py deleted file mode 100644 index 99145ee..0000000 --- a/app/config_generator/output/R605/fund_store/fund_config_09-08-2024.py +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "c80bd7ce-91a7-4e99-8944-01798953df1c", - "short_name": "SFF863", - "welsh_available": False, - "owner_organisation_name": "Department for Fishing", - "owner_organisation_shortname": "DF", - "owner_organisation_logo_uri": "http://www.google.com", - "name_json": {"en": "Salmon Fishing Fund"}, - "title_json": {"en": "funding to improve access to salmon fishing"}, - "description_json": { - "en": "A £10m fund to improve access to salmon fishing facilities across the devolved nations." - }, -} diff --git a/app/config_generator/output/R605/fund_store/round_config_09-08-2024.py b/app/config_generator/output/R605/fund_store/round_config_09-08-2024.py deleted file mode 100644 index 1a588f4..0000000 --- a/app/config_generator/output/R605/fund_store/round_config_09-08-2024.py +++ /dev/null @@ -1,39 +0,0 @@ -{ - "id": "debde85d-2b1d-4c88-bec5-6eeaa735e6ea", - "fund_id": "c80bd7ce-91a7-4e99-8944-01798953df1c", - "short_name": "R605", - "opens": "2024-08-09T09:12:05.697258", - "assessment_start": "2024-08-09T09:12:05.697259", - "deadline": "2024-08-09T09:12:05.697259", - "application_reminder_sent": False, - "reminder_date": "2024-08-09T09:12:05.697260", - "assessment_deadline": "2024-08-09T09:12:05.697260", - "prospectus": "http://www.google.com", - "privacy_notice": "http://www.google.com", - "reference_contact_page_over_email": False, - "contact_email": None, - "contact_phone": None, - "contact_textphone": None, - "support_times": None, - "support_days": None, - "instructions_json": None, - "feedback_link": None, - "project_name_field_id": None, - "application_guidance_json": None, - "guidance_url": None, - "all_uploaded_documents_section_available": None, - "application_fields_download_available": None, - "display_logo_on_pdf_exports": None, - "mark_as_complete_enabled": None, - "is_expression_of_interest": None, - "eoi_decision_schema": None, - "feedback_survey_config": { - "has_feedback_survey": None, - "has_section_feedback": None, - "is_feedback_survey_optional": None, - "is_section_feedback_optional": None, - }, - "eligibility_config": {"has_eligibility": None}, - "title_json": {"en": "round the first"}, - "contact_us_banner_json": {"en": "", "cy": ""}, -} diff --git a/app/config_generator/output/R605/fund_store/sections_config_09-08-2024.py b/app/config_generator/output/R605/fund_store/sections_config_09-08-2024.py deleted file mode 100644 index dba73b5..0000000 --- a/app/config_generator/output/R605/fund_store/sections_config_09-08-2024.py +++ /dev/null @@ -1,13 +0,0 @@ -[ - {"section_name": {"en": "1. Organisation Information", "cy": ""}, "tree_path": "12.1", "requires_feedback": None}, - { - "section_name": {"en": "1.1 About your organisation", "cy": ""}, - "tree_path": "12.1.1", - "form_name_json": {"en": "about-your-org", "cy": ""}, - }, - { - "section_name": {"en": "1.2 Contact Details", "cy": ""}, - "tree_path": "12.1.2", - "form_name_json": {"en": "contact-details", "cy": ""}, - }, -] diff --git a/app/config_generator/output/R605/html/full_application.html b/app/config_generator/output/R605/html/full_application.html deleted file mode 100644 index db9bed6..0000000 --- a/app/config_generator/output/R605/html/full_application.html +++ /dev/null @@ -1,81 +0,0 @@ -
-

- Table of contents -

-
    -
  1. - - Organisation Information - -
  2. -
-
-

- 1. Organisation Information -

-

- 1.1. About your organisation -

-

- 1.1.1. Organisation Name -

-

- What is your organisation's name? -

-

- This must match the regsitered legal organisation name -

-

- Does your organisation use any other names? -

-

- If 'No', go to 1.1.2 -

-

- If 'Yes', go to 1.1.1.2 -

-

- 1.1.1.2. Alternative names of your organisation -

-

- Alternative Name 1 -

-

- 1.1.2. Organisation Address -

-

- What is your organisation's address? -

-

- This must match the regsitered organisation address -

-

- 1.1.3. Organisation Classification -

-

- How is your organisation classified? -

-
    -
  • - Charity -
  • -
  • - Public Limited Company -
  • -
-

- 1.2. Contact Details -

-

- 1.2.1. Lead Contact Details -

-

- Main Contact Name -

-

- Main Contact Email -

-

- Main Contact Address -

-
diff --git a/app/db/migrations/versions/~2024_08_19_1303-4ec629449867_.py b/app/db/migrations/versions/~2024_08_19_1303-4ec629449867_.py new file mode 100644 index 0000000..49be9c3 --- /dev/null +++ b/app/db/migrations/versions/~2024_08_19_1303-4ec629449867_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: 4ec629449867 +Revises: +Create Date: 2024-08-19 13:03:32.320669 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "4ec629449867" +down_revision = "ab7d40d652d5" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.execute("ALTER TYPE componenttype ADD VALUE 'PARA';") + op.execute("ALTER TYPE componenttype ADD VALUE 'DATE_PARTS_FIELD';") + op.execute("ALTER TYPE componenttype ADD VALUE 'CHECKBOXES_FIELD';") + op.execute("ALTER TYPE componenttype ADD VALUE 'CLIENT_SIDE_FILE_UPLOAD_FIELD';") + op.execute("ALTER TYPE componenttype ADD VALUE 'WEBSITE_FIELD';") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # Note: PostgreSQL does not support removing values from enums directly. + # To fully support downgrade, consider creating a new enum without the values and swapping them, + # or use a workaround that suits your database version and requirements. + # ### end Alembic commands ### + pass diff --git a/app/db/migrations/versions/~2024_08_21_1123-ed84bb152ee3_.py b/app/db/migrations/versions/~2024_08_21_1123-ed84bb152ee3_.py new file mode 100644 index 0000000..2804fd2 --- /dev/null +++ b/app/db/migrations/versions/~2024_08_21_1123-ed84bb152ee3_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: ed84bb152ee3 +Revises: 4ec629449867 +Create Date: 2024-08-21 11:23:11.330243 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "ed84bb152ee3" +down_revision = "4ec629449867" +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("default_next_page_id", sa.UUID(), nullable=True)) + batch_op.create_foreign_key( + batch_op.f("fk_page_default_next_page_id_page"), "page", ["default_next_page_id"], ["page_id"] + ) + + # ### 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_constraint(batch_op.f("fk_page_default_next_page_id_page"), type_="foreignkey") + batch_op.drop_column("default_next_page_id") + + # ### end Alembic commands ### diff --git a/app/db/migrations/versions/~2024_08_21_1528-117417bed885_.py b/app/db/migrations/versions/~2024_08_21_1528-117417bed885_.py new file mode 100644 index 0000000..6da3046 --- /dev/null +++ b/app/db/migrations/versions/~2024_08_21_1528-117417bed885_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: 117417bed885 +Revises: ed84bb152ee3 +Create Date: 2024-08-21 15:28:58.583369 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "117417bed885" +down_revision = "ed84bb152ee3" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("lizt", schema=None) as batch_op: + batch_op.add_column(sa.Column("title", sa.String(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("lizt", schema=None) as batch_op: + batch_op.drop_column("title") + + # ### end Alembic commands ### diff --git a/app/db/models/application_config.py b/app/db/models/application_config.py index 3bf547f..8ce4d6d 100644 --- a/app/db/models/application_config.py +++ b/app/db/models/application_config.py @@ -34,6 +34,11 @@ class ComponentType(Enum): HTML = "Html" YES_NO_FIELD = "YesNoField" RADIOS_FIELD = "RadiosField" + PARA = "Para" + DATE_PARTS_FIELD = "DatePartsField" + CHECKBOXES_FIELD = "CheckboxesField" + CLIENT_SIDE_FILE_UPLOAD_FIELD = "ClientSideFileUploadField" + WEBSITE_FIELD = "WebsiteField" @dataclass @@ -122,6 +127,7 @@ class Page(BaseModel): audit_info = Column(JSON(none_as_null=True)) form_index = Column(Integer()) display_path = Column(String()) + default_next_page_id = Column(UUID(as_uuid=True), ForeignKey("page.page_id"), nullable=True) components: Mapped[List["Component"]] = relationship( "Component", order_by="Component.page_index", @@ -154,6 +160,7 @@ class Lizt(BaseModel): default=uuid.uuid4, ) name = Column(String()) + title = Column(String()) type = Column(String()) items = Column(JSON()) is_template = Column(Boolean, default=False, nullable=False) diff --git a/app/config_generator/README.md b/app/export_config/README.md similarity index 93% rename from app/config_generator/README.md rename to app/export_config/README.md index 0d12130..6379b3a 100644 --- a/app/config_generator/README.md +++ b/app/export_config/README.md @@ -1,4 +1,4 @@ -# FAB Config output +# FAB Config Output This directory contains the scripts and output required to generate the FAB configuration files. @@ -60,19 +60,19 @@ inv generate-round-html {roundid} The scripts generate the following output file structure: ```plaintext app/ - config_generator/ - - scripts/ - -- ** - -- output/ - -- round_short_name/ - -- form_runner/ - -- form_name.json - -- fund_store/ - -- fund_config.py - -- round_config.py - -- sections_config.py - -- html/ - -- full_aplication.html + - export/ + - config_generator/ + -- scripts[.py] + - output/ + - round_short_name/ + - form_runner/ + -- form_name.json + - fund_store/ + -- fund_config.py + -- round_config.py + -- sections_config.py + - html/ + -- full_aplication.html ``` diff --git a/app/config_generator/generate_all_questions.py b/app/export_config/generate_all_questions.py similarity index 100% rename from app/config_generator/generate_all_questions.py rename to app/export_config/generate_all_questions.py diff --git a/app/config_generator/scripts/generate_assessment_config.py b/app/export_config/generate_assessment_config.py similarity index 100% rename from app/config_generator/scripts/generate_assessment_config.py rename to app/export_config/generate_assessment_config.py diff --git a/app/config_generator/generate_form.py b/app/export_config/generate_form.py similarity index 74% rename from app/config_generator/generate_form.py rename to app/export_config/generate_form.py index c16f3c4..6fb7aaf 100644 --- a/app/config_generator/generate_form.py +++ b/app/export_config/generate_form.py @@ -32,7 +32,6 @@ "title": None, "components": [], "next": [], - "options": {}, } @@ -90,7 +89,9 @@ def build_component(component: Component) -> dict: "hint": component.hint_text or "", "schema": {}, "name": component.runner_component_name, - "metadata": {"fund_builder_id": str(component.component_id)}, + "metadata": { + # "fund_builder_id": str(component.component_id) TODO why do we need this? + }, } # add a reference to the relevant list if this component use a list if component.lizt: @@ -133,22 +134,23 @@ 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 - elif i == len(input_pages) - 1: - next_path = "summary" + for page in input_pages: + if page.controller and page.controller.endswith("summary.js"): + continue + next_page_id = page.default_next_page_id + if next_page_id: + find_next_page = lambda id: next(p for p in input_pages if p.page_id == id) # noqa:E731 + next_page = find_next_page(next_page_id) + next_path = next_page.display_path else: + # all page paths are conditionals which will be processed later next_path = None - this_page = input_pages[i] - this_page_in_results = next(p for p in partial_form_json["pages"] if p["path"] == f"/{this_page.display_path}") + # find page in prepared output results + this_page_in_results = next(p for p in partial_form_json["pages"] if p["path"] == f"/{page.display_path}") has_conditions = False - - for component in this_page.components: + for component in page.components: if not component.conditions: continue form_json_conditions = build_conditions(component) @@ -159,18 +161,18 @@ def build_navigation(partial_form_json: dict, input_pages: list[Page]) -> dict: if condition["destination_page_path"] == "CONTINUE": destination_path = f"/{next_path}" else: - destination_path = f"/{condition['destination_page_path']}" - + destination_path = f"/{condition['destination_page_path'].lstrip('/')}" + # TODO No longer needed since db schema change? # If this points to a pre-built page flow, add that in now (it won't be in the input) - if ( - destination_path not in [page["path"] for page in partial_form_json["pages"]] - and not destination_path == "/summary" - ): - sub_page = build_page(page_display_path=destination_path[1:]) - if not sub_page.get("next", None): - sub_page["next"] = [{"path": f"/{next_path}"}] + # if ( + # destination_path not in [page["path"] for page in partial_form_json["pages"]] + # and not destination_path == "/summary" + # ): + # sub_page = build_page(page_display_path=destination_path[1:]) + # if not sub_page.get("next", None): + # sub_page["next"] = [{"path": f"/{next_path}"}] - partial_form_json["pages"].append(sub_page) + # partial_form_json["pages"].append(sub_page) this_page_in_results["next"].append( { @@ -180,8 +182,10 @@ def build_navigation(partial_form_json: dict, input_pages: list[Page]) -> dict: ) # If there were no conditions we just continue to the next page - if not has_conditions: + if not has_conditions and next_path: this_page_in_results["next"].append({"path": f"/{next_path}"}) + if not has_conditions and not next_path: + this_page_in_results["next"].append({"path": "/summary"}) return partial_form_json @@ -193,12 +197,24 @@ def build_lists(pages: list[dict]) -> list: for component in page["components"]: if component.get("list"): list_from_db = get_list_by_id(component["metadata"]["fund_builder_list_id"]) - list = {"type": list_from_db.type, "items": list_from_db.items, "name": list_from_db.name} + list = { + "type": list_from_db.type, + "items": list_from_db.items, + "name": list_from_db.name, + "title": list_from_db.title, + } lists.append(list) + # Remove the metadata key from built_component (no longer needed) + component.pop("metadata", None) # The second argument prevents KeyError if 'metadata' is not found return lists +def _find_page_by_controller(pages, controller_name) -> dict: + + return next((p for p in pages if p.controller and p.controller.endswith(controller_name)), None) + + def build_start_page(content: str, form: Form) -> dict: """ Builds the start page which contains just an html component comprising a bullet @@ -252,10 +268,15 @@ def build_form_json(form: Form) -> dict: for page in form.pages: results["pages"].append(build_page(page=page)) - # Create the start page - start_page = build_start_page(content=None, form=form) - results["pages"].append(start_page) - results["startPage"] = start_page["path"] + # start page is the page with the controller ending start.js + start_page = _find_page_by_controller(form.pages, "start.js") + if start_page: + results["startPage"] = f"/{start_page.display_path}" + else: + # 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) @@ -264,6 +285,8 @@ def build_form_json(form: Form) -> dict: results["lists"] = build_lists(results["pages"]) # Add on the summary page - results["pages"].append(SUMMARY_PAGE) + summary_page = _find_page_by_controller(form.pages, "summary.js") + if not summary_page: + results["pages"].append(SUMMARY_PAGE) return results diff --git a/app/config_generator/scripts/generate_fund_round_config.py b/app/export_config/generate_fund_round_config.py similarity index 61% rename from app/config_generator/scripts/generate_fund_round_config.py rename to app/export_config/generate_fund_round_config.py index fae2fd6..bb07913 100644 --- a/app/config_generator/scripts/generate_fund_round_config.py +++ b/app/export_config/generate_fund_round_config.py @@ -1,23 +1,24 @@ import copy -from dataclasses import dataclass -from dataclasses import field -from typing import Dict -from typing import Optional from flask import current_app -from app.config_generator.scripts.helpers import write_config from app.db import db from app.db.models import Form from app.db.models import Section from app.db.queries.fund import get_fund_by_id from app.db.queries.round import get_round_by_id +from app.export_config.helpers import write_config +from app.shared.data_classes import FundExport +from app.shared.data_classes import FundSectionForm +from app.shared.data_classes import FundSectionSection +from app.shared.data_classes import RoundExport # TODO : The Round path might be better as a placeholder to avoid conflict in the actual fund store. # Decide on this further down the line. ROUND_BASE_PATHS = { # Should increment for each new round, anything that shares the same base path will also share # the child tree path config. + "TEST": 0, "COF_R2_W2": 1, "COF_R2_W3": 1, "COF_R3_W1": 2, @@ -29,38 +30,9 @@ "COF_R4_W1": 9, "HSRA": 10, "COF_R4_W2": 11, - "R605": 12, } -@dataclass -class SectionName: - en: str - cy: str - - -@dataclass -class FormNameJson: - en: str - cy: str - - -@dataclass -class FundSectionBase: - section_name: SectionName - tree_path: str - - -@dataclass -class FundSectionSection(FundSectionBase): - requires_feedback: Optional[bool] = None - - -@dataclass -class FundSectionForm(FundSectionBase): - form_name_json: FormNameJson - - def generate_application_display_config(round_id): ordered_sections = [] @@ -105,92 +77,6 @@ def generate_application_display_config(round_id): write_config(ordered_sections, "sections_config", round.short_name, "python_file") -@dataclass -class NameJson: - en: str - cy: str - - -@dataclass -class TitleJson: - en: str - cy: str - - -@dataclass -class DescriptionJson: - en: str - cy: str - - -@dataclass -class ContactUsBannerJson: - en: str = "" - cy: str = "" - - -@dataclass -class FeedbackSurveyConfig: - has_feedback_survey: Optional[bool] = None - has_section_feedback: Optional[bool] = None - is_feedback_survey_optional: Optional[bool] = None - is_section_feedback_optional: Optional[bool] = None - - -@dataclass -class EligibilityConfig: - has_eligibility: Optional[bool] = None - - -@dataclass -class FundExport: - id: str - short_name: dict - welsh_available: bool - owner_organisation_name: str - owner_organisation_shortname: str - owner_organisation_logo_uri: str - name_json: NameJson = field(default_factory=NameJson) - title_json: TitleJson = field(default_factory=TitleJson) - description_json: DescriptionJson = field(default_factory=DescriptionJson) - - -@dataclass -class RoundExport: - id: Optional[str] = None - fund_id: Optional[str] = None - short_name: Optional[str] = None - opens: Optional[str] = None # Assuming date/time as string; adjust type as needed - assessment_start: Optional[str] = None # Adjust type as needed - deadline: Optional[str] = None # Adjust type as needed - application_reminder_sent: Optional[bool] = None - reminder_date: Optional[str] = None # Adjust type as needed - assessment_deadline: Optional[str] = None # Adjust type as needed - prospectus: Optional[str] = None - privacy_notice: Optional[str] = None - reference_contact_page_over_email: Optional[bool] = None - contact_email: Optional[str] = None - contact_phone: Optional[str] = None - contact_textphone: Optional[str] = None - support_times: Optional[str] = None - support_days: Optional[str] = None - instructions_json: Optional[Dict[str, str]] = None # Assuming simple dict; adjust as needed - feedback_link: Optional[str] = None - project_name_field_id: Optional[str] = None - application_guidance_json: Optional[Dict[str, str]] = None # Adjust as needed - guidance_url: Optional[str] = None - all_uploaded_documents_section_available: Optional[bool] = None - application_fields_download_available: Optional[bool] = None - display_logo_on_pdf_exports: Optional[bool] = None - mark_as_complete_enabled: Optional[bool] = None - is_expression_of_interest: Optional[bool] = None - eoi_decision_schema: Optional[str] = None # Adjust type as - feedback_survey_config: FeedbackSurveyConfig = field(default_factory=FeedbackSurveyConfig) - eligibility_config: EligibilityConfig = field(default_factory=EligibilityConfig) - title_json: TitleJson = field(default_factory=TitleJson) - contact_us_banner_json: ContactUsBannerJson = field(default_factory=ContactUsBannerJson) - - def generate_fund_config(round_id): round = get_round_by_id(round_id) fund_id = round.fund_id diff --git a/app/config_generator/scripts/generate_fund_round_form_jsons.py b/app/export_config/generate_fund_round_form_jsons.py similarity index 95% rename from app/config_generator/scripts/generate_fund_round_form_jsons.py rename to app/export_config/generate_fund_round_form_jsons.py index 8093b2a..7538f36 100644 --- a/app/config_generator/scripts/generate_fund_round_form_jsons.py +++ b/app/export_config/generate_fund_round_form_jsons.py @@ -2,10 +2,10 @@ from flask import current_app -from app.config_generator.generate_form import build_form_json -from app.config_generator.scripts.helpers import validate_json -from app.config_generator.scripts.helpers import write_config from app.db.queries.round import get_round_by_id +from app.export_config.generate_form import build_form_json +from app.export_config.helpers import validate_json +from app.export_config.helpers import write_config form_schema = { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/app/config_generator/scripts/generate_fund_round_html.py b/app/export_config/generate_fund_round_html.py similarity index 91% rename from app/config_generator/scripts/generate_fund_round_html.py rename to app/export_config/generate_fund_round_html.py index b6c8937..489ca0c 100644 --- a/app/config_generator/scripts/generate_fund_round_html.py +++ b/app/export_config/generate_fund_round_html.py @@ -1,10 +1,10 @@ from flask import current_app from app.all_questions.metadata_utils import generate_print_data_for_sections -from app.config_generator.generate_all_questions import print_html -from app.config_generator.generate_form import build_form_json -from app.config_generator.scripts.helpers import write_config from app.db.queries.round import get_round_by_id +from app.export_config.generate_all_questions import print_html +from app.export_config.generate_form import build_form_json +from app.export_config.helpers import write_config def generate_all_round_html(round_id): diff --git a/app/config_generator/scripts/helpers.py b/app/export_config/helpers.py similarity index 83% rename from app/config_generator/scripts/helpers.py rename to app/export_config/helpers.py index 73b0ee5..b613bbc 100644 --- a/app/config_generator/scripts/helpers.py +++ b/app/export_config/helpers.py @@ -1,6 +1,4 @@ import os -from dataclasses import asdict -from dataclasses import is_dataclass from datetime import date import jsonschema @@ -9,20 +7,15 @@ from app.blueprints.self_serve.routes import human_to_kebab_case from app.blueprints.self_serve.routes import human_to_snake_case - - -def convert_to_dict(obj): - if is_dataclass(obj): - return asdict(obj) - elif isinstance(obj, list): - return [asdict(item) if is_dataclass(item) else item for item in obj] - else: - return obj +from app.shared.helpers import convert_to_dict def write_config(config, filename, round_short_name, config_type): - # Determine the base output directory - base_output_dir = f"app/config_generator/output/{round_short_name}/" + # Get the directory of the current file + current_dir = os.path.dirname(__file__) + + # Construct the path to the output directory relative to this file's location + base_output_dir = os.path.join(current_dir, f"output/{round_short_name}/") if config_type == "form_json": output_dir = os.path.join(base_output_dir, "form_runner/") diff --git a/app/import_config/README.md b/app/import_config/README.md new file mode 100644 index 0000000..61988d9 --- /dev/null +++ b/app/import_config/README.md @@ -0,0 +1,13 @@ +# FAB Config Import + +This directory contains the scripts to import the FAB configuration files. + +The import configuration file is a form JSON that has been produced by the form designer. The forms are ingested into the FAB database as baseline templates. These templates can then be cloned into applications were users can optionally perform light touch editing of the contained pages and components. + +To import a form JSON, each file should be places in the following directory: + + app/import_config/files_to_import/ ** ({formname}.json) + +The import can be triggered manually by running the following command: + + python app/import_config/import_config.py diff --git a/app/import_config/files_to_import/asset-information-cof-r3-w2.json b/app/import_config/files_to_import/asset-information-cof-r3-w2.json new file mode 100644 index 0000000..4f5ce54 --- /dev/null +++ b/app/import_config/files_to_import/asset-information-cof-r3-w2.json @@ -0,0 +1,1003 @@ +{ + "metadata": {}, + "startPage": "/asset-information", + "backLinkText": "Go back to application overview", + "pages": [ + { + "title": "Asset information", + "path": "/asset-information", + "components": [ + { + "name": "JGhnud", + "options": {}, + "type": "Para", + "content": "\n\n
    \n
  • asset type
  • \n
  • what you intend to do with the asset
  • \n
  • asset ownership status
  • \n
  • asset ownership details (if applicable)
  • \n
  • public ownership details (if applicable)
  • \n
  • reason asset is at risk
  • \n
  • Asset of Community Value details (if applicable)
  • \n
  • Community Asset Transfer details (if applicable)
  • \n
" + } + ], + "next": [ + { + "path": "/how-the-asset-is-used-in-the-community" + } + ], + "controller": "./pages/start.js" + }, + { + "path": "/how-the-asset-is-used-in-the-community", + "title": "How the asset is used in the community", + "section": "wxYZcT", + "components": [ + { + "name": "oXGwlA", + "options": {}, + "type": "RadiosField", + "title": "Asset type", + "hint": "Select how the asset is mainly used. For example, if it is a theatre that also has a cafe, select 'Theatre'", + "list": "jWXaBR" + } + ], + "next": [ + { + "path": "/the-asset-in-community-ownership" + }, + { + "path": "/how-the-asset-is-used-in-the-community-BgwTSc", + "condition": "VxBLMN" + } + ] + }, + { + "title": "Check your answers", + "path": "/summary", + "controller": "./pages/summary.js", + "components": [], + "next": [], + "section": "wxYZcT" + }, + { + "path": "/the-asset-in-community-ownership", + "title": "The asset in community ownership", + "components": [ + { + "name": "LaxeJN", + "options": {}, + "type": "RadiosField", + "title": "How do you intend to take community ownership of the asset?", + "list": "grEEzr" + } + ], + "next": [ + { + "path": "/risk-of-closure-KVISjF", + "condition": "FwscXH" + }, + { + "path": "/upload-asset-valuation-or-lease-agreement" + }, + { + "path": "/upload-asset-valuation-or-lease-agreement-pJQtXk", + "condition": "hWsHyx" + } + ], + "section": "wxYZcT" + }, + { + "path": "/how-the-asset-is-used-in-the-community-BgwTSc", + "title": "How the asset is used in the community", + "components": [ + { + "name": "aJGyCR", + "options": { + "classes": "govuk-input--width-20" + }, + "type": "TextField", + "title": "Type of asset (other)" + } + ], + "next": [ + { + "path": "/the-asset-in-community-ownership" + } + ], + "section": "wxYZcT" + }, + { + "path": "/expected-terms-of-your-ownership-or-lease", + "title": "Expected terms of your ownership or lease", + "components": [ + { + "name": "XPcbJx", + "options": {}, + "type": "FreeTextField", + "title": "Describe the expected sale process, or the proposed terms of your lease if you are planning to rent the asset", + "hint": "" + }, + { + "name": "jGjScT", + "options": {}, + "type": "DatePartsField", + "title": "Expected date of sale or lease", + "hint": "For example, 27 3 2007\n" + } + ], + "next": [ + { + "path": "/public-ownership" + } + ], + "section": "wxYZcT" + }, + { + "path": "/public-ownership", + "title": "Public ownership", + "components": [ + { + "name": "VGXXyq", + "options": {}, + "type": "YesNoField", + "title": "Is your asset currently publicly owned?" + } + ], + "next": [ + { + "path": "/public-ownership-details-and-declarations", + "condition": "oHpvvn" + }, + { + "path": "/risk-of-closure-JncvIu", + "condition": "ujYYpF" + } + ], + "section": "wxYZcT" + }, + { + "path": "/public-ownership-details-and-declarations", + "title": "Public ownership details and declarations", + "components": [ + { + "name": "rNYmXq", + "options": { + "classes": "govuk-!-width-full" + }, + "type": "TextField", + "title": "Tell us about the person you have spoken to at the relevant public body about the asset", + "hint": "" + }, + { + "name": "SiOmZn", + "options": { + "hideTitle": true, + "classes": "govuk-!-width-full" + }, + "type": "TextField", + "hint": "Job title of contact", + "title": "Job title of contact" + }, + { + "name": "XXVXuj", + "options": { + "hideTitle": true, + "classes": "govuk-!-width-full" + }, + "type": "TextField", + "title": "Organisation name", + "hint": "Organisation name" + }, + { + "name": "MIqglh", + "options": {}, + "type": "CheckboxesField", + "title": "When you buy or lease a publicly owned asset, the public authority cannot transfer statutory services or duties to the community group.", + "hint": "This includes things like social care, waste collection and planning services.\n\n

We do not define individual libraries as statutory services for this purpose.

", + "list": "OSzelY" + }, + { + "name": "JdEmqn", + "options": {}, + "type": "CheckboxesField", + "list": "OSzelY", + "title": "Grants from this fund cannot be used to buy the freehold or premium on the lease of a publicly owned asset. Money must only be used for renovation and refurbishment costs" + }, + { + "name": "RFTloT", + "options": { + "dropzoneConfig": { + "maxFiles": 1, + "parallelUploads": 1, + "maxFilesize": 10, + "acceptedFiles": "image/jpeg,image/png,application/pdf,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.text,text/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.spreadsheet" + }, + "showNoScriptWarning": false, + "minimumRequiredFiles": 1 + }, + "type": "ClientSideFileUploadField", + "title": "Upload evidence to confirm the above information and that the asset is at risk", + "hint": "This could be from:\n

\n

    \n
  • a letter from an appropriate public authority officer or cabinet member
  • \n
  • a published cabinet paper from a local authority
  • \n
\n

\n\n\n\n

It should be a single file no bigger than 10MB in an accepted format (jpg, jpeg, png, pdf, txt, doc, docx, odt, csv, xls, xlsx, ods).

" + } + ], + "next": [ + { + "path": "/risk-of-closure-JncvIu" + } + ], + "section": "wxYZcT" + }, + { + "path": "/who-owns-the-asset", + "title": "Who owns the asset", + "components": [ + { + "name": "wAUFqr", + "options": {}, + "type": "YesNoField", + "title": "Do you know who currently owns your asset?" + } + ], + "next": [ + { + "path": "/who-currently-owns-your-asset", + "condition": "YBZKoC" + }, + { + "path": "/current-ownership-status", + "condition": "oFfQbV" + } + ], + "section": "wxYZcT" + }, + { + "path": "/who-currently-owns-your-asset", + "title": "Who currently owns your asset", + "components": [ + { + "name": "FOURVe", + "options": {}, + "type": "TextField", + "title": "Name of current asset owner" + } + ], + "next": [ + { + "path": "/expected-terms-of-your-ownership-or-lease" + } + ], + "section": "wxYZcT" + }, + { + "path": "/current-ownership-status", + "title": "Current ownership status", + "components": [ + { + "name": "XiHjDO", + "options": {}, + "type": "TextField", + "title": "Tell us what you know about the sale or lease of the asset" + } + ], + "next": [ + { + "path": "/expected-terms-of-your-ownership-or-lease" + } + ], + "section": "wxYZcT" + }, + { + "path": "/risk-of-closure-JncvIu", + "title": "Risk of closure", + "components": [ + { + "name": "qlqyUq", + "options": {}, + "type": "CheckboxesField", + "title": "Why is the asset at risk of closure?", + "hint": "Select all that apply", + "list": "YNTnCC" + } + ], + "next": [ + { + "path": "/asset-listing-details", + "condition": "EQpfAl" + }, + { + "path": "/community-asset-transfer", + "condition": "rrSKPO" + }, + { + "path": "/assets-of-community-value" + } + ], + "section": "wxYZcT" + }, + { + "path": "/assets-of-community-value", + "title": "Assets of community value", + "components": [ + { + "name": "iqnlTk", + "options": {}, + "type": "YesNoField", + "title": "Is this a registered Asset of Community Value (ACV)?" + } + ], + "next": [ + { + "path": "/summary" + } + ], + "section": "wxYZcT" + }, + { + "path": "/risk-of-closure-KVISjF", + "title": "Risk of closure", + "components": [ + { + "name": "LmOXLT", + "options": {}, + "type": "CheckboxesField", + "title": "Why is the asset at risk of closure?", + "hint": "Select all that apply", + "list": "IZlNBH" + } + ], + "next": [ + { + "path": "/assets-of-community-value" + } + ], + "section": "wxYZcT" + }, + { + "path": "/upload-asset-valuation-or-lease-agreement", + "title": "Upload asset valuation or lease agreement", + "components": [ + { + "name": "tTOrEp", + "options": { + "dropzoneConfig": { + "maxFiles": 1, + "parallelUploads": 1, + "maxFilesize": 10, + "acceptedFiles": "image/jpeg,image/png,application/pdf,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.text,text/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.spreadsheet" + }, + "showNoScriptWarning": false, + "minimumRequiredFiles": 1 + }, + "type": "ClientSideFileUploadField", + "title": "Please upload evidence that shows the asset valuation (if you are buying the asset) or the lease agreement (if you are leasing the asset).", + "hint": "" + } + ], + "next": [ + { + "path": "/terms-of-your-lease", + "condition": "hWsHyx" + }, + { + "path": "/who-owns-the-asset" + } + ], + "section": "wxYZcT" + }, + { + "path": "/terms-of-your-lease", + "title": "Terms of your lease", + "components": [ + { + "name": "apJIBm", + "options": {}, + "type": "FreeTextField", + "title": "Describe the terms of your lease if you have rented the asset", + "hint": "For example, length of lease, conditions of lease or break clauses" + } + ], + "next": [ + { + "path": "/public-ownership" + } + ], + "section": "wxYZcT" + }, + { + "path": "/asset-listing-details", + "title": "Asset listing details", + "components": [ + { + "name": "QPIPjx", + "options": {}, + "type": "DatePartsField", + "title": "When was the asset listed?", + "hint": "For example, 27 3 2007" + }, + { + "name": "OJWGGr", + "options": { + "classes": "govuk-!-width-full" + }, + "type": "WebsiteField", + "title": "Provide a link to the listing" + } + ], + "next": [ + { + "path": "/community-asset-transfer", + "condition": "rrSKPO" + }, + { + "path": "/assets-of-community-value" + } + ], + "section": "wxYZcT" + }, + { + "path": "/community-asset-transfer", + "title": "Community asset transfer", + "components": [ + { + "name": "WKIGQE", + "options": { + "maxWords": "500" + }, + "type": "FreeTextField", + "title": "Describe the current status of the Community Asset Transfer" + } + ], + "next": [ + { + "path": "/assets-of-community-value" + } + ], + "section": "wxYZcT" + }, + { + "path": "/upload-asset-valuation-or-lease-agreement-pJQtXk", + "title": "Upload asset valuation or lease agreement", + "components": [ + { + "name": "TtOReP", + "options": { + "dropzoneConfig": { + "maxFiles": 1, + "parallelUploads": 1, + "maxFilesize": 10, + "acceptedFiles": "image/jpeg,image/png,application/pdf,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.text,text/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.spreadsheet" + }, + "showNoScriptWarning": false, + "minimumRequiredFiles": 1 + }, + "type": "ClientSideFileUploadField", + "title": "Please upload evidence that shows the asset is already leased by your organisation.", + "hint": "" + } + ], + "next": [ + { + "path": "/terms-of-your-lease" + } + ], + "section": "wxYZcT" + } + ], + "lists": [ + { + "title": "Asset type", + "name": "jWXaBR", + "type": "string", + "items": [ + { + "text": "Community centre", + "value": "community-centre" + }, + { + "text": "Cinema", + "value": "cinema" + }, + { + "text": "Gallery", + "value": "gallery" + }, + { + "text": "Museum", + "value": "museum" + }, + { + "text": "Music venue", + "value": "music-venue" + }, + { + "text": "Park", + "value": "park" + }, + { + "text": "Post office building", + "value": "post-office" + }, + { + "text": "Pub", + "value": "pub" + }, + { + "text": "Shop", + "value": "shop" + }, + { + "text": "Sporting or leisure facility", + "value": "sporting" + }, + { + "text": "Theatre", + "value": "theatre" + }, + { + "text": "Other", + "value": "other" + } + ] + }, + { + "title": "How do you intend to take community ownership of the asset?", + "name": "grEEzr", + "type": "string", + "items": [ + { + "text": "Buy the asset", + "value": "buy-the-asset" + }, + { + "text": "Lease the asset", + "value": "lease-the-asset" + }, + { + "text": "Already owned by organisation", + "value": "already-owned-by-organisation" + }, + { + "text": "Already leased by organisation", + "value": "already-leased-by-organisation" + } + ] + }, + { + "title": "I Confirm", + "name": "OSzelY", + "type": "string", + "items": [ + { + "text": "I confirm", + "value": "confirm" + } + ] + }, + { + "title": "Risk of closure", + "name": "RGVpbi", + "type": "string", + "items": [ + { + "text": "For sale or listed for disposal", + "value": "for-sale-or-listed-for-disposal" + }, + { + "text": "Future use not secured", + "value": "future-use-not-secured" + }, + { + "text": "Unprofitable under current business model", + "value": "unprofitable-under-current-business-model" + }, + { + "text": "Current ownership not tenable", + "value": "current-ownership-not-tenable" + } + ] + }, + { + "title": "Why is the asset at risk of closure?", + "name": "YNTnCC", + "type": "string", + "items": [ + { + "text": "Closure", + "value": "Closure" + }, + { + "text": "Sale", + "value": "Sale" + }, + { + "text": "Neglect or dereliction", + "value": "Neglect or dereliction" + }, + { + "text": "Unsustainable current business model", + "value": "Unsustainable current business model" + }, + { + "text": "Listed for disposal", + "value": "Listed for disposal" + }, + { + "text": "Part of a Community Asset Transfer", + "value": "Part of a Community Asset Transfer" + } + ] + }, + { + "title": "Why is the asset at risk of closure? 2", + "name": "IZlNBH", + "type": "string", + "items": [ + { + "text": "Closure", + "value": "Closure" + }, + { + "text": "Neglect or dereliction", + "value": "Neglect or dereliction" + }, + { + "text": "Unsustainable current business model", + "value": "Unsustainable current business model" + } + ] + } + ], + "sections": [ + { + "name": "wxYZcT", + "title": "Asset information" + } + ], + "conditions": [ + { + "displayName": "How the asset is used-other", + "name": "VxBLMN", + "value": { + "name": "How the asset is used-other", + "conditions": [ + { + "field": { + "name": "oXGwlA", + "type": "RadiosField", + "display": "Asset type" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "other", + "display": "other" + } + } + ] + } + }, + { + "displayName": "Do you know who currently owns your asset-yes", + "name": "KZLYTF", + "value": { + "name": "Do you know who currently owns your asset-yes", + "conditions": [ + { + "field": { + "name": "hdmYjg", + "type": "YesNoField", + "display": "Do you know who currently owns your asset?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "true", + "display": "true" + } + } + ] + } + }, + { + "displayName": "Do you know who currently owns your asset-no", + "name": "GRGSGT", + "value": { + "name": "Do you know who currently owns your asset-no", + "conditions": [ + { + "field": { + "name": "hdmYjg", + "type": "YesNoField", + "display": "Do you know who currently owns your asset?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "false", + "display": "false" + } + } + ] + } + }, + { + "displayName": "Is your asset currently publicly owned-yes", + "name": "oHpvvn", + "value": { + "name": "Is your asset currently publicly owned-yes", + "conditions": [ + { + "field": { + "name": "VGXXyq", + "type": "YesNoField", + "display": "Is your asset currently publicly owned?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "true", + "display": "true" + } + } + ] + } + }, + { + "displayName": "Is your asset currently publicly owned-no", + "name": "ujYYpF", + "value": { + "name": "Is your asset currently publicly owned-no", + "conditions": [ + { + "field": { + "name": "VGXXyq", + "type": "YesNoField", + "display": "Is your asset currently publicly owned?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "false", + "display": "false" + } + } + ] + } + }, + { + "displayName": "Is this a registered Asset of Community Value (ACV)-yes", + "name": "pErRiw", + "value": { + "name": "Is this a registered Asset of Community Value (ACV)-yes", + "conditions": [ + { + "field": { + "name": "wjBFTf", + "type": "YesNoField", + "display": "Is this a registered Asset of Community Value (ACV)?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "true", + "display": "true" + } + } + ] + } + }, + { + "displayName": "Is the asset listed for disposal, or part of a Community Asset Transfer-yes", + "name": "XVOzZh", + "value": { + "name": "Is the asset listed for disposal, or part of a Community Asset Transfer-yes", + "conditions": [ + { + "field": { + "name": "HyWPwE", + "type": "YesNoField", + "display": "Is the asset listed for disposal, or part of a Community Asset Transfer?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "true", + "display": "true" + } + } + ] + } + }, + { + "displayName": "Is this a registered Asset of Community Value (ACV)-no", + "name": "nxnCXz", + "value": { + "name": "Is this a registered Asset of Community Value (ACV)-no", + "conditions": [ + { + "field": { + "name": "wjBFTf", + "type": "YesNoField", + "display": "Is this a registered Asset of Community Value (ACV)?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "false", + "display": "false" + } + } + ] + } + }, + { + "displayName": "Is the asset listed for disposal, or part of a Community Asset Transfer-no", + "name": "lmmYts", + "value": { + "name": "Is the asset listed for disposal, or part of a Community Asset Transfer-no", + "conditions": [ + { + "field": { + "name": "HyWPwE", + "type": "YesNoField", + "display": "Is the asset listed for disposal, or part of a Community Asset Transfer?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "false", + "display": "false" + } + } + ] + } + }, + { + "displayName": "Do you know who owns the asset - yes", + "name": "YBZKoC", + "value": { + "name": "Do you know who owns the asset - yes", + "conditions": [ + { + "field": { + "name": "wAUFqr", + "type": "YesNoField", + "display": "Do you know who currently owns your asset?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "true", + "display": "true" + } + } + ] + } + }, + { + "displayName": "Do you know who owns the asset - no", + "name": "oFfQbV", + "value": { + "name": "Do you know who owns the asset - no", + "conditions": [ + { + "field": { + "name": "wAUFqr", + "type": "YesNoField", + "display": "Do you know who currently owns your asset?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "false", + "display": "false" + } + } + ] + } + }, + { + "displayName": "already-owned-by-organisation-yes", + "name": "FwscXH", + "value": { + "name": "already-owned-by-organisation-yes", + "conditions": [ + { + "field": { + "name": "LaxeJN", + "type": "RadiosField", + "display": "How do you intend to take community ownership of the asset?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "already-owned-by-organisation", + "display": "already-owned-by-organisation" + } + } + ] + } + }, + { + "displayName": "already-leased-by-organisation-yes", + "name": "hWsHyx", + "value": { + "name": "already-leased-by-organisation-yes", + "conditions": [ + { + "field": { + "name": "LaxeJN", + "type": "RadiosField", + "display": "How do you intend to take community ownership of the asset?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "already-leased-by-organisation", + "display": "already-leased-by-organisation" + } + } + ] + } + }, + { + "displayName": "listed-for-disposal", + "name": "EQpfAl", + "value": { + "name": "listed-for-disposal", + "conditions": [ + { + "field": { + "name": "qlqyUq", + "type": "CheckboxesField", + "display": "Why is the asset at risk of closure?" + }, + "operator": "contains", + "value": { + "type": "Value", + "value": "Listed for disposal", + "display": "Listed for disposal" + } + } + ] + } + }, + { + "displayName": "part-of-a-community-asset-transfer", + "name": "rrSKPO", + "value": { + "name": "part-of-a-community-asset-transfer", + "conditions": [ + { + "field": { + "name": "qlqyUq", + "type": "CheckboxesField", + "display": "Why is the asset at risk of closure?" + }, + "operator": "contains", + "value": { + "type": "Value", + "value": "Part of a Community Asset Transfer", + "display": "Part of a Community Asset Transfer" + } + } + ] + } + } + ], + "fees": [], + "outputs": [ + { + "name": "update-form", + "title": "Update form in application store", + "type": "savePerPage", + "outputConfiguration": { + "savePerPageUrl": "True" + } + } + ], + "version": 2, + "skipSummary": false, + "name": "Apply for funding to save an asset in your community", + "feedback": { + "feedbackForm": false, + "url": "" + }, + "phaseBanner": { + "phase": "beta" + } +} diff --git a/app/import_config/load_form_json.py b/app/import_config/load_form_json.py new file mode 100644 index 0000000..ea369e7 --- /dev/null +++ b/app/import_config/load_form_json.py @@ -0,0 +1,228 @@ +# pip install pandas openpyxl + +import json +import os +import sys + +sys.path.insert(1, ".") +from dataclasses import asdict # noqa:E402 + +from app.create_app import app # noqa:E402 +from app.db import db # noqa:E402 +from app.db.models import Component # noqa:E402 +from app.db.models import ComponentType # noqa:E402 +from app.db.models import Form # noqa:E402 +from app.db.models import Lizt # noqa:E402 +from app.db.models import Page # noqa:E402 +from app.db.models import Section # noqa:E402 +from app.shared.data_classes import Condition # noqa:E402 +from app.shared.helpers import find_enum # noqa:E402 + + +def add_conditions_to_components(db, page, conditions): + # Convert conditions list to a dictionary for faster lookup + conditions_dict = {cond["name"]: cond for cond in conditions} + + # Initialize a cache for components to reduce database queries + components_cache = {} + + for path in page["next"]: + if "condition" in path: + target_condition_name = path["condition"] + # Use the conditions dictionary for faster lookup + if target_condition_name in conditions_dict: + condition_data = conditions_dict[target_condition_name] + runner_component_name = condition_data["value"]["conditions"][0]["field"]["name"] + + # Use the cache to reduce database queries + if runner_component_name not in components_cache: + component_to_update = ( + db.session.query(Component) + .filter(Component.runner_component_name == runner_component_name) + .first() + ) + components_cache[runner_component_name] = component_to_update + else: + component_to_update = components_cache[runner_component_name] + + # Create a new Condition instance with a different variable name + new_condition = Condition( + name=condition_data["value"]["name"], + value=condition_data["value"]["conditions"][0]["value"]["value"], + operator=condition_data["value"]["conditions"][0]["operator"], + destination_page_path=path["path"], + ) + + # Add the new condition to the conditions list of the component to update + if component_to_update.conditions: + component_to_update.conditions.append(asdict(new_condition)) + else: + component_to_update.conditions = [asdict(new_condition)] + + +def insert_component_as_template(component, page_id, page_index, lizts): + # if component has a list, insert the list into the database + list_id = None + component_list = component.get("list", None) + if component_list: + for li in lizts: + if li["name"] == component_list: + new_list = Lizt( + is_template=True, + name=li.get("name"), + title=li.get("title"), + type=li.get("type"), + items=li.get("items"), + ) + try: + db.session.add(new_list) + except Exception as e: + print(e) + raise e + db.session.flush() # flush to get the list id + list_id = new_list.list_id + break + + new_component = Component( + page_id=page_id, + theme_id=None, + title=component.get("title", ""), + hint_text=component.get("hint", None), + options=component.get("options", None), + type=find_enum(ComponentType, component.get("type", None)), + template_name=component.get("title"), + is_template=True, + page_index=page_index, + # theme_index=component.get('theme_index', None), TODO: add theme_index to json + runner_component_name=component.get("name", ""), + list_id=list_id, + ) + try: + db.session.add(new_component) + except Exception as e: + print(e) + raise e + return new_component + + +def insert_page_as_template(page, form_id): + new_page = Page( + form_id=form_id, + display_path=page.get("path").lstrip("/"), + form_index=None, + name_in_apply_json={"en": page.get("title")}, + controller=page.get("controller", None), + is_template=True, + template_name=page.get("title", None), + ) + try: + db.session.add(new_page) + except Exception as e: + print(e) + raise e + return new_page + + +def find_page_by_path(path): + page = db.session.query(Page).filter(Page.display_path == path.lstrip("/")).first() + return page + + +def insert_page_default_next_page(pages_config, db_pages): + for current_page_config in pages_config: + for db_page in db_pages: + if db_page.display_path == current_page_config.get("path").lstrip("/"): + current_db_page = db_page + page_nexts = current_page_config.get("next", []) + next_page_path_with_no_condition = next((p for p in page_nexts if not p.get("condition")), None) + if not next_page_path_with_no_condition: + # no default next page so move on (next page is based on conditions) + continue + + # set default next page id + for db_page in db_pages: + if db_page.display_path == next_page_path_with_no_condition.get("path").lstrip("/"): + current_db_page.default_next_page_id = db_page.page_id + # Update the page in the database + db.session.add(current_db_page) + db.session.flush() + + +def insert_form_config(form_config, form_id): + inserted_pages = [] + inserted_components = [] + for page in form_config.get("pages", []): + inserted_page = insert_page_as_template(page, form_id) + inserted_pages.append(inserted_page) + db.session.flush() # flush to get the page id + for c_idx, component in enumerate(page.get("components", [])): + inserted_component = insert_component_as_template( + component, inserted_page.page_id, (c_idx + 1), form_config["lists"] + ) + inserted_components.append(inserted_component) + db.session.flush() # flush to make components available for conditions + add_conditions_to_components(db, page, form_config["conditions"]) + insert_page_default_next_page(form_config.get("pages", None), inserted_pages) + db + return inserted_pages, inserted_components + + +def insert_form_as_template(form): + section = db.session.query(Section.section_id).first() + new_form = Form( + section_id=section.section_id, + name_in_apply_json={"en": form.get("name")}, + template_name=form.get("name"), + is_template=True, + audit_info=None, + section_index=None, + runner_publish_name=form["filename"], + source_template_id=None, + ) + + try: + db.session.add(new_form) + except Exception as e: + print(e) + raise e + + return new_form + + +def read_json_from_directory(directory_path): + form_configs = [] + for filename in os.listdir(directory_path): + if filename.endswith(".json"): + file_path = os.path.join(directory_path, filename) + with open(file_path, "r") as json_file: + form = json.load(json_file) + form["filename"] = filename + form_configs.append(form) + return form_configs + + +def load_form_jsons(override_fund_config): + db = app.extensions["sqlalchemy"] # Move db definition here + try: + if not override_fund_config: + db = app.extensions["sqlalchemy"] + script_dir = os.path.dirname(__file__) + full_directory_path = os.path.join(script_dir, "files_to_import") + form_configs = read_json_from_directory(full_directory_path) + else: + form_configs = override_fund_config + for form_config in form_configs: + # prepare all row commits + inserted_form = insert_form_as_template(form_config) + db.session.flush() # flush to get the form id + inserted_pages, inserted_components = insert_form_config(form_config, inserted_form.form_id) + db.session.commit() + except Exception as e: + print(e) + db.session.rollback() + raise e + + +if __name__ == "__main__": + with app.app_context(): + load_form_jsons() diff --git a/app/shared/data_classes.py b/app/shared/data_classes.py new file mode 100644 index 0000000..8bc4a3c --- /dev/null +++ b/app/shared/data_classes.py @@ -0,0 +1,126 @@ +from dataclasses import dataclass +from dataclasses import field +from typing import Dict +from typing import Optional + + +@dataclass +class Condition: + name: str + value: str + operator: str + destination_page_path: str + + +@dataclass +class SectionName: + en: str + cy: str + + +@dataclass +class FormNameJson: + en: str + cy: str + + +@dataclass +class FundSectionBase: + section_name: SectionName + tree_path: str + + +@dataclass +class FundSectionSection(FundSectionBase): + requires_feedback: Optional[bool] = None + + +@dataclass +class FundSectionForm(FundSectionBase): + form_name_json: FormNameJson + + +@dataclass +class NameJson: + en: str + cy: str + + +@dataclass +class TitleJson: + en: str + cy: str + + +@dataclass +class DescriptionJson: + en: str + cy: str + + +@dataclass +class ContactUsBannerJson: + en: str = "" + cy: str = "" + + +@dataclass +class FeedbackSurveyConfig: + has_feedback_survey: Optional[bool] = None + has_section_feedback: Optional[bool] = None + is_feedback_survey_optional: Optional[bool] = None + is_section_feedback_optional: Optional[bool] = None + + +@dataclass +class EligibilityConfig: + has_eligibility: Optional[bool] = None + + +@dataclass +class FundExport: + id: str + short_name: dict + welsh_available: bool + owner_organisation_name: str + owner_organisation_shortname: str + owner_organisation_logo_uri: str + name_json: NameJson = field(default_factory=NameJson) + title_json: TitleJson = field(default_factory=TitleJson) + description_json: DescriptionJson = field(default_factory=DescriptionJson) + + +@dataclass +class RoundExport: + id: Optional[str] = None + fund_id: Optional[str] = None + short_name: Optional[str] = None + opens: Optional[str] = None # Assuming date/time as string; adjust type as needed + assessment_start: Optional[str] = None # Adjust type as needed + deadline: Optional[str] = None # Adjust type as needed + application_reminder_sent: Optional[bool] = None + reminder_date: Optional[str] = None # Adjust type as needed + assessment_deadline: Optional[str] = None # Adjust type as needed + prospectus: Optional[str] = None + privacy_notice: Optional[str] = None + reference_contact_page_over_email: Optional[bool] = None + contact_email: Optional[str] = None + contact_phone: Optional[str] = None + contact_textphone: Optional[str] = None + support_times: Optional[str] = None + support_days: Optional[str] = None + instructions_json: Optional[Dict[str, str]] = None # Assuming simple dict; adjust as needed + feedback_link: Optional[str] = None + project_name_field_id: Optional[str] = None + application_guidance_json: Optional[Dict[str, str]] = None # Adjust as needed + guidance_url: Optional[str] = None + all_uploaded_documents_section_available: Optional[bool] = None + application_fields_download_available: Optional[bool] = None + display_logo_on_pdf_exports: Optional[bool] = None + mark_as_complete_enabled: Optional[bool] = None + is_expression_of_interest: Optional[bool] = None + eoi_decision_schema: Optional[str] = None # Adjust type as + feedback_survey_config: FeedbackSurveyConfig = field(default_factory=FeedbackSurveyConfig) + eligibility_config: EligibilityConfig = field(default_factory=EligibilityConfig) + title_json: TitleJson = field(default_factory=TitleJson) + contact_us_banner_json: ContactUsBannerJson = field(default_factory=ContactUsBannerJson) diff --git a/app/shared/helpers.py b/app/shared/helpers.py new file mode 100644 index 0000000..7637183 --- /dev/null +++ b/app/shared/helpers.py @@ -0,0 +1,18 @@ +from dataclasses import asdict +from dataclasses import is_dataclass + + +def convert_to_dict(obj): + if is_dataclass(obj): + return asdict(obj) + elif isinstance(obj, list): + return [asdict(item) if is_dataclass(item) else item for item in obj] + else: + return obj + + +def find_enum(enum_class, value): + for enum in enum_class: + if enum.value == value: + return enum + return None diff --git a/requirements-dev.txt b/requirements-dev.txt index d7f8cf0..7378d8e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -71,6 +71,8 @@ editorconfig==0.12.4 # via # cssbeautifier # jsbeautifier +et-xmlfile==1.1.0 + # via openpyxl filelock==3.15.4 # via virtualenv flake8==7.0.0 @@ -185,12 +187,18 @@ mypy-extensions==1.0.0 # mypy nodeenv==1.9.1 # via pre-commit +numpy==2.0.1 + # via pandas +openpyxl==3.1.5 + # via -r requirements.in packaging==24.1 # via # black # gunicorn # marshmallow # pytest +pandas==2.2.2 + # via -r requirements.in pathspec==0.12.1 # via # black @@ -237,7 +245,9 @@ pytest-mock==3.14.0 python-consul==1.1.0 # via flipper-client python-dateutil==2.9.0.post0 - # via botocore + # via + # botocore + # pandas python-dotenv==1.0.1 # via # -r requirements-dev.in @@ -248,6 +258,7 @@ pytz==2024.1 # via # flask-babel # funding-service-design-utils + # pandas pyyaml==6.0.1 # via # djlint @@ -311,6 +322,8 @@ typing-extensions==4.12.2 # alembic # mypy # sqlalchemy +tzdata==2024.1 + # via pandas urllib3==2.2.2 # via # botocore diff --git a/requirements.in b/requirements.in index f63a0ad..1e4317a 100644 --- a/requirements.in +++ b/requirements.in @@ -59,3 +59,5 @@ Flask-WTF==1.2.1 #----------------------------------- funding-service-design-utils>=4.0.1 jsonschema +openpyxl +pandas diff --git a/requirements.txt b/requirements.txt index 99cf5cd..0577987 100644 --- a/requirements.txt +++ b/requirements.txt @@ -48,6 +48,8 @@ cryptography==42.0.8 # via pyjwt cssmin==0.2.0 # via -r requirements.in +et-xmlfile==1.1.0 + # via openpyxl flask==3.0.3 # via # -r requirements.in @@ -131,10 +133,16 @@ mypy==1.10.1 # via sqlalchemy mypy-extensions==1.0.0 # via mypy +numpy==2.0.1 + # via pandas +openpyxl==3.1.5 + # via -r requirements.in packaging==24.1 # via # gunicorn # marshmallow +pandas==2.2.2 + # via -r requirements.in psycopg2-binary==2.9.9 # via -r requirements.in pycparser==2.22 @@ -150,7 +158,9 @@ pyscss==1.4.0 python-consul==1.1.0 # via flipper-client python-dateutil==2.9.0.post0 - # via botocore + # via + # botocore + # pandas python-dotenv==1.0.1 # via funding-service-design-utils python-json-logger==2.0.7 @@ -159,6 +169,7 @@ pytz==2024.1 # via # flask-babel # funding-service-design-utils + # pandas pyyaml==6.0.1 # via funding-service-design-utils redis==4.6.0 @@ -213,6 +224,8 @@ typing-extensions==4.12.2 # alembic # mypy # sqlalchemy +tzdata==2024.1 + # via pandas urllib3==2.2.2 # via # botocore diff --git a/tasks/export_tasks.py b/tasks/export_tasks.py index 0514b1f..f547626 100644 --- a/tasks/export_tasks.py +++ b/tasks/export_tasks.py @@ -9,13 +9,13 @@ from invoke import task # noqa:E402 from app.blueprints.self_serve.routes import human_to_kebab_case # noqa:E402 -from app.config_generator.scripts.generate_fund_round_config import ( # noqa:E402 +from app.export_config.generate_fund_round_config import ( # noqa:E402 generate_config_for_round, ) -from app.config_generator.scripts.generate_fund_round_form_jsons import ( # noqa:E402 +from app.export_config.generate_fund_round_form_jsons import ( # noqa:E402 generate_form_jsons_for_round, ) -from app.config_generator.scripts.generate_fund_round_html import ( # noqa:E402 +from app.export_config.generate_fund_round_html import ( # noqa:E402 generate_all_round_html, ) from config import Config # noqa:E402 diff --git a/tasks/test_data.py b/tasks/test_data.py index 7916299..a3300aa 100644 --- a/tasks/test_data.py +++ b/tasks/test_data.py @@ -34,6 +34,13 @@ "privacy_notice_link": "http://www.google.com", } +page_one_id = uuid4() +page_two_id = uuid4() +page_three_id = uuid4() +page_four_id = uuid4() +page_five_id = uuid4() +alt_page_id = uuid4() + def init_salmon_fishing_fund(): organisation_uuid = uuid4() @@ -104,36 +111,37 @@ def init_salmon_fishing_fund(): runner_publish_name="contact-details", ) p1: Page = Page( - page_id=uuid4(), + page_id=page_one_id, form_id=f1.form_id, display_path="organisation-name", name_in_apply_json={"en": "Organisation Name"}, form_index=1, ) p2: Page = Page( - page_id=uuid4(), + page_id=page_two_id, display_path="organisation-address", form_id=f1.form_id, name_in_apply_json={"en": "Organisation Address"}, form_index=3, ) p3: Page = Page( - page_id=uuid4(), + page_id=page_three_id, form_id=f2.form_id, display_path="lead-contact-details", name_in_apply_json={"en": "Lead Contact Details"}, form_index=1, ) p5: Page = Page( - page_id=uuid4(), + page_id=page_five_id, display_path="organisation-classification", form_id=f1.form_id, name_in_apply_json={"en": "Organisation Classification"}, form_index=4, + default_next_page_id=None, ) p_org_alt_names: Page = Page( - page_id=uuid4(), - form_id=None, + page_id=alt_page_id, + form_id=f1.form_id, display_path="organisation-alternative-names", name_in_apply_json={"en": "Alternative names of your organisation"}, form_index=2, @@ -205,13 +213,13 @@ def init_salmon_fishing_fund(): conditions=[ { "name": "organisation_other_names_no", - "value": "false", # this must be lowercaes or the navigation doesn't work + "value": "false", # this must be lowercase or the navigation doesn't work "operator": "is", - "destination_page_path": "CONTINUE", + "destination_page_path": "organisation-address", }, { "name": "organisation_other_names_yes", - "value": "true", # this must be lowercaes or the navigation doesn't work + "value": "true", # this must be lowercase or the navigation doesn't work "operator": "is", "destination_page_path": "organisation-alternative-names", }, @@ -266,6 +274,10 @@ def init_salmon_fishing_fund(): "sections": [s1], "forms": [f1, f2], "pages": [p1, p2, p3, p5, p_org_alt_names], + "default_next_pages": [ + {"page_id": alt_page_id, "default_next_page_id": page_two_id}, + {"page_id": page_two_id, "default_next_page_id": page_five_id}, + ], "components": [c1, c2, c4, c5, c6, c8, c3, c7], "criteria": [cri1], "subcriteria": [sc1], @@ -339,6 +351,7 @@ def init_unit_test_data() -> dict: display_path="organisation-name", name_in_apply_json={"en": "Organisation Name"}, form_index=1, + default_next_page_id=None, ) cri1: Criteria = Criteria(criteria_id=uuid4(), index=1, round_id=r.round_id, name="Unscored", weighting=0.0) @@ -396,6 +409,14 @@ def init_unit_test_data() -> dict: } +def add_default_page_paths(db, default_next_page_config): + # set up the default paths + for page_config in default_next_page_config: + page = Page.query.filter_by(page_id=page_config["page_id"]).first() + page.default_next_page_id = page_config["default_next_page_id"] + db.session.commit() + + def insert_test_data(db, test_data={}): db.session.bulk_save_objects(test_data.get("organisations", [])) db.session.commit() @@ -409,6 +430,7 @@ def insert_test_data(db, test_data={}): db.session.commit() db.session.bulk_save_objects(test_data.get("pages", [])) db.session.commit() + add_default_page_paths(db, test_data.get("default_next_pages", [])) db.session.bulk_save_objects(test_data.get("criteria", [])) db.session.commit() db.session.bulk_save_objects(test_data.get("subcriteria", [])) diff --git a/tests/test-import-form.json b/tests/test-import-form.json new file mode 100644 index 0000000..4f5ce54 --- /dev/null +++ b/tests/test-import-form.json @@ -0,0 +1,1003 @@ +{ + "metadata": {}, + "startPage": "/asset-information", + "backLinkText": "Go back to application overview", + "pages": [ + { + "title": "Asset information", + "path": "/asset-information", + "components": [ + { + "name": "JGhnud", + "options": {}, + "type": "Para", + "content": "\n\n
    \n
  • asset type
  • \n
  • what you intend to do with the asset
  • \n
  • asset ownership status
  • \n
  • asset ownership details (if applicable)
  • \n
  • public ownership details (if applicable)
  • \n
  • reason asset is at risk
  • \n
  • Asset of Community Value details (if applicable)
  • \n
  • Community Asset Transfer details (if applicable)
  • \n
" + } + ], + "next": [ + { + "path": "/how-the-asset-is-used-in-the-community" + } + ], + "controller": "./pages/start.js" + }, + { + "path": "/how-the-asset-is-used-in-the-community", + "title": "How the asset is used in the community", + "section": "wxYZcT", + "components": [ + { + "name": "oXGwlA", + "options": {}, + "type": "RadiosField", + "title": "Asset type", + "hint": "Select how the asset is mainly used. For example, if it is a theatre that also has a cafe, select 'Theatre'", + "list": "jWXaBR" + } + ], + "next": [ + { + "path": "/the-asset-in-community-ownership" + }, + { + "path": "/how-the-asset-is-used-in-the-community-BgwTSc", + "condition": "VxBLMN" + } + ] + }, + { + "title": "Check your answers", + "path": "/summary", + "controller": "./pages/summary.js", + "components": [], + "next": [], + "section": "wxYZcT" + }, + { + "path": "/the-asset-in-community-ownership", + "title": "The asset in community ownership", + "components": [ + { + "name": "LaxeJN", + "options": {}, + "type": "RadiosField", + "title": "How do you intend to take community ownership of the asset?", + "list": "grEEzr" + } + ], + "next": [ + { + "path": "/risk-of-closure-KVISjF", + "condition": "FwscXH" + }, + { + "path": "/upload-asset-valuation-or-lease-agreement" + }, + { + "path": "/upload-asset-valuation-or-lease-agreement-pJQtXk", + "condition": "hWsHyx" + } + ], + "section": "wxYZcT" + }, + { + "path": "/how-the-asset-is-used-in-the-community-BgwTSc", + "title": "How the asset is used in the community", + "components": [ + { + "name": "aJGyCR", + "options": { + "classes": "govuk-input--width-20" + }, + "type": "TextField", + "title": "Type of asset (other)" + } + ], + "next": [ + { + "path": "/the-asset-in-community-ownership" + } + ], + "section": "wxYZcT" + }, + { + "path": "/expected-terms-of-your-ownership-or-lease", + "title": "Expected terms of your ownership or lease", + "components": [ + { + "name": "XPcbJx", + "options": {}, + "type": "FreeTextField", + "title": "Describe the expected sale process, or the proposed terms of your lease if you are planning to rent the asset", + "hint": "" + }, + { + "name": "jGjScT", + "options": {}, + "type": "DatePartsField", + "title": "Expected date of sale or lease", + "hint": "For example, 27 3 2007\n" + } + ], + "next": [ + { + "path": "/public-ownership" + } + ], + "section": "wxYZcT" + }, + { + "path": "/public-ownership", + "title": "Public ownership", + "components": [ + { + "name": "VGXXyq", + "options": {}, + "type": "YesNoField", + "title": "Is your asset currently publicly owned?" + } + ], + "next": [ + { + "path": "/public-ownership-details-and-declarations", + "condition": "oHpvvn" + }, + { + "path": "/risk-of-closure-JncvIu", + "condition": "ujYYpF" + } + ], + "section": "wxYZcT" + }, + { + "path": "/public-ownership-details-and-declarations", + "title": "Public ownership details and declarations", + "components": [ + { + "name": "rNYmXq", + "options": { + "classes": "govuk-!-width-full" + }, + "type": "TextField", + "title": "Tell us about the person you have spoken to at the relevant public body about the asset", + "hint": "" + }, + { + "name": "SiOmZn", + "options": { + "hideTitle": true, + "classes": "govuk-!-width-full" + }, + "type": "TextField", + "hint": "Job title of contact", + "title": "Job title of contact" + }, + { + "name": "XXVXuj", + "options": { + "hideTitle": true, + "classes": "govuk-!-width-full" + }, + "type": "TextField", + "title": "Organisation name", + "hint": "Organisation name" + }, + { + "name": "MIqglh", + "options": {}, + "type": "CheckboxesField", + "title": "When you buy or lease a publicly owned asset, the public authority cannot transfer statutory services or duties to the community group.", + "hint": "This includes things like social care, waste collection and planning services.\n\n

We do not define individual libraries as statutory services for this purpose.

", + "list": "OSzelY" + }, + { + "name": "JdEmqn", + "options": {}, + "type": "CheckboxesField", + "list": "OSzelY", + "title": "Grants from this fund cannot be used to buy the freehold or premium on the lease of a publicly owned asset. Money must only be used for renovation and refurbishment costs" + }, + { + "name": "RFTloT", + "options": { + "dropzoneConfig": { + "maxFiles": 1, + "parallelUploads": 1, + "maxFilesize": 10, + "acceptedFiles": "image/jpeg,image/png,application/pdf,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.text,text/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.spreadsheet" + }, + "showNoScriptWarning": false, + "minimumRequiredFiles": 1 + }, + "type": "ClientSideFileUploadField", + "title": "Upload evidence to confirm the above information and that the asset is at risk", + "hint": "This could be from:\n

\n

    \n
  • a letter from an appropriate public authority officer or cabinet member
  • \n
  • a published cabinet paper from a local authority
  • \n
\n

\n\n\n\n

It should be a single file no bigger than 10MB in an accepted format (jpg, jpeg, png, pdf, txt, doc, docx, odt, csv, xls, xlsx, ods).

" + } + ], + "next": [ + { + "path": "/risk-of-closure-JncvIu" + } + ], + "section": "wxYZcT" + }, + { + "path": "/who-owns-the-asset", + "title": "Who owns the asset", + "components": [ + { + "name": "wAUFqr", + "options": {}, + "type": "YesNoField", + "title": "Do you know who currently owns your asset?" + } + ], + "next": [ + { + "path": "/who-currently-owns-your-asset", + "condition": "YBZKoC" + }, + { + "path": "/current-ownership-status", + "condition": "oFfQbV" + } + ], + "section": "wxYZcT" + }, + { + "path": "/who-currently-owns-your-asset", + "title": "Who currently owns your asset", + "components": [ + { + "name": "FOURVe", + "options": {}, + "type": "TextField", + "title": "Name of current asset owner" + } + ], + "next": [ + { + "path": "/expected-terms-of-your-ownership-or-lease" + } + ], + "section": "wxYZcT" + }, + { + "path": "/current-ownership-status", + "title": "Current ownership status", + "components": [ + { + "name": "XiHjDO", + "options": {}, + "type": "TextField", + "title": "Tell us what you know about the sale or lease of the asset" + } + ], + "next": [ + { + "path": "/expected-terms-of-your-ownership-or-lease" + } + ], + "section": "wxYZcT" + }, + { + "path": "/risk-of-closure-JncvIu", + "title": "Risk of closure", + "components": [ + { + "name": "qlqyUq", + "options": {}, + "type": "CheckboxesField", + "title": "Why is the asset at risk of closure?", + "hint": "Select all that apply", + "list": "YNTnCC" + } + ], + "next": [ + { + "path": "/asset-listing-details", + "condition": "EQpfAl" + }, + { + "path": "/community-asset-transfer", + "condition": "rrSKPO" + }, + { + "path": "/assets-of-community-value" + } + ], + "section": "wxYZcT" + }, + { + "path": "/assets-of-community-value", + "title": "Assets of community value", + "components": [ + { + "name": "iqnlTk", + "options": {}, + "type": "YesNoField", + "title": "Is this a registered Asset of Community Value (ACV)?" + } + ], + "next": [ + { + "path": "/summary" + } + ], + "section": "wxYZcT" + }, + { + "path": "/risk-of-closure-KVISjF", + "title": "Risk of closure", + "components": [ + { + "name": "LmOXLT", + "options": {}, + "type": "CheckboxesField", + "title": "Why is the asset at risk of closure?", + "hint": "Select all that apply", + "list": "IZlNBH" + } + ], + "next": [ + { + "path": "/assets-of-community-value" + } + ], + "section": "wxYZcT" + }, + { + "path": "/upload-asset-valuation-or-lease-agreement", + "title": "Upload asset valuation or lease agreement", + "components": [ + { + "name": "tTOrEp", + "options": { + "dropzoneConfig": { + "maxFiles": 1, + "parallelUploads": 1, + "maxFilesize": 10, + "acceptedFiles": "image/jpeg,image/png,application/pdf,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.text,text/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.spreadsheet" + }, + "showNoScriptWarning": false, + "minimumRequiredFiles": 1 + }, + "type": "ClientSideFileUploadField", + "title": "Please upload evidence that shows the asset valuation (if you are buying the asset) or the lease agreement (if you are leasing the asset).", + "hint": "" + } + ], + "next": [ + { + "path": "/terms-of-your-lease", + "condition": "hWsHyx" + }, + { + "path": "/who-owns-the-asset" + } + ], + "section": "wxYZcT" + }, + { + "path": "/terms-of-your-lease", + "title": "Terms of your lease", + "components": [ + { + "name": "apJIBm", + "options": {}, + "type": "FreeTextField", + "title": "Describe the terms of your lease if you have rented the asset", + "hint": "For example, length of lease, conditions of lease or break clauses" + } + ], + "next": [ + { + "path": "/public-ownership" + } + ], + "section": "wxYZcT" + }, + { + "path": "/asset-listing-details", + "title": "Asset listing details", + "components": [ + { + "name": "QPIPjx", + "options": {}, + "type": "DatePartsField", + "title": "When was the asset listed?", + "hint": "For example, 27 3 2007" + }, + { + "name": "OJWGGr", + "options": { + "classes": "govuk-!-width-full" + }, + "type": "WebsiteField", + "title": "Provide a link to the listing" + } + ], + "next": [ + { + "path": "/community-asset-transfer", + "condition": "rrSKPO" + }, + { + "path": "/assets-of-community-value" + } + ], + "section": "wxYZcT" + }, + { + "path": "/community-asset-transfer", + "title": "Community asset transfer", + "components": [ + { + "name": "WKIGQE", + "options": { + "maxWords": "500" + }, + "type": "FreeTextField", + "title": "Describe the current status of the Community Asset Transfer" + } + ], + "next": [ + { + "path": "/assets-of-community-value" + } + ], + "section": "wxYZcT" + }, + { + "path": "/upload-asset-valuation-or-lease-agreement-pJQtXk", + "title": "Upload asset valuation or lease agreement", + "components": [ + { + "name": "TtOReP", + "options": { + "dropzoneConfig": { + "maxFiles": 1, + "parallelUploads": 1, + "maxFilesize": 10, + "acceptedFiles": "image/jpeg,image/png,application/pdf,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.text,text/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.spreadsheet" + }, + "showNoScriptWarning": false, + "minimumRequiredFiles": 1 + }, + "type": "ClientSideFileUploadField", + "title": "Please upload evidence that shows the asset is already leased by your organisation.", + "hint": "" + } + ], + "next": [ + { + "path": "/terms-of-your-lease" + } + ], + "section": "wxYZcT" + } + ], + "lists": [ + { + "title": "Asset type", + "name": "jWXaBR", + "type": "string", + "items": [ + { + "text": "Community centre", + "value": "community-centre" + }, + { + "text": "Cinema", + "value": "cinema" + }, + { + "text": "Gallery", + "value": "gallery" + }, + { + "text": "Museum", + "value": "museum" + }, + { + "text": "Music venue", + "value": "music-venue" + }, + { + "text": "Park", + "value": "park" + }, + { + "text": "Post office building", + "value": "post-office" + }, + { + "text": "Pub", + "value": "pub" + }, + { + "text": "Shop", + "value": "shop" + }, + { + "text": "Sporting or leisure facility", + "value": "sporting" + }, + { + "text": "Theatre", + "value": "theatre" + }, + { + "text": "Other", + "value": "other" + } + ] + }, + { + "title": "How do you intend to take community ownership of the asset?", + "name": "grEEzr", + "type": "string", + "items": [ + { + "text": "Buy the asset", + "value": "buy-the-asset" + }, + { + "text": "Lease the asset", + "value": "lease-the-asset" + }, + { + "text": "Already owned by organisation", + "value": "already-owned-by-organisation" + }, + { + "text": "Already leased by organisation", + "value": "already-leased-by-organisation" + } + ] + }, + { + "title": "I Confirm", + "name": "OSzelY", + "type": "string", + "items": [ + { + "text": "I confirm", + "value": "confirm" + } + ] + }, + { + "title": "Risk of closure", + "name": "RGVpbi", + "type": "string", + "items": [ + { + "text": "For sale or listed for disposal", + "value": "for-sale-or-listed-for-disposal" + }, + { + "text": "Future use not secured", + "value": "future-use-not-secured" + }, + { + "text": "Unprofitable under current business model", + "value": "unprofitable-under-current-business-model" + }, + { + "text": "Current ownership not tenable", + "value": "current-ownership-not-tenable" + } + ] + }, + { + "title": "Why is the asset at risk of closure?", + "name": "YNTnCC", + "type": "string", + "items": [ + { + "text": "Closure", + "value": "Closure" + }, + { + "text": "Sale", + "value": "Sale" + }, + { + "text": "Neglect or dereliction", + "value": "Neglect or dereliction" + }, + { + "text": "Unsustainable current business model", + "value": "Unsustainable current business model" + }, + { + "text": "Listed for disposal", + "value": "Listed for disposal" + }, + { + "text": "Part of a Community Asset Transfer", + "value": "Part of a Community Asset Transfer" + } + ] + }, + { + "title": "Why is the asset at risk of closure? 2", + "name": "IZlNBH", + "type": "string", + "items": [ + { + "text": "Closure", + "value": "Closure" + }, + { + "text": "Neglect or dereliction", + "value": "Neglect or dereliction" + }, + { + "text": "Unsustainable current business model", + "value": "Unsustainable current business model" + } + ] + } + ], + "sections": [ + { + "name": "wxYZcT", + "title": "Asset information" + } + ], + "conditions": [ + { + "displayName": "How the asset is used-other", + "name": "VxBLMN", + "value": { + "name": "How the asset is used-other", + "conditions": [ + { + "field": { + "name": "oXGwlA", + "type": "RadiosField", + "display": "Asset type" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "other", + "display": "other" + } + } + ] + } + }, + { + "displayName": "Do you know who currently owns your asset-yes", + "name": "KZLYTF", + "value": { + "name": "Do you know who currently owns your asset-yes", + "conditions": [ + { + "field": { + "name": "hdmYjg", + "type": "YesNoField", + "display": "Do you know who currently owns your asset?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "true", + "display": "true" + } + } + ] + } + }, + { + "displayName": "Do you know who currently owns your asset-no", + "name": "GRGSGT", + "value": { + "name": "Do you know who currently owns your asset-no", + "conditions": [ + { + "field": { + "name": "hdmYjg", + "type": "YesNoField", + "display": "Do you know who currently owns your asset?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "false", + "display": "false" + } + } + ] + } + }, + { + "displayName": "Is your asset currently publicly owned-yes", + "name": "oHpvvn", + "value": { + "name": "Is your asset currently publicly owned-yes", + "conditions": [ + { + "field": { + "name": "VGXXyq", + "type": "YesNoField", + "display": "Is your asset currently publicly owned?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "true", + "display": "true" + } + } + ] + } + }, + { + "displayName": "Is your asset currently publicly owned-no", + "name": "ujYYpF", + "value": { + "name": "Is your asset currently publicly owned-no", + "conditions": [ + { + "field": { + "name": "VGXXyq", + "type": "YesNoField", + "display": "Is your asset currently publicly owned?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "false", + "display": "false" + } + } + ] + } + }, + { + "displayName": "Is this a registered Asset of Community Value (ACV)-yes", + "name": "pErRiw", + "value": { + "name": "Is this a registered Asset of Community Value (ACV)-yes", + "conditions": [ + { + "field": { + "name": "wjBFTf", + "type": "YesNoField", + "display": "Is this a registered Asset of Community Value (ACV)?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "true", + "display": "true" + } + } + ] + } + }, + { + "displayName": "Is the asset listed for disposal, or part of a Community Asset Transfer-yes", + "name": "XVOzZh", + "value": { + "name": "Is the asset listed for disposal, or part of a Community Asset Transfer-yes", + "conditions": [ + { + "field": { + "name": "HyWPwE", + "type": "YesNoField", + "display": "Is the asset listed for disposal, or part of a Community Asset Transfer?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "true", + "display": "true" + } + } + ] + } + }, + { + "displayName": "Is this a registered Asset of Community Value (ACV)-no", + "name": "nxnCXz", + "value": { + "name": "Is this a registered Asset of Community Value (ACV)-no", + "conditions": [ + { + "field": { + "name": "wjBFTf", + "type": "YesNoField", + "display": "Is this a registered Asset of Community Value (ACV)?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "false", + "display": "false" + } + } + ] + } + }, + { + "displayName": "Is the asset listed for disposal, or part of a Community Asset Transfer-no", + "name": "lmmYts", + "value": { + "name": "Is the asset listed for disposal, or part of a Community Asset Transfer-no", + "conditions": [ + { + "field": { + "name": "HyWPwE", + "type": "YesNoField", + "display": "Is the asset listed for disposal, or part of a Community Asset Transfer?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "false", + "display": "false" + } + } + ] + } + }, + { + "displayName": "Do you know who owns the asset - yes", + "name": "YBZKoC", + "value": { + "name": "Do you know who owns the asset - yes", + "conditions": [ + { + "field": { + "name": "wAUFqr", + "type": "YesNoField", + "display": "Do you know who currently owns your asset?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "true", + "display": "true" + } + } + ] + } + }, + { + "displayName": "Do you know who owns the asset - no", + "name": "oFfQbV", + "value": { + "name": "Do you know who owns the asset - no", + "conditions": [ + { + "field": { + "name": "wAUFqr", + "type": "YesNoField", + "display": "Do you know who currently owns your asset?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "false", + "display": "false" + } + } + ] + } + }, + { + "displayName": "already-owned-by-organisation-yes", + "name": "FwscXH", + "value": { + "name": "already-owned-by-organisation-yes", + "conditions": [ + { + "field": { + "name": "LaxeJN", + "type": "RadiosField", + "display": "How do you intend to take community ownership of the asset?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "already-owned-by-organisation", + "display": "already-owned-by-organisation" + } + } + ] + } + }, + { + "displayName": "already-leased-by-organisation-yes", + "name": "hWsHyx", + "value": { + "name": "already-leased-by-organisation-yes", + "conditions": [ + { + "field": { + "name": "LaxeJN", + "type": "RadiosField", + "display": "How do you intend to take community ownership of the asset?" + }, + "operator": "is", + "value": { + "type": "Value", + "value": "already-leased-by-organisation", + "display": "already-leased-by-organisation" + } + } + ] + } + }, + { + "displayName": "listed-for-disposal", + "name": "EQpfAl", + "value": { + "name": "listed-for-disposal", + "conditions": [ + { + "field": { + "name": "qlqyUq", + "type": "CheckboxesField", + "display": "Why is the asset at risk of closure?" + }, + "operator": "contains", + "value": { + "type": "Value", + "value": "Listed for disposal", + "display": "Listed for disposal" + } + } + ] + } + }, + { + "displayName": "part-of-a-community-asset-transfer", + "name": "rrSKPO", + "value": { + "name": "part-of-a-community-asset-transfer", + "conditions": [ + { + "field": { + "name": "qlqyUq", + "type": "CheckboxesField", + "display": "Why is the asset at risk of closure?" + }, + "operator": "contains", + "value": { + "type": "Value", + "value": "Part of a Community Asset Transfer", + "display": "Part of a Community Asset Transfer" + } + } + ] + } + } + ], + "fees": [], + "outputs": [ + { + "name": "update-form", + "title": "Update form in application store", + "type": "savePerPage", + "outputConfiguration": { + "savePerPageUrl": "True" + } + } + ], + "version": 2, + "skipSummary": false, + "name": "Apply for funding to save an asset in your community", + "feedback": { + "feedbackForm": false, + "url": "" + }, + "phaseBanner": { + "phase": "beta" + } +} diff --git a/tests/test_build_assessment_config.py b/tests/test_build_assessment_config.py index ca77f23..6974fc0 100644 --- a/tests/test_build_assessment_config.py +++ b/tests/test_build_assessment_config.py @@ -1,15 +1,11 @@ -from app.config_generator.scripts.generate_assessment_config import ( - build_assessment_config, -) +from app.export_config.generate_assessment_config import build_assessment_config from tests.unit_test_data import cri1 from tests.unit_test_data import crit_1_id from tests.unit_test_data import mock_form_1 def test_build_basic_structure(mocker): - mocker.patch( - "app.config_generator.scripts.generate_assessment_config.get_form_for_component", return_value=mock_form_1 - ) + mocker.patch("app.export_config.generate_assessment_config.get_form_for_component", return_value=mock_form_1) results = build_assessment_config([cri1]) assert "unscored_sections" in results @@ -19,9 +15,7 @@ def test_build_basic_structure(mocker): def test_with_field_info(mocker): - mocker.patch( - "app.config_generator.scripts.generate_assessment_config.get_form_for_component", return_value=mock_form_1 - ) + mocker.patch("app.export_config.generate_assessment_config.get_form_for_component", return_value=mock_form_1) results = build_assessment_config([cri1]) assert len(results["unscored_sections"]) == 1 unscored_subcriteria = next(section for section in results["unscored_sections"] if section["id"] == crit_1_id)[ diff --git a/tests/test_build_forms.py b/tests/test_build_forms.py index 5bbeea2..b8bb4b8 100644 --- a/tests/test_build_forms.py +++ b/tests/test_build_forms.py @@ -2,7 +2,7 @@ # import pytest -# from app.config_generator.generate_form import build_form_json +# from app.export_config.generate_form import build_form_json # @pytest.mark.parametrize( # "input_json,form_id, form_title, exp_results", diff --git a/tests/test_config_generators.py b/tests/test_config_export.py similarity index 81% rename from tests/test_config_generators.py rename to tests/test_config_export.py index 50b6e77..4fc873a 100644 --- a/tests/test_config_generators.py +++ b/tests/test_config_export.py @@ -6,18 +6,14 @@ import pytest -from app.config_generator.scripts.generate_fund_round_config import ( - generate_config_for_round, -) -from app.config_generator.scripts.generate_fund_round_form_jsons import ( +from app.export_config.generate_fund_round_config import generate_config_for_round +from app.export_config.generate_fund_round_form_jsons import ( generate_form_jsons_for_round, ) -from app.config_generator.scripts.generate_fund_round_html import ( - generate_all_round_html, -) -from app.config_generator.scripts.helpers import validate_json +from app.export_config.generate_fund_round_html import generate_all_round_html +from app.export_config.helpers import validate_json -output_base_path = Path("app") / "config_generator" / "output" +output_base_path = Path("app") / "export_config" / "output" def test_generate_config_for_round_valid_input(seed_dynamic_data, monkeypatch): @@ -30,7 +26,7 @@ def test_generate_config_for_round_valid_input(seed_dynamic_data, monkeypatch): mock_round_base_paths = {round_short_name: 99} # Use monkeypatch to temporarily replace ROUND_BASE_PATHS - import app.config_generator.scripts.generate_fund_round_config as generate_fund_round_config + import app.export_config.generate_fund_round_config as generate_fund_round_config monkeypatch.setattr(generate_fund_round_config, "ROUND_BASE_PATHS", mock_round_base_paths) # Execute: Call the function with valid inputs @@ -158,7 +154,27 @@ def test_generate_form_jsons_for_round_valid_input(seed_dynamic_data): expected_files = [ { "path": output_base_path / round_short_name / "form_runner" / f"{form_publish_name}.json", - "expected_output": '{"metadata": {}, "startPage": "/intro-about-your-organisation", "backLinkText": "Go back to application overview", "pages": [{"path": "/organisation-name", "title": "Organisation Name", "components": [{"options": {"hideTitle": false, "classes": ""}, "type": "TextField", "title": "What is your organisation\'s name?", "hint": "This must match the regsitered legal organisation name", "schema": {}, "name": "organisation_name"}, {"options": {"hideTitle": false, "classes": ""}, "type": "RadiosField", "title": "How is your organisation classified?", "hint": "", "schema": {}, "name": "organisation_classification", "list": "classifications_list"}], "next": [{"path": "/summary"}], "options": {}}, {"path": "/intro-about-your-organisation", "title": "About your organisation", "components": [{"name": "start-page-content", "options": {}, "type": "Html", "content": "

None

We will ask you about:

  • Organisation Name
", "schema": {}}], "next": [{"path": "/organisation-name"}], "options": {}, "controller": "./pages/start.js"}, {"path": "/summary", "title": "Check your answers", "components": [], "next": [], "section": "uLwBuz", "controller": "./pages/summary.js"}], "lists": [{"type": "string", "items": [{"text": "Charity", "value": "charity"}, {"text": "Public Limited Company", "value": "plc"}], "name": "classifications_list"}], "conditions": [], "fees": [], "sections": [], "outputs": [{"name": "update-form", "title": "Update form in application store", "type": "savePerPage", "outputConfiguration": {"savePerPageUrl": true}}], "skipSummary": false, "name": "About your organisation"}', # noqa: E501 + "expected_output": ( + '{"metadata": {}, "startPage": "/intro-about-your-organisation", "backLinkText": ' + '"Go back to application overview", "pages": [{"path": "/organisation-name", "title": ' + '"Organisation Name", "components": [{"options": {"hideTitle": false, "classes": ""}, ' + '"type": "TextField", "title": "What is your organisation\'s name?", "hint": "This ' + 'must match the regsitered legal organisation name", "schema": {}, "name": ' + '"organisation_name"}, {"options": {"hideTitle": false, "classes": ""}, "type": ' + '"RadiosField", "title": "How is your organisation classified?", "hint": "", "schema":' + ' {}, "name": "organisation_classification", "list": "classifications_list"}], "next":' + ' [{"path": "/summary"}]}, {"path": "/intro-about-your-organisation", "title": "About' + ' your organisation", "components": [{"name": "start-page-content", "options": {}, ' + '"type": "Html", "content": "

None

' + 'We will ask you about:

  • Organisation Name
", "schema": {}}], "next":' + ' [{"path": "/organisation-name"}], "controller": "./pages/start.js"}, {"path": "/summary",' + ' "title": "Check your answers", "components": [], "next": [], "section": "uLwBuz", "controller":' + ' "./pages/summary.js"}], "lists": [{"type": "string", "items": [{"text": "Charity", "value":' + ' "charity"}, {"text": "Public Limited Company", "value": "plc"}], "name": "classifications_list",' + ' "title": null}], "conditions": [], "fees": [], "sections": [], "outputs": [{"name": "update-form",' + ' "title": "Update form in application store", "type": "savePerPage", "outputConfiguration":' + ' {"savePerPageUrl": true}}], "skipSummary": false, "name": "About your organisation"}' + ), } ] try: diff --git a/tests/test_config_import.py b/tests/test_config_import.py new file mode 100644 index 0000000..fca91db --- /dev/null +++ b/tests/test_config_import.py @@ -0,0 +1,33 @@ +import json +import os + +from app.db.models import Component +from app.db.models import Form +from app.db.models import Page +from app.import_config.load_form_json import load_form_jsons + + +def test_generate_config_for_round_valid_input(seed_dynamic_data, _db): + form_configs = [] + filename = "test-import-form.json" + script_dir = os.path.dirname(__file__) + file_path = os.path.join(script_dir, filename) + with open(file_path, "r") as json_file: + form = json.load(json_file) + form["filename"] = filename + form_configs.append(form) + load_form_jsons(form_configs) + + expected_form_count = 1 + expected_page_count_for_form = 19 + expected_component_count_for_form = 25 + # check form config is in the database + forms = _db.session.query(Form).filter(Form.template_name == "Apply for funding to save an asset in your community") + assert forms.count() == expected_form_count + form = forms.first() + pages = _db.session.query(Page).filter(Page.form_id == form.form_id) + assert pages.count() == expected_page_count_for_form + total_components_count = sum( + _db.session.query(Component).filter(Component.page_id == page.page_id).count() for page in pages + ) + assert total_components_count == expected_component_count_for_form diff --git a/tests/test_generate_form.py b/tests/test_generate_form.py index 6ae01cc..6cdc9fc 100644 --- a/tests/test_generate_form.py +++ b/tests/test_generate_form.py @@ -2,16 +2,16 @@ import pytest -from app.config_generator.generate_form import build_conditions -from app.config_generator.generate_form import build_form_json -from app.config_generator.generate_form import build_lists -from app.config_generator.generate_form import build_navigation -from app.config_generator.generate_form import build_page -from app.config_generator.generate_form import human_to_kebab_case from app.db.models import Component from app.db.models import ComponentType from app.db.models import Lizt from app.db.models import Page +from app.export_config.generate_form import build_conditions +from app.export_config.generate_form import build_form_json +from app.export_config.generate_form import build_lists +from app.export_config.generate_form import build_navigation +from app.export_config.generate_form import build_page +from app.export_config.generate_form import human_to_kebab_case from tests.unit_test_data import mock_c_1 from tests.unit_test_data import mock_form_1 @@ -73,7 +73,7 @@ def test_human_to_kebab(input, exp_output): ) def test_build_lists(mocker, pages, exp_result): mocker.patch( - "app.config_generator.generate_form.get_list_by_id", + "app.export_config.generate_form.get_list_by_id", return_value=Lizt( name="greetings_list", type="string", @@ -224,11 +224,10 @@ def test_build_lists(mocker, pages, exp_result): "title": "Organisation name", "hint": "This must match your registered legal organisation name", "schema": {}, - "metadata": {"fund_builder_id": str(mock_c_1.component_id)}, + "metadata": {}, } ], "next": [], - "options": {}, }, ) ], @@ -394,9 +393,10 @@ def test_build_conditions(input_component, exp_results): display_path="organisation-single-name", name_in_apply_json={"en": "Organisation Name"}, form_index=1, + default_next_page_id=id2, ), Page( - page_id=uuid4(), + page_id=id2, form_id=uuid4(), display_path="organisation-charitable-objects", name_in_apply_json={"en": "What are your organisation's charitable objects?"}, @@ -479,7 +479,7 @@ def test_build_navigation_no_conditions(mocker, input_partial_json, input_pages, "name": "organisation_other_names_no", "operator": "is", "value": "no", - "destination_page_path": "CONTINUE", + "destination_page_path": "summary", }, { "name": "organisation_other_names_yes", @@ -492,13 +492,13 @@ def test_build_navigation_no_conditions(mocker, input_partial_json, input_pages, ) ], ), - # Page( - # page_id=uuid4(), - # form_id=uuid4(), - # display_path="organisation-alternative-names", - # name_in_apply_json={"en": "Organisation Alternative Names"}, - # form_index=2, - # ), + Page( + page_id=uuid4(), + form_id=uuid4(), + display_path="organisation-alternative-names", + name_in_apply_json={"en": "Organisation Alternative Names"}, + form_index=2, + ), ], { "conditions": [], @@ -529,21 +529,13 @@ def test_build_navigation_no_conditions(mocker, input_partial_json, input_pages, "next": [], "options": {}, }, - # { - # "path": "/organisation-alternative-names", - # "title": "Organisation Alternative Names", - # "components": [ - # # { - # # "name": "reuse-charitable-objects", - # # "options": {"hideTitle": True, "maxWords": "500"}, - # # "type": "FreeTextField", - # # "title": "What are your organisation's charitable objects?", - # # "hint": "You can find this in your organisation's governing document.", - # # }, - # ], - # "next": [], - # "options": {}, - # }, + { + "path": "/organisation-alternative-names", + "title": "Organisation Alternative Names", + "components": [], + "next": [], + "options": {}, + }, ], }, { @@ -610,7 +602,7 @@ def test_build_navigation_no_conditions(mocker, input_partial_json, input_pages, ) def test_build_navigation_with_conditions(mocker, input_pages, input_partial_json, exp_next, exp_conditions): mocker.patch( - "app.config_generator.generate_form.build_page", + "app.export_config.generate_form.build_page", return_value={"path": "/organisation-alternative-names", "next": []}, ) results = build_navigation(partial_form_json=input_partial_json, input_pages=input_pages) diff --git a/tests/test_integration.py b/tests/test_integration.py index b20022b..94ec4ee 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,10 +2,6 @@ import pytest -from app.config_generator.generate_form import build_form_json -from app.config_generator.scripts.generate_assessment_config import ( - build_assessment_config, -) from app.db.models import Component from app.db.models import ComponentType from app.db.models import Form @@ -16,6 +12,8 @@ from app.db.models import Section from app.db.queries.application import get_component_by_id from app.db.queries.fund import get_fund_by_id +from app.export_config.generate_assessment_config import build_assessment_config +from app.export_config.generate_form import build_form_json from tasks.test_data import BASIC_FUND_INFO from tasks.test_data import BASIC_ROUND_INFO @@ -95,13 +93,16 @@ def test_build_form_json_no_conditions(seed_dynamic_data): ), Page( page_id=page_2_id, - form_id=None, + form_id=form_id, display_path="organisation-alternative-names", name_in_apply_json={"en": "Alternative names of your organisation"}, form_index=2, is_template=True, ), ], + "default_next_pages": [ + {"page_id": page_1_id, "default_next_page_id": page_2_id}, + ], "components": [ Component( component_id=uuid4(),