diff --git a/.vscode/launch.json b/.vscode/launch.json
index 1f849c0..40d9143 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -1,24 +1,51 @@
{
- // Use IntelliSense to learn about possible attributes.
- // Hover to view descriptions of existing attributes.
- // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
- "version": "0.2.0",
- "configurations": [
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Python Debugger: Current File",
+ "type": "debugpy",
+ "request": "launch",
+ "env": {
+ "FLASK_ENV": "development"
+ },
+ "program": "${file}",
+ "console": "integratedTerminal"
+ },
+ {
+ "name": "Python Debugger: Flask",
+ "type": "debugpy",
+ "request": "launch",
+ "module": "flask",
+ "env": {
+ "FLASK_APP": "app.app.py",
+ "FLASK_DEBUG": "1"
+ },
+ "args": ["run", "--debug"],
+ "jinja": true,
+ "autoStartBrowser": false
+ },
+ {
+ "name": "Docker Runner FAB",
+ "type": "debugpy",
+ "env": {
+ "FLASK_APP": "app.app.py",
+ "FLASK_DEBUG": "1"
+ },
+ "request": "attach",
+ "connect": {
+ "host": "localhost",
+ "port": 5686
+ },
+ "pathMappings": [
{
- "name": "Python Debugger: Flask",
- "type": "debugpy",
- "request": "launch",
- "module": "flask",
- "env": {
- "FLASK_APP": "app.app.py",
- "FLASK_DEBUG": "1"
- },
- "args": [
- "run",
- "--debug"
- ],
- "jinja": true,
- "autoStartBrowser": false
+ "localRoot": "${workspaceFolder:funding-service-design-fund-application-builder}",
+ "remoteRoot": "."
}
- ]
+ ],
+ "justMyCode": true
+ }
+ ]
}
diff --git a/README.md b/README.md
index 2ff52d1..0b7b31f 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,18 @@ Run the app with `flask run` (include `--debug` for auto reloading on file chang
## Helper Tasks
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.
+
+## Database
+### Schema
+The database schema is defined in [app/db/models.py](./app/db/models.py) and is managed by Alembic. The migrations are stored in [app/db/migrations/versions](./app/db/migrations/versions/)
+
+### Entity Relationship Diagram
+See [Here](./app/db/database_ERD_9-8-24.png)
+
### Recreate Local DBs
For both `DATABASE_URL` and `DATABASE_URL_UNIT_TEST`, drops the database if it exists and then recreates it.
diff --git a/app/question_reuse/__init__.py b/app/__init__.py
similarity index 100%
rename from app/question_reuse/__init__.py
rename to app/__init__.py
diff --git a/app/blueprints/fund_builder/routes.py b/app/blueprints/fund_builder/routes.py
index 52c5536..f937c34 100644
--- a/app/blueprints/fund_builder/routes.py
+++ b/app/blueprints/fund_builder/routes.py
@@ -14,6 +14,8 @@
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
@@ -23,8 +25,6 @@
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.question_reuse.generate_all_questions import print_html
-from app.question_reuse.generate_form import build_form_json
from config import Config
# Blueprint for routes used by v1 of FAB - using the DB
diff --git a/app/blueprints/self_serve/data/data_access.py b/app/blueprints/self_serve/data/data_access.py
index 853aaff..499aebe 100644
--- a/app/blueprints/self_serve/data/data_access.py
+++ b/app/blueprints/self_serve/data/data_access.py
@@ -3,6 +3,7 @@
from app.blueprints.self_serve.data.not_a_db import LISTS
from app.blueprints.self_serve.data.not_a_db import PAGES
from app.blueprints.self_serve.data.not_a_db import SECTIONS
+from app.db.queries.application import insert_new_section
saved_responses = []
saved_sections = {}
@@ -66,20 +67,134 @@ def get_list_by_id(id: str) -> dict:
return LISTS.get(id, None)
-def save_question(question: dict):
+# TODO Implement front end journey that can use the section/form/page/component CRUD operations
+# from app.db.queries.application import insert_new_section
+# from app.db.queries.application import insert_new_form
+# from app.db.queries.application import insert_new_page
+# from app.db.queries.application import insert_new_component
+
+
+def save_template_component(component: dict):
+ """
+ TODO:
+ Save a template component to the database
+ Parameters:
+ component: dict The component to save to the database as a template
+ Returns:
+ dict The saved component
+
+ component_config = {
+ "page_id": component.get("page_id"),
+ "theme_id": component.get("theme_id"),
+ "title": component.get("title"),
+ "hint_text": component.get("hint"),
+ "options": component.get("options"),
+ "type": component.get("question_type"),
+ "template_name": component.get("template_name"),
+ "is_template": True,
+ "audit_info": component.get("audit_info"),
+ "page_index": component.get("page_index"),
+ "theme_index": component.get("theme_index"),
+ "conditions": component.get("conditions"),
+ "runner_component_name": component.get("runner_component_name"),
+ "list_id": component.get("list_id"),
+ }
+
+ return insert_new_component(component_config)
+ """
+
+ # temp in memory solution
COMPONENTS.append(
{
"json_snippet": {
"options": {},
- "type": question["question_type"],
- "title": question["title"],
- "hint": question["hint"],
+ "type": component["question_type"],
+ "title": component["title"],
+ "hint": component["hint"],
},
- "id": question["id"],
- "builder_display_name": question["builder_display_name"],
+ "id": component["id"],
+ "builder_display_name": component["builder_display_name"],
}
)
-def save_section(section: dict):
+def save_template_page(page: dict):
+ """
+ TODO:
+ Save a template page to the database
+ Parameters:
+ page: dict The page to save to the database as a template
+ Returns:
+ dict The saved page
+
+ page_config = {
+ "form_id": page.get("form_id"),
+ "name_in_apply_json": {
+ "en": page.get("form_display_name"),
+ },
+ "template_name": page.get("builder_display_name"),
+ "is_template": True,
+ "audit_info": page.get("audit_info"),
+ "form_index": page.get("form_index"),
+ "display_path": page.get("display_path"),
+ "controller": page.get("controller"),
+ }
+
+ return insert_new_page(page_config)
+ """
+
+ # Temp in memory solution
+ PAGES.append(page)
+
+
+def save_template_form(form: dict):
+ """
+ TODO:
+ Save a template form to the database
+ Parameters:
+ form: dict The form to save to the database as a template
+ Returns:
+ dict The saved form
+ form_config = {
+ "name_in_apply_json": {
+ "en": form.get("form_title"),
+ },
+ "is_template": True,
+ "template_name": form.get("builder_display_name"),
+ "audit_info": form.get("audit_info"),
+ "section_id": form.get("section_id"),
+ "section_index": form.get("section_index"),
+ "runner_publish_name": None # This is a template
+ }
+
+ insert_new_form(form_config)
+ """
+
+ # Temp in memory solution
+ FORMS.append(form)
+
+
+def save_template_section(section: dict):
+ """
+ TODO:
+ Save a template section to the database
+ Parameters:
+ section: dict The section to save to the database as a template
+ Returns:
+ dict The saved section
+
+ section_config = {
+ "name_in_apply_json": {
+ "en": section.get("section_display_name"),
+ },
+ "is_template": True, # Assuming this remains a constant value
+ "template_name": section.get("builder_display_name"),
+ "description": section.get("description"),
+ "audit_info": section.get("audit_info"),
+ }
+
+ return insert_new_section(section_config)
+ """
+
+ # Temp in memory solution
SECTIONS.append(section)
diff --git a/app/blueprints/self_serve/routes.py b/app/blueprints/self_serve/routes.py
index 96afaaa..5a32e8b 100644
--- a/app/blueprints/self_serve/routes.py
+++ b/app/blueprints/self_serve/routes.py
@@ -1,7 +1,6 @@
import json
import os
-import requests
from flask import Blueprint
from flask import Response
from flask import flash
@@ -15,16 +14,16 @@
from app.blueprints.self_serve.data.data_access import get_component_by_name
from app.blueprints.self_serve.data.data_access import get_pages_to_display_in_builder
from app.blueprints.self_serve.data.data_access import get_saved_forms
-from app.blueprints.self_serve.data.data_access import save_form
-from app.blueprints.self_serve.data.data_access import save_page
-from app.blueprints.self_serve.data.data_access import save_question
-from app.blueprints.self_serve.data.data_access import save_section
+from app.blueprints.self_serve.data.data_access import save_template_component
+from app.blueprints.self_serve.data.data_access import save_template_form
+from app.blueprints.self_serve.data.data_access import save_template_page
+from app.blueprints.self_serve.data.data_access import save_template_section
from app.blueprints.self_serve.forms.form_form import FormForm
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.question_reuse.generate_all_questions import print_html
-from app.question_reuse.generate_form import build_form_json
+from app.config_generator.generate_all_questions import print_html
+from app.config_generator.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")
@@ -43,38 +42,6 @@ def index():
return render_template("index.html")
-@self_serve_bp.route("/build_form", methods=["GET", "POST"])
-def build_form():
- form = FormForm()
- if form.validate_on_submit():
- new_form = {
- "builder_display_name": form.builder_display_name.data,
- "start_page_guidance": form.start_page_guidance.data,
- "form_display_name": form.form_title.data,
- "id": human_to_kebab_case(form.form_title.data),
- "pages": form.selected_pages.data,
- }
- save_form(new_form)
- flash(message=f'Form {new_form["form_display_name"]} was saved')
- return redirect(url_for("self_serve_bp.index"))
-
- available_pages = []
- pages = get_pages_to_display_in_builder()
- for page in pages:
- questions = [
- x["json_snippet"]["title"] if (x := get_component_by_name(comp_name)) else comp_name
- for comp_name in page["component_names"]
- ]
- available_pages.append(
- {
- "id": page["id"],
- "display_name": page["builder_display_name"],
- "hover_info": {"title": page["form_display_name"], "questions": questions},
- }
- )
- return render_template("build_form.html", available_pages=available_pages, form=form)
-
-
@self_serve_bp.route("/download_json", methods=["POST"])
def generate_json():
form_json = generate_form_config_from_request()["form_json"]
@@ -91,28 +58,18 @@ def human_to_kebab_case(word: str) -> str | None:
return word.replace(" ", "-").strip().lower()
+def human_to_snake_case(word: str) -> str | None:
+ if word:
+ return word.replace(" ", "_").strip().lower()
+
+
def generate_form_config_from_request():
- pages = request.form.getlist("selected_pages")
title = request.form.get("form_title", "My Form")
- intro_content = request.form.get("startPageContent")
form_id = human_to_kebab_case(title)
- input_data = {"title": form_id, "pages": pages, "intro_content": intro_content}
- form_json = build_form_json(form_title=title, input_json=input_data, form_id=form_id)
+ form_json = build_form_json(form)
return {"form_json": form_json, "form_id": form_id, "title": title}
-@self_serve_bp.route("/preview", methods=["POST"])
-def preview_form():
- form_config = generate_form_config_from_request()
- form_config["form_json"]["outputs"][0]["outputConfiguration"][
- "savePerPageUrl"
- ] = "http://fsd-self-serve:8080/dev/save"
- requests.post(
- url=f"{FORM_RUNNER_URL}/publish", json={"id": form_config["form_id"], "configuration": form_config["form_json"]}
- )
- return redirect(f"{FORM_RUNNER_URL_REDIRECT}/{form_config['form_id']}")
-
-
@self_serve_bp.route("/form_questions", methods=["POST"])
def view_form_questions():
form_config = generate_form_config_from_request()
@@ -129,11 +86,38 @@ def view_form_questions():
return render_template("view_questions.html", section_name=form_config["title"], question_html=html)
-@self_serve_bp.route("build_section", methods=["GET", "POST"])
-def build_section():
+@self_serve_bp.route("/section_questions", methods=["POST"])
+def view_section_questions():
+ # form_config = generate_form_config_from_request()
+ # print_data = generate_print_data_for_sections(
+ # sections=[
+ # {
+ # "section_title": form_config["title"],
+ # "forms": [{"name": form_config["form_id"], "form_data": form_config["form_json"]}],
+ # }
+ # ],
+ # lang="en",
+ # )
+ # html = print_html(print_data)
+ # return render_template("view_questions.html", section_name=form_config["title"], question_html=html)
+ pass
+
+
+# CRUD routes
+
+
+# Create routes
+@self_serve_bp.route("section", methods=["GET", "POST", "PUT", "DELETE"])
+def section():
+ # TODO: Create frontend routes and connect to middleware
+ if request.method == "PUT":
+ pass
+ if request.method == "DELETE":
+ pass
+
form = SectionForm()
- if form.validate_on_submit():
- save_section(form.as_dict())
+ if request.method == "POST" and form.validate_on_submit():
+ save_template_section(form.as_dict())
flash(message=f"Section '{form['builder_display_name'].data}' was saved")
return redirect(url_for("self_serve_bp.index"))
@@ -147,24 +131,58 @@ def build_section():
"hover_info": {"title": f["builder_display_name"], "pages": f["pages"]},
}
)
- return render_template("build_section.html", available_forms=available_forms, form=form)
+ # save to db here
+ return render_template("create_section.html", available_forms=available_forms, form=form)
-@self_serve_bp.route("/add_question", methods=["GET", "POST"])
-def add_question():
- form = QuestionForm()
- question = form.as_dict()
- if form.validate_on_submit():
- save_question(question)
- flash(message=f"Question '{question['title']}' was saved")
+@self_serve_bp.route("/form", methods=["GET", "POST", "PUT", "DELETE"])
+def form():
+ # TODO: Create frontend routes and connect to middleware
+ if request.method == "PUT":
+ pass
+ if request.method == "DELETE":
+ pass
+
+ form = FormForm()
+ if request.method == "POST" and form.validate_on_submit():
+ new_form = {
+ "builder_display_name": form.builder_display_name.data,
+ "start_page_guidance": form.start_page_guidance.data,
+ "form_display_name": form.form_title.data,
+ "id": human_to_kebab_case(form.form_title.data),
+ "pages": form.selected_pages.data,
+ }
+ save_template_form(new_form)
+ flash(message=f'Form {new_form["form_display_name"]} was saved')
return redirect(url_for("self_serve_bp.index"))
- return render_template("add_question.html", form=form)
+
+ available_pages = []
+ pages = get_pages_to_display_in_builder()
+ for page in pages:
+ questions = [
+ x["json_snippet"]["title"] if (x := get_component_by_name(comp_name)) else comp_name
+ for comp_name in page["component_names"]
+ ]
+ available_pages.append(
+ {
+ "id": page["id"],
+ "display_name": page["builder_display_name"],
+ "hover_info": {"title": page["form_display_name"], "questions": questions},
+ }
+ )
+ return render_template("create_form.html", available_pages=available_pages, form=form)
-@self_serve_bp.route("/build_page", methods=["GET", "POST"])
-def build_page():
+@self_serve_bp.route("/page", methods=["GET", "POST", "PUT", "DELETE"])
+def page():
+ # TODO: Create frontend routes and connect to middleware
+ if request.method == "PUT":
+ pass
+ if request.method == "DELETE":
+ pass
+
form = PageForm()
- if form.validate_on_submit():
+ if request.method == "POST" and form.validate_on_submit():
new_page = {
"id": form.id.data,
"builder_display_name": form.builder_display_name.data,
@@ -172,7 +190,7 @@ def build_page():
"component_names": form.selected_components.data,
"show_in_builder": True,
}
- save_page(new_page)
+ save_template_page(new_page)
flash(message=f"Page '{form.builder_display_name.data}' was saved")
return redirect(url_for("self_serve_bp.index"))
components = get_all_components()
@@ -184,21 +202,21 @@ def build_page():
}
for c in components
]
- return render_template("build_page.html", form=form, available_questions=available_questions)
+ return render_template("create_page.html", form=form, available_questions=available_questions)
-@self_serve_bp.route("/section_questions", methods=["POST"])
-def view_section_questions():
- # form_config = generate_form_config_from_request()
- # print_data = generate_print_data_for_sections(
- # sections=[
- # {
- # "section_title": form_config["title"],
- # "forms": [{"name": form_config["form_id"], "form_data": form_config["form_json"]}],
- # }
- # ],
- # lang="en",
- # )
- # html = print_html(print_data)
- # return render_template("view_questions.html", section_name=form_config["title"], question_html=html)
- pass
+@self_serve_bp.route("/question", methods=["GET", "PUT", "POST", "DELETE"])
+def question():
+ # TODO: Create frontend routes and connect to middleware
+ if request.method == "PUT":
+ pass
+ if request.method == "DELETE":
+ pass
+
+ form = QuestionForm()
+ question = form.as_dict()
+ if request.method == "POST" and form.validate_on_submit():
+ save_template_component(question)
+ flash(message=f"Question '{question['title']}' was saved")
+ return redirect(url_for("self_serve_bp.index"))
+ return render_template("create_question.html", form=form)
diff --git a/app/blueprints/self_serve/templates/build_form.html b/app/blueprints/self_serve/templates/create_form.html
similarity index 98%
rename from app/blueprints/self_serve/templates/build_form.html
rename to app/blueprints/self_serve/templates/create_form.html
index 12ba792..9ebeef5 100644
--- a/app/blueprints/self_serve/templates/build_form.html
+++ b/app/blueprints/self_serve/templates/create_form.html
@@ -198,7 +198,7 @@
function previewForm() {
section_form = document.getElementById("form_form")
- section_form.setAttribute("action", "{{url_for('self_serve_bp.preview_form')}}")
+ section_form.setAttribute("action", "{{url_for('build_fund_bp.preview_form', form_id=form.id)}}")
section_form.submit()
}
function allQuestions() {
@@ -208,7 +208,7 @@
}
function saveForm() {
section_form = document.getElementById("form_form")
- section_form.setAttribute("action", "{{url_for('self_serve_bp.build_form')}}")
+ section_form.setAttribute("action", "{{url_for('self_serve_bp.form')}}")
section_form.submit()
}
diff --git a/app/blueprints/self_serve/templates/build_page.html b/app/blueprints/self_serve/templates/create_page.html
similarity index 99%
rename from app/blueprints/self_serve/templates/build_page.html
rename to app/blueprints/self_serve/templates/create_page.html
index ff95f02..6b73cb5 100644
--- a/app/blueprints/self_serve/templates/build_page.html
+++ b/app/blueprints/self_serve/templates/create_page.html
@@ -211,7 +211,7 @@
}
function savePage() {
section_form = document.getElementById("page_form")
- section_form.setAttribute("action", "{{url_for('self_serve_bp.build_page')}}")
+ section_form.setAttribute("action", "{{url_for('self_serve_bp.create_page')}}")
section_form.submit()
}
diff --git a/app/blueprints/self_serve/templates/add_question.html b/app/blueprints/self_serve/templates/create_question.html
similarity index 100%
rename from app/blueprints/self_serve/templates/add_question.html
rename to app/blueprints/self_serve/templates/create_question.html
diff --git a/app/blueprints/self_serve/templates/build_section.html b/app/blueprints/self_serve/templates/create_section.html
similarity index 99%
rename from app/blueprints/self_serve/templates/build_section.html
rename to app/blueprints/self_serve/templates/create_section.html
index e385e05..d79fd61 100644
--- a/app/blueprints/self_serve/templates/build_section.html
+++ b/app/blueprints/self_serve/templates/create_section.html
@@ -207,7 +207,7 @@
}
function saveSection() {
section_form = document.getElementById("section_form")
- section_form.setAttribute("action", "{{url_for('self_serve_bp.build_section')}}")
+ section_form.setAttribute("action", "{{url_for('self_serve_bp.section')}}")
section_form.submit()
}
diff --git a/app/blueprints/self_serve/templates/index.html b/app/blueprints/self_serve/templates/index.html
index 212a18f..1c831d4 100644
--- a/app/blueprints/self_serve/templates/index.html
+++ b/app/blueprints/self_serve/templates/index.html
@@ -19,18 +19,24 @@
What do you want to do?
-
Application Setup
+
Template Setup
Fund Metadata
diff --git a/app/config_generator/README.md b/app/config_generator/README.md
new file mode 100644
index 0000000..0d12130
--- /dev/null
+++ b/app/config_generator/README.md
@@ -0,0 +1,109 @@
+# FAB Config output
+
+This directory contains the scripts and output required to generate the FAB configuration files.
+
+## Scripts
+### Generate fund round configuration
+ Generates configuration for a specific funding round.
+
+ This function orchestrates the generation of various configurations needed for a funding round.
+ It calls three specific functions in sequence to generate the fund configuration, round configuration,
+ and application display configuration for the given round ID.
+
+ Args:
+ round_id (str): The unique identifier for the funding round.
+
+ The functions called within this function are:
+ - generate_fund_config: Generates the fund configuration for the given round ID.
+ - generate_round_config: Generates the round configuration for the given round ID.
+ - generate_application_display_config: Generates the application display configuration for the given round ID.
+
+### Generate round form JSONs
+ Generates JSON configurations for all forms associated with a given funding round.
+
+ This function iterates through all sections of a specified funding round, and for each form
+ within those sections, it generates a JSON configuration. These configurations are then written
+ to files named after the forms, organized by the round's short name.
+
+ Args:
+ round_id (str): The unique identifier for the funding round.
+
+ The generated files are named after the form names and are stored in a directory
+ corresponding to the round's short name.
+
+### Generate round html
+ Generates an HTML representation for a specific funding round.
+
+ This function creates an HTML document that represents all the sections and forms
+ associated with a given funding round. It retrieves the round and its related fund
+ information, iterates through each section of the round to collect form data, and
+ then generates HTML content based on this data.
+
+ Args:
+ round_id (str): The unique identifier for the funding round.
+
+ The generated HTML is intended to provide a comprehensive overview of the round,
+ including details of each section and form, for printing or web display purposes.
+
+## Running the scripts
+To run the scripts, execute the following commands:
+
+```bash
+inv generate-fund-and-round-config {roundid}
+
+inv generate-round-form-jsons {roundid}
+
+inv generate-round-html {roundid}
+```
+
+## Output
+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
+```
+
+
+
+
diff --git a/app/question_reuse/generate_all_questions.py b/app/config_generator/generate_all_questions.py
similarity index 100%
rename from app/question_reuse/generate_all_questions.py
rename to app/config_generator/generate_all_questions.py
diff --git a/app/question_reuse/generate_form.py b/app/config_generator/generate_form.py
similarity index 100%
rename from app/question_reuse/generate_form.py
rename to app/config_generator/generate_form.py
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
new file mode 100644
index 0000000..babaaf8
--- /dev/null
+++ b/app/config_generator/output/R605/form_runner/about-your-org.json
@@ -0,0 +1,236 @@
+{
+ "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
new file mode 100644
index 0000000..a2d4893
--- /dev/null
+++ b/app/config_generator/output/R605/form_runner/contact-details.json
@@ -0,0 +1,105 @@
+{
+ "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:
",
+ "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
new file mode 100644
index 0000000..99145ee
--- /dev/null
+++ b/app/config_generator/output/R605/fund_store/fund_config_09-08-2024.py
@@ -0,0 +1,13 @@
+{
+ "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
new file mode 100644
index 0000000..1a588f4
--- /dev/null
+++ b/app/config_generator/output/R605/fund_store/round_config_09-08-2024.py
@@ -0,0 +1,39 @@
+{
+ "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
new file mode 100644
index 0000000..dba73b5
--- /dev/null
+++ b/app/config_generator/output/R605/fund_store/sections_config_09-08-2024.py
@@ -0,0 +1,13 @@
+[
+ {"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
new file mode 100644
index 0000000..db9bed6
--- /dev/null
+++ b/app/config_generator/output/R605/html/full_application.html
@@ -0,0 +1,81 @@
+
+
+ Table of contents
+
+
+ -
+
+ 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/question_reuse/generate_assessment_config.py b/app/config_generator/scripts/generate_assessment_config.py
similarity index 86%
rename from app/question_reuse/generate_assessment_config.py
rename to app/config_generator/scripts/generate_assessment_config.py
index 57d7594..4dbc858 100644
--- a/app/question_reuse/generate_assessment_config.py
+++ b/app/config_generator/scripts/generate_assessment_config.py
@@ -25,22 +25,22 @@
# }
-# def generate_field_info_from_forms(forms_dir: str) -> dict:
-# """Generates the display info for all fields in a form
+def generate_field_info_from_forms(forms_dir: str) -> dict:
+ """Generates the display info for all fields in a form
-# Args:
-# forms_dir (str): Directory containing the forms
+ Args:
+ forms_dir (str): Directory containing the forms
-# Returns:
-# dict: Dictionary of field IDs to display info
-# """
-# results = {}
-# for file_name in os.listdir(forms_dir):
-# with open(os.path.join(forms_dir, file_name), "r") as f:
-# form_data = json.load(f)
-# results.update(build_answers_from_form(form_data, file_name.split(".")[0]))
+ Returns:
+ dict: Dictionary of field IDs to display info
+ """
+ results = {}
+ for file_name in os.listdir(forms_dir):
+ with open(os.path.join(forms_dir, file_name), "r") as f:
+ form_data = json.load(f)
+ results.update(build_answers_from_form(form_data, file_name.split(".")[0]))
-# return results
+ return results
def build_answers_from_form(form_data: dict, form_name: str) -> dict:
@@ -71,7 +71,8 @@ def build_answers_from_form(form_data: dict, form_name: str) -> dict:
"field_id": component["name"],
"form_name": form_name,
"field_type": component["type"],
- # TODO fix this "presentation_type": form_json_to_assessment_display_types.get(component["type"].lower(), None),
+ # TODO fix this "presentation_type":
+ # form_json_to_assessment_display_types.get(component["type"].lower(), None),
"question": question,
}
@@ -158,7 +159,7 @@ def build_assessment_config(criteria_list: list[Criteria]) -> dict:
@click.command()
@click.option(
"--input_folder",
- default="./question_reuse/test_data/in/",
+ default="./config_reuse/test_data/in/",
help="Input configuration",
prompt=True,
)
@@ -170,7 +171,7 @@ def build_assessment_config(criteria_list: list[Criteria]) -> dict:
)
@click.option(
"--output_folder",
- default="./question_reuse/test_data/out",
+ default="./config_reuse/test_data/out",
help="Output destination",
prompt=True,
)
@@ -182,7 +183,7 @@ def build_assessment_config(criteria_list: list[Criteria]) -> dict:
)
@click.option(
"--forms_dir",
- default="./question_reuse/test_data/out/forms/",
+ default="./config_reuse/test_data/out/forms/",
help="Directory containing forms",
prompt=True,
)
diff --git a/app/config_generator/scripts/generate_fund_round_config.py b/app/config_generator/scripts/generate_fund_round_config.py
new file mode 100644
index 0000000..fae2fd6
--- /dev/null
+++ b/app/config_generator/scripts/generate_fund_round_config.py
@@ -0,0 +1,257 @@
+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
+
+# 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.
+ "COF_R2_W2": 1,
+ "COF_R2_W3": 1,
+ "COF_R3_W1": 2,
+ "COF_R3_W2H": 4,
+ "CYP_R1": 5,
+ "DPI_R2": 6,
+ "COF_R3_W3": 7,
+ "COF_EOI": 8,
+ "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 = []
+ # get round
+ round = get_round_by_id(round_id)
+ round_base_path = ROUND_BASE_PATHS[round.short_name]
+ "sort by Section.index"
+ sections = db.session.query(Section).filter(Section.round_id == round_id).order_by(Section.index).all()
+ current_app.logger.info(f"Generating application display config for round {round_id}")
+
+ for original_section in sections:
+ section = copy.deepcopy(original_section)
+ # add to ordered_sections list in order of index
+ section.name_in_apply_json["en"] = f"{section.index}. {section.name_in_apply_json['en']}"
+ section.name_in_apply_json["cy"] = (
+ f"{section.index}. {section.name_in_apply_json['cy']}" if section.name_in_apply_json.get("cy") else ""
+ )
+ ordered_sections.append(
+ FundSectionSection(section_name=section.name_in_apply_json, tree_path=f"{round_base_path}.{section.index}")
+ )
+ forms = db.session.query(Form).filter(Form.section_id == section.section_id).order_by(Form.section_index).all()
+ for original_form in forms:
+ # Create a deep copy of the form object
+ form = copy.deepcopy(original_form)
+ form.name_in_apply_json["en"] = f"{section.index}.{form.section_index} {form.name_in_apply_json['en']}"
+ form.name_in_apply_json["cy"] = (
+ f"{section.index}.{form.section_index} {form.name_in_apply_json['cy']}"
+ if form.name_in_apply_json.get("cy")
+ else ""
+ )
+ form.runner_publish_name = {
+ "en": form.runner_publish_name,
+ "cy": "",
+ }
+ ordered_sections.append(
+ FundSectionForm(
+ section_name=form.name_in_apply_json,
+ form_name_json=form.runner_publish_name,
+ tree_path=f"{round_base_path}.{section.index}.{form.section_index}",
+ )
+ )
+ 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
+ fund = get_fund_by_id(fund_id)
+ current_app.logger.info(f"Generating fund config for fund {fund_id}")
+
+ fund_export = FundExport(
+ id=str(fund.fund_id),
+ name_json=fund.name_json,
+ title_json=fund.title_json,
+ short_name=fund.short_name,
+ description_json=fund.description_json,
+ welsh_available=fund.welsh_available,
+ owner_organisation_name=fund.owner_organisation.name,
+ owner_organisation_shortname=fund.owner_organisation.short_name,
+ owner_organisation_logo_uri=fund.owner_organisation.logo_uri,
+ )
+ write_config(fund_export, "fund_config", round.short_name, "python_file")
+
+
+def generate_round_config(round_id):
+ round = get_round_by_id(round_id)
+ current_app.logger.info(f"Generating round config for round {round_id}")
+
+ round_export = RoundExport(
+ id=str(round.round_id),
+ fund_id=str(round.fund_id),
+ title_json=round.title_json,
+ short_name=round.short_name,
+ opens=round.opens.isoformat(),
+ deadline=round.deadline.isoformat(),
+ assessment_start=round.assessment_start.isoformat(),
+ assessment_deadline=round.assessment_deadline.isoformat(),
+ application_reminder_sent=False,
+ reminder_date=round.reminder_date.isoformat(),
+ prospectus=round.prospectus_link,
+ privacy_notice=round.privacy_notice_link,
+ reference_contact_page_over_email=False,
+ )
+
+ write_config(round_export, "round_config", round.short_name, "python_file")
+
+
+def generate_config_for_round(round_id):
+ """
+ Generates configuration for a specific funding round.
+
+ This function orchestrates the generation of various configurations needed for a funding round.
+ It calls three specific functions in sequence to generate the fund configuration, round configuration,
+ and application display configuration for the given round ID.
+
+ Args:
+ round_id (str): The unique identifier for the funding round.
+
+ The functions called within this function are:
+ - generate_fund_config: Generates the fund configuration for the given round ID.
+ - generate_round_config: Generates the round configuration for the given round ID.
+ - generate_application_display_config: Generates the application display configuration for the given round ID.
+ """
+ if round_id is None:
+ raise ValueError("Valid round ID is required to generate configuration.")
+ generate_fund_config(round_id)
+ generate_round_config(round_id)
+ generate_application_display_config(round_id)
diff --git a/app/config_generator/scripts/generate_fund_round_form_jsons.py b/app/config_generator/scripts/generate_fund_round_form_jsons.py
new file mode 100644
index 0000000..8093b2a
--- /dev/null
+++ b/app/config_generator/scripts/generate_fund_round_form_jsons.py
@@ -0,0 +1,115 @@
+import json
+
+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
+
+form_schema = {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "metadata": {"type": "object"},
+ "startPage": {"type": "string"},
+ "backLinkText": {"type": "string"},
+ "sections": {"type": "array"},
+ "pages": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "path": {"type": "string"},
+ "title": {"type": "string"},
+ "components": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "options": {
+ "type": "object",
+ "properties": {"hideTitle": {"type": "boolean"}, "classes": {"type": "string"}},
+ },
+ "type": {"type": "string"},
+ "title": {"type": "string"},
+ "hint": {"type": "string"},
+ "schema": {"type": "object"},
+ "name": {"type": "string"},
+ "metadata": {
+ "type": "object",
+ },
+ },
+ },
+ },
+ },
+ "required": ["path", "title", "components"],
+ },
+ },
+ "lists": {"type": "array"},
+ "conditions": {"type": "array"},
+ "fees": {"type": "array"},
+ "outputs": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "title": {"type": "string"},
+ "type": {"type": "string"},
+ "outputConfiguration": {
+ "type": "object",
+ "properties": {"savePerPageUrl": {"type": "boolean"}},
+ "required": ["savePerPageUrl"],
+ },
+ },
+ "required": ["name", "title", "type", "outputConfiguration"],
+ },
+ },
+ "skipSummary": {"type": "boolean"},
+ # Add other top-level keys as needed
+ },
+ "required": [
+ "metadata",
+ "startPage",
+ "backLinkText",
+ "pages",
+ "lists",
+ "conditions",
+ "fees",
+ "outputs",
+ "skipSummary",
+ "name",
+ "sections",
+ ],
+}
+
+
+def generate_form_jsons_for_round(round_id):
+ """
+ Generates JSON configurations for all forms associated with a given funding round.
+
+ This function iterates through all sections of a specified funding round, and for each form
+ within those sections, it generates a JSON configuration. These configurations are then written
+ to files named after the forms, organized by the round's short name.
+
+ Args:
+ round_id (str): The unique identifier for the funding round.
+
+ The generated files are named after the form names and are stored in a directory
+ corresponding to the round's short name.
+ """
+ if not round_id:
+ raise ValueError("Round ID is required to generate form JSONs.")
+ round = get_round_by_id(round_id)
+ current_app.logger.info(f"Generating form JSONs for round {round_id}.")
+ for section in round.sections:
+ for form in section.forms:
+ result = build_form_json(form)
+ form_json = json.dumps(result, indent=4)
+ valid_json = validate_json(result, form_schema)
+ if valid_json:
+ write_config(form_json, form.runner_publish_name, round.short_name, "form_json")
+ else:
+ current_app.logger.error(f"Form JSON for {form.runner_publish_name} is invalid.")
diff --git a/app/config_generator/scripts/generate_fund_round_html.py b/app/config_generator/scripts/generate_fund_round_html.py
new file mode 100644
index 0000000..b6c8937
--- /dev/null
+++ b/app/config_generator/scripts/generate_fund_round_html.py
@@ -0,0 +1,48 @@
+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
+
+
+def generate_all_round_html(round_id):
+ """
+ Generates an HTML representation for a specific funding round.
+
+ This function creates an HTML document that represents all the sections and forms
+ associated with a given funding round. It retrieves the round and its related fund
+ information, iterates through each section of the round to collect form data, and
+ then generates HTML content based on this data.
+
+ Args:
+ round_id (str): The unique identifier for the funding round.
+
+ The process involves:
+ 1. Fetching the round details using its ID.
+ 2. Collecting data for each section and its forms within the round.
+ 3. Generating print data for the sections.
+ 4. Converting the print data into HTML content.
+ 5. Writing the HTML content to a file named 'html_full' within a directory
+ corresponding to the round's short name.
+
+ The generated HTML is intended to provide a comprehensive overview of the round,
+ including details of each section and form, for printing or web display purposes.
+ """
+ if not round_id:
+ raise ValueError("Round ID is required to generate HTML.")
+ current_app.logger.info(f"Generating HTML for round {round_id}.")
+ round = get_round_by_id(round_id)
+ sections_in_round = round.sections
+ section_data = []
+ for section in sections_in_round:
+ forms = [{"name": form.runner_publish_name, "form_data": build_form_json(form)} for form in section.forms]
+ section_data.append({"section_title": section.name_in_apply_json["en"], "forms": forms})
+
+ print_data = generate_print_data_for_sections(
+ section_data,
+ lang="en",
+ )
+ html_content = print_html(print_data)
+ write_config(html_content, "full_application", round.short_name, "html")
diff --git a/app/config_generator/scripts/helpers.py b/app/config_generator/scripts/helpers.py
new file mode 100644
index 0000000..73b0ee5
--- /dev/null
+++ b/app/config_generator/scripts/helpers.py
@@ -0,0 +1,63 @@
+import os
+from dataclasses import asdict
+from dataclasses import is_dataclass
+from datetime import date
+
+import jsonschema
+from flask import current_app
+from jsonschema import validate
+
+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
+
+
+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}/"
+
+ if config_type == "form_json":
+ output_dir = os.path.join(base_output_dir, "form_runner/")
+ content_to_write = config
+ file_path = os.path.join(output_dir, f"{human_to_kebab_case(filename)}.json")
+ elif config_type == "python_file":
+ output_dir = os.path.join(base_output_dir, "fund_store/")
+ config_dict = convert_to_dict(config) # Convert config to dict for non-JSON types
+ content_to_write = str(config_dict)
+ file_path = os.path.join(output_dir, f"{human_to_snake_case(filename)}_{date.today().strftime('%d-%m-%Y')}.py")
+ elif config_type == "html":
+ output_dir = os.path.join(base_output_dir, "html/")
+ content_to_write = config
+ file_path = os.path.join(output_dir, f"{filename}.html")
+
+ # Ensure the output directory exists
+ os.makedirs(output_dir, exist_ok=True)
+
+ # Write the content to the file
+ with open(file_path, "w") as f:
+ if config_type == "form_json":
+ f.write(content_to_write) # Write JSON string directly
+ elif config_type == "python_file":
+ print(content_to_write, file=f) # Print the dictionary for non-JSON types
+ elif config_type == "html":
+ f.write(content_to_write)
+
+
+# Function to validate JSON data against the schema
+def validate_json(data, schema):
+ try:
+ validate(instance=data, schema=schema)
+ current_app.logger.info("Given JSON data is valid")
+ return True
+ except jsonschema.exceptions.ValidationError as err:
+ current_app.logger.error("Given JSON data is invalid")
+ current_app.logger.error(err)
+ return False
diff --git a/app/db/database_ERD_9-8-24.png b/app/db/database_ERD_9-8-24.png
new file mode 100644
index 0000000..5fc5855
Binary files /dev/null and b/app/db/database_ERD_9-8-24.png differ
diff --git a/app/db/migrations/versions/~2024_08_02_1452-ab7d40d652d5_.py b/app/db/migrations/versions/~2024_08_02_1452-ab7d40d652d5_.py
new file mode 100644
index 0000000..6521394
--- /dev/null
+++ b/app/db/migrations/versions/~2024_08_02_1452-ab7d40d652d5_.py
@@ -0,0 +1,52 @@
+"""empty message
+
+Revision ID: ab7d40d652d5
+Revises: da88c6b36588
+Create Date: 2024-08-02 14:52:53.157158
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = "ab7d40d652d5"
+down_revision = "da88c6b36588"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ "organisation",
+ sa.Column("organisation_id", sa.UUID(), nullable=False),
+ sa.Column("name", sa.String(length=100), nullable=False),
+ sa.Column("short_name", sa.String(length=15), nullable=False),
+ sa.Column("logo_uri", sa.String(length=100), nullable=True),
+ sa.Column("audit_info", postgresql.JSON(none_as_null=True, astext_type=sa.Text()), nullable=True),
+ sa.PrimaryKeyConstraint("organisation_id", name=op.f("pk_organisation")),
+ sa.UniqueConstraint("name", name=op.f("uq_organisation_name")),
+ sa.UniqueConstraint("short_name", name=op.f("uq_organisation_short_name")),
+ )
+ with op.batch_alter_table("fund", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("owner_organisation_id", sa.UUID(), nullable=True))
+ batch_op.create_foreign_key(
+ batch_op.f("fk_fund_owner_organisation_id_organisation"),
+ "organisation",
+ ["owner_organisation_id"],
+ ["organisation_id"],
+ )
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("fund", schema=None) as batch_op:
+ batch_op.drop_constraint(batch_op.f("fk_fund_owner_organisation_id_organisation"), type_="foreignkey")
+ batch_op.drop_column("owner_organisation_id")
+
+ op.drop_table("organisation")
+ # ### end Alembic commands ###
diff --git a/app/db/models/__init__.py b/app/db/models/__init__.py
index 53cc9c2..85e1acf 100644
--- a/app/db/models/__init__.py
+++ b/app/db/models/__init__.py
@@ -8,6 +8,7 @@
from .assessment_config import Subcriteria
from .assessment_config import Theme
from .fund import Fund
+from .fund import Organisation
from .round import Round
-__all__ = [Fund, Round, Section, Form, Page, Lizt, Component, ComponentType, Criteria, Subcriteria, Theme]
+__all__ = [Fund, Round, Section, Form, Page, Lizt, Component, ComponentType, Criteria, Subcriteria, Theme, Organisation]
diff --git a/app/db/models/application_config.py b/app/db/models/application_config.py
index ad59dde..3bf547f 100644
--- a/app/db/models/application_config.py
+++ b/app/db/models/application_config.py
@@ -54,7 +54,7 @@ class Section(BaseModel):
is_template = Column(Boolean, default=False, nullable=False)
audit_info = Column(JSON(none_as_null=True))
forms: Mapped[List["Form"]] = relationship(
- "Form", order_by="Form.section_index", collection_class=ordering_list("section_index")
+ "Form", order_by="Form.section_index", collection_class=ordering_list("section_index"), passive_deletes="all"
)
index = Column(Integer())
source_template_id = Column(UUID(as_uuid=True), nullable=True)
@@ -62,8 +62,11 @@ class Section(BaseModel):
def __repr__(self):
return f"Section({self.name_in_apply_json['en']}, Forms: {self.forms})"
- def as_dict(self):
- return {col.name: self.__getattribute__(col.name) for col in inspect(self).mapper.columns}
+ def as_dict(self, include_relationships=False):
+ result = {col.name: getattr(self, col.name) for col in inspect(self).mapper.columns}
+ if include_relationships & hasattr(self, "forms"):
+ result["forms"] = [form.as_dict() for form in self.forms if self.forms is not None]
+ return result
@dataclass
@@ -85,7 +88,7 @@ class Form(BaseModel):
audit_info = Column(JSON(none_as_null=True))
section_index = Column(Integer())
pages: Mapped[List["Page"]] = relationship(
- "Page", order_by="Page.form_index", collection_class=ordering_list("form_index")
+ "Page", order_by="Page.form_index", collection_class=ordering_list("form_index"), passive_deletes="all"
)
runner_publish_name = Column(db.String())
source_template_id = Column(UUID(as_uuid=True), nullable=True)
@@ -93,8 +96,11 @@ class Form(BaseModel):
def __repr__(self):
return f"Form({self.runner_publish_name} - {self.name_in_apply_json['en']}, Pages: {self.pages})"
- def as_dict(self):
- return {col.name: self.__getattribute__(col.name) for col in inspect(self).mapper.columns}
+ def as_dict(self, include_relationships=False):
+ result = {col.name: getattr(self, col.name) for col in inspect(self).mapper.columns}
+ if include_relationships & hasattr(self, "pages"):
+ result["pages"] = [page.as_dict() for page in self.pages if self.pages is not None]
+ return result
@dataclass
@@ -117,7 +123,10 @@ class Page(BaseModel):
form_index = Column(Integer())
display_path = Column(String())
components: Mapped[List["Component"]] = relationship(
- "Component", order_by="Component.page_index", collection_class=ordering_list("page_index")
+ "Component",
+ order_by="Component.page_index",
+ collection_class=ordering_list("page_index"),
+ passive_deletes="all",
)
source_template_id = Column(UUID(as_uuid=True), nullable=True)
controller = Column(String(), nullable=True)
@@ -125,8 +134,13 @@ class Page(BaseModel):
def __repr__(self):
return f"Page(/{self.display_path} - {self.name_in_apply_json['en']}, Components: {self.components})"
- def as_dict(self):
- return {col.name: self.__getattribute__(col.name) for col in inspect(self).mapper.columns}
+ def as_dict(self, include_relationships=False):
+ result = {col.name: getattr(self, col.name) for col in inspect(self).mapper.columns}
+
+ if include_relationships & hasattr(self, "components"):
+ result["components"] = [component.as_dict() for component in self.components if self.components is not None]
+
+ return result
# Ensure we can only have one template with a particular display_path value
diff --git a/app/db/models/fund.py b/app/db/models/fund.py
index 5d37473..7bcd113 100644
--- a/app/db/models/fund.py
+++ b/app/db/models/fund.py
@@ -4,6 +4,7 @@
from flask_sqlalchemy.model import DefaultMeta
from sqlalchemy import Column
+from sqlalchemy import ForeignKey
from sqlalchemy.dialects.postgresql import JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped
@@ -17,6 +18,22 @@
BaseModel: DefaultMeta = db.Model
+@dataclass
+class Organisation(BaseModel):
+ organisation_id = Column(
+ "organisation_id",
+ UUID(as_uuid=True),
+ primary_key=True,
+ default=uuid.uuid4,
+ nullable=False,
+ )
+ name = Column("name", db.String(100), nullable=False, unique=True)
+ short_name = Column("short_name", db.String(15), nullable=False, unique=True)
+ logo_uri = Column("logo_uri", db.String(100), nullable=True, unique=False)
+ audit_info = Column("audit_info", JSON(none_as_null=True))
+ funds: Mapped[List["Fund"]] = relationship("Fund", back_populates="owner_organisation", passive_deletes="all")
+
+
@dataclass
class Fund(BaseModel):
fund_id = Column(
@@ -28,9 +45,12 @@ class Fund(BaseModel):
)
name_json = Column("name_json", JSON(none_as_null=True), nullable=False, unique=False)
title_json = Column("title_json", JSON(none_as_null=True), nullable=False, unique=False)
- short_name = Column("short_name", db.String(), nullable=False, unique=True)
+ short_name = Column("short_name", db.String(15), nullable=False, unique=True)
description_json = Column("description_json", JSON(none_as_null=True), nullable=False, unique=False)
- rounds: Mapped[List["Round"]] = relationship("Round")
welsh_available = Column("welsh_available", Boolean, default=False, nullable=False)
is_template = Column("is_template", Boolean, default=False, nullable=False)
audit_info = Column("audit_info", JSON(none_as_null=True))
+ rounds: Mapped[List["Round"]] = relationship("Round")
+ owner_organisation_id = Column(UUID(as_uuid=True), ForeignKey("organisation.organisation_id"), nullable=True)
+ # Define the relationship to access the owning Organisation directly
+ owner_organisation: Mapped["Organisation"] = relationship("Organisation", back_populates="funds")
diff --git a/app/db/models/round.py b/app/db/models/round.py
index c3ceca2..d6e87da 100644
--- a/app/db/models/round.py
+++ b/app/db/models/round.py
@@ -53,7 +53,7 @@ class Round(BaseModel):
# several other fields to add
def __repr__(self):
- return f"Round({self.short_name - self.title_json['en']}, Sections: {self.sections})"
+ return f"Round({self.short_name} - {self.title_json['en']}, Sections: {self.sections})"
def as_dict(self):
return {col.name: self.__getattribute__(col.name) for col in inspect(self).mapper.columns}
diff --git a/app/db/queries/application.py b/app/db/queries/application.py
index 727c651..c8ce85f 100644
--- a/app/db/queries/application.py
+++ b/app/db/queries/application.py
@@ -199,3 +199,285 @@ def clone_single_round(round_id, new_fund_id, new_short_name) -> Round:
clone_single_section(section.section_id, cloned_round.round_id)
return cloned_round
+
+
+# CRUD operations for Section, Form, Page, and Component
+# CRUD SECTION
+def insert_new_section(new_section_config):
+ """
+ Inserts a section object based on the provided configuration.
+
+ Parameters:
+ new_section_config (dict): A dictionary containing the configuration for the new section.
+ new_section_config keys:
+ - round_id (str): The ID of the round to which the section belongs.
+ - name_in_apply_json (dict): The name of the section as it will be in the Application JSON (support multiple languages/keys).
+ - template_name (str): The name of the template.
+ - is_template (bool): A flag indicating whether the section is a template.
+ - source_template_id (str): The ID of the source template.
+ - audit_info (dict): Audit information for the section.
+ - index (int): The index of the section.
+ Returns:
+ Section: The newly created section object.
+ """
+ section = Section(
+ section_id=uuid4(),
+ round_id=new_section_config.get("round_id", None),
+ name_in_apply_json=new_section_config.get("name_in_apply_json"),
+ template_name=new_section_config.get("template_name", None),
+ is_template=new_section_config.get("is_template", False),
+ source_template_id=new_section_config.get("source_template_id", None),
+ audit_info=new_section_config.get("audit_info", {}),
+ index=new_section_config.get("index"),
+ )
+ db.session.add(section)
+ db.session.commit()
+ return section
+
+
+def update_section(section_id, new_section_config):
+ section = db.session.query(Section).where(Section.section_id == section_id).one_or_none()
+ if section:
+ # Define a list of allowed keys to update
+ allowed_keys = ["round_id", "name_in_apply_json", "template_name", "is_template", "audit_info", "index"]
+
+ for key, value in new_section_config.items():
+ # Update the section if the key is allowed
+ if key in allowed_keys:
+ setattr(section, key, value)
+
+ db.session.commit()
+ return section
+
+
+def delete_section(section_id):
+ section = db.session.query(Section).where(Section.section_id == section_id).one_or_none()
+ db.session.delete(section)
+ db.session.commit()
+ return section
+
+
+# CRUD FORM
+def insert_new_form(new_form_config):
+ """
+ Inserts a form object based on the provided configuration.
+
+ Parameters:
+ new_form_config (dict): A dictionary containing the configuration for the new form.
+ new_form_config keys:
+ - section_id (str): The ID of the section to which the form belongs.
+ - name_in_apply_json (dict): The name of the form as it will be in the Application JSON (support multiple languages/keys).
+ - is_template (bool): A flag indicating whether the form is a template.
+ - template_name (str): The name of the template.
+ - source_template_id (str): The ID of the source template.
+ - audit_info (dict): Audit information for the form.
+ - section_index (int): The index of the form within the section.
+ - runner_publish_name (bool): The path of the form in the form runner (kebab case).
+ Returns:
+ Form: The newly created form object.
+ """
+
+ form = Form(
+ form_id=uuid4(),
+ section_id=new_form_config.get("section_id", None),
+ name_in_apply_json=new_form_config.get("name_in_apply_json"),
+ is_template=new_form_config.get("is_template", False),
+ template_name=new_form_config.get("template_name", None),
+ source_template_id=new_form_config.get("source_template_id", None),
+ audit_info=new_form_config.get("audit_info", {}),
+ section_index=new_form_config.get("section_index"),
+ runner_publish_name=new_form_config.get("runner_publish_name", None),
+ )
+ db.session.add(form)
+ db.session.commit()
+ return form
+
+
+def update_form(form_id, new_form_config):
+ form = db.session.query(Form).where(Form.form_id == form_id).one_or_none()
+ if form:
+ # Define a list of allowed keys to update
+ allowed_keys = [
+ "section_id",
+ "name_in_apply_json",
+ "template_name",
+ "is_template",
+ "audit_info",
+ "section_index",
+ "runner_publish_name",
+ ]
+
+ # Iterate over the new_form_config dictionary
+ for key, value in new_form_config.items():
+ # Update the form if the key is allowed
+ if key in allowed_keys:
+ setattr(form, key, value)
+
+ db.session.commit()
+ return form
+
+
+def delete_form(form_id):
+ form = db.session.query(Form).where(Form.form_id == form_id).one_or_none()
+ db.session.delete(form)
+ db.session.commit()
+ return form
+
+
+# CRUD PAGE
+def insert_new_page(new_page_config):
+ """
+ Inserts a page object based on the provided configuration.
+
+ Parameters:
+ new_page_config (dict): A dictionary containing the configuration for the new page.
+ new_page_config keys:
+ - form_id (str): The ID of the form to which the page belongs.
+ - name_in_apply_json (str): The name of the page as it will be in the Application JSON.
+ - template_name (str): The name of the template.
+ - is_template (bool): A flag indicating whether the page is a template.
+ - source_template_id (str): The ID of the source template.
+ - audit_info (dict): Audit information for the page.
+ - form_index (int): The index of the page within the form.
+ - display_path (str): The form runner display path of the page (kebab case).
+ - controller (str): The form runner controller path for the page (e.g. './pages/summary.js').
+ Returns:
+ Page: The newly created page object.
+ """
+ page = Page(
+ page_id=uuid4(),
+ form_id=new_page_config.get("form_id", None),
+ name_in_apply_json=new_page_config.get("name_in_apply_json"),
+ template_name=new_page_config.get("template_name", None),
+ is_template=new_page_config.get("is_template", False),
+ source_template_id=new_page_config.get("source_template_id", None),
+ audit_info=new_page_config.get("audit_info", {}),
+ form_index=new_page_config.get("form_index"),
+ display_path=new_page_config.get("display_path"),
+ controller=new_page_config.get("controller", None),
+ )
+ db.session.add(page)
+ db.session.commit()
+ return page
+
+
+def update_page(page_id, new_page_config):
+ page = db.session.query(Page).where(Page.page_id == page_id).one_or_none()
+ if page:
+ # Define a list of allowed keys to update
+ allowed_keys = [
+ "form_id",
+ "name_in_apply_json",
+ "template_name",
+ "is_template",
+ "audit_info",
+ "form_index",
+ "display_path",
+ "controller",
+ ]
+
+ for key, value in new_page_config.items():
+ # Update the page if the key is allowed
+ if key in allowed_keys:
+ setattr(page, key, value)
+
+ db.session.commit()
+ return page
+
+
+def delete_page(page_id):
+ page = db.session.query(Page).where(Page.page_id == page_id).one_or_none()
+ db.session.delete(page)
+ db.session.commit()
+ return page
+
+
+# CRUD COMPONENT
+def insert_new_component(new_component_config: dict):
+ """
+ Inserts a component object based on the provided configuration.
+
+ Parameters:
+ new_component_config (dict): A dictionary containing the configuration for the new component.
+ new_component_config keys:
+ - page_id (str): The ID of the page to which the component belongs.
+ - theme_id (str): The ID of the theme to which the component belongs.
+ - title (str): The title of the component.
+ - hint_text (str): The hint text for the component.
+ - options (dict): The options such as classes, prefix etc
+ - type (str): The type of the component.
+ - template_name (str): The name of the template.
+ - is_template (bool): A flag indicating whether the component is a template.
+ - source_template_id (str): The ID of the source template.
+ - audit_info (dict): Audit information for the component.
+ - page_index (int): The index of the component within the page.
+ - theme_index (int): The index of the component within the theme.
+ - conditions (dict): The conditions such as potential routes based on the components value (can specify page path).
+ - runner_component_name (str): The name of the runner component.
+ - list_id (str): The ID of the list to which the component belongs.
+ Returns:
+ Component: The newly created component object.
+ """
+ # Instantiate the Component object with the provided and default values
+ component = Component(
+ component_id=uuid4(),
+ page_id=new_component_config.get("page_id", None),
+ theme_id=new_component_config.get("theme_id", None),
+ title=new_component_config.get("title"),
+ hint_text=new_component_config.get("hint_text"),
+ options=new_component_config.get("options", {}),
+ type=new_component_config.get("type"),
+ is_template=new_component_config.get("is_template", False),
+ template_name=new_component_config.get("template_name", None),
+ source_template_id=new_component_config.get("source_template_id", None),
+ audit_info=new_component_config.get("audit_info", {}),
+ page_index=new_component_config.get("page_index"),
+ theme_index=new_component_config.get("theme_index"),
+ conditions=new_component_config.get("conditions", []),
+ runner_component_name=new_component_config.get("runner_component_name"),
+ list_id=new_component_config.get("list_id", None),
+ )
+
+ # Add the component to the session and commit
+ db.session.add(component)
+ db.session.commit()
+
+ # Return the created component object or its ID based on your requirements
+ return component
+
+
+def update_component(component_id, new_component_config):
+ component = db.session.query(Component).where(Component.component_id == component_id).one_or_none()
+ if component:
+ # Define a list of allowed keys to update to prevent updating unintended fields
+ allowed_keys = [
+ "page_id",
+ "theme_id",
+ "title",
+ "hint_text",
+ "options",
+ "type",
+ "template_name",
+ "is_template",
+ "audit_info",
+ "page_index",
+ "theme_index",
+ "conditions",
+ "runner_component_name",
+ "list_id",
+ ]
+
+ for key, value in new_component_config.items():
+ # Update the component if the key is allowed
+ if key in allowed_keys:
+ setattr(component, key, value)
+
+ db.session.commit()
+ return component
+
+
+def delete_component(component_id):
+ component = db.session.query(Component).where(Component.component_id == component_id).one_or_none()
+ db.session.delete(component)
+ db.session.commit()
+ return component
diff --git a/app/db/queries/fund.py b/app/db/queries/fund.py
index 1810daf..1b7b8b5 100644
--- a/app/db/queries/fund.py
+++ b/app/db/queries/fund.py
@@ -2,6 +2,13 @@
from app.db import db
from app.db.models.fund import Fund
+from app.db.models.fund import Organisation
+
+
+def add_organisation(organisation: Organisation) -> Organisation:
+ db.session.add(organisation)
+ db.session.commit()
+ return organisation
def add_fund(fund: Fund) -> Fund:
@@ -16,4 +23,7 @@ def get_all_funds() -> list:
def get_fund_by_id(id: str) -> Fund:
- return db.session.get(Fund, id)
+ fund = db.session.get(Fund, id)
+ if not fund:
+ raise ValueError(f"Fund with id {id} not found")
+ return fund
diff --git a/app/db/queries/round.py b/app/db/queries/round.py
index 4c84e8d..849cee4 100644
--- a/app/db/queries/round.py
+++ b/app/db/queries/round.py
@@ -9,4 +9,7 @@ def add_round(round: Round) -> Round:
def get_round_by_id(id: str) -> Round:
- return db.session.get(Round, id)
+ round = db.session.get(Round, id)
+ if not round:
+ raise ValueError(f"Round with id {id} not found")
+ return round
diff --git a/app/question_reuse/README b/app/question_reuse/README
deleted file mode 100644
index 50c8ff5..0000000
--- a/app/question_reuse/README
+++ /dev/null
@@ -1,32 +0,0 @@
-# SPIKE to look at Question Bank and answer question_reuse
-By storing some reusable configuration outside the form jsons, we can allow parts of forms to be generated from minimal input information - making it more feasible for less technical colleagues to create this input information, or for it to be gnerated by a UI.
-
-Creating forms from reusable questions means the answers to those questions will line up between applications, so we can more easily allow applicants to take information from one application and reuse in another.
-
-Having reusable questions also means we can have reusable assessment config - eg. the organisation information can be reused in unscored general information sections without duplicating the config.
-
-## Reusable configuration
-[Components](./config/components_to_reuse.py) Configuration for individual components (fields). Structure is as in the form json, except for conditions which are simplified
-[Pages](./config/pages_to_reuse.py) Configuration for pages that can be inserted into forms. Basically each page is a list of component IDs that existin in `components_to_reuse.py` above.
-[Lookups](./config/lookups.py) General place to convert from an ID to a display string, eg. organisation_information to Organisation information
-[Sub Pages](./config/sub_pages_to_reuse.py) Contains full form json info for some pages that are constant when reused, eg. the summary page. But also ones that are needed for sub flows based on conditions - eg. the 'what alternative names does your org use' page is in here, as it will always be required if you add the component `reuse_organisation_other_names_yes_no`
-[Assessment Themes](./config/themes_to_reuse.py) Specifies themes that can be reused across assessments, basically a list of the components in each theme. These component names are the same as in `components_to_reuse.py`
-
-## Example inputs - Forms
-These are examples of the inputs required from a fund to create forms based on reusable components. Once the form json is generated, it can always be edited to add non-reusable components/pages as well.
-[Org Info Basic](./test_data/in/org-info_basic_name_address.json) Just asks for organisation name and address
-[Org info with alternative names](./test_data/in/org-info_alt_name_address.json) As above but allows alternative names
-[Full organisation info](./test_data/in/org-info_all.json) Uses all the components configured as part of the POC - org name, address, alternative names, purpose and web links
-
-## Example inputs - Assessment
-Example of input to generate assessment configuration for the `Full Organisation Info` example form above
-[Unscored Full org info](./test_data/in/assmnt_unscored.json) Lists the themes within each subcriteria for the assessment sections
-
-# Steps to generate form json
-1. Create an input file, as per [example inputs](#example-inputs---forms) specifying the pages you want in your form
-1. Execute the form generation script: `python -m question_reuse.generate_form` and complete the command prompts to generate the json from the input
-
-# Steps to generate assessment config for a set of questions
-1. Create an input file, as per [example inputs](#example-inputs---assessment) specifying the layout of themes etc that you need
-1. Generate field info for the forms you are using - atm run `test_generate_assessment_fields_for_testing` in `test_generate_all_questions.py` in fund-store.
-1. Run the assessment config generation script: `python -m question_reuse.generate_assessment_config` and answer the prompts, point it to the input file you created and the generated field info from the previous step.
diff --git a/build.py b/build.py
new file mode 100644
index 0000000..17d4ece
--- /dev/null
+++ b/build.py
@@ -0,0 +1,94 @@
+import glob
+import os
+import shutil
+import urllib.request
+import zipfile
+
+import static_assets
+
+
+def build_govuk_assets(static_dist_root="app/static/dist"):
+ DIST_ROOT = "./" + static_dist_root
+ GOVUK_DIR = "/govuk-frontend"
+ GOVUK_URL = "https://github.com/alphagov/govuk-frontend/releases/download/v5.4.0/release-v5.4.0.zip"
+ ZIP_FILE = "./govuk_frontend.zip"
+ DIST_PATH = DIST_ROOT + GOVUK_DIR
+ ASSETS_DIR = "/assets"
+ ASSETS_PATH = DIST_PATH + ASSETS_DIR
+
+ # Checks if GovUK Frontend Assets already built
+ if os.path.exists(DIST_PATH):
+ print("GovUK Frontend assets already built.If you require a rebuild manually run build.build_govuk_assets")
+ return True
+
+ # Download zips from GOVUK_URL
+ # There is a known problem on Mac where one must manually
+ # run the script "Install Certificates.command" found
+ # in the python application folder for this to work.
+
+ print("Downloading static file zip.")
+ urllib.request.urlretrieve(GOVUK_URL, ZIP_FILE) # nosec
+
+ # Attempts to delete the old files, states if
+ # one doesn't exist.
+
+ print("Deleting old " + DIST_PATH)
+ try:
+ shutil.rmtree(DIST_PATH)
+ except FileNotFoundError:
+ print("No old " + DIST_PATH + " to remove.")
+
+ # Extract the previously downloaded zip to DIST_PATH
+
+ print("Unzipping file to " + DIST_PATH + "...")
+ with zipfile.ZipFile(ZIP_FILE, "r") as zip_ref:
+ zip_ref.extractall(DIST_PATH)
+
+ # Move files from ASSETS_PATH to DIST_PATH
+
+ print("Moving files from " + ASSETS_PATH + " to " + DIST_PATH)
+ for file_to_move in os.listdir(ASSETS_PATH):
+ shutil.move("/".join([ASSETS_PATH, file_to_move]), DIST_PATH)
+
+ # Update relative paths
+
+ print("Updating relative paths in css files to " + GOVUK_DIR)
+ cwd = os.getcwd()
+ os.chdir(DIST_PATH)
+ for css_file in glob.glob("*.css"):
+
+ # Read in the file
+ with open(css_file, "r") as file:
+ filedata = file.read()
+
+ # Replace the target string
+ filedata = filedata.replace(ASSETS_DIR, ASSETS_DIR + GOVUK_DIR)
+
+ # Write the file out again
+ with open(css_file, "w") as file:
+ file.write(filedata)
+ os.chdir(cwd)
+
+ # Copy css
+ os.makedirs("./app/static/dist/styles")
+
+ # Copy over JS source
+ os.makedirs("./app/static/dist/js")
+
+ # Delete temp files
+ print("Deleting " + ASSETS_PATH)
+ shutil.rmtree(ASSETS_PATH)
+ os.remove(ZIP_FILE)
+
+
+def build_all(static_dist_root="app/static/dist", remove_existing=False):
+ if remove_existing:
+ relative_dist_root = "./" + static_dist_root
+ if os.path.exists(relative_dist_root):
+ shutil.rmtree(relative_dist_root)
+ build_govuk_assets(static_dist_root=static_dist_root)
+ static_assets.build_bundles(static_folder=static_dist_root)
+
+
+if __name__ == "__main__":
+ build_all(remove_existing=True)
diff --git a/pytest.ini b/pytest.ini
index 1fe24d0..8e438c9 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -5,3 +5,6 @@ env =
GITHUB_SHA=123123
markers =
seed_config: specify config to seed the db
+filterwarnings =
+ error
+ default::Warning
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 9a81990..d7f8cf0 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,5 +1,5 @@
#
-# This file is autogenerated by pip-compile with Python 3.10
+# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile requirements-dev.in
@@ -10,6 +10,10 @@ alembic==1.13.2
# via flask-migrate
async-timeout==4.0.3
# via redis
+attrs==24.2.0
+ # via
+ # jsonschema
+ # referencing
babel==2.15.0
# via flask-babel
beautifulsoup4==4.12.3
@@ -67,8 +71,6 @@ editorconfig==0.12.4
# via
# cssbeautifier
# jsbeautifier
-exceptiongroup==1.2.2
- # via pytest
filelock==3.15.4
# via virtualenv
flake8==7.0.0
@@ -156,6 +158,10 @@ jsmin==3.0.1
# via -r requirements.in
json5==0.9.25
# via djlint
+jsonschema==4.23.0
+ # via -r requirements.in
+jsonschema-specifications==2023.12.1
+ # via jsonschema
mako==1.3.5
# via alembic
markupsafe==2.1.5
@@ -210,9 +216,7 @@ pyflakes==3.2.0
pygments==2.18.0
# via rich
pyjwt[crypto]==2.8.0
- # via
- # funding-service-design-utils
- # pyjwt
+ # via funding-service-design-utils
pyscss==1.4.0
# via -r requirements.in
pytest==8.2.2
@@ -253,6 +257,10 @@ redis==4.6.0
# via
# flask-redis
# flipper-client
+referencing==0.35.1
+ # via
+ # jsonschema
+ # jsonschema-specifications
regex==2023.12.25
# via djlint
requests==2.32.3
@@ -262,6 +270,10 @@ requests==2.32.3
# python-consul
rich==12.6.0
# via funding-service-design-utils
+rpds-py==0.20.0
+ # via
+ # jsonschema
+ # referencing
s3transfer==0.10.2
# via boto3
sentry-sdk[flask]==2.10.0
@@ -282,7 +294,6 @@ sqlalchemy[mypy]==2.0.31
# alembic
# flask-sqlalchemy
# marshmallow-sqlalchemy
- # sqlalchemy
# sqlalchemy-json
# sqlalchemy-utils
sqlalchemy-json==0.7.0
@@ -293,20 +304,11 @@ sqlalchemy-utils==0.41.2
# funding-service-design-utils
thrift==0.20.0
# via flipper-client
-tomli==2.0.1
- # via
- # black
- # djlint
- # flake8-pyproject
- # mypy
- # pytest
- # pytest-env
tqdm==4.66.4
# via djlint
typing-extensions==4.12.2
# via
# alembic
- # black
# mypy
# sqlalchemy
urllib3==2.2.2
diff --git a/requirements.in b/requirements.in
index 5546570..f63a0ad 100644
--- a/requirements.in
+++ b/requirements.in
@@ -58,3 +58,4 @@ Flask-WTF==1.2.1
# Utils
#-----------------------------------
funding-service-design-utils>=4.0.1
+jsonschema
diff --git a/requirements.txt b/requirements.txt
index 5eaf1b7..99cf5cd 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
#
-# This file is autogenerated by pip-compile with Python 3.10
+# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile requirements.in
@@ -10,6 +10,10 @@ alembic==1.13.2
# via flask-migrate
async-timeout==4.0.3
# via redis
+attrs==24.2.0
+ # via
+ # jsonschema
+ # referencing
babel==2.15.0
# via flask-babel
beautifulsoup4==4.12.3
@@ -106,6 +110,10 @@ jmespath==1.0.1
# botocore
jsmin==3.0.1
# via -r requirements.in
+jsonschema==4.23.0
+ # via -r requirements.in
+jsonschema-specifications==2023.12.1
+ # via jsonschema
mako==1.3.5
# via alembic
markupsafe==2.1.5
@@ -136,9 +144,7 @@ pyee==6.0.0
pygments==2.18.0
# via rich
pyjwt[crypto]==2.8.0
- # via
- # funding-service-design-utils
- # pyjwt
+ # via funding-service-design-utils
pyscss==1.4.0
# via -r requirements.in
python-consul==1.1.0
@@ -159,6 +165,10 @@ redis==4.6.0
# via
# flask-redis
# flipper-client
+referencing==0.35.1
+ # via
+ # jsonschema
+ # jsonschema-specifications
requests==2.32.3
# via
# -r requirements.in
@@ -166,6 +176,10 @@ requests==2.32.3
# python-consul
rich==12.6.0
# via funding-service-design-utils
+rpds-py==0.20.0
+ # via
+ # jsonschema
+ # referencing
s3transfer==0.10.2
# via boto3
sentry-sdk[flask]==2.10.0
@@ -184,7 +198,6 @@ sqlalchemy[mypy]==2.0.31
# alembic
# flask-sqlalchemy
# marshmallow-sqlalchemy
- # sqlalchemy
# sqlalchemy-json
# sqlalchemy-utils
sqlalchemy-json==0.7.0
@@ -195,8 +208,6 @@ sqlalchemy-utils==0.41.2
# funding-service-design-utils
thrift==0.20.0
# via flipper-client
-tomli==2.0.1
- # via mypy
typing-extensions==4.12.2
# via
# alembic
diff --git a/tasks/__init__.py b/tasks/__init__.py
index 8a9b550..f3debea 100644
--- a/tasks/__init__.py
+++ b/tasks/__init__.py
@@ -3,7 +3,19 @@
from tasks.db_tasks import create_test_data
from tasks.db_tasks import init_migrations
from tasks.db_tasks import recreate_local_dbs
+from tasks.export_tasks import generate_fund_and_round_config
+from tasks.export_tasks import generate_round_form_jsons
+from tasks.export_tasks import generate_round_html
+from tasks.export_tasks import publish_form_json_to_runner
task.auto_dash_names = True
-__all__ = [recreate_local_dbs, create_test_data, init_migrations]
+__all__ = [
+ recreate_local_dbs,
+ create_test_data,
+ init_migrations,
+ generate_fund_and_round_config,
+ generate_round_form_jsons,
+ generate_round_html,
+ publish_form_json_to_runner,
+]
diff --git a/tasks/db_tasks.py b/tasks/db_tasks.py
index 122aad9..f1001b7 100644
--- a/tasks/db_tasks.py
+++ b/tasks/db_tasks.py
@@ -59,7 +59,8 @@ def create_test_data(c):
db = app.extensions["sqlalchemy"]
db.session.execute(
text(
- "TRUNCATE TABLE fund, round, section,form, page, component, criteria, subcriteria, theme, lizt CASCADE;"
+ "TRUNCATE TABLE fund, round, section,form, page, component, criteria, "
+ "subcriteria, theme, lizt, organisation CASCADE;"
)
)
db.session.commit()
diff --git a/tasks/export_tasks.py b/tasks/export_tasks.py
new file mode 100644
index 0000000..4dce9de
--- /dev/null
+++ b/tasks/export_tasks.py
@@ -0,0 +1,76 @@
+import json
+import sys
+
+import requests
+
+sys.path.insert(1, ".")
+from invoke import task # noqa:E402
+
+from app.app import app # 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
+ generate_config_for_round,
+)
+from app.config_generator.scripts.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
+ generate_all_round_html,
+)
+from config import Config # noqa:E402
+
+
+@task
+def generate_fund_and_round_config(c, roundid):
+ if not roundid:
+ print("Round ID is required.")
+ return
+ print(f"Generating fund-round configuration for round ID: {roundid}.")
+ with app.app_context():
+ generate_config_for_round(roundid)
+
+
+@task
+def generate_round_form_jsons(c, roundid):
+ if not roundid:
+ print("Round ID is required.")
+ return
+ print(f"Generating form JSON configuration for round ID: {roundid}.")
+ with app.app_context():
+ generate_form_jsons_for_round(roundid)
+
+
+@task
+def generate_round_html(c, roundid):
+ if not roundid:
+ print("Round ID is required.")
+ return
+ print(f"Generating HTML for round ID: {roundid}.")
+ with app.app_context():
+ generate_all_round_html(roundid)
+
+
+@task
+def publish_form_json_to_runner(c, filename):
+
+ if not filename:
+ print("filename is required.")
+ return
+ print(f"Publishing: {filename} to form runner.")
+
+ # load json from filename
+ with open(filename, "r") as file:
+ form_json = file.read()
+ form_dict = json.loads(form_json)
+ form_dict["outputs"][0]["outputConfiguration"][
+ "savePerPageUrl"
+ ] = f"http://{Config.FAB_HOST}{Config.FAB_SAVE_PER_PAGE}"
+ try:
+ publish_response = requests.post(
+ url=f"{Config.FORM_RUNNER_URL}/publish",
+ json={"id": human_to_kebab_case(form_dict["name"]), "configuration": form_dict},
+ )
+ if not str(publish_response.status_code).startswith("2"):
+ return "Error during form publish", 500
+ except Exception as e:
+ return f"unable to publish form: {str(e)}", 500
diff --git a/tasks/test_data.py b/tasks/test_data.py
index 8dc31f4..7916299 100644
--- a/tasks/test_data.py
+++ b/tasks/test_data.py
@@ -8,6 +8,7 @@
from app.db.models import Form
from app.db.models import Fund
from app.db.models import Lizt
+from app.db.models import Organisation
from app.db.models import Page
from app.db.models import Round
from app.db.models import Section
@@ -19,6 +20,7 @@
"title_json": {"en": "funding to improve testing"},
"description_json": {"en": "A £10m fund to improve testing across the devolved nations."},
"welsh_available": False,
+ "owner_organisation_id": None,
}
BASIC_ROUND_INFO = {
"audit_info": {"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "create"},
@@ -34,16 +36,25 @@
def init_salmon_fishing_fund():
+ organisation_uuid = uuid4()
+ o: Organisation = Organisation(
+ organisation_id=organisation_uuid,
+ name="Department for Fishing",
+ short_name="DF",
+ logo_uri="http://www.google.com",
+ audit_info={"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "create"},
+ )
f: Fund = Fund(
fund_id=uuid4(),
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 salmong fishing facilities across the devolved nations."
+ "en": "A £10m fund to improve access to salmon fishing facilities across the devolved nations."
},
welsh_available=False,
short_name=f"SFF{randint(0,999)}",
+ owner_organisation_id=o.organisation_id,
)
r: Round = Round(
@@ -259,10 +270,20 @@ def init_salmon_fishing_fund():
"criteria": [cri1],
"subcriteria": [sc1],
"themes": [t1, t2],
+ "organisations": [o],
}
def init_unit_test_data() -> dict:
+ organisation_uuid = uuid4()
+ o: Organisation = Organisation(
+ organisation_id=organisation_uuid,
+ name=f"Ministry of Testing - {str(organisation_uuid)[:5]}",
+ short_name=f"MoT-{str(organisation_uuid)[:5]}",
+ logo_uri="http://www.google.com",
+ audit_info={"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "create"},
+ )
+
f: Fund = Fund(
fund_id=uuid4(),
name_json={"en": "Unit Test Fund 1"},
@@ -270,6 +291,7 @@ def init_unit_test_data() -> dict:
description_json={"en": "A £10m fund to improve testing across the devolved nations."},
welsh_available=False,
short_name=f"UTF{randint(0,999)}",
+ owner_organisation_id=o.organisation_id,
)
r: Round = Round(
@@ -362,6 +384,7 @@ def init_unit_test_data() -> dict:
return {
"lists": [l1],
"funds": [f],
+ "organisations": [o],
"rounds": [r],
"sections": [s1],
"forms": [f1],
@@ -374,6 +397,8 @@ def init_unit_test_data() -> dict:
def insert_test_data(db, test_data={}):
+ db.session.bulk_save_objects(test_data.get("organisations", []))
+ db.session.commit()
db.session.bulk_save_objects(test_data.get("funds", []))
db.session.commit()
db.session.bulk_save_objects(test_data.get("rounds", []))
diff --git a/tests/conftest.py b/tests/conftest.py
index 2c5b73f..873c821 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,5 +1,6 @@
import pytest
from flask_migrate import upgrade
+from sqlalchemy import text
from app.app import create_app
from tasks.test_data import init_unit_test_data
@@ -18,6 +19,17 @@ def seed_dynamic_data(request, app, clear_test_data, _db, enable_preserve_test_d
fab_seed_data = marker.args[0]
insert_test_data(db=_db, test_data=fab_seed_data)
yield fab_seed_data
+ # cleanup data after test
+ # rollback incase of any errors during test session
+ _db.session.rollback()
+ # disable foreign key checks
+ _db.session.execute(text("SET session_replication_role = replica"))
+ # delete all data from tables
+ for table in reversed(_db.metadata.sorted_tables):
+ _db.session.execute(table.delete())
+ # reset foreign key checks
+ _db.session.execute(text("SET session_replication_role = DEFAULT"))
+ _db.session.commit()
@pytest.fixture(scope="session")
diff --git a/tests/test_build_assessment_config.py b/tests/test_build_assessment_config.py
index 0bae7ad..ca77f23 100644
--- a/tests/test_build_assessment_config.py
+++ b/tests/test_build_assessment_config.py
@@ -1,11 +1,15 @@
-from app.question_reuse.generate_assessment_config import build_assessment_config
+from app.config_generator.scripts.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.question_reuse.generate_assessment_config.get_form_for_component", return_value=mock_form_1)
+ mocker.patch(
+ "app.config_generator.scripts.generate_assessment_config.get_form_for_component", return_value=mock_form_1
+ )
results = build_assessment_config([cri1])
assert "unscored_sections" in results
@@ -15,7 +19,9 @@ def test_build_basic_structure(mocker):
def test_with_field_info(mocker):
- mocker.patch("app.question_reuse.generate_assessment_config.get_form_for_component", return_value=mock_form_1)
+ mocker.patch(
+ "app.config_generator.scripts.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 904ec0f..5bbeea2 100644
--- a/tests/test_build_forms.py
+++ b/tests/test_build_forms.py
@@ -2,7 +2,7 @@
# import pytest
-# from app.question_reuse.generate_form import build_form_json
+# from app.config_generator.generate_form import build_form_json
# @pytest.mark.parametrize(
# "input_json,form_id, form_title, exp_results",
diff --git a/tests/test_clone.py b/tests/test_clone.py
index 30ef412..cd64680 100644
--- a/tests/test_clone.py
+++ b/tests/test_clone.py
@@ -107,7 +107,7 @@ def test_initiate_cloned_component(mock_new_uuid):
clone: Component = Component(
component_id="old-id",
page_id="pre-clone",
- title="Template qustion 1?",
+ title="Template question 1?",
type=ComponentType.TEXT_FIELD,
template_name="Template Component",
is_template=True,
@@ -149,7 +149,7 @@ def test_clone_single_component(flask_test_client, _db):
template_component: Component = Component(
component_id=uuid4(),
page_id=None,
- title="Template qustion 1?",
+ title="Template question 1?",
type=ComponentType.YES_NO_FIELD,
page_index=1,
theme_id=None,
@@ -169,6 +169,8 @@ def test_clone_single_component(flask_test_client, _db):
assert result
new_id = result.component_id
+ assert old_id != new_id
+
# check can retrieve new component
assert _db.session.get(Component, new_id)
@@ -198,7 +200,7 @@ def test_clone_single_component(flask_test_client, _db):
Component(
component_id=uuid4(),
page_id=None,
- title="Template qustion 1?",
+ title="Template question 1?",
type=ComponentType.YES_NO_FIELD,
page_index=1,
theme_id=None,
@@ -210,7 +212,7 @@ def test_clone_single_component(flask_test_client, _db):
Component(
component_id=uuid4(),
page_id=None,
- title="Template qustion 2?",
+ title="Template question 2?",
type=ComponentType.YES_NO_FIELD,
page_index=1,
theme_id=None,
@@ -222,7 +224,7 @@ def test_clone_single_component(flask_test_client, _db):
Component(
component_id=uuid4(),
page_id=None,
- title="Template qustion 3?",
+ title="Template question 3?",
type=ComponentType.YES_NO_FIELD,
page_index=1,
theme_id=None,
@@ -286,6 +288,8 @@ def test_clone_page_no_components(seed_dynamic_data, _db):
assert result
new_id = result.page_id
+ assert old_id != new_id
+
# check new page exists
new_page_from_db = _db.session.query(Page).where(Page.page_id == new_id).one_or_none()
assert new_page_from_db
@@ -314,7 +318,7 @@ def test_clone_page_no_components(seed_dynamic_data, _db):
Component(
component_id=uuid4(),
page_id=page_id,
- title="Template qustion 1?",
+ title="Template question 1?",
type=ComponentType.YES_NO_FIELD,
page_index=1,
theme_id=None,
@@ -326,7 +330,7 @@ def test_clone_page_no_components(seed_dynamic_data, _db):
Component(
component_id=uuid4(),
page_id=page_id,
- title="Template qustion 2?",
+ title="Template question 2?",
type=ComponentType.YES_NO_FIELD,
page_index=1,
theme_id=None,
@@ -338,7 +342,7 @@ def test_clone_page_no_components(seed_dynamic_data, _db):
Component(
component_id=uuid4(),
page_id=page_id,
- title="Template qustion 3?",
+ title="Template question 3?",
type=ComponentType.YES_NO_FIELD,
page_index=1,
theme_id=None,
@@ -363,6 +367,8 @@ def test_clone_page_with_components(seed_dynamic_data, _db):
assert result
new_id = result.page_id
+ assert old_page_id != new_id
+
# check new page exists
new_page_from_db = _db.session.query(Page).where(Page.page_id == new_id).one_or_none()
assert new_page_from_db
@@ -494,7 +500,7 @@ def test_clone_form_with_page(seed_dynamic_data, _db):
Component(
component_id=uuid4(),
page_id=page_id_2,
- title="Template qustion 1?",
+ title="Template question 1?",
type=ComponentType.YES_NO_FIELD,
page_index=1,
theme_id=None,
@@ -506,7 +512,7 @@ def test_clone_form_with_page(seed_dynamic_data, _db):
Component(
component_id=uuid4(),
page_id=page_id_2,
- title="Template qustion 2?",
+ title="Template question 2?",
type=ComponentType.YES_NO_FIELD,
page_index=1,
theme_id=None,
@@ -518,7 +524,7 @@ def test_clone_form_with_page(seed_dynamic_data, _db):
Component(
component_id=uuid4(),
page_id=page_id_3,
- title="Template qustion 3?",
+ title="Template question 3?",
type=ComponentType.YES_NO_FIELD,
page_index=1,
theme_id=None,
@@ -648,7 +654,7 @@ def test_clone_section_no_forms(seed_dynamic_data, _db):
Component(
component_id=uuid4(),
page_id=page_id_to_clone_1,
- title="Template qustion 1?",
+ title="Template question 1?",
type=ComponentType.YES_NO_FIELD,
page_index=1,
theme_id=None,
@@ -660,7 +666,7 @@ def test_clone_section_no_forms(seed_dynamic_data, _db):
Component(
component_id=uuid4(),
page_id=page_id_to_clone_1,
- title="Template qustion 2?",
+ title="Template question 2?",
type=ComponentType.YES_NO_FIELD,
page_index=1,
theme_id=None,
@@ -672,7 +678,7 @@ def test_clone_section_no_forms(seed_dynamic_data, _db):
Component(
component_id=uuid4(),
page_id=page_id_to_clone_2,
- title="Template qustion 3?",
+ title="Template question 3?",
type=ComponentType.YES_NO_FIELD,
page_index=1,
theme_id=None,
diff --git a/tests/test_config_generators.py b/tests/test_config_generators.py
new file mode 100644
index 0000000..50b6e77
--- /dev/null
+++ b/tests/test_config_generators.py
@@ -0,0 +1,254 @@
+import ast
+import json
+import shutil
+from datetime import date
+from pathlib import Path
+
+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 (
+ 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
+
+output_base_path = Path("app") / "config_generator" / "output"
+
+
+def test_generate_config_for_round_valid_input(seed_dynamic_data, monkeypatch):
+ # Setup: Prepare valid input parameters
+ fund_id = seed_dynamic_data["funds"][0].fund_id
+ fund_short_name = seed_dynamic_data["funds"][0].short_name
+ round_id = seed_dynamic_data["rounds"][0].round_id
+ org_id = seed_dynamic_data["organisations"][0].organisation_id
+ round_short_name = seed_dynamic_data["rounds"][0].short_name
+ 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
+
+ monkeypatch.setattr(generate_fund_round_config, "ROUND_BASE_PATHS", mock_round_base_paths)
+ # Execute: Call the function with valid inputs
+ result = generate_config_for_round(round_id)
+ # Simply writes the files to the output directory so no result is given directly
+ assert result is None
+ # Assert: Check if the directory structure and files are created as expected
+ expected_files = [
+ {
+ "path": output_base_path
+ / round_short_name
+ / "fund_store"
+ / f"fund_config_{date.today().strftime('%d-%m-%Y')}.py",
+ "expected_output": {
+ "id": str(fund_id),
+ "short_name": fund_short_name,
+ "welsh_available": False,
+ "owner_organisation_name": f"Ministry of Testing - {str(org_id)[:5]}",
+ "owner_organisation_shortname": f"MoT-{str(org_id)[:5]}",
+ "owner_organisation_logo_uri": "http://www.google.com",
+ "name_json": {"en": "Unit Test Fund 1"},
+ "title_json": {"en": "funding to improve testing"},
+ "description_json": {"en": "A £10m fund to improve testing across the devolved nations."},
+ },
+ },
+ {
+ "path": output_base_path
+ / round_short_name
+ / "fund_store"
+ / f"round_config_{date.today().strftime('%d-%m-%Y')}.py",
+ "expected_output": {
+ "id": str(round_id),
+ "fund_id": str(fund_id),
+ "short_name": round_short_name,
+ "application_reminder_sent": False,
+ "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": ""},
+ },
+ },
+ {
+ "path": output_base_path
+ / round_short_name
+ / "fund_store"
+ / f"sections_config_{date.today().strftime('%d-%m-%Y')}.py",
+ "expected_output": [
+ {
+ "section_name": {"en": "1. Organisation Information", "cy": ""},
+ "tree_path": "99.1",
+ "requires_feedback": None,
+ },
+ {
+ "section_name": {"en": "1.1 About your organisation", "cy": ""},
+ "tree_path": "99.1.1",
+ "form_name_json": {"en": "about-your-org", "cy": ""},
+ },
+ ],
+ },
+ ]
+ try:
+ for expected_file in expected_files:
+ path = expected_file["path"]
+ assert path.exists(), f"Expected file {path} does not exist."
+
+ with open(expected_file["path"], "r") as file:
+ content = file.read()
+ # Safely evaluate the Python literal structure
+ # only evaluates literals and not arbitrary code
+ data = ast.literal_eval(content)
+ # remove keys that can't be accurately compared
+ if isinstance(data, dict):
+ keys_to_remove = ["reminder_date", "assessment_start", "assessment_deadline", "deadline", "opens"]
+ data = {k: v for k, v in data.items() if k not in keys_to_remove}
+
+ assert data == expected_file["expected_output"]
+ finally:
+ # Cleanup step to remove the directory
+ directory_path = output_base_path / round_short_name
+ if directory_path.exists():
+ shutil.rmtree(directory_path)
+
+
+def test_generate_config_for_round_invalid_input(seed_dynamic_data):
+ # Setup: Prepare invalid input parameters
+ round_id = None
+ # Execute and Assert: Ensure the function raises an exception for invalid inputs
+ with pytest.raises(ValueError):
+ generate_config_for_round(round_id)
+
+
+def test_generate_form_jsons_for_round_valid_input(seed_dynamic_data):
+ # Setup: Prepare valid input parameters
+ round_id = seed_dynamic_data["rounds"][0].round_id
+ round_short_name = seed_dynamic_data["rounds"][0].short_name
+ form_publish_name = seed_dynamic_data["forms"][0].runner_publish_name
+
+ # Execute: Call the function with valid inputs
+ generate_form_jsons_for_round(round_id)
+ # Assert: Check if the directory structure and files are created as expected
+ 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:
", "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
+ }
+ ]
+ try:
+ for expected_file in expected_files:
+ path = expected_file["path"]
+ assert path.exists(), f"Expected file {path} does not exist."
+
+ with open(expected_file["path"], "r") as file:
+ data = json.load(file)
+ for page in data["pages"]:
+ for component in page["components"]:
+ component.pop("metadata", None)
+ expected = json.loads(expected_file["expected_output"])
+ assert data == expected
+ finally:
+ # Cleanup step to remove the directory
+ directory_path = output_base_path / round_short_name
+ if directory_path.exists():
+ shutil.rmtree(directory_path)
+
+
+def test_generate_form_jsons_for_round_invalid_input(seed_dynamic_data):
+ # Setup: Prepare invalid input parameters
+ round_id = None
+ # Execute and Assert: Ensure the function raises an exception for invalid inputs
+ with pytest.raises(ValueError):
+ generate_form_jsons_for_round(round_id)
+
+
+def test_generate_fund_round_html(seed_dynamic_data):
+ # Setup: Prepare valid input parameters
+ round_id = seed_dynamic_data["rounds"][0].round_id
+ round_short_name = seed_dynamic_data["rounds"][0].short_name
+ # Execute: Call the function with valid inputs
+ generate_all_round_html(round_id)
+ # Assert: Check if the directory structure and files are created as expected
+ expected_files = [
+ {
+ "path": output_base_path / round_short_name / "html" / "full_application.html",
+ "expected_output": '\n
\n Table of contents\n
\n
\n - \n \n Organisation Information\n \n
\n
\n
\n
\n
\n 1.1. About your organisation\n
\n
\n 1.1.1. Organisation Name\n
\n
\n What is your organisation\'s name?\n
\n
\n This must match the regsitered legal organisation name\n
\n
\n How is your organisation classified?\n
\n
\n - \n Charity\n
\n - \n Public Limited Company\n
\n
\n
', # noqa: E501
+ }
+ ]
+ try:
+ for expected_file in expected_files:
+ path = expected_file["path"]
+ assert path.exists(), f"Expected file {path} does not exist."
+
+ with open(expected_file["path"], "r") as file:
+ data = file.read()
+ assert data == expected_file["expected_output"]
+ finally:
+ # Cleanup step to remove the directory
+ directory_path = output_base_path / round_short_name
+ if directory_path.exists():
+ shutil.rmtree(directory_path)
+
+
+def test_generate_fund_round_html_invalid_input(seed_dynamic_data):
+ # Setup: Prepare invalid input parameters
+ round_id = None
+ # Execute and Assert: Ensure the function raises an exception for invalid inputs
+ with pytest.raises(ValueError):
+ generate_all_round_html(round_id)
+
+
+test_json_schema = {
+ "type": "object",
+ "properties": {"name": {"type": "string"}, "age": {"type": "number"}},
+ "required": ["name", "age"],
+}
+
+
+def test_valid_data_validate_json():
+ # Data that matches the schema
+ data = {"name": "John Doe", "age": 30}
+ result = validate_json(data, test_json_schema)
+ assert result, "The data should be valid according to the schema"
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ ({"age": 30}), # Missing 'name'
+ ({"name": 123}), # 'name' should be a string
+ ({"name": ""}), # 'name' is empty
+ ({}), # Empty object
+ ({"name": "John Doe", "extra_field": "not allowed"}), # Extra field not defined in schema
+ # Add more invalid cases as needed
+ ],
+)
+def test_invalid_data_validate_json(data):
+ result = validate_json(data, test_json_schema)
+ assert not result, "The data should be invalid according to the schema"
diff --git a/tests/test_db.py b/tests/test_db.py
index d8a60e3..759b71b 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -6,24 +6,47 @@
from app.db.models import Form
from app.db.models import Fund
+from app.db.models import Organisation
from app.db.models import Page
from app.db.models import Round
from app.db.models import Section
from app.db.queries.application import get_template_page_by_display_path
from app.db.queries.fund import add_fund
+from app.db.queries.fund import add_organisation
from app.db.queries.fund import get_all_funds
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
-def test_add_fund(flask_test_client, _db):
+def test_add_organisation(flask_test_client, _db, clear_test_data):
+ o = Organisation(
+ name="test_org_1",
+ short_name=f"X{randint(0,99999)}",
+ logo_uri="http://www.google.com",
+ funds=[],
+ )
+ result = add_organisation(o)
+ assert result
+ assert result.organisation_id
+
+
+def test_add_fund(flask_test_client, _db, clear_test_data):
+ o = add_organisation(
+ Organisation(
+ name="test_org_2",
+ short_name=f"X{randint(0,99999)}",
+ logo_uri="http://www.google.com",
+ funds=[],
+ )
+ )
f = Fund(
name_json={"en": "hello"},
title_json={"en": "longer hello"},
description_json={"en": "reeeaaaaallly loooooooog helloooooooooo"},
welsh_available=False,
short_name=f"X{randint(0,99999)}",
+ owner_organisation_id=o.organisation_id,
)
result = add_fund(f)
assert result
@@ -64,7 +87,7 @@ def test_add_round(seed_dynamic_data):
assert result.round_id
-def test_get_all_funds(flask_test_client, _db):
+def test_get_all_funds(flask_test_client, _db, seed_dynamic_data):
results = get_all_funds()
assert results
assert results[0].fund_id
@@ -91,13 +114,15 @@ def test_get_fund_by_id(seed_dynamic_data):
def test_get_fund_by_id_none(flask_test_client, _db):
- result: Fund = get_fund_by_id(str(uuid4()))
- assert result is None
+ with pytest.raises(ValueError) as exc_info:
+ get_fund_by_id(str(uuid4()))
+ assert "not found" in str(exc_info.value)
def test_get_round_by_id_none(flask_test_client, _db):
- result: Round = get_round_by_id(str(uuid4()))
- assert result is None
+ with pytest.raises(ValueError) as exc_info:
+ get_round_by_id(str(uuid4()))
+ assert "not found" in str(exc_info.value)
fund_id = uuid4()
diff --git a/tests/test_db_template_CRUD.py b/tests/test_db_template_CRUD.py
new file mode 100644
index 0000000..960d418
--- /dev/null
+++ b/tests/test_db_template_CRUD.py
@@ -0,0 +1,521 @@
+import uuid
+from copy import deepcopy
+
+from app.db.models import ComponentType
+from app.db.models.application_config import Component
+from app.db.models.application_config import Form
+from app.db.models.application_config import Page
+from app.db.models.application_config import Section
+from app.db.queries.application import delete_component
+from app.db.queries.application import delete_form
+from app.db.queries.application import delete_page
+from app.db.queries.application import delete_section
+from app.db.queries.application import insert_new_component
+from app.db.queries.application import insert_new_form
+from app.db.queries.application import insert_new_page
+from app.db.queries.application import insert_new_section
+from app.db.queries.application import update_component
+from app.db.queries.application import update_form
+from app.db.queries.application import update_page
+from app.db.queries.application import update_section
+
+new_template_section_config = {
+ "round_id": uuid.uuid4(),
+ "name_in_apply_json": {"en": "Section Name"},
+ "template_name": "Template Name",
+ "is_template": True,
+ "audit_info": {"created_by": "John Doe", "created_at": "2022-01-01"},
+ "index": 1,
+}
+
+new_section_config = {
+ "round_id": uuid.uuid4(),
+ "name_in_apply_json": {"en": "Template Section Name"},
+ "audit_info": {"created_by": "John Doe", "created_at": "2022-01-01"},
+ "index": 1,
+}
+
+
+def test_insert_new_section(flask_test_client, _db, clear_test_data, seed_dynamic_data):
+ # Access actual round_id from seed_dynamic_data (could also be None)
+ round_id = seed_dynamic_data["rounds"][0].round_id
+
+ # Update the configs with the round_id
+ new_template_section_config["round_id"] = round_id
+ new_section_config["round_id"] = round_id
+
+ new_section = insert_new_section(new_section_config)
+ template_section = insert_new_section(new_template_section_config)
+
+ assert isinstance(template_section, Section)
+ assert template_section.round_id == new_template_section_config["round_id"]
+ assert template_section.name_in_apply_json == new_template_section_config["name_in_apply_json"]
+ assert template_section.template_name == new_template_section_config["template_name"]
+ assert template_section.is_template == True
+ assert new_section.source_template_id == None
+ assert template_section.audit_info == new_template_section_config["audit_info"]
+ assert template_section.index == new_template_section_config["index"]
+
+ assert isinstance(new_section, Section)
+ assert new_section.round_id == new_section_config["round_id"]
+ assert new_section.name_in_apply_json == new_section_config["name_in_apply_json"]
+ assert new_section.template_name == None
+ assert new_section.is_template == False
+ assert new_section.source_template_id == None
+ assert new_section.audit_info == new_section_config["audit_info"]
+ assert new_section.index == new_section_config["index"]
+
+
+def test_update_section(flask_test_client, _db, clear_test_data, seed_dynamic_data):
+ round_id = seed_dynamic_data["rounds"][0].round_id
+ new_section_config["round_id"] = round_id
+ new_section = insert_new_section(new_section_config)
+
+ assert new_section.round_id == new_section_config["round_id"]
+ assert new_section.name_in_apply_json == new_section_config["name_in_apply_json"]
+ assert new_section.template_name == None
+ assert new_section.is_template == False
+ assert new_section.source_template_id == None
+ assert new_section.audit_info == new_section_config["audit_info"]
+ assert new_section.index == new_section_config["index"]
+
+ # Update new_section_config
+ updated_section_config = deepcopy(new_section_config)
+ updated_section_config["name_in_apply_json"] = {"en": "Updated Section Name"}
+ updated_section_config["audit_info"] = {"created_by": "Jonny Doe", "created_at": "2024-01-02"}
+
+ updated_section = update_section(new_section.section_id, updated_section_config)
+ # write assertions for updated_section
+ assert isinstance(updated_section, Section)
+ assert updated_section.round_id == updated_section_config["round_id"]
+ assert updated_section.name_in_apply_json == updated_section_config["name_in_apply_json"]
+ assert updated_section.audit_info == updated_section_config["audit_info"]
+
+
+def test_delete_section(flask_test_client, _db, clear_test_data, seed_dynamic_data):
+ round_id = seed_dynamic_data["rounds"][0].round_id
+ new_section_config["round_id"] = round_id
+ new_section = insert_new_section(new_section_config)
+
+ assert isinstance(new_section, Section)
+ assert new_section.audit_info == new_section_config["audit_info"]
+
+ delete_section(new_section.section_id)
+ assert _db.session.query(Section).filter(Section.section_id == new_section.section_id).one_or_none() == None
+
+
+from sqlalchemy.exc import IntegrityError
+
+
+def test_failed_delete_section_with_fk_to_forms(flask_test_client, _db, clear_test_data, seed_dynamic_data):
+ new_section_config["round_id"] = None
+ section = insert_new_section(new_section_config)
+ # CREATE FK link to Form
+ new_form_config["section_id"] = section.section_id
+ form = insert_new_form(new_form_config)
+ # check inserted form has same section_id
+ assert form.section_id == section.section_id
+ assert isinstance(section, Section)
+ assert section.audit_info == new_section_config["audit_info"]
+
+ try:
+ delete_section(form.section_id)
+ assert False, "Expected IntegrityError was not raised"
+ except IntegrityError:
+ _db.session.rollback() # Rollback the failed transaction to maintain DB integrity
+ assert True # Explicitly pass the test to indicate the expected error was caught
+
+ existing_section = _db.session.query(Section).filter(Section.section_id == section.section_id).one_or_none()
+ assert existing_section is not None, "Section was unexpectedly deleted"
+
+
+new_form_config = {
+ "section_id": uuid.uuid4(),
+ "name_in_apply_json": {"en": "Form Name"},
+ "is_template": False,
+ "audit_info": {"created_by": "John Doe", "created_at": "2022-01-01"},
+ "section_index": 1,
+ "runner_publish_name": "test-form",
+}
+
+new_template_form_config = {
+ "section_id": uuid.uuid4(),
+ "name_in_apply_json": {"en": "Template Form Name"},
+ "template_name": "Form Template Name",
+ "is_template": True,
+ "audit_info": {"created_by": "John Doe", "created_at": "2022-01-01"},
+ "section_index": 1,
+ "runner_publish_name": None, # This is a template
+}
+
+
+def test_insert_new_form(flask_test_client, _db, clear_test_data, seed_dynamic_data):
+ round_id = seed_dynamic_data["rounds"][0].round_id
+ new_section_config["round_id"] = round_id
+ new_section = insert_new_section(new_section_config)
+ # Point to a section that exists in the db
+ new_form_config["section_id"] = new_section.section_id # *Does not need to belong to a section
+ new_template_form_config["section_id"] = new_section.section_id # *Does not need to belong to a section
+
+ new_template_form = insert_new_form(new_template_form_config)
+ assert isinstance(new_template_form, Form)
+ assert new_template_form.section_id == new_template_form_config["section_id"]
+ assert new_template_form.name_in_apply_json == new_template_form_config["name_in_apply_json"]
+ assert new_template_form.template_name == new_template_form_config["template_name"]
+ assert new_template_form.is_template == True
+ assert new_template_form.source_template_id == None
+ assert new_template_form.audit_info == new_template_form_config["audit_info"]
+ assert new_template_form.section_index == new_template_form_config["section_index"]
+ assert new_template_form.runner_publish_name == None
+
+ new_form = insert_new_form(new_form_config)
+ assert isinstance(new_form, Form)
+ assert new_form.section_id == new_form_config["section_id"]
+ assert new_form.name_in_apply_json == new_form_config["name_in_apply_json"]
+ assert new_form.template_name == None
+ assert new_form.source_template_id == None # not cloned, its a new non-template form
+ assert new_form.is_template == False
+ assert new_form.audit_info == new_form_config["audit_info"]
+ assert new_form.section_index == new_form_config["section_index"]
+ assert new_form.runner_publish_name == new_form_config["runner_publish_name"]
+
+ new_form_config["section_index"] = 2
+ new_form = insert_new_form(new_form_config)
+ assert new_form.section_index == new_form_config["section_index"]
+
+
+def test_update_form(flask_test_client, _db, clear_test_data, seed_dynamic_data):
+ round_id = seed_dynamic_data["rounds"][0].round_id
+ new_section_config["round_id"] = round_id
+ new_section = insert_new_section(new_section_config)
+ new_form_config["section_id"] = new_section.section_id
+ new_form = insert_new_form(new_form_config)
+
+ assert new_form.section_id == new_form_config["section_id"]
+ assert new_form.name_in_apply_json == new_form_config["name_in_apply_json"]
+ assert new_form.template_name == None
+ assert new_form.is_template == False
+ assert new_form.source_template_id == None
+ assert new_form.audit_info == new_form_config["audit_info"]
+ assert new_form.section_index == new_form_config["section_index"]
+ assert new_form.runner_publish_name == new_form_config["runner_publish_name"]
+
+ # Update new_form_config
+ updated_form_config = deepcopy(new_form_config)
+ updated_form_config["name_in_apply_json"] = {"en": "Updated Form Name"}
+ updated_form_config["audit_info"] = {"created_by": "Jonny Doe", "created_at": "2024-01-02"}
+
+ updated_form = update_form(new_form.form_id, updated_form_config)
+
+ assert isinstance(updated_form, Form)
+ assert updated_form.section_id == updated_form_config["section_id"]
+ assert updated_form.name_in_apply_json == updated_form_config["name_in_apply_json"]
+ assert updated_form.audit_info == updated_form_config["audit_info"]
+
+
+def test_delete_form(flask_test_client, _db, clear_test_data, seed_dynamic_data):
+ round_id = seed_dynamic_data["rounds"][0].round_id
+ new_section_config["round_id"] = round_id
+ new_section = insert_new_section(new_section_config)
+ new_form_config["section_id"] = new_section.section_id
+ new_form = insert_new_form(new_form_config)
+
+ assert isinstance(new_form, Form)
+ assert new_form.audit_info == new_form_config["audit_info"]
+
+ delete_form(new_form.form_id)
+ assert _db.session.query(Form).filter(Form.form_id == new_form.form_id).one_or_none() == None
+
+
+def test_failed_delete_form_with_fk_to_page(flask_test_client, _db, clear_test_data, seed_dynamic_data):
+ new_form_config["section_id"] = None
+ form = insert_new_form(new_form_config)
+ # CREATE FK link to Form
+ new_page_config["form_id"] = form.form_id
+ page = insert_new_page(new_page_config)
+
+ try:
+ delete_form(page.form_id)
+ assert False, "Expected IntegrityError was not raised"
+ except IntegrityError:
+ _db.session.rollback() # Rollback the failed transaction to maintain DB integrity
+ assert True # Explicitly pass the test to indicate the expected error was caught
+
+ existing_form = _db.session.query(Form).filter(Form.form_id == form.form_id).one_or_none()
+ assert existing_form is not None, "Form was unexpectedly deleted"
+
+
+new_page_config = {
+ "form_id": uuid.uuid4(),
+ "name_in_apply_json": {"en": "Page Name"},
+ "is_template": False,
+ "template_name": None,
+ "source_template_id": None,
+ "audit_info": {"created_by": "John Doe", "created_at": "2022-01-01"},
+ "form_index": 1,
+ "display_path": "test-page",
+ "controller": "./test-controller",
+}
+
+new_template_page_config = {
+ "form_id": uuid.uuid4(),
+ "name_in_apply_json": {"en": "Template Page Name"},
+ "is_template": True,
+ "template_name": "Page Template Name",
+ "source_template_id": None,
+ "audit_info": {"created_by": "John Doe", "created_at": "2022-01-01"},
+ "form_index": 1,
+ "display_path": "test-page",
+ "controller": None,
+}
+
+
+def test_insert_new_page(flask_test_client, _db, clear_test_data, seed_dynamic_data):
+
+ new_form_config["section_id"] = None
+ new_form = insert_new_form(new_form_config)
+
+ new_page_config["form_id"] = new_form.form_id # *Does not need to belong to a form
+ new_template_page_config["form_id"] = None # *Does not need to belong to a form
+
+ new_template_page = insert_new_page(new_template_page_config)
+ assert isinstance(new_template_page, Page)
+ assert new_template_page.form_id is None
+ assert new_template_page.name_in_apply_json == new_template_page_config["name_in_apply_json"]
+ assert new_template_page.template_name == new_template_page_config["template_name"]
+ assert new_template_page.is_template == True
+ assert new_template_page.source_template_id == None
+ assert new_template_page.audit_info == new_template_page_config["audit_info"]
+ assert new_template_page.form_index == new_template_page_config["form_index"]
+ assert new_template_page.display_path == new_page_config["display_path"]
+ assert new_template_page.controller == new_template_page_config["controller"]
+
+ new_page = insert_new_page(new_page_config)
+ assert isinstance(new_page, Page)
+ assert new_page.form_id == new_page_config["form_id"]
+ assert new_page.name_in_apply_json == new_page_config["name_in_apply_json"]
+ assert new_page.template_name == None
+ assert new_page.is_template == False
+ assert new_page.source_template_id == None
+ assert new_page.audit_info == new_page_config["audit_info"]
+ assert new_page.form_index == new_page_config["form_index"]
+ assert new_page.display_path == new_page_config["display_path"]
+ assert new_page.controller == new_page_config["controller"]
+
+
+def test_update_page(flask_test_client, _db, clear_test_data, seed_dynamic_data):
+ new_page_config["form_id"] = None
+ new_page = insert_new_page(new_page_config)
+
+ assert new_page.form_id is None
+ assert new_page.name_in_apply_json == new_page_config["name_in_apply_json"]
+ assert new_page.template_name == None
+ assert new_page.is_template == False
+ assert new_page.source_template_id == None
+ assert new_page.audit_info == new_page_config["audit_info"]
+ assert new_page.form_index == new_page_config["form_index"]
+ assert new_page.display_path == new_page_config["display_path"]
+ assert new_page.controller == new_page_config["controller"]
+
+ # Update new_page_config
+ updated_page_config = deepcopy(new_page_config)
+ updated_page_config["name_in_apply_json"] = {"en": "Updated Page Name"}
+ updated_page_config["audit_info"] = {"created_by": "Jonny Doe", "created_at": "2024-01-02"}
+
+ updated_page = update_page(new_page.page_id, updated_page_config)
+
+ assert isinstance(updated_page, Page)
+ assert updated_page.form_id == updated_page_config["form_id"]
+ assert updated_page.name_in_apply_json == updated_page_config["name_in_apply_json"]
+ assert updated_page.audit_info == updated_page_config["audit_info"]
+
+
+def test_delete_page(flask_test_client, _db, clear_test_data, seed_dynamic_data):
+ new_page_config["form_id"] = None
+ new_page = insert_new_page(new_page_config)
+
+ assert isinstance(new_page, Page)
+ assert new_page.audit_info == new_page_config["audit_info"]
+
+ delete_page(new_page.page_id)
+ assert _db.session.query(Page).filter(Page.page_id == new_page.page_id).one_or_none() == None
+
+
+def test_failed_delete_page_with_fk_to_component(flask_test_client, _db, clear_test_data, seed_dynamic_data):
+ new_page_config["form_id"] = None
+ new_page = insert_new_page(new_page_config)
+ # CREATE FK link to Component
+ new_component_config["page_id"] = new_page.page_id
+ new_component_config["list_id"] = None
+ new_component_config["theme_id"] = None
+ component = insert_new_component(new_component_config)
+ # check inserted component has same page_id
+ assert component.page_id == new_page.page_id
+ assert isinstance(new_page, Page)
+ assert new_page.audit_info == new_page_config["audit_info"]
+
+ try:
+ delete_page(component.page_id)
+ assert False, "Expected IntegrityError was not raised"
+ except IntegrityError:
+ _db.session.rollback() # Rollback the failed transaction to maintain DB integrity
+ assert True # Explicitly pass the test to indicate the expected error was caught
+
+ existing_page = _db.session.query(Page).filter(Page.page_id == new_page.page_id).one_or_none()
+ assert existing_page is not None, "Page was unexpectedly deleted"
+
+
+new_component_config = {
+ "page_id": uuid.uuid4(),
+ "theme_id": uuid.uuid4(),
+ "title": "Component Title",
+ "hint_text": "Component Hint Text",
+ "options": {"hideTitle": False, "classes": "test-class"},
+ "type": ComponentType.TEXT_FIELD,
+ "is_template": False,
+ "template_name": None,
+ "source_template_id": None,
+ "audit_info": {"created_by": "John Doe", "created_at": "2022-01-01"},
+ "page_index": 1,
+ "theme_index": 1,
+ "conditions": [
+ {
+ "name": "organisation_other_names_no",
+ "value": "false", # this must be lowercaes or the navigation doesn't work
+ "operator": "is",
+ "destination_page_path": "CONTINUE",
+ },
+ {
+ "name": "organisation_other_names_yes",
+ "value": "true", # this must be lowercaes or the navigation doesn't work
+ "operator": "is",
+ "destination_page_path": "organisation-alternative-names",
+ },
+ ],
+ "runner_component_name": "test-component",
+ "list_id": uuid.uuid4(),
+}
+
+
+new_template_component_config = {
+ "page_id": uuid.uuid4(),
+ "theme_id": uuid.uuid4(),
+ "title": "Template Component Title",
+ "hint_text": "Template Component Hint Text",
+ "options": {"hideTitle": False, "classes": "test-class"},
+ "type": ComponentType.TEXT_FIELD,
+ "is_template": True,
+ "template_name": "Component Template Name",
+ "source_template_id": None,
+ "audit_info": {"created_by": "John Doe", "created_at": "2022-01-01"},
+ "page_index": 1,
+ "theme_index": 2,
+ "conditions": [
+ {
+ "name": "path_start_no",
+ "value": "false", # this must be lowercaes or the navigation doesn't work
+ "operator": "is",
+ "destination_page_path": "path-1",
+ },
+ {
+ "name": "path_start_yes",
+ "value": "true", # this must be lowercaes or the navigation doesn't work
+ "operator": "is",
+ "destination_page_path": "path-2",
+ },
+ ],
+ "runner_component_name": "test-component",
+ "list_id": uuid.uuid4(),
+}
+
+
+def test_insert_new_component(flask_test_client, _db, clear_test_data, seed_dynamic_data):
+ page_id = seed_dynamic_data["pages"][0].page_id
+ list_id = seed_dynamic_data["lists"][0].list_id
+ theme_id = seed_dynamic_data["themes"][0].theme_id
+ new_component_config["page_id"] = page_id
+ new_template_component_config["page_id"] = None
+ new_component_config["list_id"] = list_id
+ new_template_component_config["list_id"] = list_id
+ new_component_config["theme_id"] = theme_id
+ new_template_component_config["theme_id"] = theme_id
+
+ component = insert_new_component(new_component_config)
+ assert isinstance(component, Component)
+ assert component.page_id == new_component_config["page_id"]
+ assert component.theme_id == new_component_config["theme_id"]
+ assert component.title == new_component_config["title"]
+ assert component.hint_text == new_component_config["hint_text"]
+ assert component.options == new_component_config["options"]
+ assert component.type == new_component_config["type"]
+ assert component.is_template == False
+ assert component.template_name == None
+ assert component.source_template_id == None
+ assert component.audit_info == new_component_config["audit_info"]
+ assert component.page_index == new_component_config["page_index"]
+ assert component.theme_index == new_component_config["theme_index"]
+ assert component.conditions == new_component_config["conditions"]
+ assert component.runner_component_name == new_component_config["runner_component_name"]
+ assert component.list_id == new_component_config["list_id"]
+
+ template_component = insert_new_component(new_template_component_config)
+ assert isinstance(template_component, Component)
+ assert template_component.page_id is None
+ assert template_component.theme_id == new_template_component_config["theme_id"]
+ assert template_component.title == new_template_component_config["title"]
+ assert template_component.hint_text == new_template_component_config["hint_text"]
+ assert template_component.options == new_template_component_config["options"]
+ assert template_component.type == new_template_component_config["type"]
+ assert template_component.is_template == True
+ assert template_component.template_name == new_template_component_config["template_name"]
+ assert template_component.source_template_id == None
+ assert template_component.audit_info == new_template_component_config["audit_info"]
+ assert template_component.page_index == new_template_component_config["page_index"]
+ assert template_component.theme_index == new_template_component_config["theme_index"]
+ assert template_component.conditions == new_template_component_config["conditions"]
+ assert template_component.runner_component_name == new_template_component_config["runner_component_name"]
+ assert template_component.list_id == new_template_component_config["list_id"]
+
+
+def test_update_component(flask_test_client, _db, clear_test_data, seed_dynamic_data):
+ page_id = seed_dynamic_data["pages"][0].page_id
+ list_id = seed_dynamic_data["lists"][0].list_id
+ theme_id = seed_dynamic_data["themes"][0].theme_id
+ new_component_config["page_id"] = page_id
+ new_component_config["list_id"] = list_id
+ new_component_config["theme_id"] = theme_id
+
+ component = insert_new_component(new_component_config)
+
+ assert component.title == new_component_config["title"]
+ assert component.audit_info == new_component_config["audit_info"]
+ assert component.is_template == False
+
+ # Update new_component_config
+ updated_component_config = deepcopy(new_component_config)
+ updated_component_config["title"] = "Updated Component Title"
+ updated_component_config["audit_info"] = {"created_by": "Adam Doe", "created_at": "2024-01-02"}
+
+ updated_component = update_component(component.component_id, updated_component_config)
+
+ assert isinstance(updated_component, Component)
+ assert updated_component.title == updated_component_config["title"]
+ assert updated_component.audit_info == updated_component_config["audit_info"]
+ assert updated_component.is_template == False
+
+
+def test_delete_component(flask_test_client, _db, clear_test_data, seed_dynamic_data):
+ page_id = seed_dynamic_data["pages"][0].page_id
+ list_id = seed_dynamic_data["lists"][0].list_id
+ theme_id = seed_dynamic_data["themes"][0].theme_id
+ new_component_config["page_id"] = page_id
+ new_component_config["list_id"] = list_id
+ new_component_config["theme_id"] = theme_id
+
+ component = insert_new_component(new_component_config)
+
+ assert isinstance(component, Component)
+ assert component.audit_info == new_component_config["audit_info"]
+
+ delete_component(component.component_id)
+ assert _db.session.query(Component).filter(Component.component_id == component.component_id).one_or_none() == None
diff --git a/tests/test_generate_form.py b/tests/test_generate_form.py
index c618607..6ae01cc 100644
--- a/tests/test_generate_form.py
+++ b/tests/test_generate_form.py
@@ -2,18 +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.question_reuse.generate_form import build_conditions
-from app.question_reuse.generate_form import build_conditions
-from app.question_reuse.generate_form import build_form_json
-from app.question_reuse.generate_form import build_lists
-from app.question_reuse.generate_form import build_navigation
-from app.question_reuse.generate_form import build_page
-from app.question_reuse.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
@@ -75,7 +73,7 @@ def test_human_to_kebab(input, exp_output):
)
def test_build_lists(mocker, pages, exp_result):
mocker.patch(
- "app.question_reuse.generate_form.get_list_by_id",
+ "app.config_generator.generate_form.get_list_by_id",
return_value=Lizt(
name="greetings_list",
type="string",
@@ -612,7 +610,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.question_reuse.generate_form.build_page",
+ "app.config_generator.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 73552fe..b20022b 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -2,6 +2,10 @@
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
@@ -12,8 +16,6 @@
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.question_reuse.generate_assessment_config import build_assessment_config
-from app.question_reuse.generate_form import build_form_json
from tasks.test_data import BASIC_FUND_INFO
from tasks.test_data import BASIC_ROUND_INFO